diff --git a/internal/runner/element_extractor_trace_test.go b/internal/runner/element_extractor_trace_test.go new file mode 100644 index 0000000..52dee59 --- /dev/null +++ b/internal/runner/element_extractor_trace_test.go @@ -0,0 +1,135 @@ +package runner + +import ( + "bufio" + "context" + "encoding/json" + "os" + "path/filepath" + "testing" + "time" + + "github.com/priyanshujain/sanderling/internal/trace" +) + +// elementExtractorSpec records accessibility elements directly, the shape an +// author reaches for when the question is "what was on screen at this step": +// one element and the list a generator would have been offered. +const elementExtractorSpec = ` +import { actions, extract } from "@sanderling/spec"; +extract("field", state => state.ax.find({ testTag: "LoginEmail" })); +extract("rows", state => state.ax.findAll({ testTag: "Row" })); +globalThis.properties = {}; +globalThis.actions = actions(() => []); +` + +const elementExtractorHierarchy = `{ + "attributes": {"class": "android.widget.LinearLayout", "bounds": "[0,0,1080,2340]"}, + "children": [ + {"attributes": {"resource-id": "LoginEmail", "class": "android.widget.EditText", + "text": "you@example.com", "bounds": "[10,20,200,60]"}, "children": []}, + {"attributes": {"resource-id": "Row", "class": "android.widget.TextView", + "text": "first", "bounds": "[0,100,1080,200]"}, "children": []}, + {"attributes": {"resource-id": "Row", "class": "android.widget.TextView", + "text": "second", "bounds": "[0,200,1080,300]"}, "children": []} + ] +}` + +// TestRunner_TraceRecordsElementValuedExtractors pins that an extractor holding +// an accessibility element reaches the trace. Elements carry host functions +// (find/findAll), which json.Marshal refuses; the encoder used to answer nil +// and the diff then emitted no entry, so the run finished clean with the +// extractor missing from every step and no error anywhere. +func TestRunner_TraceRecordsElementValuedExtractors(t *testing.T) { + state := newHarnessWithSpec(t, elementExtractorSpec) + state.mock.HierarchyJSON = elementExtractorHierarchy + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if _, err := Run(ctx, Options{ + Duration: 100 * time.Millisecond, + IdleTimeout: 20 * time.Millisecond, + Driver: state.mock, + Verifier: state.verifier, + TraceWriter: state.writer, + }); err != nil { + t.Fatalf("Run: %v", err) + } + + field, rows := elementChangesFromTrace(t, state.directory) + if field == nil { + t.Fatal("no extractor change for the element-valued extractor reached the trace") + } + if rows == nil { + t.Fatal("no extractor change for the element-list extractor reached the trace") + } + + var element map[string]any + if err := json.Unmarshal(field, &element); err != nil { + t.Fatalf("field value is not a JSON object: %v (%s)", err, field) + } + if got := element["id"]; got != "LoginEmail" { + t.Errorf("field.id = %v, want LoginEmail", got) + } + if got := element["text"]; got != "you@example.com" { + t.Errorf("field.text = %v, want you@example.com", got) + } + if got := element["class"]; got != "android.widget.EditText" { + t.Errorf("field.class = %v, want android.widget.EditText", got) + } + bounds, ok := element["bounds"].(map[string]any) + if !ok { + t.Fatalf("field.bounds missing or not an object: %v", element["bounds"]) + } + if bounds["left"] != float64(10) || bounds["top"] != float64(20) || + bounds["right"] != float64(200) || bounds["bottom"] != float64(60) { + t.Errorf("field.bounds = %v, want left/top/right/bottom 10/20/200/60", bounds) + } + for _, key := range []string{"find", "findAll"} { + if _, present := element[key]; present { + t.Errorf("field carries the host function %q into the trace", key) + } + } + + var list []map[string]any + if err := json.Unmarshal(rows, &list); err != nil { + t.Fatalf("rows value is not a JSON array: %v (%s)", err, rows) + } + if len(list) != 2 { + t.Fatalf("rows recorded %d elements, want 2", len(list)) + } + if list[0]["text"] != "first" || list[1]["text"] != "second" { + t.Errorf("rows recorded %v, want the two Row elements in tree order", list) + } +} + +// elementChangesFromTrace returns the first recorded value of each extractor. +func elementChangesFromTrace(t *testing.T, directory string) (field, rows json.RawMessage) { + t.Helper() + file, err := os.Open(filepath.Join(directory, "trace.jsonl")) + if err != nil { + t.Fatal(err) + } + defer file.Close() + + scanner := bufio.NewScanner(file) + scanner.Buffer(make([]byte, 0, 64*1024), 8*1024*1024) + for scanner.Scan() { + var line struct { + ExtractorChanges map[string]trace.ExtractorChange `json:"extractor_changes"` + } + if err := json.Unmarshal(scanner.Bytes(), &line); err != nil { + t.Fatalf("trace line decode: %v", err) + } + if change, ok := line.ExtractorChanges["field"]; ok && field == nil { + field = change.Curr + } + if change, ok := line.ExtractorChanges["rows"]; ok && rows == nil { + rows = change.Curr + } + } + if err := scanner.Err(); err != nil { + t.Fatalf("scan trace: %v", err) + } + return field, rows +}