From ff1a8928dc7911a441ce31174e0735d1d59e095f Mon Sep 17 00:00:00 2001 From: PJ Date: Sun, 31 May 2026 18:50:28 +0530 Subject: [PATCH] fix(runner): mark nil/empty hierarchy as transitional A failed or empty sidecar hierarchy fetch was pushed straight to the verifier, letting spec extractors crash with "Cannot read property 'map' of undefined" when findAll returned null. Treat that case like a transitional capture: skip the verifier push, still record the step, and keep the loop progressing. --- internal/runner/runner.go | 8 ++++ internal/runner/runner_test.go | 73 ++++++++++++++++++++++++++++++++++ 2 files changed, 81 insertions(+) diff --git a/internal/runner/runner.go b/internal/runner/runner.go index 203f6ad..8ad31c8 100644 --- a/internal/runner/runner.go +++ b/internal/runner/runner.go @@ -139,6 +139,14 @@ func Run(ctx context.Context, options Options) (Summary, error) { if tree != nil { treeSize = len(tree.Elements) } + // A nil or empty tree means the sidecar's hierarchy fetch failed or + // returned nothing (e.g. transient device-side timeout). Pushing it + // would let spec extractors call findAll() and chain .map() on a null + // result; treat it like a transitional capture so the verifier is + // skipped, the step is still recorded, and the loop progresses. + if treeSize == 0 { + transitional = true + } lastLogTime = stepStart screen := "" diff --git a/internal/runner/runner_test.go b/internal/runner/runner_test.go index 2f5f58f..84680fc 100644 --- a/internal/runner/runner_test.go +++ b/internal/runner/runner_test.go @@ -773,6 +773,79 @@ func TestRunner_CleanTreeStillVerified(t *testing.T) { } } +// snapshotFailFirst wraps a mock driver so the first Snapshot call returns an +// error (mimicking a sidecar timeout while fetching view hierarchy), then +// delegates every subsequent call back to the mock. +type snapshotFailFirst struct { + *mockdriver.Driver + calls int +} + +func (d *snapshotFailFirst) Snapshot(ctx context.Context) (string, driver.Image, error) { + d.calls++ + if d.calls == 1 { + return "", driver.Image{}, errors.New("Timeout while fetching view hierarchy") + } + return d.Driver.Snapshot(ctx) +} + +// TestRunner_NilHierarchyMarksTransitional verifies that when the sidecar's +// hierarchy fetch fails (nil tree), the runner marks the step transitional and +// skips the verifier instead of pushing a nil tree that would crash the spec. +// Subsequent steps with a clean tree still drive the verifier normally. +func TestRunner_NilHierarchyMarksTransitional(t *testing.T) { + state := newHarnessWithSpec(t, violationSpec) + state.mock.HierarchyJSON = `{"attributes":{"resource-id":"HomeScreen"},"children":[]}` + wrapped := &snapshotFailFirst{Driver: state.mock} + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + summary, err := Run(ctx, Options{ + Duration: 200 * time.Millisecond, + IdleTimeout: 20 * time.Millisecond, + Driver: wrapped, + Verifier: state.verifier, + TraceWriter: state.writer, + }) + if err != nil { + t.Fatalf("Run: %v", err) + } + if summary.Steps < 2 { + t.Fatalf("need at least 2 steps to verify the first is skipped and the second runs, got %d", summary.Steps) + } + // violationSpec always() => false fires on the first verifier push. With + // step 1's verifier skipped, onset moves to step 2. + if len(summary.Violations) != 1 { + t.Fatalf("expected exactly one onset record, got %d: %v", len(summary.Violations), summary.Violations) + } + if summary.Violations[0].StepIndex != 2 { + t.Errorf("onset step: got %d, want 2 (step 1 verifier skipped due to nil tree)", summary.Violations[0].StepIndex) + } + + type traceLine struct { + Step int `json:"step"` + Transitional bool `json:"transitional"` + Violations []string `json:"violations"` + } + body, err := os.ReadFile(filepath.Join(state.writer.Directory(), "trace.jsonl")) + if err != nil { + t.Fatal(err) + } + var first traceLine + if err := json.Unmarshal(bytes.SplitN(bytes.TrimSpace(body), []byte("\n"), 2)[0], &first); err != nil { + t.Fatalf("decode first trace line: %v", err) + } + if first.Step != 1 { + t.Fatalf("first trace line step: got %d, want 1", first.Step) + } + if !first.Transitional { + t.Error("first step must be marked transitional when the hierarchy fetch failed") + } + if len(first.Violations) != 0 { + t.Errorf("step 1 must skip the verifier; got violations %v", first.Violations) + } +} + // TestRunner_WaitActionSkipsIdle ensures the runner does not call WaitForIdle // after a Wait action - the action already provides settling time. func TestRunner_WaitActionSkipsIdle(t *testing.T) {