diff --git a/server/lib/cdpclient/cdpclient.go b/server/lib/cdpclient/cdpclient.go index e2b2a974..3c53e8c0 100644 --- a/server/lib/cdpclient/cdpclient.go +++ b/server/lib/cdpclient/cdpclient.go @@ -592,10 +592,38 @@ func relatedHosts(a, b string) bool { return a == b || strings.HasSuffix(a, "."+b) || strings.HasSuffix(b, "."+a) } +// DevTools can accept connections shortly before Chromium opens its first tab, +// so firstPageTargetID polls for a page target. Vars so tests can adjust them. +var ( + pageTargetWaitTimeout = 5 * time.Second + pageTargetPollInterval = 100 * time.Millisecond +) + // firstPageTargetID returns the targetId of the first page target reported -// by Target.getTargets. Callers that need to operate on the user-facing -// browser window (Emulation, Browser.* window bounds) use this to find it. +// by Target.getTargets, polling for up to pageTargetWaitTimeout if none +// exists yet. Callers that need to operate on the user-facing browser window +// (Emulation, Browser.* window bounds) use this to find it. func (c *Client) firstPageTargetID(ctx context.Context) (string, error) { + deadline := time.Now().Add(pageTargetWaitTimeout) + for { + targetID, err := c.findPageTargetID(ctx) + if err != nil || targetID != "" { + return targetID, err + } + if time.Now().After(deadline) { + return "", fmt.Errorf("no page target found") + } + select { + case <-ctx.Done(): + return "", fmt.Errorf("no page target found: %w", ctx.Err()) + case <-time.After(min(pageTargetPollInterval, time.Until(deadline))): + } + } +} + +// findPageTargetID returns the targetId of the first page target, or "" if +// there is none. +func (c *Client) findPageTargetID(ctx context.Context) (string, error) { targetsResult, err := c.Send(ctx, "Target.getTargets", nil, "") if err != nil { return "", fmt.Errorf("Target.getTargets: %w", err) @@ -614,7 +642,7 @@ func (c *Client) firstPageTargetID(ctx context.Context) (string, error) { return t.TargetID, nil } } - return "", fmt.Errorf("no page target found") + return "", nil } // SetWindowBoundsMaximized puts the OS window backing the first page target diff --git a/server/lib/cdpclient/cdpclient_test.go b/server/lib/cdpclient/cdpclient_test.go index a5b1db6e..01408b64 100644 --- a/server/lib/cdpclient/cdpclient_test.go +++ b/server/lib/cdpclient/cdpclient_test.go @@ -18,7 +18,7 @@ import ( // fakeCDP is a minimal CDP server that responds to the commands used by // SetDeviceMetricsOverride and GetBrowserVersion. type fakeCDP struct { - getTargetsCalled bool + getTargetsCalls int attachCalled bool setMetricsCalled bool setMetricsWidth int @@ -29,6 +29,7 @@ type fakeCDP struct { failGetTargets bool failSetMetrics bool returnNoPageTargets bool + noPageTargetsFor int getVersionCalled bool failGetVersion bool productResponse string @@ -70,12 +71,12 @@ func (f *fakeCDP) handler(w http.ResponseWriter, r *http.Request) { switch req.Method { case "Target.getTargets": - f.getTargetsCalled = true + f.getTargetsCalls++ if f.failGetTargets { cdpErr = &Error{Code: -1, Message: "mock error"} } else { targets := []map[string]string{} - if !f.returnNoPageTargets { + if !f.returnNoPageTargets && f.getTargetsCalls > f.noPageTargetsFor { targets = append(targets, map[string]string{ "targetId": f.pageTargetID, "type": "page", @@ -187,7 +188,7 @@ func TestSetDeviceMetricsOverride(t *testing.T) { err = client.SetDeviceMetricsOverride(ctx, 1920, 1080) require.NoError(t, err) - assert.True(t, f.getTargetsCalled) + assert.Equal(t, 1, f.getTargetsCalls) assert.True(t, f.attachCalled) assert.True(t, f.setMetricsCalled) assert.True(t, f.detachCalled) @@ -196,19 +197,73 @@ func TestSetDeviceMetricsOverride(t *testing.T) { }) t.Run("no page target", func(t *testing.T) { + defer func(d time.Duration) { pageTargetWaitTimeout = d }(pageTargetWaitTimeout) + pageTargetWaitTimeout = 300 * time.Millisecond + defer func(d time.Duration) { pageTargetPollInterval = d }(pageTargetPollInterval) + pageTargetPollInterval = time.Minute // only the deadline clamp ends the wait in time + f := &fakeCDP{ returnNoPageTargets: true, } url := startFakeCDP(t, f) - ctx := context.Background() + // Fail instead of hanging if the wait is unbounded. + ctx, cancel := context.WithTimeout(context.Background(), pageTargetWaitTimeout+time.Second) + defer cancel() client, err := Dial(ctx, url) require.NoError(t, err) defer client.Close() + start := time.Now() err = client.SetDeviceMetricsOverride(ctx, 1920, 1080) require.Error(t, err) assert.Contains(t, err.Error(), "no page target found") + assert.NotErrorIs(t, err, context.DeadlineExceeded) + assert.GreaterOrEqual(t, time.Since(start), pageTargetWaitTimeout) + assert.False(t, f.attachCalled) + }) + + t.Run("waits for first page target", func(t *testing.T) { + f := &fakeCDP{ + pageTargetID: "target-123", + sessionID: "session-abc", + noPageTargetsFor: 3, + } + url := startFakeCDP(t, f) + + ctx := context.Background() + client, err := Dial(ctx, url) + require.NoError(t, err) + defer client.Close() + + err = client.SetDeviceMetricsOverride(ctx, 1920, 1080) + require.NoError(t, err) + + assert.Equal(t, 4, f.getTargetsCalls) + assert.True(t, f.setMetricsCalled) + }) + + t.Run("no page target respects context", func(t *testing.T) { + defer func(d time.Duration) { pageTargetPollInterval = d }(pageTargetPollInterval) + pageTargetPollInterval = time.Minute // only ctx.Done() can end the wait early + + f := &fakeCDP{ + returnNoPageTargets: true, + } + url := startFakeCDP(t, f) + + client, err := Dial(context.Background(), url) + require.NoError(t, err) + defer client.Close() + + ctx, cancel := context.WithCancel(context.Background()) + time.AfterFunc(250*time.Millisecond, cancel) + + start := time.Now() + err = client.SetDeviceMetricsOverride(ctx, 1920, 1080) + require.Error(t, err) + assert.ErrorIs(t, err, context.Canceled) + assert.Less(t, time.Since(start), pageTargetWaitTimeout) }) t.Run("getTargets failure", func(t *testing.T) {