mirror of
https://github.com/priyanshujain/sanderling.git
synced 2026-10-02 19:17:10 +00:00
feat(runner): re-fetch on transitional hierarchy capture
Some actions trigger async work (DB write, ViewModel coroutine) whose navigation transition begins after the sidecar settle poll has already exited. Without intervention, the next iteration's hierarchy fetch lands mid cross-fade and the verifier observes a partial extractor state which then surfaces as a false-positive violation at the step where the transition completes. fetchSyncedState pairs hierarchy + screenshot in one goroutine and retries the pair (up to 4 times, 200ms apart) while the captured tree contains more than one route-level *Screen tag. Steps that observe no transition get no added cost; steps that catch a transition pay up to ~600ms extra wall time but record a tree that matches the post-transition state the property language expects to compare.
This commit is contained in:
1 parent
8deef1a425
commit
ddec95a2c5
2 files changed
+118
-20
No files matched your search
+89
-20
@@ -7,6 +7,7 @@ import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/sync/errgroup"
|
||||
@@ -90,11 +91,14 @@ func Run(ctx context.Context, options Options) (Summary, error) {
|
||||
// goroutine, whose CDP round-trip can otherwise outrun the step
|
||||
// budget on a hung tab.
|
||||
g, gctx := errgroup.WithContext(ctx)
|
||||
si := stepIndex
|
||||
// fetchSyncedState pairs hierarchy and screenshot in the same goroutine
|
||||
// so when a retry happens on a transitional tree, the screenshot stays
|
||||
// aligned with the final hierarchy snapshot.
|
||||
g.Go(func() error {
|
||||
tree, hierarchyErr = fetchHierarchy(gctx, options.Driver)
|
||||
tree, hierarchyErr = fetchSyncedState(gctx, options, logger, si)
|
||||
return nil
|
||||
})
|
||||
si := stepIndex
|
||||
g.Go(func() error {
|
||||
metrics = captureMetrics(gctx, options, logger, si)
|
||||
return nil
|
||||
@@ -116,10 +120,6 @@ func Run(ctx context.Context, options Options) (Summary, error) {
|
||||
return nil
|
||||
})
|
||||
}
|
||||
g.Go(func() error {
|
||||
captureScreenshot(gctx, options, logger, si)
|
||||
return nil
|
||||
})
|
||||
// All goroutines write to local variables and return nil, so the Wait
|
||||
// error is always nil; ignored intentionally.
|
||||
_ = g.Wait()
|
||||
@@ -414,6 +414,89 @@ func fetchHierarchy(ctx context.Context, drv driver.DeviceDriver) (*hierarchy.Tr
|
||||
return hierarchy.Parse(xmlText)
|
||||
}
|
||||
|
||||
// transitionalRetryAttempts caps how many times we re-fetch hierarchy when a
|
||||
// tree carries more than one route-level Screen tag (NavHost cross-fade in
|
||||
// flight). Each retry pauses transitionalRetrySleep before the next fetch.
|
||||
const (
|
||||
transitionalRetryAttempts = 4
|
||||
transitionalRetrySleep = 200 * time.Millisecond
|
||||
)
|
||||
|
||||
// fetchSyncedState fetches hierarchy and screenshot together so the recorded
|
||||
// pair shows the same UI moment. If the hierarchy looks like a NavHost
|
||||
// cross-fade (multiple route-level *Screen tags), the function waits briefly
|
||||
// and re-fetches the pair, up to transitionalRetryAttempts times. This
|
||||
// handles transitions whose async work begins after the sidecar's settle
|
||||
// poll has already exited.
|
||||
func fetchSyncedState(ctx context.Context, options Options, logger *slog.Logger, stepIndex int) (*hierarchy.Tree, error) {
|
||||
var tree *hierarchy.Tree
|
||||
var hierarchyErr error
|
||||
var pngBytes []byte
|
||||
retryLoop:
|
||||
for attempt := range transitionalRetryAttempts {
|
||||
var wg sync.WaitGroup
|
||||
var localTree *hierarchy.Tree
|
||||
var localHierErr error
|
||||
var localImg driver.Image
|
||||
var localImgErr error
|
||||
wg.Add(2)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
localTree, localHierErr = fetchHierarchy(ctx, options.Driver)
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
localImg, localImgErr = options.Driver.Screenshot(ctx)
|
||||
}()
|
||||
wg.Wait()
|
||||
tree = localTree
|
||||
hierarchyErr = localHierErr
|
||||
if localImgErr == nil {
|
||||
pngBytes = localImg.PNG
|
||||
}
|
||||
if hierarchyErr != nil || !isTransitionalHierarchy(tree) {
|
||||
break
|
||||
}
|
||||
if attempt == transitionalRetryAttempts-1 {
|
||||
break
|
||||
}
|
||||
timer := time.NewTimer(transitionalRetrySleep)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
timer.Stop()
|
||||
break retryLoop
|
||||
case <-timer.C:
|
||||
}
|
||||
}
|
||||
if len(pngBytes) > 0 {
|
||||
if writeErr := options.TraceWriter.WriteScreenshot(stepIndex, pngBytes); writeErr != nil {
|
||||
logger.Warn("screenshot write failed", "step", stepIndex, "err", writeErr)
|
||||
}
|
||||
}
|
||||
return tree, hierarchyErr
|
||||
}
|
||||
|
||||
// isTransitionalHierarchy returns true when the tree carries more than one
|
||||
// resource-id ending in "Screen" - the marker of a Compose NavHost mid
|
||||
// cross-fade where both source and destination route composables are alive.
|
||||
// Mirrors the sidecar's stabilitySnapshot heuristic so runner-side rejection
|
||||
// stays consistent with the settle poll.
|
||||
func isTransitionalHierarchy(tree *hierarchy.Tree) bool {
|
||||
if tree == nil {
|
||||
return false
|
||||
}
|
||||
screens := 0
|
||||
for _, element := range tree.Elements {
|
||||
if strings.HasSuffix(element.ResourceID, "Screen") {
|
||||
screens++
|
||||
if screens > 1 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func traceActionFor(action verifier.Action, tree *hierarchy.Tree) *trace.Action {
|
||||
traceAction := &trace.Action{Kind: string(action.Kind), X: action.X, Y: action.Y}
|
||||
switch action.Kind {
|
||||
@@ -541,20 +624,6 @@ func nextActionFromV8(ctx context.Context, web driver.WebDriver) (verifier.Actio
|
||||
}
|
||||
}
|
||||
|
||||
func captureScreenshot(ctx context.Context, options Options, logger *slog.Logger, stepIndex int) {
|
||||
image, err := options.Driver.Screenshot(ctx)
|
||||
if err != nil {
|
||||
logger.Warn("screenshot capture failed", "step", stepIndex, "err", err)
|
||||
return
|
||||
}
|
||||
if len(image.PNG) == 0 {
|
||||
return
|
||||
}
|
||||
if writeErr := options.TraceWriter.WriteScreenshot(stepIndex, image.PNG); writeErr != nil {
|
||||
logger.Warn("screenshot write failed", "step", stepIndex, "err", writeErr)
|
||||
}
|
||||
}
|
||||
|
||||
func encodeExtractorChanges(changes map[string]verifier.ExtractorChange) map[string]trace.ExtractorChange {
|
||||
if len(changes) == 0 {
|
||||
return nil
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
|
||||
"github.com/priyanshujain/sanderling/internal/driver"
|
||||
mockdriver "github.com/priyanshujain/sanderling/internal/driver/mock"
|
||||
"github.com/priyanshujain/sanderling/internal/hierarchy"
|
||||
"github.com/priyanshujain/sanderling/internal/trace"
|
||||
"github.com/priyanshujain/sanderling/internal/verifier"
|
||||
)
|
||||
@@ -493,6 +494,34 @@ func TestRunner_OneScreenshotPerStep(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestIsTransitionalHierarchy_DetectsMultipleScreens covers the runner-side
|
||||
// guard that re-fetches when the hierarchy still carries two route-level
|
||||
// *Screen ids - the NavHost cross-fade signature.
|
||||
func TestIsTransitionalHierarchy_DetectsMultipleScreens(t *testing.T) {
|
||||
multi, err := hierarchy.Parse(`{"attributes":{"resource-id":"root"},"children":[
|
||||
{"attributes":{"resource-id":"AddAccountScreen"},"children":[]},
|
||||
{"attributes":{"resource-id":"HomeScreen"},"children":[]}
|
||||
]}`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !isTransitionalHierarchy(multi) {
|
||||
t.Error("expected multi-screen tree to be flagged as transitional")
|
||||
}
|
||||
|
||||
single, err := hierarchy.Parse(`{"attributes":{"resource-id":"HomeScreen"},"children":[]}`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if isTransitionalHierarchy(single) {
|
||||
t.Error("single-screen tree must not be flagged as transitional")
|
||||
}
|
||||
|
||||
if isTransitionalHierarchy(nil) {
|
||||
t.Error("nil tree must not be flagged as transitional")
|
||||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
|
||||
Reference in new issue
Block a user