Skip to content

Commit f8e08e5

Browse files
sanketsudakeclaude
andcommitted
feat: wait --idle (network-idle) for SPA loads
`wait --idle` blocks until network activity settles — no in-flight requests for a short window — tracking requestWillBeSent vs loadingFinished/Failed via a target listener. For SPA loads (Outlook, Workday) where the load event fires long before the content is fetched and rendered, so a fixed sleep is a guess. Test: a post-load fetch is waited out (its response is applied by the time idle returns). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent b51b010 commit f8e08e5

4 files changed

Lines changed: 111 additions & 6 deletions

File tree

internal/chrome/browser.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@ type WaitCond struct {
8585
Gone string
8686
Text string // until the accessibility tree contains this text (e.g. a "Success" toast)
8787
Stable bool // until the accessibility tree stops changing (the page settled)
88+
Idle bool // until network activity settles (no in-flight requests for a window)
8889
Query QueryOpts
8990
}
9091

internal/chrome/cdp.go

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1184,8 +1184,10 @@ func (c *CDP) Wait(ctx context.Context, id string, cond WaitCond) (map[string]an
11841184
action, what = waitText(cond.Text), "text:"+cond.Text
11851185
case cond.Stable:
11861186
action, what = waitStable(800*time.Millisecond), "stable"
1187+
case cond.Idle:
1188+
action, what = waitIdle(500*time.Millisecond), "idle"
11871189
default:
1188-
return nil, fmt.Errorf("wait needs one of --url, --visible, --gone, --text, --stable, --for")
1190+
return nil, fmt.Errorf("wait needs one of --url, --visible, --gone, --text, --stable, --idle, --for")
11891191
}
11901192
if err := c.run(ctx, id, action); err != nil {
11911193
return nil, err
@@ -1263,6 +1265,50 @@ func waitStable(window time.Duration) chromedp.Action {
12631265
})
12641266
}
12651267

1268+
// waitIdle returns once network activity has settled: no in-flight requests for
1269+
// `window`. It tracks requestWillBeSent vs loadingFinished/Failed — for SPA
1270+
// loads (Outlook, Workday) where "DOM loaded" ≠ "content rendered".
1271+
func waitIdle(window time.Duration) chromedp.Action {
1272+
return chromedp.ActionFunc(func(ctx context.Context) error {
1273+
if err := network.Enable().Do(ctx); err != nil {
1274+
return err
1275+
}
1276+
var mu sync.Mutex
1277+
inflight := 0
1278+
lastZero := time.Now()
1279+
chromedp.ListenTarget(ctx, func(ev interface{}) {
1280+
mu.Lock()
1281+
defer mu.Unlock()
1282+
switch ev.(type) {
1283+
case *network.EventRequestWillBeSent:
1284+
inflight++
1285+
case *network.EventLoadingFinished, *network.EventLoadingFailed:
1286+
if inflight > 0 {
1287+
inflight--
1288+
}
1289+
if inflight == 0 {
1290+
lastZero = time.Now()
1291+
}
1292+
}
1293+
})
1294+
t := time.NewTicker(100 * time.Millisecond)
1295+
defer t.Stop()
1296+
for {
1297+
mu.Lock()
1298+
idle := inflight == 0 && time.Since(lastZero) >= window
1299+
mu.Unlock()
1300+
if idle {
1301+
return nil
1302+
}
1303+
select {
1304+
case <-ctx.Done():
1305+
return ctx.Err()
1306+
case <-t.C:
1307+
}
1308+
}
1309+
})
1310+
}
1311+
12661312
// axSig is a cheap signature of the accessibility tree's shape (roles + names),
12671313
// used to detect when it stops changing.
12681314
func axSig(nodes []*accessibility.Node) string {

internal/chrome/wait_idle_test.go

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
package chrome
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"net/http"
7+
"net/http/httptest"
8+
"testing"
9+
"time"
10+
)
11+
12+
// wait --idle blocks until network activity settles — it waits out a fetch that
13+
// starts after load, so the response has been applied by the time it returns.
14+
func TestWaitIdle(t *testing.T) {
15+
if testing.Short() {
16+
t.Skip("skipping live-Chrome integration in -short mode")
17+
}
18+
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
19+
defer cancel()
20+
21+
b, err := launch(true, tmpProfile(t), 0)
22+
if err != nil {
23+
t.Skipf("cannot launch a managed headless Chrome here: %v", err)
24+
}
25+
defer b.Close()
26+
27+
mux := http.NewServeMux()
28+
mux.HandleFunc("/slow", func(w http.ResponseWriter, _ *http.Request) {
29+
time.Sleep(300 * time.Millisecond)
30+
fmt.Fprint(w, "ok")
31+
})
32+
mux.HandleFunc("/", func(w http.ResponseWriter, _ *http.Request) {
33+
fmt.Fprint(w, `<!doctype html><title>Idle</title><body><script>
34+
window.__done = false;
35+
addEventListener('load', () => setTimeout(() => {
36+
fetch('/slow').then(() => { window.__done = true; });
37+
}, 50));
38+
</script></body>`)
39+
})
40+
srv := httptest.NewServer(mux)
41+
defer srv.Close()
42+
43+
id := firstTab(ctx, t, b)
44+
if _, err := b.Navigate(ctx, id, srv.URL); err != nil {
45+
t.Fatalf("Navigate: %v", err)
46+
}
47+
48+
start := time.Now()
49+
if _, err := b.Wait(ctx, id, WaitCond{Idle: true}); err != nil {
50+
t.Fatalf("Wait --idle: %v", err)
51+
}
52+
// The post-load fetch (started at +50ms, ~300ms) must have completed by the
53+
// time idle returned.
54+
if got := evalString(ctx, t, b, id, "String(window.__done)"); got != "true" {
55+
t.Errorf("window.__done = %q after wait --idle, want true (idle returned too early, in %v)", got, time.Since(start))
56+
}
57+
}

