diff --git a/element_test.go b/element_test.go index e10eb7c4..9b163949 100644 --- a/element_test.go +++ b/element_test.go @@ -2,6 +2,7 @@ package rod_test import ( "bytes" + "context" "encoding/json" "errors" "fmt" @@ -10,6 +11,7 @@ import ( "net" "os" "path/filepath" + "sync" "testing" "time" @@ -709,6 +711,100 @@ func TestWaitStableRAP(t *testing.T) { g.Err(el.WaitStableRAF()) } +func TestWaitRepaintHonorsPageContext(t *testing.T) { + g := setup(t) + ctx, cancel := context.WithCancel(g.page.GetContext()) + cancel() + + err := g.page.Context(ctx).WaitRepaint() + if !errors.Is(err, context.Canceled) { + t.Fatalf("WaitRepaint error = %v, want context.Canceled", err) + } +} + +const waitStableRAFDemoPage = ` + + +` + +func TestWaitStableRAFOnBackgroundPageHonorsContext(t *testing.T) { + g := setup(t) + target := g.newPage(g.html(waitStableRAFDemoPage)).MustWaitLoad() + other := g.newPage(g.blank()).MustWaitLoad() + other.MustActivate() + + // Freeze the hidden target so requestAnimationFrame cannot resolve. The + // operation must still return when its caller context expires. + if err := (proto.PageSetWebLifecycleState{State: proto.PageSetWebLifecycleStateStateFrozen}).Call(target); err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) + defer cancel() + errCh := make(chan error, 1) + go func() { + errCh <- target.MustElement("#target").Context(ctx).WaitStableRAF() + }() + select { + case err := <-errCh: + if !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("background WaitStableRAF error = %v, want context deadline exceeded", err) + } + case <-time.After(1 * time.Second): + t.Fatal("background WaitStableRAF remained blocked; this reproduces the pre-fix root-context regression") + } +} + +func TestWaitStableRAFSurvivesFrequentActivationSwitches(t *testing.T) { + g := setup(t) + target := g.newPage(g.html(waitStableRAFDemoPage)).MustWaitLoad() + other := g.newPage(g.blank()).MustWaitLoad() + + ctx, cancel := context.WithTimeout(context.Background(), 250*time.Millisecond) + defer cancel() + errCh := make(chan error, 1) + go func() { + errCh <- target.MustElement("#target").Context(ctx).WaitStableRAF() + }() + + done := make(chan struct{}) + var switchers sync.WaitGroup + switchers.Add(1) + go func() { + defer switchers.Done() + ticker := time.NewTicker(10 * time.Millisecond) + defer ticker.Stop() + for { + select { + case <-ticker.C: + _, _ = target.Activate() + _, _ = other.Activate() + case <-done: + return + } + } + }() + defer func() { + close(done) + switchers.Wait() + }() + + select { + case err := <-errCh: + if !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("WaitStableRAF during activation switches error = %v, want context deadline exceeded", err) + } + case <-time.After(2 * time.Second): + t.Fatal("WaitStableRAF remained blocked during frequent activation switches") + } +} + func TestCanvasToImage(t *testing.T) { g := setup(t) diff --git a/page.go b/page.go index f646ecea..0114e4bd 100644 --- a/page.go +++ b/page.go @@ -848,7 +848,10 @@ func (p *Page) WaitIdle(timeout time.Duration) (err error) { // Doc: https://developer.mozilla.org/en-US/docs/Web/API/window/requestAnimationFrame func (p *Page) WaitRepaint() error { // we use root here because iframe doesn't trigger requestAnimationFrame - _, err := p.root.Eval(`() => new Promise(r => requestAnimationFrame(r))`) + // but the root evaluation must still inherit the caller's context. Otherwise + // Page.Context/Timeout cannot cancel this wait when the root page is hidden + // or keeps repainting indefinitely. + _, err := p.root.Context(p.ctx).Eval(`() => new Promise(r => requestAnimationFrame(r))`) return err }