From b5ec7b2ce65290b21699f32bb2eb7ad33899524d Mon Sep 17 00:00:00 2001 From: PJ Date: Sat, 15 Aug 2026 13:38:51 +0530 Subject: [PATCH] test(runner): assert an element reaches trace.jsonl and its witness --- .../runner/element_extractor_trace_test.go | 121 ++++++++++++++++++ 1 file changed, 121 insertions(+) create mode 100644 internal/runner/element_extractor_trace_test.go diff --git a/internal/runner/element_extractor_trace_test.go b/internal/runner/element_extractor_trace_test.go new file mode 100644 index 0000000..4eb7611 --- /dev/null +++ b/internal/runner/element_extractor_trace_test.go @@ -0,0 +1,121 @@ +package runner + +import ( + "bufio" + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "testing" + "time" + + "github.com/priyanshujain/sanderling/internal/trace" +) + +// elementExtractorSpec reads a live ax element, the shape every field and +// button in examples/folio/sanderling/spec.ts is extracted with. The property +// is false the moment the field is on screen, so the run records a witness +// whose only interesting content is that element. +const elementExtractorSpec = ` +import { actions, always, extract } from "@sanderling/spec"; +const amountField = extract("amountField", s => s.ax.find({ "resource-id": "TxnAmountField" })); +globalThis.properties = { + noAmountField: always(() => amountField.current === undefined), +}; +globalThis.actions = actions(() => []); +` + +const amountFieldTreeJSON = `{ + "attributes": {"resource-id": "root", "bounds": "[0,0,400,800]"}, + "enabled": true, + "children": [ + {"attributes": {"resource-id": "TxnAmountField", "text": "199", "bounds": "[0,100,400,160]"}, + "editable": true, "enabled": true, "children": []} + ] +}` + +// TestRunner_TraceRecordsElementValuedExtractors is the guard on the artifact a +// person opens to decide whether a conviction is real. An element-valued +// extractor used to reach the trace as null on the goja hosts (ios, android): +// its exported value carries the element's find/findAll host functions, which +// json.Marshal refuses, so the encoding failed and both the per-step diff and +// the witness recorded nothing. A witness that reads null for the field the +// property fired on describes a state the property could not have fired in, +// which is worse than a blank. +func TestRunner_TraceRecordsElementValuedExtractors(t *testing.T) { + state := newHarnessWithSpec(t, elementExtractorSpec) + state.mock.HierarchyJSON = amountFieldTreeJSON + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + summary, err := Run(ctx, Options{ + Duration: time.Hour, + IdleTimeout: 20 * time.Millisecond, + MaxSteps: 2, + Driver: state.mock, + Verifier: state.verifier, + TraceWriter: state.writer, + }) + if err != nil { + t.Fatalf("Run: %v", err) + } + if !containsProperty(summary.Violations, "noAmountField") { + t.Fatalf("noAmountField did not violate, so the element never reached a predicate: %v", + summary.Violations) + } + + file, err := os.Open(filepath.Join(state.writer.Directory(), "trace.jsonl")) + if err != nil { + t.Fatal(err) + } + defer file.Close() + + type traceLine struct { + Step int `json:"step"` + ExtractorChanges map[string]trace.ExtractorChange `json:"extractor_changes"` + Witnesses map[string]trace.Witness `json:"witnesses"` + } + changes, witnesses := 0, 0 + scanner := bufio.NewScanner(file) + scanner.Buffer(make([]byte, 0, 64*1024), 8*1024*1024) + for scanner.Scan() { + var line traceLine + if err := json.Unmarshal(scanner.Bytes(), &line); err != nil { + t.Fatalf("trace line decode: %v", err) + } + if change, ok := line.ExtractorChanges["amountField"]; ok { + changes++ + assertAmountField(t, fmt.Sprintf("step %d extractor_changes", line.Step), change.Curr) + } + for name, witness := range line.Witnesses { + witnesses++ + assertAmountField(t, fmt.Sprintf("step %d %s witness", line.Step, name), + witness.Extractors["amountField"]) + } + } + if err := scanner.Err(); err != nil { + t.Fatalf("scan trace: %v", err) + } + if changes == 0 { + t.Error("amountField never appears in extractor_changes; the element the run read is not in the trace") + } + if witnesses == 0 { + t.Error("no witness reached the trace; nothing was compared") + } +} + +// assertAmountField reads the recorded element the way a person opening the +// trace would: the field's text is the number the property was judged on. +func assertAmountField(t *testing.T, where string, recorded json.RawMessage) { + t.Helper() + var element struct { + Text string `json:"text"` + } + if err := json.Unmarshal(recorded, &element); err != nil { + t.Fatalf("%s: decode %s: %v", where, recorded, err) + } + if element.Text != "199" { + t.Errorf("%s: recorded element is %s, want its text to read 199", where, recorded) + } +}