mirror of
https://github.com/priyanshujain/sanderling.git
synced 2026-10-02 19:17:10 +00:00
replay ui fix (#56)
* refactor: rename inspect to replay across the codebase Renames inspect-ui/ to replay-ui/, internal/inspect/ to internal/replay/, the CLI subcommand from `sanderling inspect` to `sanderling replay`, and updates all references in docs, Makefile, README, and Go comments. * feat(replay-ui): show spec filename with full path on hover RunList and RunDetail now render the basename of spec_path (e.g. login.spec.ts) with the full path available as a title tooltip.
This commit is contained in:
71 files changed
+104
-92
No files matched your search
@@ -0,0 +1,21 @@
|
||||
// Package replay serves the embedded web UI for browsing recorded runs.
|
||||
package replay
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"io/fs"
|
||||
)
|
||||
|
||||
//go:embed all:dist
|
||||
var distFS embed.FS
|
||||
|
||||
// Assets returns the embedded SPA bundle rooted at the dist directory.
|
||||
// In Stage 2 this contains a stub index.html. Stage 4 wires the real
|
||||
// bundle in via Makefile (copy replay-ui/dist -> internal/replay/dist).
|
||||
func Assets() fs.FS {
|
||||
sub, err := fs.Sub(distFS, "dist")
|
||||
if err != nil {
|
||||
panic("replay: dist embed missing: " + err.Error())
|
||||
}
|
||||
return sub
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package replay
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"net/url"
|
||||
)
|
||||
|
||||
// DevTarget is the hardcoded Vite dev server address. The --dev flag
|
||||
// enables a reverse proxy to this address for non-API requests.
|
||||
const DevTarget = "http://127.0.0.1:5173"
|
||||
|
||||
func newDevProxy(target string) (http.Handler, error) {
|
||||
parsed, err := url.Parse(target)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
proxy := httputil.NewSingleHostReverseProxy(parsed)
|
||||
return proxy, nil
|
||||
}
|
||||
Vendored
Whitespace-only changes.
@@ -0,0 +1,132 @@
|
||||
package replay
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"github.com/priyanshujain/sanderling/internal/trace"
|
||||
)
|
||||
|
||||
// Maximum size of a single trace.jsonl line. Hierarchies and snapshots
|
||||
// can be large; 16 MiB is enough headroom for realistic traces.
|
||||
const maxScanTokenSize = 16 * 1024 * 1024
|
||||
|
||||
// RunSummary is the lightweight per-run record returned by Scan and the
|
||||
// /api/runs handler. Keep this in lockstep with the JSON shape consumed
|
||||
// by the SPA's run list view.
|
||||
type RunSummary struct {
|
||||
ID string `json:"id"`
|
||||
StartedAt time.Time `json:"started_at"`
|
||||
EndedAt *time.Time `json:"ended_at,omitempty"`
|
||||
SpecPath string `json:"spec_path"`
|
||||
Seed int64 `json:"seed"`
|
||||
Platform string `json:"platform"`
|
||||
BundleID string `json:"bundle_id"`
|
||||
DurationMillis int64 `json:"duration_millis"`
|
||||
StepCount int `json:"step_count"`
|
||||
ViolationCount int `json:"violation_count"`
|
||||
InProgress bool `json:"in_progress"`
|
||||
}
|
||||
|
||||
// StepSummary is the slim per-step record used to render the step list
|
||||
// and timeline. Heavy fields (hierarchy, snapshots, residuals) are sent
|
||||
// only via the per-step endpoint.
|
||||
type StepSummary struct {
|
||||
Index int `json:"index"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
Screen string `json:"screen,omitempty"`
|
||||
ActionKind string `json:"action_kind,omitempty"`
|
||||
ActionLabel string `json:"action_label,omitempty"`
|
||||
HasViolations bool `json:"has_violations"`
|
||||
HasExceptions bool `json:"has_exceptions"`
|
||||
}
|
||||
|
||||
// RunDetail is the full /api/runs/{id} payload: meta + slim step list.
|
||||
type RunDetail struct {
|
||||
RunSummary
|
||||
Meta trace.Meta `json:"meta"`
|
||||
Steps []StepSummary `json:"steps"`
|
||||
}
|
||||
|
||||
// Run is a cached parse of one run directory. Step lookups re-read the
|
||||
// JSONL file from disk; only the line offsets are kept in memory.
|
||||
type Run struct {
|
||||
ID string
|
||||
Directory string
|
||||
Meta trace.Meta
|
||||
Summary RunSummary
|
||||
Steps []StepSummary
|
||||
tracePath string
|
||||
traceMtime time.Time
|
||||
offsets []int64
|
||||
}
|
||||
|
||||
// IsRunDirectory reports whether dir looks like a single run (has meta.json).
|
||||
func IsRunDirectory(directory string) bool {
|
||||
_, err := os.Stat(filepath.Join(directory, "meta.json"))
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// Scan walks runsDirectory and returns one RunSummary per child directory
|
||||
// that contains meta.json. Results are sorted by StartedAt descending.
|
||||
func Scan(runsDirectory string) ([]RunSummary, error) {
|
||||
entries, err := os.ReadDir(runsDirectory)
|
||||
if err != nil {
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return []RunSummary{}, nil
|
||||
}
|
||||
return nil, fmt.Errorf("read runs dir: %w", err)
|
||||
}
|
||||
summaries := make([]RunSummary, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
if !entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
runDirectory := filepath.Join(runsDirectory, entry.Name())
|
||||
summary, err := summarize(runDirectory, entry.Name())
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
summaries = append(summaries, summary)
|
||||
}
|
||||
sort.Slice(summaries, func(i, j int) bool {
|
||||
return summaries[i].StartedAt.After(summaries[j].StartedAt)
|
||||
})
|
||||
return summaries, nil
|
||||
}
|
||||
|
||||
func summarize(runDirectory, id string) (RunSummary, error) {
|
||||
meta, err := readMeta(runDirectory)
|
||||
if err != nil {
|
||||
return RunSummary{}, err
|
||||
}
|
||||
stepCount, violationCount, err := tallyTrace(filepath.Join(runDirectory, "trace.jsonl"))
|
||||
if err != nil {
|
||||
return RunSummary{}, err
|
||||
}
|
||||
return buildSummary(id, meta, stepCount, violationCount), nil
|
||||
}
|
||||
|
||||
func buildSummary(id string, meta trace.Meta, stepCount, violationCount int) RunSummary {
|
||||
summary := RunSummary{
|
||||
ID: id,
|
||||
StartedAt: meta.StartedAt,
|
||||
EndedAt: meta.EndedAt,
|
||||
SpecPath: meta.SpecPath,
|
||||
Seed: meta.Seed,
|
||||
Platform: meta.Platform,
|
||||
BundleID: meta.BundleID,
|
||||
StepCount: stepCount,
|
||||
ViolationCount: violationCount,
|
||||
InProgress: meta.EndedAt == nil,
|
||||
}
|
||||
if meta.EndedAt != nil {
|
||||
summary.DurationMillis = meta.EndedAt.Sub(meta.StartedAt).Milliseconds()
|
||||
}
|
||||
return summary
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
package replay
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/priyanshujain/sanderling/internal/trace"
|
||||
)
|
||||
|
||||
// Cache holds parsed Run records keyed by id. Open returns a fresh parse
|
||||
// when the underlying trace.jsonl mtime changes.
|
||||
type Cache struct {
|
||||
root string
|
||||
mutex sync.Mutex
|
||||
runs map[string]*Run
|
||||
}
|
||||
|
||||
func NewCache(runsDirectory string) *Cache {
|
||||
return &Cache{root: runsDirectory, runs: map[string]*Run{}}
|
||||
}
|
||||
|
||||
func (c *Cache) Root() string { return c.root }
|
||||
|
||||
// Open parses (or returns a cached parse of) the run named id.
|
||||
func (c *Cache) Open(id string) (*Run, error) {
|
||||
if !validRunID(id) {
|
||||
return nil, fs.ErrNotExist
|
||||
}
|
||||
runDirectory := filepath.Join(c.root, id)
|
||||
tracePath := filepath.Join(runDirectory, "trace.jsonl")
|
||||
traceInfo, traceErr := os.Stat(tracePath)
|
||||
|
||||
c.mutex.Lock()
|
||||
defer c.mutex.Unlock()
|
||||
if cached, ok := c.runs[id]; ok {
|
||||
if traceErr == nil && cached.traceMtime.Equal(traceInfo.ModTime()) {
|
||||
return cached, nil
|
||||
}
|
||||
}
|
||||
run, err := parseRun(runDirectory, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.runs[id] = run
|
||||
return run, nil
|
||||
}
|
||||
|
||||
func parseRun(runDirectory, id string) (*Run, error) {
|
||||
meta, err := readMeta(runDirectory)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tracePath := filepath.Join(runDirectory, "trace.jsonl")
|
||||
steps, offsets, violationCount, traceMtime, err := scanSteps(tracePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
summary := buildSummary(id, meta, len(steps), violationCount)
|
||||
return &Run{
|
||||
ID: id,
|
||||
Directory: runDirectory,
|
||||
Meta: meta,
|
||||
Summary: summary,
|
||||
Steps: steps,
|
||||
tracePath: tracePath,
|
||||
traceMtime: traceMtime,
|
||||
offsets: offsets,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func scanSteps(tracePath string) ([]StepSummary, []int64, int, time.Time, error) {
|
||||
file, err := os.Open(tracePath)
|
||||
if err != nil {
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return []StepSummary{}, nil, 0, time.Time{}, nil
|
||||
}
|
||||
return nil, nil, 0, time.Time{}, fmt.Errorf("open trace: %w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
info, err := file.Stat()
|
||||
if err != nil {
|
||||
return nil, nil, 0, time.Time{}, fmt.Errorf("stat trace: %w", err)
|
||||
}
|
||||
reader := bufio.NewReaderSize(file, 64*1024)
|
||||
steps := []StepSummary{}
|
||||
offsets := []int64{}
|
||||
violationCount := 0
|
||||
var offset int64
|
||||
for {
|
||||
lineStart := offset
|
||||
line, err := reader.ReadBytes('\n')
|
||||
offset += int64(len(line))
|
||||
trimmed := line
|
||||
if len(trimmed) > 0 && trimmed[len(trimmed)-1] == '\n' {
|
||||
trimmed = trimmed[:len(trimmed)-1]
|
||||
}
|
||||
if len(trimmed) > 0 {
|
||||
summary, partial, decodeErr := decodeStepSummary(trimmed)
|
||||
if decodeErr != nil {
|
||||
return nil, nil, 0, time.Time{}, decodeErr
|
||||
}
|
||||
steps = append(steps, summary)
|
||||
offsets = append(offsets, lineStart)
|
||||
violationCount += partial
|
||||
}
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
return steps, offsets, violationCount, info.ModTime(), nil
|
||||
}
|
||||
|
||||
// Step decodes the full Step record at index n (1-based, matching trace.Step.Index).
|
||||
func (c *Cache) Step(run *Run, index int) (trace.Step, error) {
|
||||
position := -1
|
||||
for i, summary := range run.Steps {
|
||||
if summary.Index == index {
|
||||
position = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if position == -1 {
|
||||
return trace.Step{}, fs.ErrNotExist
|
||||
}
|
||||
file, err := os.Open(run.tracePath)
|
||||
if err != nil {
|
||||
return trace.Step{}, fmt.Errorf("open trace: %w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
if _, err := file.Seek(run.offsets[position], 0); err != nil {
|
||||
return trace.Step{}, fmt.Errorf("seek trace: %w", err)
|
||||
}
|
||||
reader := bufio.NewReaderSize(file, 64*1024)
|
||||
line, err := reader.ReadBytes('\n')
|
||||
if err != nil && len(line) == 0 {
|
||||
return trace.Step{}, fmt.Errorf("read step line: %w", err)
|
||||
}
|
||||
if len(line) > 0 && line[len(line)-1] == '\n' {
|
||||
line = line[:len(line)-1]
|
||||
}
|
||||
var step trace.Step
|
||||
if err := json.Unmarshal(line, &step); err != nil {
|
||||
return trace.Step{}, fmt.Errorf("decode step: %w", err)
|
||||
}
|
||||
return step, nil
|
||||
}
|
||||
|
||||
// Detail returns the /api/runs/{id} payload.
|
||||
func (c *Cache) Detail(id string) (RunDetail, error) {
|
||||
run, err := c.Open(id)
|
||||
if err != nil {
|
||||
return RunDetail{}, err
|
||||
}
|
||||
return RunDetail{
|
||||
RunSummary: run.Summary,
|
||||
Meta: run.Meta,
|
||||
Steps: run.Steps,
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
package replay
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/priyanshujain/sanderling/internal/trace"
|
||||
)
|
||||
|
||||
func readMeta(runDirectory string) (trace.Meta, error) {
|
||||
body, err := os.ReadFile(filepath.Join(runDirectory, "meta.json"))
|
||||
if err != nil {
|
||||
return trace.Meta{}, fmt.Errorf("read meta: %w", err)
|
||||
}
|
||||
var meta trace.Meta
|
||||
if err := json.Unmarshal(body, &meta); err != nil {
|
||||
return trace.Meta{}, fmt.Errorf("decode meta: %w", err)
|
||||
}
|
||||
return meta, nil
|
||||
}
|
||||
|
||||
func tallyTrace(tracePath string) (steps, violations int, err error) {
|
||||
file, err := os.Open(tracePath)
|
||||
if err != nil {
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return 0, 0, nil
|
||||
}
|
||||
return 0, 0, fmt.Errorf("open trace: %w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
scanner := bufio.NewScanner(file)
|
||||
scanner.Buffer(make([]byte, 64*1024), maxScanTokenSize)
|
||||
for scanner.Scan() {
|
||||
line := scanner.Bytes()
|
||||
if len(line) == 0 {
|
||||
continue
|
||||
}
|
||||
var partial struct {
|
||||
Violations []string `json:"violations,omitempty"`
|
||||
}
|
||||
if err := json.Unmarshal(line, &partial); err != nil {
|
||||
return 0, 0, fmt.Errorf("decode step: %w", err)
|
||||
}
|
||||
steps++
|
||||
violations += len(partial.Violations)
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
return 0, 0, fmt.Errorf("scan trace: %w", err)
|
||||
}
|
||||
return steps, violations, nil
|
||||
}
|
||||
|
||||
func decodeStepSummary(line []byte) (StepSummary, int, error) {
|
||||
var partial struct {
|
||||
Index int `json:"step"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
Screen string `json:"screen,omitempty"`
|
||||
NextAction *struct {
|
||||
Kind string `json:"kind"`
|
||||
X int `json:"x,omitempty"`
|
||||
Y int `json:"y,omitempty"`
|
||||
FromX int `json:"from_x,omitempty"`
|
||||
FromY int `json:"from_y,omitempty"`
|
||||
ToX int `json:"to_x,omitempty"`
|
||||
ToY int `json:"to_y,omitempty"`
|
||||
Key string `json:"key,omitempty"`
|
||||
Text string `json:"text,omitempty"`
|
||||
Selector string `json:"selector,omitempty"`
|
||||
DurationMillis int `json:"duration_millis,omitempty"`
|
||||
} `json:"next_action,omitempty"`
|
||||
Exceptions []json.RawMessage `json:"exceptions,omitempty"`
|
||||
Violations []string `json:"violations,omitempty"`
|
||||
}
|
||||
if err := json.Unmarshal(line, &partial); err != nil {
|
||||
return StepSummary{}, 0, fmt.Errorf("decode step: %w", err)
|
||||
}
|
||||
summary := StepSummary{
|
||||
Index: partial.Index,
|
||||
Timestamp: partial.Timestamp,
|
||||
Screen: partial.Screen,
|
||||
HasViolations: len(partial.Violations) > 0,
|
||||
HasExceptions: len(partial.Exceptions) > 0,
|
||||
}
|
||||
if partial.NextAction != nil {
|
||||
summary.ActionKind = partial.NextAction.Kind
|
||||
switch partial.NextAction.Kind {
|
||||
case "Tap", "DoubleTap":
|
||||
if partial.NextAction.Selector != "" {
|
||||
summary.ActionLabel = partial.NextAction.Selector
|
||||
} else if partial.NextAction.Text != "" {
|
||||
summary.ActionLabel = partial.NextAction.Text
|
||||
} else if partial.NextAction.X != 0 || partial.NextAction.Y != 0 {
|
||||
summary.ActionLabel = fmt.Sprintf("(%d,%d)", partial.NextAction.X, partial.NextAction.Y)
|
||||
}
|
||||
case "InputText":
|
||||
summary.ActionLabel = fmt.Sprintf("%q", partial.NextAction.Text)
|
||||
case "Swipe":
|
||||
summary.ActionLabel = swipeDirectionLabel(
|
||||
partial.NextAction.FromX, partial.NextAction.FromY,
|
||||
partial.NextAction.ToX, partial.NextAction.ToY,
|
||||
)
|
||||
case "PressKey":
|
||||
summary.ActionLabel = partial.NextAction.Key
|
||||
case "Wait":
|
||||
if partial.NextAction.DurationMillis > 0 {
|
||||
summary.ActionLabel = fmt.Sprintf("%dms", partial.NextAction.DurationMillis)
|
||||
}
|
||||
}
|
||||
}
|
||||
return summary, len(partial.Violations), nil
|
||||
}
|
||||
|
||||
func swipeDirectionLabel(fromX, fromY, toX, toY int) string {
|
||||
dx := toX - fromX
|
||||
dy := toY - fromY
|
||||
absX := dx
|
||||
if absX < 0 {
|
||||
absX = -absX
|
||||
}
|
||||
absY := dy
|
||||
if absY < 0 {
|
||||
absY = -absY
|
||||
}
|
||||
if absY >= absX {
|
||||
if dy < 0 {
|
||||
return "up"
|
||||
}
|
||||
return "down"
|
||||
}
|
||||
if dx < 0 {
|
||||
return "left"
|
||||
}
|
||||
return "right"
|
||||
}
|
||||
|
||||
func validRunID(id string) bool {
|
||||
if id == "" || id == "." || id == ".." {
|
||||
return false
|
||||
}
|
||||
for _, r := range id {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z':
|
||||
case r >= 'A' && r <= 'Z':
|
||||
case r >= '0' && r <= '9':
|
||||
case r == '-' || r == '_' || r == '.':
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
package replay
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/priyanshujain/sanderling/internal/trace"
|
||||
)
|
||||
|
||||
func TestScan_OrdersByStartedAtDescendingAndCountsViolations(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
older := time.Date(2026, 4, 17, 10, 0, 0, 0, time.UTC)
|
||||
newer := time.Date(2026, 4, 17, 12, 0, 0, 0, time.UTC)
|
||||
writeRun(t, root, "older", trace.Meta{StartedAt: older, EndedAt: timePointer(older.Add(2 * time.Second))}, []trace.Step{
|
||||
{Index: 1, Timestamp: older, Violations: []string{"propA"}},
|
||||
{Index: 2, Timestamp: older.Add(time.Second)},
|
||||
})
|
||||
writeRun(t, root, "newer", trace.Meta{StartedAt: newer, EndedAt: timePointer(newer.Add(time.Second))}, []trace.Step{
|
||||
{Index: 1, Timestamp: newer, Violations: []string{"propA", "propB"}},
|
||||
})
|
||||
|
||||
summaries, err := Scan(root)
|
||||
if err != nil {
|
||||
t.Fatalf("Scan: %v", err)
|
||||
}
|
||||
if len(summaries) != 2 {
|
||||
t.Fatalf("len(summaries) = %d, want 2", len(summaries))
|
||||
}
|
||||
if summaries[0].ID != "newer" {
|
||||
t.Errorf("first id = %q, want newer", summaries[0].ID)
|
||||
}
|
||||
if summaries[0].ViolationCount != 2 {
|
||||
t.Errorf("newer violations = %d, want 2", summaries[0].ViolationCount)
|
||||
}
|
||||
if summaries[1].ViolationCount != 1 {
|
||||
t.Errorf("older violations = %d, want 1", summaries[1].ViolationCount)
|
||||
}
|
||||
if summaries[1].StepCount != 2 {
|
||||
t.Errorf("older steps = %d, want 2", summaries[1].StepCount)
|
||||
}
|
||||
if summaries[0].DurationMillis != 1000 {
|
||||
t.Errorf("newer duration = %d, want 1000", summaries[0].DurationMillis)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScan_MissingDirectoryReturnsEmpty(t *testing.T) {
|
||||
summaries, err := Scan(filepath.Join(t.TempDir(), "does-not-exist"))
|
||||
if err != nil {
|
||||
t.Fatalf("Scan: %v", err)
|
||||
}
|
||||
if len(summaries) != 0 {
|
||||
t.Errorf("len(summaries) = %d, want 0", len(summaries))
|
||||
}
|
||||
}
|
||||
|
||||
func TestScan_MissingEndedAtSurfacesInProgress(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeRun(t, root, "live", trace.Meta{StartedAt: time.Now().UTC()}, []trace.Step{
|
||||
{Index: 1, Timestamp: time.Now().UTC()},
|
||||
})
|
||||
summaries, err := Scan(root)
|
||||
if err != nil {
|
||||
t.Fatalf("Scan: %v", err)
|
||||
}
|
||||
if len(summaries) != 1 || !summaries[0].InProgress {
|
||||
t.Errorf("expected in_progress=true, got %+v", summaries)
|
||||
}
|
||||
if summaries[0].EndedAt != nil {
|
||||
t.Errorf("ended_at should be nil, got %v", summaries[0].EndedAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScan_EmptyTraceTreatedAsZeroSteps(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
directory := filepath.Join(root, "empty")
|
||||
if err := os.MkdirAll(directory, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
meta := trace.Meta{StartedAt: time.Now().UTC()}
|
||||
body, _ := json.Marshal(meta)
|
||||
if err := os.WriteFile(filepath.Join(directory, "meta.json"), body, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
summaries, err := Scan(root)
|
||||
if err != nil {
|
||||
t.Fatalf("Scan: %v", err)
|
||||
}
|
||||
if len(summaries) != 1 {
|
||||
t.Fatalf("len = %d", len(summaries))
|
||||
}
|
||||
if summaries[0].StepCount != 0 {
|
||||
t.Errorf("step_count = %d, want 0", summaries[0].StepCount)
|
||||
}
|
||||
if !summaries[0].InProgress {
|
||||
t.Error("expected in_progress")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCacheStep_LazyDecodeReturnsFullStep(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
startedAt := time.Now().UTC()
|
||||
steps := []trace.Step{
|
||||
{Index: 1, Timestamp: startedAt, Screen: "A"},
|
||||
{Index: 2, Timestamp: startedAt.Add(time.Second), Screen: "B", NextAction: &trace.Action{Kind: "tap"}},
|
||||
{Index: 3, Timestamp: startedAt.Add(2 * time.Second), Screen: "C", Violations: []string{"prop1"}},
|
||||
}
|
||||
writeRun(t, root, "r1", trace.Meta{StartedAt: startedAt, EndedAt: timePointer(startedAt.Add(3 * time.Second))}, steps)
|
||||
|
||||
cache := NewCache(root)
|
||||
run, err := cache.Open("r1")
|
||||
if err != nil {
|
||||
t.Fatalf("Open: %v", err)
|
||||
}
|
||||
if len(run.Steps) != 3 {
|
||||
t.Fatalf("steps = %d, want 3", len(run.Steps))
|
||||
}
|
||||
if !run.Steps[2].HasViolations {
|
||||
t.Error("step 3 should HasViolations")
|
||||
}
|
||||
if run.Steps[1].ActionKind != "tap" {
|
||||
t.Errorf("step 2 action = %q, want tap", run.Steps[1].ActionKind)
|
||||
}
|
||||
|
||||
for _, target := range []int{1, 2, 3} {
|
||||
step, err := cache.Step(run, target)
|
||||
if err != nil {
|
||||
t.Fatalf("Step(%d): %v", target, err)
|
||||
}
|
||||
if step.Index != target {
|
||||
t.Errorf("Step(%d).Index = %d", target, step.Index)
|
||||
}
|
||||
}
|
||||
if _, err := cache.Step(run, 99); err == nil {
|
||||
t.Error("expected error for out-of-range step")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeStepSummary_ActionLabelPerKind(t *testing.T) {
|
||||
cases := []struct {
|
||||
line string
|
||||
wantKind string
|
||||
wantLabel string
|
||||
}{
|
||||
{`{"step":1,"timestamp":"2026-04-20T10:00:00Z","next_action":{"kind":"Tap","selector":"id:save"}}`, "Tap", "id:save"},
|
||||
{`{"step":2,"timestamp":"2026-04-20T10:00:01Z","next_action":{"kind":"Tap","x":140,"y":220}}`, "Tap", "(140,220)"},
|
||||
{`{"step":3,"timestamp":"2026-04-20T10:00:02Z","next_action":{"kind":"InputText","text":"alice"}}`, "InputText", `"alice"`},
|
||||
{`{"step":4,"timestamp":"2026-04-20T10:00:03Z","next_action":{"kind":"Swipe","from_x":10,"from_y":500,"to_x":10,"to_y":50}}`, "Swipe", "up"},
|
||||
{`{"step":5,"timestamp":"2026-04-20T10:00:04Z","next_action":{"kind":"Swipe","from_x":100,"from_y":50,"to_x":600,"to_y":50}}`, "Swipe", "right"},
|
||||
{`{"step":6,"timestamp":"2026-04-20T10:00:05Z","next_action":{"kind":"PressKey","key":"back"}}`, "PressKey", "back"},
|
||||
{`{"step":7,"timestamp":"2026-04-20T10:00:06Z","next_action":{"kind":"Wait","duration_millis":500}}`, "Wait", "500ms"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
summary, _, err := decodeStepSummary([]byte(tc.line))
|
||||
if err != nil {
|
||||
t.Fatalf("decode %s: %v", tc.line, err)
|
||||
}
|
||||
if summary.ActionKind != tc.wantKind {
|
||||
t.Errorf("kind = %q, want %q (line=%s)", summary.ActionKind, tc.wantKind, tc.line)
|
||||
}
|
||||
if summary.ActionLabel != tc.wantLabel {
|
||||
t.Errorf("label = %q, want %q (line=%s)", summary.ActionLabel, tc.wantLabel, tc.line)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCacheOpen_RejectsTraversalIDs(t *testing.T) {
|
||||
cache := NewCache(t.TempDir())
|
||||
for _, id := range []string{"", ".", "..", "../etc", "a/b", "a\\b"} {
|
||||
if _, err := cache.Open(id); err == nil {
|
||||
t.Errorf("Open(%q) should fail", id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsRunDirectory(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeRun(t, root, "x", trace.Meta{StartedAt: time.Now().UTC()}, nil)
|
||||
if !IsRunDirectory(filepath.Join(root, "x")) {
|
||||
t.Error("expected true for run dir")
|
||||
}
|
||||
if IsRunDirectory(root) {
|
||||
t.Error("expected false for parent dir")
|
||||
}
|
||||
}
|
||||
|
||||
func writeRun(t *testing.T, root, id string, meta trace.Meta, steps []trace.Step) {
|
||||
t.Helper()
|
||||
directory := filepath.Join(root, id)
|
||||
if err := os.MkdirAll(directory, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
metaBody, err := json.MarshalIndent(meta, "", " ")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(directory, "meta.json"), metaBody, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if steps == nil {
|
||||
return
|
||||
}
|
||||
file, err := os.Create(filepath.Join(directory, "trace.jsonl"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer file.Close()
|
||||
encoder := json.NewEncoder(file)
|
||||
for _, step := range steps {
|
||||
if err := encoder.Encode(step); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func timePointer(t time.Time) *time.Time { return &t }
|
||||
@@ -0,0 +1,287 @@
|
||||
package replay
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ServerOptions configures a new Server.
|
||||
type ServerOptions struct {
|
||||
RunsDirectory string
|
||||
DevTarget string
|
||||
// AssetsFS overrides the default embedded dist FS. Intended for tests.
|
||||
AssetsFS fs.FS
|
||||
}
|
||||
|
||||
// Server holds the HTTP handlers for `sanderling replay`.
|
||||
type Server struct {
|
||||
options ServerOptions
|
||||
cache *Cache
|
||||
watcher *Watcher
|
||||
assets http.Handler
|
||||
dev http.Handler
|
||||
}
|
||||
|
||||
// NewServer constructs a Server. When options.DevTarget is non-empty the
|
||||
// server reverse-proxies non-API GETs to it; otherwise it serves embedded
|
||||
// assets from the dist FS.
|
||||
func NewServer(options ServerOptions) (*Server, error) {
|
||||
assetsFS := options.AssetsFS
|
||||
if assetsFS == nil {
|
||||
assetsFS = Assets()
|
||||
}
|
||||
server := &Server{
|
||||
options: options,
|
||||
cache: NewCache(options.RunsDirectory),
|
||||
watcher: NewWatcher(options.RunsDirectory),
|
||||
assets: spaHandler(assetsFS),
|
||||
}
|
||||
if options.DevTarget != "" {
|
||||
proxy, err := newDevProxy(options.DevTarget)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("dev proxy: %w", err)
|
||||
}
|
||||
server.dev = proxy
|
||||
}
|
||||
return server, nil
|
||||
}
|
||||
|
||||
// Watcher exposes the runs-directory watcher so callers can run it under their
|
||||
// own context.
|
||||
func (s *Server) Watcher() *Watcher { return s.watcher }
|
||||
|
||||
// Handler returns the root HTTP handler.
|
||||
func (s *Server) Handler() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/api/runs", s.handleRunsList)
|
||||
mux.HandleFunc("/api/runs/", s.handleRunsTree)
|
||||
mux.HandleFunc("/api/events", s.handleEvents)
|
||||
mux.HandleFunc("/", s.handleAssets)
|
||||
return mux
|
||||
}
|
||||
|
||||
func (s *Server) handleRunsList(responseWriter http.ResponseWriter, request *http.Request) {
|
||||
if request.Method != http.MethodGet {
|
||||
http.Error(responseWriter, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
summaries, err := Scan(s.options.RunsDirectory)
|
||||
if err != nil {
|
||||
http.Error(responseWriter, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
writeJSON(responseWriter, http.StatusOK, summaries)
|
||||
}
|
||||
|
||||
var stepPathPattern = regexp.MustCompile(`^([a-zA-Z0-9._-]+)/steps/([^/]+)$`)
|
||||
var screenshotPathPattern = regexp.MustCompile(`^([a-zA-Z0-9._-]+)/screenshots/([a-zA-Z0-9._-]+\.png)$`)
|
||||
var runDetailPathPattern = regexp.MustCompile(`^([a-zA-Z0-9._-]+)/?$`)
|
||||
|
||||
func (s *Server) handleRunsTree(responseWriter http.ResponseWriter, request *http.Request) {
|
||||
if request.Method != http.MethodGet {
|
||||
http.Error(responseWriter, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
rest := strings.TrimPrefix(request.URL.Path, "/api/runs/")
|
||||
if rest == "" {
|
||||
s.handleRunsList(responseWriter, request)
|
||||
return
|
||||
}
|
||||
if match := stepPathPattern.FindStringSubmatch(rest); match != nil {
|
||||
s.serveStep(responseWriter, match[1], match[2])
|
||||
return
|
||||
}
|
||||
if match := screenshotPathPattern.FindStringSubmatch(rest); match != nil {
|
||||
s.serveScreenshot(responseWriter, request, match[1], match[2])
|
||||
return
|
||||
}
|
||||
if match := runDetailPathPattern.FindStringSubmatch(rest); match != nil {
|
||||
s.serveDetail(responseWriter, match[1])
|
||||
return
|
||||
}
|
||||
http.NotFound(responseWriter, request)
|
||||
}
|
||||
|
||||
func (s *Server) serveDetail(responseWriter http.ResponseWriter, id string) {
|
||||
detail, err := s.cache.Detail(id)
|
||||
if err != nil {
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
http.Error(responseWriter, "run not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
http.Error(responseWriter, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
writeJSON(responseWriter, http.StatusOK, detail)
|
||||
}
|
||||
|
||||
func (s *Server) serveStep(responseWriter http.ResponseWriter, id, indexText string) {
|
||||
index, err := strconv.Atoi(indexText)
|
||||
if err != nil {
|
||||
http.Error(responseWriter, "step index must be numeric", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
run, err := s.cache.Open(id)
|
||||
if err != nil {
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
http.Error(responseWriter, "run not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
http.Error(responseWriter, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
step, err := s.cache.Step(run, index)
|
||||
if err != nil {
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
http.Error(responseWriter, "step not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
http.Error(responseWriter, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
writeJSON(responseWriter, http.StatusOK, step)
|
||||
}
|
||||
|
||||
func (s *Server) serveScreenshot(responseWriter http.ResponseWriter, request *http.Request, id, name string) {
|
||||
if !validRunID(id) {
|
||||
http.Error(responseWriter, "run not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
full := filepath.Join(s.options.RunsDirectory, id, "screenshots", name)
|
||||
http.ServeFile(responseWriter, request, full)
|
||||
}
|
||||
|
||||
func (s *Server) handleEvents(responseWriter http.ResponseWriter, request *http.Request) {
|
||||
flusher, ok := responseWriter.(http.Flusher)
|
||||
if !ok {
|
||||
http.Error(responseWriter, "streaming unsupported", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
responseWriter.Header().Set("Content-Type", "text/event-stream")
|
||||
responseWriter.Header().Set("Cache-Control", "no-cache")
|
||||
responseWriter.Header().Set("Connection", "keep-alive")
|
||||
responseWriter.WriteHeader(http.StatusOK)
|
||||
flusher.Flush()
|
||||
|
||||
subscription := s.watcher.Subscribe()
|
||||
defer s.watcher.Unsubscribe(subscription)
|
||||
heartbeat := time.NewTicker(15 * time.Second)
|
||||
defer heartbeat.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-request.Context().Done():
|
||||
return
|
||||
case _, ok := <-subscription:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
fmt.Fprint(responseWriter, "event: runs.changed\ndata: {\"type\":\"runs.changed\"}\n\n")
|
||||
flusher.Flush()
|
||||
case <-heartbeat.C:
|
||||
fmt.Fprint(responseWriter, ": ping\n\n")
|
||||
flusher.Flush()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleAssets(responseWriter http.ResponseWriter, request *http.Request) {
|
||||
if strings.HasPrefix(request.URL.Path, "/api/") {
|
||||
http.NotFound(responseWriter, request)
|
||||
return
|
||||
}
|
||||
if s.dev != nil {
|
||||
s.dev.ServeHTTP(responseWriter, request)
|
||||
return
|
||||
}
|
||||
s.assets.ServeHTTP(responseWriter, request)
|
||||
}
|
||||
|
||||
// spaHandler serves files from assets, falling back to index.html for
|
||||
// unknown paths so the SPA router can take over.
|
||||
func spaHandler(assets fs.FS) http.Handler {
|
||||
fileServer := http.FileServer(http.FS(assets))
|
||||
return http.HandlerFunc(func(responseWriter http.ResponseWriter, request *http.Request) {
|
||||
clean := strings.TrimPrefix(path.Clean(request.URL.Path), "/")
|
||||
if clean == "" {
|
||||
serveIndex(responseWriter, assets)
|
||||
return
|
||||
}
|
||||
file, err := assets.Open(clean)
|
||||
if err != nil {
|
||||
serveIndex(responseWriter, assets)
|
||||
return
|
||||
}
|
||||
file.Close()
|
||||
fileServer.ServeHTTP(responseWriter, request)
|
||||
})
|
||||
}
|
||||
|
||||
func serveIndex(responseWriter http.ResponseWriter, assets fs.FS) {
|
||||
file, err := assets.Open("index.html")
|
||||
if err != nil {
|
||||
http.Error(responseWriter, "index.html missing from embedded assets", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
body, err := readAll(file)
|
||||
if err != nil {
|
||||
http.Error(responseWriter, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
responseWriter.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
_, _ = responseWriter.Write(body)
|
||||
}
|
||||
|
||||
func readAll(file fs.File) ([]byte, error) {
|
||||
const initialCapacity = 4 * 1024
|
||||
buffer := make([]byte, 0, initialCapacity)
|
||||
chunk := make([]byte, 4*1024)
|
||||
for {
|
||||
read, err := file.Read(chunk)
|
||||
if read > 0 {
|
||||
buffer = append(buffer, chunk[:read]...)
|
||||
}
|
||||
if err != nil {
|
||||
if errors.Is(err, fs.ErrInvalid) {
|
||||
return nil, err
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
return buffer, nil
|
||||
}
|
||||
|
||||
func writeJSON(responseWriter http.ResponseWriter, status int, payload any) {
|
||||
responseWriter.Header().Set("Content-Type", "application/json")
|
||||
responseWriter.WriteHeader(status)
|
||||
encoder := json.NewEncoder(responseWriter)
|
||||
_ = encoder.Encode(payload)
|
||||
}
|
||||
|
||||
// ResolveRunsDirectory takes the optional positional argument and returns
|
||||
// (runsDirectory, deepLinkID, error). When argument is "" it falls back
|
||||
// to ./runs. When argument is a single run directory (has meta.json), the
|
||||
// parent becomes runsDirectory and the basename becomes the deep-link id.
|
||||
func ResolveRunsDirectory(argument string) (string, string, error) {
|
||||
if argument == "" {
|
||||
return "./runs", "", nil
|
||||
}
|
||||
if IsRunDirectory(argument) {
|
||||
cleaned := filepath.Clean(argument)
|
||||
parent := filepath.Dir(cleaned)
|
||||
base := filepath.Base(cleaned)
|
||||
return parent, base, nil
|
||||
}
|
||||
return argument, "", nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,323 @@
|
||||
package replay
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
"time"
|
||||
|
||||
"github.com/priyanshujain/sanderling/internal/trace"
|
||||
)
|
||||
|
||||
var testAssetsFS fs.FS = fstest.MapFS{
|
||||
"index.html": &fstest.MapFile{
|
||||
Data: []byte(`<!doctype html><html><body><div id="root"></div></body></html>`),
|
||||
},
|
||||
}
|
||||
|
||||
func newFixtureServer(t *testing.T) (*Server, string) {
|
||||
t.Helper()
|
||||
root := t.TempDir()
|
||||
startedAt := time.Date(2026, 4, 17, 18, 0, 0, 0, time.UTC)
|
||||
endedAt := startedAt.Add(5 * time.Second)
|
||||
writeRun(t, root, "run-a", trace.Meta{
|
||||
StartedAt: startedAt,
|
||||
EndedAt: &endedAt,
|
||||
SpecPath: "spec.ts",
|
||||
Seed: 42,
|
||||
Platform: "android",
|
||||
BundleID: "com.example",
|
||||
}, []trace.Step{
|
||||
{Index: 1, Timestamp: startedAt, Screen: "Home"},
|
||||
{Index: 2, Timestamp: startedAt.Add(time.Second), Screen: "Home", NextAction: &trace.Action{Kind: "tap"}},
|
||||
{Index: 3, Timestamp: startedAt.Add(2 * time.Second), Screen: "Cart", Violations: []string{"propA"}},
|
||||
})
|
||||
writeRun(t, root, "run-b", trace.Meta{
|
||||
StartedAt: startedAt.Add(time.Hour),
|
||||
}, []trace.Step{
|
||||
{Index: 1, Timestamp: startedAt.Add(time.Hour)},
|
||||
})
|
||||
|
||||
screenshotsDirectory := filepath.Join(root, "run-a", "screenshots")
|
||||
if err := os.MkdirAll(screenshotsDirectory, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pngBody := []byte{0x89, 'P', 'N', 'G', 0, 1, 2, 3}
|
||||
if err := os.WriteFile(filepath.Join(screenshotsDirectory, "step-00001.png"), pngBody, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
server, err := NewServer(ServerOptions{RunsDirectory: root, AssetsFS: testAssetsFS})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return server, root
|
||||
}
|
||||
|
||||
func TestHandleRunsList_OrdersAndCountsViolations(t *testing.T) {
|
||||
server, _ := newFixtureServer(t)
|
||||
recorder := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(http.MethodGet, "/api/runs", nil)
|
||||
server.Handler().ServeHTTP(recorder, request)
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d", recorder.Code)
|
||||
}
|
||||
var summaries []RunSummary
|
||||
if err := json.Unmarshal(recorder.Body.Bytes(), &summaries); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(summaries) != 2 {
|
||||
t.Fatalf("len = %d", len(summaries))
|
||||
}
|
||||
if summaries[0].ID != "run-b" {
|
||||
t.Errorf("first id = %q, want run-b (most recent)", summaries[0].ID)
|
||||
}
|
||||
if summaries[1].ViolationCount != 1 {
|
||||
t.Errorf("run-a violation count = %d, want 1", summaries[1].ViolationCount)
|
||||
}
|
||||
if !summaries[0].InProgress {
|
||||
t.Error("run-b should be in_progress (no ended_at)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleRunDetail_DecodesMetaAndStepSummaries(t *testing.T) {
|
||||
server, _ := newFixtureServer(t)
|
||||
recorder := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(http.MethodGet, "/api/runs/run-a", nil)
|
||||
server.Handler().ServeHTTP(recorder, request)
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body=%s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
var detail RunDetail
|
||||
if err := json.Unmarshal(recorder.Body.Bytes(), &detail); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if detail.Meta.BundleID != "com.example" {
|
||||
t.Errorf("bundle = %q", detail.Meta.BundleID)
|
||||
}
|
||||
if len(detail.Steps) != 3 {
|
||||
t.Fatalf("steps len = %d", len(detail.Steps))
|
||||
}
|
||||
if detail.Steps[1].ActionKind != "tap" {
|
||||
t.Errorf("step 2 action = %q", detail.Steps[1].ActionKind)
|
||||
}
|
||||
if !detail.Steps[2].HasViolations {
|
||||
t.Error("step 3 should HasViolations")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleStep_ReturnsFullStep(t *testing.T) {
|
||||
server, _ := newFixtureServer(t)
|
||||
recorder := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(http.MethodGet, "/api/runs/run-a/steps/3", nil)
|
||||
server.Handler().ServeHTTP(recorder, request)
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d", recorder.Code)
|
||||
}
|
||||
var step trace.Step
|
||||
if err := json.Unmarshal(recorder.Body.Bytes(), &step); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if step.Index != 3 || step.Screen != "Cart" {
|
||||
t.Errorf("step = %+v", step)
|
||||
}
|
||||
if len(step.Violations) != 1 {
|
||||
t.Errorf("violations = %v", step.Violations)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleStep_ErrorCases(t *testing.T) {
|
||||
server, _ := newFixtureServer(t)
|
||||
cases := []struct {
|
||||
name string
|
||||
path string
|
||||
wantStatus int
|
||||
}{
|
||||
{"unknown run", "/api/runs/nope", http.StatusNotFound},
|
||||
{"non-numeric step", "/api/runs/run-a/steps/abc", http.StatusBadRequest},
|
||||
{"out-of-range step", "/api/runs/run-a/steps/999", http.StatusNotFound},
|
||||
}
|
||||
for _, testCase := range cases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
recorder := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(http.MethodGet, testCase.path, nil)
|
||||
server.Handler().ServeHTTP(recorder, request)
|
||||
if recorder.Code != testCase.wantStatus {
|
||||
t.Errorf("status = %d, want %d, body=%s", recorder.Code, testCase.wantStatus, recorder.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestScreenshot_ServesWhitelistedPNG(t *testing.T) {
|
||||
server, _ := newFixtureServer(t)
|
||||
recorder := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(http.MethodGet, "/api/runs/run-a/screenshots/step-00001.png", nil)
|
||||
server.Handler().ServeHTTP(recorder, request)
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d", recorder.Code)
|
||||
}
|
||||
if recorder.Body.Len() == 0 {
|
||||
t.Error("empty body")
|
||||
}
|
||||
}
|
||||
|
||||
func TestScreenshot_RejectsTraversalAndBadNames(t *testing.T) {
|
||||
server, _ := newFixtureServer(t)
|
||||
cases := []string{
|
||||
"/api/runs/run-a/screenshots/../meta.json",
|
||||
"/api/runs/run-a/screenshots/..%2Fmeta.json",
|
||||
"/api/runs/run-a/screenshots/step-00001.txt",
|
||||
"/api/runs/run-a/screenshots/.png",
|
||||
}
|
||||
for _, path := range cases {
|
||||
t.Run(path, func(t *testing.T) {
|
||||
recorder := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(http.MethodGet, path, nil)
|
||||
server.Handler().ServeHTTP(recorder, request)
|
||||
if recorder.Code == http.StatusOK {
|
||||
t.Errorf("status = %d (should not be 200) body=%s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSSE_ReturnsWhenContextCanceled(t *testing.T) {
|
||||
server, _ := newFixtureServer(t)
|
||||
httpServer := httptest.NewServer(server.Handler())
|
||||
defer httpServer.Close()
|
||||
|
||||
context, cancel := context.WithCancel(context.Background())
|
||||
request, err := http.NewRequestWithContext(context, http.MethodGet, httpServer.URL+"/api/events", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
response, err := http.DefaultClient.Do(request)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d", response.StatusCode)
|
||||
}
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
_, _ = io.Copy(io.Discard, response.Body)
|
||||
close(done)
|
||||
}()
|
||||
cancel()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("SSE handler did not exit after context cancel")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDevProxy_ForwardsRequestBodyUnchanged(t *testing.T) {
|
||||
received := make(chan string, 1)
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(responseWriter http.ResponseWriter, request *http.Request) {
|
||||
body, err := io.ReadAll(request.Body)
|
||||
if err != nil {
|
||||
t.Errorf("upstream read: %v", err)
|
||||
}
|
||||
received <- string(body)
|
||||
responseWriter.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer upstream.Close()
|
||||
|
||||
root := t.TempDir()
|
||||
server, err := NewServer(ServerOptions{RunsDirectory: root, DevTarget: upstream.URL})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
httpServer := httptest.NewServer(server.Handler())
|
||||
defer httpServer.Close()
|
||||
|
||||
body := strings.NewReader("hello world")
|
||||
request, err := http.NewRequest(http.MethodPost, httpServer.URL+"/anything", body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
response, err := http.DefaultClient.Do(request)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
response.Body.Close()
|
||||
if response.StatusCode != http.StatusOK {
|
||||
t.Errorf("status = %d", response.StatusCode)
|
||||
}
|
||||
select {
|
||||
case got := <-received:
|
||||
if got != "hello world" {
|
||||
t.Errorf("upstream body = %q, want %q", got, "hello world")
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("upstream never received the request")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssets_FallbackToIndexHTML(t *testing.T) {
|
||||
server, _ := newFixtureServer(t)
|
||||
recorder := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(http.MethodGet, "/runs/some-id-that-only-the-spa-knows", nil)
|
||||
server.Handler().ServeHTTP(recorder, request)
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d", recorder.Code)
|
||||
}
|
||||
body := recorder.Body.String()
|
||||
if !strings.Contains(body, "<div id=\"app\"></div>") && !strings.Contains(body, "<div id=\"root\"></div>") {
|
||||
t.Errorf("expected SPA shell with #app or #root, got %q", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssets_API404DoesNotFallThrough(t *testing.T) {
|
||||
server, _ := newFixtureServer(t)
|
||||
recorder := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(http.MethodGet, "/api/wat", nil)
|
||||
server.Handler().ServeHTTP(recorder, request)
|
||||
if recorder.Code != http.StatusNotFound {
|
||||
t.Errorf("status = %d, want 404", recorder.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveRunsDirectory(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeRun(t, root, "r1", trace.Meta{StartedAt: time.Now().UTC()}, nil)
|
||||
|
||||
runsDirectory, deepLink, err := ResolveRunsDirectory("")
|
||||
if err != nil || runsDirectory != "./runs" || deepLink != "" {
|
||||
t.Errorf("default = (%q, %q, %v)", runsDirectory, deepLink, err)
|
||||
}
|
||||
runsDirectory, deepLink, err = ResolveRunsDirectory(root)
|
||||
if err != nil || runsDirectory != root || deepLink != "" {
|
||||
t.Errorf("multi-run dir = (%q, %q, %v)", runsDirectory, deepLink, err)
|
||||
}
|
||||
singleRun := filepath.Join(root, "r1")
|
||||
runsDirectory, deepLink, err = ResolveRunsDirectory(singleRun)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if filepath.Clean(runsDirectory) != filepath.Clean(root) || deepLink != "r1" {
|
||||
t.Errorf("single-run dir = (%q, %q)", runsDirectory, deepLink)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDevProxy_ParsesTarget(t *testing.T) {
|
||||
if _, err := newDevProxy(":://bad-url"); err == nil {
|
||||
t.Error("expected parse error for invalid URL")
|
||||
}
|
||||
parsed, err := url.Parse(DevTarget)
|
||||
if err != nil || parsed.Host != "127.0.0.1:5173" {
|
||||
t.Errorf("DevTarget parsed wrong: %v %q", err, parsed.Host)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
package replay
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io/fs"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/fsnotify/fsnotify"
|
||||
)
|
||||
|
||||
const watcherDebounce = 200 * time.Millisecond
|
||||
|
||||
// Watcher reports coalesced runs.changed events from the runs directory.
|
||||
// Subscribe returns a channel that receives one event per debounce window.
|
||||
// The watcher tolerates a missing runs directory by polling for it to appear.
|
||||
type Watcher struct {
|
||||
directory string
|
||||
debounce time.Duration
|
||||
mutex sync.Mutex
|
||||
subscribers []chan struct{}
|
||||
closed bool
|
||||
}
|
||||
|
||||
func NewWatcher(directory string) *Watcher {
|
||||
return &Watcher{directory: directory, debounce: watcherDebounce}
|
||||
}
|
||||
|
||||
func (w *Watcher) Subscribe() <-chan struct{} {
|
||||
w.mutex.Lock()
|
||||
defer w.mutex.Unlock()
|
||||
channel := make(chan struct{}, 4)
|
||||
if w.closed {
|
||||
close(channel)
|
||||
return channel
|
||||
}
|
||||
w.subscribers = append(w.subscribers, channel)
|
||||
return channel
|
||||
}
|
||||
|
||||
// Unsubscribe removes a channel previously returned by Subscribe. The channel
|
||||
// is not closed because broadcast snapshots subscribers without holding the
|
||||
// mutex and a concurrent close would race with its non-blocking send.
|
||||
// Safe to call multiple times; unknown channels are ignored.
|
||||
func (w *Watcher) Unsubscribe(subscription <-chan struct{}) {
|
||||
w.mutex.Lock()
|
||||
defer w.mutex.Unlock()
|
||||
for index, channel := range w.subscribers {
|
||||
if (<-chan struct{})(channel) != subscription {
|
||||
continue
|
||||
}
|
||||
last := len(w.subscribers) - 1
|
||||
w.subscribers[index] = w.subscribers[last]
|
||||
w.subscribers[last] = nil
|
||||
w.subscribers = w.subscribers[:last]
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Run blocks until ctx is canceled, watching directory for create/remove/rename
|
||||
// events and emitting one notification per debounce window to all subscribers.
|
||||
func (w *Watcher) Run(ctx context.Context) error {
|
||||
watcher, err := fsnotify.NewWatcher()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer watcher.Close()
|
||||
|
||||
if err := watchOrWaitForDirectory(ctx, watcher, w.directory); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var pending bool
|
||||
timer := time.NewTimer(w.debounce)
|
||||
if !timer.Stop() {
|
||||
<-timer.C
|
||||
}
|
||||
defer timer.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
w.shutdown()
|
||||
return nil
|
||||
case event, ok := <-watcher.Events:
|
||||
if !ok {
|
||||
w.shutdown()
|
||||
return nil
|
||||
}
|
||||
if event.Op&(fsnotify.Create|fsnotify.Remove|fsnotify.Rename) == 0 {
|
||||
continue
|
||||
}
|
||||
if !pending {
|
||||
pending = true
|
||||
timer.Reset(w.debounce)
|
||||
}
|
||||
case <-watcher.Errors:
|
||||
// Drop transient errors; SSE is best-effort.
|
||||
case <-timer.C:
|
||||
if pending {
|
||||
pending = false
|
||||
w.broadcast()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (w *Watcher) broadcast() {
|
||||
w.mutex.Lock()
|
||||
subscribers := append([]chan struct{}(nil), w.subscribers...)
|
||||
w.mutex.Unlock()
|
||||
for _, channel := range subscribers {
|
||||
select {
|
||||
case channel <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (w *Watcher) shutdown() {
|
||||
w.mutex.Lock()
|
||||
defer w.mutex.Unlock()
|
||||
w.closed = true
|
||||
for _, channel := range w.subscribers {
|
||||
close(channel)
|
||||
}
|
||||
w.subscribers = nil
|
||||
}
|
||||
|
||||
func watchOrWaitForDirectory(ctx context.Context, watcher *fsnotify.Watcher, directory string) error {
|
||||
for {
|
||||
err := watcher.Add(directory)
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if !errors.Is(err, fs.ErrNotExist) && !errors.Is(err, os.ErrNotExist) {
|
||||
return err
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil
|
||||
case <-time.After(time.Second):
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package replay
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestWatcher_UnsubscribeRemovesChannel(t *testing.T) {
|
||||
w := NewWatcher(t.TempDir())
|
||||
first := w.Subscribe()
|
||||
second := w.Subscribe()
|
||||
third := w.Subscribe()
|
||||
|
||||
if count := len(w.subscribers); count != 3 {
|
||||
t.Fatalf("expected 3 subscribers, got %d", count)
|
||||
}
|
||||
|
||||
w.Unsubscribe(second)
|
||||
|
||||
if count := len(w.subscribers); count != 2 {
|
||||
t.Fatalf("expected 2 subscribers after Unsubscribe, got %d", count)
|
||||
}
|
||||
|
||||
// broadcast should still notify remaining subscribers
|
||||
w.broadcast()
|
||||
select {
|
||||
case <-first:
|
||||
default:
|
||||
t.Error("first subscriber did not receive broadcast")
|
||||
}
|
||||
select {
|
||||
case <-third:
|
||||
default:
|
||||
t.Error("third subscriber did not receive broadcast")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWatcher_UnsubscribeUnknownChannelIsNoop(t *testing.T) {
|
||||
w := NewWatcher(t.TempDir())
|
||||
existing := w.Subscribe()
|
||||
|
||||
stranger := make(chan struct{})
|
||||
w.Unsubscribe(stranger)
|
||||
|
||||
if count := len(w.subscribers); count != 1 {
|
||||
t.Fatalf("expected 1 subscriber after no-op Unsubscribe, got %d", count)
|
||||
}
|
||||
|
||||
w.broadcast()
|
||||
select {
|
||||
case <-existing:
|
||||
default:
|
||||
t.Error("existing subscriber did not receive broadcast")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user