internal/cli/commands.go

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -427,10 +427,10 @@ func (a *App) cmdEmulate() *cobra.Command {
427427

428428
func (a *App) cmdWait() *cobra.Command {
429429
var url, visible, gone, text string
430-
var stable bool
430+
var stable, idle bool
431431
var forDur time.Duration
432432
c := &cobra.Command{
433-
Use: "wait", Short: "Wait for a condition: --url/--visible/--gone/--text/--stable, or a fixed --for",
433+
Use: "wait", Short: "Wait for a condition: --url/--visible/--gone/--text/--stable/--idle, or a fixed --for",
434434
RunE: func(*cobra.Command, []string) error {
435435
// --for is a fixed sleep — no tab needed (e.g. settle after a redirect
436436
// when no condition is cleaner).
@@ -439,11 +439,11 @@ func (a *App) cmdWait() *cobra.Command {
439439
a.emitOK("wait", nil, map[string]any{"waited": "for:" + forDur.String()})
440440
return nil
441441
}
442-
if url == "" && visible == "" && gone == "" && text == "" && !stable {
443-
a.emitErr("wait", result.CodeUsage, "wait needs one of --url, --visible, --gone, --text, --stable, --for", nil)
442+
if url == "" && visible == "" && gone == "" && text == "" && !stable && !idle {
443+
a.emitErr("wait", result.CodeUsage, "wait needs one of --url, --visible, --gone, --text, --stable, --idle, --for", nil)
444444
return nil
445445
}
446-
cond := chrome.WaitCond{URL: url, Visible: visible, Gone: gone, Text: text, Stable: stable, Query: a.queryOpts()}
446+
cond := chrome.WaitCond{URL: url, Visible: visible, Gone: gone, Text: text, Stable: stable, Idle: idle, Query: a.queryOpts()}
447447
a.runResolved("wait", func(ctx context.Context, b chrome.Browser, id string) (any, error) {
448448
return b.Wait(ctx, id, cond)
449449
})
@@ -455,6 +455,7 @@ func (a *App) cmdWait() *cobra.Command {
455455
c.Flags().StringVar(&gone, "gone", "", "wait until this selector is gone")
456456
c.Flags().StringVar(&text, "text", "", "wait until the page (accessibility tree) contains this text, e.g. a 'Success' toast")
457457
c.Flags().BoolVar(&stable, "stable", false, "wait until the accessibility tree stops changing (the page settled)")
458+
c.Flags().BoolVar(&idle, "idle", false, "wait until network activity settles (no in-flight requests) — for SPA loads")
458459
c.Flags().DurationVar(&forDur, "for", 0, "wait a fixed duration (e.g. 3s) — a fallback; prefer a condition")
459460
return c
460461
}

0 commit comments

Comments
 (0)