diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ef4562fa..6aa5094a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -31,8 +31,9 @@ To adapt to the new version of Playwright's protocol and feature updates, you ma 1. Apply patch `bash scripts/apply-patch.sh` 2. `cd playwright` 3. Revert the patch `git reset HEAD~1` -4. Modify the files under `docs/src/api`, etc. as needed. Available references: - - Protocol `packages/protocol/src/protocol.yml` +4. Modify the files under `docs/src/api`, etc. as needed. Available sources and references: + - Public Go API generator input: `docs/src/api/*.md`, including `params.md` (patch the relevant blocks with `langs: go` as needed). + - Playwright client/driver wire-protocol reference: `packages/protocol/spec/*.yml`. - [Playwright python](https://github.com/microsoft/playwright-python) 5. Commit the changes `git commit -am "apply patch"` 6. Regenerate a new patch `bash scripts/update-patch.sh` diff --git a/README.md b/README.md index 00789043..3c3be06c 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ [![PkgGoDev](https://pkg.go.dev/badge/github.com/mxschmitt/playwright-go)](https://pkg.go.dev/github.com/mxschmitt/playwright-go) [![License](https://img.shields.io/badge/License-MIT-blue.svg)](http://opensource.org/licenses/MIT) [![Go Report Card](https://goreportcard.com/badge/github.com/mxschmitt/playwright-go)](https://goreportcard.com/report/github.com/mxschmitt/playwright-go) ![Build Status](https://github.com/mxschmitt/playwright-go/workflows/Go/badge.svg) [![Tests](https://img.shields.io/endpoint?url=https%3A%2F%2Fflakiness.io%2Fapi%2Fbadge%3Finput%3D%257B%2522badgeToken%2522%253A%2522badge-6g4pNCL3d8qZbDdJEqFhSI%2522%257D)](https://flakiness.io/playwright-community/playwright-go) -[![Join Discord](https://img.shields.io/badge/join-discord-informational)](https://aka.ms/playwright/discord) [![Coverage Status](https://img.shields.io/coverallsCoverage/github/mxschmitt/playwright-go?branch=main)](https://coveralls.io/github/mxschmitt/playwright-go?branch=main) [![Chromium version](https://img.shields.io/badge/chromium-149.0.7827.55-blue.svg?logo=google-chrome)](https://www.chromium.org/Home) [![Firefox version](https://img.shields.io/badge/firefox-151.0-blue.svg?logo=mozilla-firefox)](https://www.mozilla.org/en-US/firefox/new/) [![WebKit version](https://img.shields.io/badge/webkit-26.5-blue.svg?logo=safari)](https://webkit.org/) +[![Join Discord](https://img.shields.io/badge/join-discord-informational)](https://aka.ms/playwright/discord) [![Coverage Status](https://img.shields.io/coverallsCoverage/github/mxschmitt/playwright-go?branch=main)](https://coveralls.io/github/mxschmitt/playwright-go?branch=main) [![Chromium version](https://img.shields.io/badge/chromium-151.0.7922.34-blue.svg?logo=google-chrome)](https://www.chromium.org/Home) [![Firefox version](https://img.shields.io/badge/firefox-153.0-blue.svg?logo=mozilla-firefox)](https://www.mozilla.org/en-US/firefox/new/) [![WebKit version](https://img.shields.io/badge/webkit-26.5-blue.svg?logo=safari)](https://webkit.org/) [API reference](https://playwright.dev/docs/api/class-playwright) | [Example recipes](https://github.com/mxschmitt/playwright-go/tree/main/examples) @@ -12,9 +12,9 @@ Playwright is a Go library to automate [Chromium](https://www.chromium.org/Home) | | Linux | macOS | Windows | | :--- | :---: | :---: | :---: | -| Chromium 149.0.7827.55 | :white_check_mark: | :white_check_mark: | :white_check_mark: | +| Chromium 151.0.7922.34 | :white_check_mark: | :white_check_mark: | :white_check_mark: | | WebKit 26.5 | :white_check_mark: | :white_check_mark: | :white_check_mark: | -| Firefox 151.0 | :white_check_mark: | :white_check_mark: | :white_check_mark: | +| Firefox 153.0 | :white_check_mark: | :white_check_mark: | :white_check_mark: | Headless execution is supported for all the browsers on all platforms. diff --git a/browser_context.go b/browser_context.go index 64dd21f2..0c4cced9 100644 --- a/browser_context.go +++ b/browser_context.go @@ -557,6 +557,7 @@ func (b *browserContextImpl) StorageState(options ...BrowserContextStorageStateO var path *string if len(options) == 1 { params["indexedDB"] = options[0].IndexedDB + params["credentials"] = options[0].Credentials path = options[0].Path } result, err := b.channel.SendReturnAsDict("storageState", params) diff --git a/browser_type.go b/browser_type.go index 7aa5da43..237a242b 100644 --- a/browser_type.go +++ b/browser_type.go @@ -4,6 +4,7 @@ import ( "errors" "fmt" "path/filepath" + "time" ) // defaultLaunchTimeout matches DEFAULT_PLAYWRIGHT_LAUNCH_TIMEOUT upstream (3 minutes). @@ -24,15 +25,17 @@ func (b *browserTypeImpl) ExecutablePath() string { func (b *browserTypeImpl) Launch(options ...BrowserTypeLaunchOptions) (Browser, error) { overrides := map[string]any{} - // timeout is required in Playwright v1.57+ protocol - if len(options) == 0 || options[0].Timeout == nil { - overrides["timeout"] = float64(defaultLaunchTimeout) // default 3 min + var launchTimeout *float64 + if len(options) == 1 && options[0].Timeout != nil { + launchTimeout = options[0].Timeout + } else { + launchTimeout = Float(float64(defaultLaunchTimeout)) } if len(options) == 1 && options[0].Env != nil { overrides["env"] = serializeMapToNameAndValue(options[0].Env) options[0].Env = nil } - channel, err := b.channel.Send("launch", options, overrides) + channel, err := b.channel.SendWithTimeout("launch", launchTimeout, options, overrides) if err != nil { return nil, err } @@ -52,9 +55,11 @@ func (b *browserTypeImpl) LaunchPersistentContext(userDataDir string, options .. overrides := map[string]any{ "userDataDir": userDataDir, } - // timeout is required in Playwright v1.57+ protocol - if len(options) == 0 || options[0].Timeout == nil { - overrides["timeout"] = float64(defaultLaunchTimeout) // default 3 min + var launchTimeout *float64 + if len(options) == 1 && options[0].Timeout != nil { + launchTimeout = options[0].Timeout + } else { + launchTimeout = Float(float64(defaultLaunchTimeout)) } option := &BrowserNewContextOptions{} var tracesDir *string = nil @@ -112,7 +117,7 @@ func (b *browserTypeImpl) LaunchPersistentContext(userDataDir string, options .. options[0].RecordHarOmitContent = nil } } - response, err := b.channel.SendReturnAsDict("launchPersistentContext", options, overrides) + response, err := b.channel.SendReturnAsDictWithTimeout("launchPersistentContext", launchTimeout, options, overrides) if err != nil { return nil, err } @@ -140,9 +145,15 @@ func (b *browserTypeImpl) Connect(wsEndpoint string, options ...BrowserTypeConne "x-playwright-browser": b.Name(), }, } - // timeout is required in Playwright v1.57+ protocol - if len(options) == 0 || options[0].Timeout == nil { - overrides["timeout"] = float64(0) // default no timeout + var connectTimeout *float64 + if len(options) == 1 && options[0].Timeout != nil { + connectTimeout = options[0].Timeout + } else { + connectTimeout = Float(0) // default no timeout + } + var deadline time.Time + if *connectTimeout != 0 { + deadline = time.Now().Add(time.Duration(*connectTimeout * float64(time.Millisecond))) } if len(options) == 1 { if options[0].Headers != nil { @@ -153,21 +164,21 @@ func (b *browserTypeImpl) Connect(wsEndpoint string, options ...BrowserTypeConne } } localUtils := b.connection.LocalUtils() - pipe, err := localUtils.channel.SendReturnAsDict("connect", options, overrides) + pipe, err := localUtils.channel.SendReturnAsDictWithTimeout("connect", connectTimeout, options, overrides) if err != nil { return nil, err } jsonPipe := fromChannel(pipe["pipe"]).(*jsonPipe) connection := newConnection(jsonPipe, localUtils) - playwright, err := connection.Start() + playwright, err := startRemoteConnection(connection, jsonPipe, deadline, *connectTimeout) if err != nil { return nil, err } playwright.setSelectors(b.playwright.Selectors) preLaunchedBrowser := fromNullableChannel(playwright.initializer["preLaunchedBrowser"]) if preLaunchedBrowser == nil { - connection.cleanup() + closeRemoteConnection(connection, jsonPipe, nil) return nil, errors.New("malformed endpoint. Did you use BrowserType.LaunchServer method?") } browser := preLaunchedBrowser.(*browserImpl) @@ -198,6 +209,75 @@ func (b *browserTypeImpl) Connect(wsEndpoint string, options ...BrowserTypeConne return browser, nil } +type remoteConnectionStartResult struct { + playwright *Playwright + err error +} + +// startRemoteConnection applies BrowserType.Connect's timeout to the complete +// operation, including the remote Root.initialize call. LocalUtils.connect +// already receives the same timeout in protocol metadata; deadline is computed +// before that call so only the remaining budget is available here. +func startRemoteConnection(connection *connection, jsonPipe *jsonPipe, deadline time.Time, timeout float64) (*Playwright, error) { + if deadline.IsZero() { + playwright, err := connection.Start() + if err != nil { + closeRemoteConnection(connection, jsonPipe, err) + } + return playwright, err + } + + remaining := time.Until(deadline) + if remaining <= 0 { + err := fmt.Errorf("%w: Timeout %gms exceeded.", ErrTimeout, timeout) + closeRemoteConnection(connection, jsonPipe, err) + return nil, err + } + + result := make(chan remoteConnectionStartResult, 1) + go func() { + playwright, err := connection.Start() + result <- remoteConnectionStartResult{playwright: playwright, err: err} + }() + + timer := time.NewTimer(remaining) + defer timer.Stop() + select { + case value := <-result: + if value.err != nil { + closeRemoteConnection(connection, jsonPipe, value.err) + } + return value.playwright, value.err + case <-timer.C: + // Prefer an initialization result that became ready at the deadline over + // spuriously timing out because select chose between two ready cases. + select { + case value := <-result: + if value.err != nil { + closeRemoteConnection(connection, jsonPipe, value.err) + } + return value.playwright, value.err + default: + } + err := fmt.Errorf("%w: Timeout %gms exceeded.", ErrTimeout, timeout) + closeRemoteConnection(connection, jsonPipe, err) + return nil, err + } +} + +// closeRemoteConnection closes both sides of a JsonPipe and aborts pending +// callbacks. JsonPipe.Close waits for a protocol reply and is therefore not +// suitable for an error path whose remote endpoint may be unresponsive. +func closeRemoteConnection(connection *connection, jsonPipe *jsonPipe, cause error) { + jsonPipe.channel.SendNoReply("close") + jsonPipe.markClosed() + if cause != nil { + connection.cleanup(cause) + } else { + connection.cleanup() + } +} + func (b *browserTypeImpl) ConnectOverCDP(endpointURL string, options ...BrowserTypeConnectOverCDPOptions) (Browser, error) { if b.Name() != "chromium" { return nil, errors.New("connecting over CDP is only supported in Chromium") @@ -205,9 +285,11 @@ func (b *browserTypeImpl) ConnectOverCDP(endpointURL string, options ...BrowserT overrides := map[string]any{ "endpointURL": endpointURL, } - // timeout is required in Playwright v1.57+ protocol - if len(options) == 0 || options[0].Timeout == nil { - overrides["timeout"] = float64(30000) // default 30s + var cdpTimeout *float64 + if len(options) == 1 && options[0].Timeout != nil { + cdpTimeout = options[0].Timeout + } else { + cdpTimeout = Float(30000) // default 30s } if len(options) == 1 { if options[0].Headers != nil { @@ -215,7 +297,7 @@ func (b *browserTypeImpl) ConnectOverCDP(endpointURL string, options ...BrowserT options[0].Headers = nil } } - response, err := b.channel.SendReturnAsDict("connectOverCDP", options, overrides) + response, err := b.channel.SendReturnAsDictWithTimeout("connectOverCDP", cdpTimeout, options, overrides) if err != nil { return nil, err } diff --git a/channel.go b/channel.go index 91b2dae3..1a805546 100644 --- a/channel.go +++ b/channel.go @@ -13,6 +13,15 @@ type channel struct { object any // retain type info (for fromChannel needed) } +// protocolCallOptions describes transport-level behavior for a single +// protocol call. timeoutAware is intentionally independent from timeout: a +// timeout-aware protocol method must strip a public `params.timeout` even when +// the caller deliberately omits metadata.timeout. +type protocolCallOptions struct { + timeout *float64 + timeoutAware bool +} + func (c *channel) MarshalJSON() ([]byte, error) { return json.Marshal(map[string]string{ "guid": c.guid, @@ -37,8 +46,20 @@ func (c *channel) CreateTask(fn func()) { } func (c *channel) Send(method string, options ...any) (any, error) { + return c.send(method, protocolCallOptions{}, options...) +} + +// SendWithTimeout sends a protocol method with the call timeout carried in +// metadata.timeout (Playwright ≥1.62). timeout may be a pointer to zero +// (unlimited); a nil timeout omits metadata.timeout. Any "timeout" key present +// in the transformed params is stripped so it is not double-sent as a param. +func (c *channel) SendWithTimeout(method string, timeout *float64, options ...any) (any, error) { + return c.send(method, protocolCallOptions{timeout: timeout, timeoutAware: true}, options...) +} + +func (c *channel) send(method string, callOptions protocolCallOptions, options ...any) (any, error) { return c.connection.WrapAPICall(func() (any, error) { - result, err := c.innerSend(method, options...).GetResultValue() + result, err := c.innerSend(method, callOptions, options...).GetResultValue() if err != nil { return nil, err } @@ -48,8 +69,17 @@ func (c *channel) Send(method string, options ...any) (any, error) { } func (c *channel) SendReturnAsDict(method string, options ...any) (map[string]any, error) { + return c.sendReturnAsDict(method, protocolCallOptions{}, options...) +} + +// SendReturnAsDictWithTimeout is the timeout-aware form of SendReturnAsDict. +func (c *channel) SendReturnAsDictWithTimeout(method string, timeout *float64, options ...any) (map[string]any, error) { + return c.sendReturnAsDict(method, protocolCallOptions{timeout: timeout, timeoutAware: true}, options...) +} + +func (c *channel) sendReturnAsDict(method string, callOptions protocolCallOptions, options ...any) (map[string]any, error) { ret, err := c.connection.WrapAPICall(func() (any, error) { - result, err := c.innerSend(method, options...).GetResult() + result, err := c.innerSend(method, callOptions, options...).GetResult() if err != nil { return nil, err } @@ -65,7 +95,7 @@ func (c *channel) SendReturnAsDict(method string, options ...any) (map[string]an return ret.(map[string]any), nil } -func (c *channel) innerSend(method string, options ...any) *protocolCallback { +func (c *channel) innerSend(method string, callOptions protocolCallOptions, options ...any) *protocolCallback { if err := c.connection.err.Get(); err != nil { c.connection.err.Set(nil) pc := newProtocolCallback(c.connection, false, c.connection.abort) @@ -73,23 +103,36 @@ func (c *channel) innerSend(method string, options ...any) *protocolCallback { return pc } params := transformOptions(options...) - return c.connection.sendMessageToServer(c.owner, method, params, false) + if callOptions.timeoutAware { + // Timeout-aware boundary: call timeout travels in metadata, not params. + delete(params, "timeout") + } + return c.connection.sendMessageToServer(c.owner, method, params, false, callOptions.timeout) } // SendNoReply ignores return value and errors // almost equivalent to `send(...).catch(() => {})` func (c *channel) SendNoReply(method string, options ...any) { - c.innerSendNoReply(method, c.owner.isInternalType, options...) + c.innerSendNoReply(method, c.owner.isInternalType, protocolCallOptions{}, options...) } func (c *channel) SendNoReplyInternal(method string, options ...any) { - c.innerSendNoReply(method, true, options...) + c.innerSendNoReply(method, true, protocolCallOptions{}, options...) } -func (c *channel) innerSendNoReply(method string, isInternal bool, options ...any) { +// SendNoReplyInternalWithTimeout is a fire-and-forget send that still carries +// metadata.timeout when needed (e.g. some internal driver ops). +func (c *channel) SendNoReplyInternalWithTimeout(method string, timeout *float64, options ...any) { + c.innerSendNoReply(method, true, protocolCallOptions{timeout: timeout, timeoutAware: true}, options...) +} + +func (c *channel) innerSendNoReply(method string, isInternal bool, callOptions protocolCallOptions, options ...any) { params := transformOptions(options...) + if callOptions.timeoutAware { + delete(params, "timeout") + } _, err := c.connection.WrapAPICall(func() (any, error) { - return c.connection.sendMessageToServer(c.owner, method, params, true).GetResult() + return c.connection.sendMessageToServer(c.owner, method, params, true, callOptions.timeout).GetResult() }, isInternal) if err != nil { // ignore error actively, log only for debug diff --git a/connection.go b/connection.go index 26136c21..f6218dc5 100644 --- a/connection.go +++ b/connection.go @@ -21,8 +21,12 @@ var ( ) type connection struct { - transport transport - apiZone sync.Map + transport transport + // apiZone is keyed by goroutine id so concurrent API calls cannot consume + // each other's stack/internal metadata. A zone is removed as soon as its + // first protocol message is built, matching upstream's exit from the API zone + // before waiting for the server or dispatching re-entrant events. + apiZone sync.Map // map[uint64]parsedStackTrace objects *safe.SyncMap[string, *channelOwner] lastID atomic.Uint32 rootObject *rootChannelOwner @@ -215,10 +219,15 @@ func (c *connection) createRemoteObject(parent *channelOwner, objectType string, } func (c *connection) WrapAPICall(cb func() (any, error), isInternal bool) (any, error) { - if _, ok := c.apiZone.Load("apiZone"); ok { + zoneKey := currentGoroutineID() + if _, ok := c.apiZone.Load(zoneKey); ok { return cb() } - c.apiZone.Store("apiZone", serializeCallStack(isInternal)) + c.apiZone.Store(zoneKey, serializeCallStack(isInternal)) + // innerSend can return before sendMessageToServer (for example, when a route + // handler left a pending error). Do not let that stale zone classify the next + // call made by the same goroutine. + defer c.apiZone.Delete(zoneKey) return cb() } @@ -266,7 +275,7 @@ func (c *connection) replaceGuidsWithChannels(payload any) (any, error) { return payload, nil } -func (c *connection) sendMessageToServer(object *channelOwner, method string, params any, noReply bool) (cb *protocolCallback) { +func (c *connection) sendMessageToServer(object *channelOwner, method string, params any, noReply bool, timeout *float64) (cb *protocolCallback) { cb = newProtocolCallback(c, noReply, c.abort) if err := c.closedError.Get(); err != nil { @@ -288,7 +297,7 @@ func (c *connection) sendMessageToServer(object *channelOwner, method string, pa metadata = make(map[string]any, 0) stack = make([]map[string]any, 0) ) - apiZone, ok := c.apiZone.LoadAndDelete("apiZone") + apiZone, ok := c.apiZone.LoadAndDelete(currentGoroutineID()) if ok { for k, v := range apiZone.(parsedStackTrace).metadata { metadata[k] = v @@ -296,6 +305,11 @@ func (c *connection) sendMessageToServer(object *channelOwner, method string, pa stack = append(stack, apiZone.(parsedStackTrace).frames...) } metadata["wallTime"] = time.Now().UnixMilli() + // Playwright 1.62+: call timeout is protocol metadata, not a method param. + // Preserve explicit zero; omit the field entirely when timeout is nil. + if timeout != nil { + metadata["timeout"] = *timeout + } message := map[string]any{ "id": id, "guid": object.guid, @@ -308,6 +322,13 @@ func (c *connection) sendMessageToServer(object *channelOwner, method string, pa } if err := c.transport.Send(message); err != nil { + // Keep the callback registered until Send returns so an immediately + // dispatched response cannot be lost. If the send itself fails, however, + // no caller can consume a future response for this id, so remove it to + // avoid retaining one callback per failed send. + if !noReply { + c.callbacks.Delete(id) + } cb.SetError(fmt.Errorf("could not send message: %w", err)) return } @@ -367,7 +388,8 @@ func serializeCallStack(isInternal bool) parsedStackTrace { apiName = strings.ToUpper(apiName[:1]) + apiName[1:] } metadata["apiName"] = apiName - metadata["isInternal"] = isInternal + // The protocol carries the internal-call marker as metadata.internal. + metadata["internal"] = isInternal return parsedStackTrace{ metadata: metadata, frames: callStack, diff --git a/connection_test.go b/connection_test.go new file mode 100644 index 00000000..b826a71b --- /dev/null +++ b/connection_test.go @@ -0,0 +1,515 @@ +package playwright + +import ( + "encoding/json" + "errors" + "runtime" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +// captureTransport records messages sent by the connection without a real driver. +type captureTransport struct { + mu sync.Mutex + messages []map[string]any + // replies maps request id -> result payload delivered on Poll. + replies chan map[string]any + closed chan struct{} +} + +// failingSendTransport deterministically exercises the error path after a +// protocol callback has been registered but before a request reaches a driver. +type failingSendTransport struct { + err error +} + +func (t *failingSendTransport) Send(map[string]any) error { + return t.err +} + +func (*failingSendTransport) Poll() (*message, error) { + return nil, ErrTargetClosed +} + +func (*failingSendTransport) Close() error { + return nil +} + +func newCaptureTransport() *captureTransport { + return &captureTransport{ + replies: make(chan map[string]any, 16), + closed: make(chan struct{}), + } +} + +func (t *captureTransport) Send(message map[string]any) error { + t.mu.Lock() + // Deep-ish copy via JSON so later mutations don't affect the capture. + b, _ := json.Marshal(message) + var copy map[string]any + _ = json.Unmarshal(b, ©) + t.messages = append(t.messages, copy) + t.mu.Unlock() + // Auto-reply with empty result so SendWithTimeout unblocks. + id, _ := copy["id"].(float64) + t.replies <- map[string]any{"id": id, "result": map[string]any{}} + return nil +} + +func (t *captureTransport) Poll() (*message, error) { + select { + case msg := <-t.replies: + return &message{ + ID: int(msg["id"].(float64)), + Result: msg["result"].(map[string]any), + }, nil + case <-t.closed: + return nil, ErrTargetClosed + } +} + +func (t *captureTransport) Close() error { + select { + case <-t.closed: + default: + close(t.closed) + } + return nil +} + +func (t *captureTransport) last() map[string]any { + t.mu.Lock() + defer t.mu.Unlock() + if len(t.messages) == 0 { + return nil + } + return t.messages[len(t.messages)-1] +} + +func newTestConnection(t *testing.T) (*connection, *captureTransport, *channelOwner) { + t.Helper() + tr := newCaptureTransport() + conn := newConnection(tr) + // Create a root-like owner so Send has a guid to target. + owner := &channelOwner{ + guid: "test-guid", + connection: conn, + objects: map[string]*channelOwner{}, + } + owner.channel = newChannel(owner, owner) + conn.objects.Store(owner.guid, owner) + // Start the receive loop so blocking Sends complete. + go func() { + for { + if !conn.pollOnce() { + return + } + } + }() + t.Cleanup(func() { _ = tr.Close() }) + return conn, tr, owner +} + +func TestFailedSendDoesNotOrphanProtocolCallback(t *testing.T) { + sendErr := errors.New("cannot serialize protocol message") + conn := newConnection(&failingSendTransport{err: sendErr}) + owner := &channelOwner{ + guid: "failing-send-guid", + connection: conn, + objects: map[string]*channelOwner{}, + } + owner.channel = newChannel(owner, owner) + + _, err := owner.channel.Send("failingSend") + require.ErrorIs(t, err, sendErr) + require.Zero(t, conn.callbacks.Len(), "a failed transport send must not retain its callback") +} + +func TestSendWithTimeoutPlacesTimeoutInMetadata(t *testing.T) { + _, tr, owner := newTestConnection(t) + timeout := Float(1234) + _, err := owner.channel.SendWithTimeout("click", timeout, map[string]any{"selector": "button"}) + require.NoError(t, err) + msg := tr.last() + require.NotNil(t, msg) + meta, ok := msg["metadata"].(map[string]any) + require.True(t, ok) + require.Equal(t, float64(1234), meta["timeout"]) + params, ok := msg["params"].(map[string]any) + require.True(t, ok) + _, hasParamsTimeout := params["timeout"] + require.False(t, hasParamsTimeout, "timeout must not appear in params for migrated commands") +} + +func TestSendWithTimeoutPreservesExplicitZero(t *testing.T) { + _, tr, owner := newTestConnection(t) + timeout := Float(0) + _, err := owner.channel.SendWithTimeout("click", timeout, map[string]any{"selector": "button", "timeout": float64(999)}) + require.NoError(t, err) + msg := tr.last() + meta := msg["metadata"].(map[string]any) + require.Equal(t, float64(0), meta["timeout"]) + params := msg["params"].(map[string]any) + _, has := params["timeout"] + require.False(t, has) +} + +func TestSendWithTimeoutNilStillStripsParamsTimeout(t *testing.T) { + _, tr, owner := newTestConnection(t) + _, err := owner.channel.SendWithTimeout("click", nil, map[string]any{ + "selector": "button", + "timeout": float64(999), + }) + require.NoError(t, err) + msg := tr.last() + meta := msg["metadata"].(map[string]any) + _, hasMetadataTimeout := meta["timeout"] + require.False(t, hasMetadataTimeout) + params := msg["params"].(map[string]any) + _, hasParamsTimeout := params["timeout"] + require.False(t, hasParamsTimeout) +} + +func TestSendWithoutTimeoutOmitsMetadataTimeout(t *testing.T) { + _, tr, owner := newTestConnection(t) + _, err := owner.channel.Send("futureProtocolMethod", map[string]any{"timeout": float64(321)}) + require.NoError(t, err) + msg := tr.last() + meta := msg["metadata"].(map[string]any) + _, has := meta["timeout"] + require.False(t, has) + params := msg["params"].(map[string]any) + require.Equal(t, float64(321), params["timeout"], "ordinary Send must preserve a legitimate protocol timeout param") +} + +func TestMetadataUsesInternalNotIsInternal(t *testing.T) { + _, tr, owner := newTestConnection(t) + _, err := owner.channel.Send("title") + require.NoError(t, err) + msg := tr.last() + meta := msg["metadata"].(map[string]any) + _, hasLegacy := meta["isInternal"] + require.False(t, hasLegacy) + _, hasInternal := meta["internal"] + require.True(t, hasInternal) +} + +func TestMetadataZonesAreIsolatedBetweenConcurrentCalls(t *testing.T) { + conn, tr, owner := newTestConnection(t) + ready := make(chan struct{}, 2) + release := make(chan struct{}) + errs := make(chan error, 2) + + call := func(method string, internal bool) { + _, err := conn.WrapAPICall(func() (any, error) { + ready <- struct{}{} + <-release + return owner.channel.Send(method) + }, internal) + errs <- err + } + go call("publicCall", false) + go call("internalCall", true) + <-ready + <-ready + close(release) + require.NoError(t, <-errs) + require.NoError(t, <-errs) + + tr.mu.Lock() + messages := append([]map[string]any(nil), tr.messages...) + tr.mu.Unlock() + require.Len(t, messages, 2) + for _, msg := range messages { + metadata := msg["metadata"].(map[string]any) + switch msg["method"] { + case "publicCall": + require.Equal(t, false, metadata["internal"]) + case "internalCall": + require.Equal(t, true, metadata["internal"]) + default: + t.Fatalf("unexpected method %v", msg["method"]) + } + } +} + +func TestMetadataZoneIsClearedWhenSendStopsBeforeTransport(t *testing.T) { + conn, tr, owner := newTestConnection(t) + conn.err.Set(errors.New("pending listener error")) + _, err := conn.WrapAPICall(func() (any, error) { + return owner.channel.Send("failedInternalCall") + }, true) + require.ErrorContains(t, err, "pending listener error") + require.Empty(t, tr.messages) + + _, err = owner.channel.Send("nextPublicCall") + require.NoError(t, err) + metadata := tr.last()["metadata"].(map[string]any) + require.Equal(t, false, metadata["internal"]) +} + +func TestTransformOptionsDoesNotInjectDefaultTimeout(t *testing.T) { + type opts struct { + Timeout *float64 `json:"timeout"` + Force *bool `json:"force"` + } + got := transformOptions([]opts{}) + _, has := got["timeout"] + require.False(t, has, "empty options slice must not invent params.timeout") + got = transformOptions(opts{Force: Bool(true)}) + _, has = got["timeout"] + require.False(t, has) + require.Equal(t, Bool(true), got["force"]) +} + +func TestAPIResponseTimingResponseEndIndependentOfTimingObject(t *testing.T) { + // responseEndTiming present without a timing object → only ResponseEnd set. + resp := newAPIResponse(nil, map[string]any{ + "status": float64(200), + "statusText": "OK", + "url": "https://example.com/", + "headers": []any{}, + "fetchUid": "u1", + "responseEndTiming": float64(42), + }) + timing := resp.Timing() + require.Equal(t, float64(-1), timing.StartTime) + require.Equal(t, float64(-1), timing.RequestStart) + require.Equal(t, float64(42), timing.ResponseEnd) +} + +func TestAPIResponseTimingAllMinusOneWhenAbsent(t *testing.T) { + // HAR-style initializer with no timing fields. + resp := newAPIResponse(nil, map[string]any{ + "status": float64(200), + "statusText": "OK", + "url": "https://example.com/", + "headers": []any{}, + "fetchUid": "u2", + }) + timing := resp.Timing() + require.Equal(t, float64(-1), timing.StartTime) + require.Equal(t, float64(-1), timing.DomainLookupStart) + require.Equal(t, float64(-1), timing.DomainLookupEnd) + require.Equal(t, float64(-1), timing.ConnectStart) + require.Equal(t, float64(-1), timing.SecureConnectionStart) + require.Equal(t, float64(-1), timing.ConnectEnd) + require.Equal(t, float64(-1), timing.RequestStart) + require.Equal(t, float64(-1), timing.ResponseStart) + require.Equal(t, float64(-1), timing.ResponseEnd) +} + +func TestAPIResponseTimingOverlaysTimingObject(t *testing.T) { + resp := newAPIResponse(nil, map[string]any{ + "status": float64(200), + "statusText": "OK", + "url": "https://example.com/", + "headers": []any{}, + "fetchUid": "u3", + "timing": map[string]any{ + "startTime": float64(1000), + "requestStart": float64(10), + "responseStart": float64(20), + }, + "responseEndTiming": float64(30), + }) + timing := resp.Timing() + require.Equal(t, float64(1000), timing.StartTime) + require.Equal(t, float64(10), timing.RequestStart) + require.Equal(t, float64(20), timing.ResponseStart) + require.Equal(t, float64(30), timing.ResponseEnd) + require.Equal(t, float64(-1), timing.DomainLookupStart) +} + +func TestScreencastAcksAndContinuesWhenOnFramePanics(t *testing.T) { + _, tr, owner := newTestConnection(t) + // Build a minimal pageImpl-like owner that emits screencastFrame. + pageOwner := &channelOwner{ + guid: "page-guid", + connection: owner.connection, + objects: map[string]*channelOwner{}, + parent: owner, + } + pageOwner.channel = newChannel(pageOwner, pageOwner) + owner.connection.objects.Store(pageOwner.guid, pageOwner) + + page := &pageImpl{} + page.createChannelOwner(page, owner, "Page", "page-guid", map[string]any{}) + // Rebind to our pageOwner channel (createChannelOwner may replace). + // Simpler approach: invoke the handler logic via screencastImpl after Start. + sc := &screencastImpl{page: page} + // Manually install the same listener Start would register. + // Monkey-patch by calling On with our own wrapper that mirrors production. + // Use production Start path with panicking OnFrame. + // page.channel must work for Send - use pageOwner + page.channel = pageOwner.channel + + var callbackCalls atomic.Int32 + require.NoError(t, sc.Start(ScreencastStartOptions{ + OnFrame: func(OnFrame) { + if callbackCalls.Add(1) == 1 { + panic("handler boom") + } + }, + })) + + page.channel.Emit("screencastFrame", map[string]any{ + "frameId": float64(7), + "data": "", + "timestamp": float64(1), + "viewportWidth": float64(100), + "viewportHeight": float64(50), + }) + require.Eventually(t, func() bool { + return owner.connection.err.Get() != nil + }, time.Second, time.Millisecond) + require.Equal(t, int32(1), callbackCalls.Load()) + + // Listener errors are reported once through the next public API call, while + // the dispatcher remains alive and can deliver later frames. + _, err := owner.channel.Send("afterCallbackPanic") + require.ErrorContains(t, err, "handler boom") + _, err = owner.channel.Send("afterReportedPanic") + require.NoError(t, err) + page.channel.Emit("screencastFrame", map[string]any{ + "frameId": float64(8), + "data": "", + "timestamp": float64(2), + "viewportWidth": float64(100), + "viewportHeight": float64(50), + }) + require.Eventually(t, func() bool { + return callbackCalls.Load() == 2 + }, time.Second, time.Millisecond) + + // An invalid frame id must not be acknowledged. + sc.mu.Lock() + sc.onFrame = nil + sc.mu.Unlock() + page.channel.Emit("screencastFrame", map[string]any{"frameId": "invalid"}) + + var msgs []map[string]any + require.Eventually(t, func() bool { + tr.mu.Lock() + defer tr.mu.Unlock() + ackCount := 0 + for _, msg := range tr.messages { + if msg["method"] == "screencastFrameAck" { + ackCount++ + } + } + if ackCount != 2 { + return false + } + msgs = append([]map[string]any{}, tr.messages...) + return true + }, time.Second, time.Millisecond) + var acks []map[string]any + for _, msg := range msgs { + if msg["method"] == "screencastFrameAck" { + acks = append(acks, msg) + } + } + require.Len(t, acks, 2, "each valid frame must produce exactly one ACK") + params := acks[0]["params"].(map[string]any) + require.Equal(t, float64(7), params["frameId"]) + params = acks[1]["params"].(map[string]any) + require.Equal(t, float64(8), params["frameId"]) + metadata := acks[0]["metadata"].(map[string]any) + require.Equal(t, true, metadata["internal"]) + require.Equal(t, float64(0), metadata["timeout"]) +} + +func TestScreencastAcksWhenOnFrameCallsGoexit(t *testing.T) { + _, tr, owner := newTestConnection(t) + page := &pageImpl{} + page.createChannelOwner(page, owner, "Page", "goexit-page-guid", map[string]any{}) + sc := &screencastImpl{page: page} + + callbackStarted := make(chan struct{}) + require.NoError(t, sc.Start(ScreencastStartOptions{ + OnFrame: func(OnFrame) { + close(callbackStarted) + runtime.Goexit() + }, + })) + + emitReturned := make(chan struct{}) + go func() { + page.channel.Emit("screencastFrame", map[string]any{ + "frameId": float64(9), + "data": "", + }) + close(emitReturned) + }() + select { + case <-emitReturned: + case <-time.After(time.Second): + t.Fatal("screencast callback blocked or terminated the event dispatcher") + } + select { + case <-callbackStarted: + case <-time.After(time.Second): + t.Fatal("screencast callback did not run") + } + + require.Eventually(t, func() bool { + tr.mu.Lock() + defer tr.mu.Unlock() + for _, msg := range tr.messages { + if msg["method"] == "screencastFrameAck" { + return true + } + } + return false + }, time.Second, time.Millisecond) + require.NoError(t, owner.connection.err.Get()) + _, err := owner.channel.Send("afterGoexit") + require.NoError(t, err) +} + +func TestScreencastRestartKeepsOneListenerAndAcksWithoutActiveCallback(t *testing.T) { + _, tr, owner := newTestConnection(t) + page := &pageImpl{} + page.createChannelOwner(page, owner, "Page", "restart-page-guid", map[string]any{}) + sc := &screencastImpl{page: page} + + var firstCalls atomic.Int32 + require.NoError(t, sc.Start(ScreencastStartOptions{OnFrame: func(OnFrame) { firstCalls.Add(1) }})) + require.Equal(t, 1, page.channel.ListenerCount("screencastFrame")) + require.NoError(t, sc.Stop()) + + // A frame already in flight after Stop still needs its ACK, but the inactive + // callback must not run. + page.channel.Emit("screencastFrame", map[string]any{ + "frameId": float64(1), + "data": "%%% not base64 %%%", + }) + require.Zero(t, firstCalls.Load()) + + var secondCalls atomic.Int32 + require.NoError(t, sc.Start(ScreencastStartOptions{OnFrame: func(OnFrame) { secondCalls.Add(1) }})) + require.Equal(t, 1, page.channel.ListenerCount("screencastFrame")) + page.channel.Emit("screencastFrame", map[string]any{ + "frameId": float64(2), + "data": "", + }) + require.Eventually(t, func() bool { + return secondCalls.Load() == 1 + }, time.Second, time.Millisecond) + require.Eventually(t, func() bool { + tr.mu.Lock() + defer tr.mu.Unlock() + ackCount := 0 + for _, msg := range tr.messages { + if msg["method"] == "screencastFrameAck" { + ackCount++ + } + } + return ackCount == 2 + }, time.Second, time.Millisecond) +} diff --git a/element_handle.go b/element_handle.go index d37ae17a..bc4b9954 100644 --- a/element_handle.go +++ b/element_handle.go @@ -12,6 +12,19 @@ type elementHandleImpl struct { jsHandleImpl } +func (e *elementHandleImpl) timeoutSettings() *timeoutSettings { + // Walk the channel parent chain (Frame → Page) without a network round-trip. + for parent := e.parent; parent != nil; parent = parent.parent { + if frame, ok := parent.channel.object.(*frameImpl); ok && frame.page != nil { + return frame.page.timeoutSettings + } + if page, ok := parent.channel.object.(*pageImpl); ok { + return page.timeoutSettings + } + } + return newTimeoutSettings(nil) +} + func (e *elementHandleImpl) AsElement() ElementHandle { return e } @@ -89,17 +102,29 @@ func (e *elementHandleImpl) DispatchEvent(typ string, initObjects ...any) error } func (e *elementHandleImpl) Hover(options ...ElementHandleHoverOptions) error { - _, err := e.channel.Send("hover", options) + var explicit *float64 + if len(options) == 1 { + explicit = options[0].Timeout + } + _, err := e.channel.SendWithTimeout("hover", resolveTimeout(e.timeoutSettings(), explicit), options) return err } func (e *elementHandleImpl) Click(options ...ElementHandleClickOptions) error { - _, err := e.channel.Send("click", options) + var explicit *float64 + if len(options) == 1 { + explicit = options[0].Timeout + } + _, err := e.channel.SendWithTimeout("click", resolveTimeout(e.timeoutSettings(), explicit), options) return err } func (e *elementHandleImpl) Dblclick(options ...ElementHandleDblclickOptions) error { - _, err := e.channel.Send("dblclick", options) + var explicit *float64 + if len(options) == 1 { + explicit = options[0].Timeout + } + _, err := e.channel.SendWithTimeout("dblclick", resolveTimeout(e.timeoutSettings(), explicit), options) return err } @@ -163,7 +188,11 @@ func (e *elementHandleImpl) EvalOnSelectorAll(selector string, expression string } func (e *elementHandleImpl) ScrollIntoViewIfNeeded(options ...ElementHandleScrollIntoViewIfNeededOptions) error { - _, err := e.channel.Send("scrollIntoViewIfNeeded", options) + var explicit *float64 + if len(options) == 1 { + explicit = options[0].Timeout + } + _, err := e.channel.SendWithTimeout("scrollIntoViewIfNeeded", resolveTimeout(e.timeoutSettings(), explicit), options) if err != nil { return err } @@ -187,13 +216,7 @@ func (e *elementHandleImpl) SetInputFiles(files any, options ...ElementHandleSet if len(options) == 1 { option = options[0] } - // timeout is required in Playwright v1.57+ protocol. Resolve the configured - // default (Page/BrowserContext.SetDefaultTimeout) instead of letting the - // serializer fall back to a hardcoded 30s, which would ignore that setting. - if option.Timeout == nil { - option.Timeout = Float(frame.(*frameImpl).page.timeoutSettings.Timeout()) - } - _, err = e.channel.Send("setInputFiles", params, option) + _, err = e.channel.SendWithTimeout("setInputFiles", resolveTimeout(e.timeoutSettings(), option.Timeout), params, option) return err } @@ -213,31 +236,51 @@ func (e *elementHandleImpl) BoundingBox() (*Rect, error) { } func (e *elementHandleImpl) Check(options ...ElementHandleCheckOptions) error { - _, err := e.channel.Send("check", options) + var explicit *float64 + if len(options) == 1 { + explicit = options[0].Timeout + } + _, err := e.channel.SendWithTimeout("check", resolveTimeout(e.timeoutSettings(), explicit), options) return err } func (e *elementHandleImpl) Uncheck(options ...ElementHandleUncheckOptions) error { - _, err := e.channel.Send("uncheck", options) + var explicit *float64 + if len(options) == 1 { + explicit = options[0].Timeout + } + _, err := e.channel.SendWithTimeout("uncheck", resolveTimeout(e.timeoutSettings(), explicit), options) return err } func (e *elementHandleImpl) Press(key string, options ...ElementHandlePressOptions) error { - _, err := e.channel.Send("press", map[string]any{ + var explicit *float64 + if len(options) == 1 { + explicit = options[0].Timeout + } + _, err := e.channel.SendWithTimeout("press", resolveTimeout(e.timeoutSettings(), explicit), map[string]any{ "key": key, }, options) return err } func (e *elementHandleImpl) Fill(value string, options ...ElementHandleFillOptions) error { - _, err := e.channel.Send("fill", map[string]any{ + var explicit *float64 + if len(options) == 1 { + explicit = options[0].Timeout + } + _, err := e.channel.SendWithTimeout("fill", resolveTimeout(e.timeoutSettings(), explicit), map[string]any{ "value": value, }, options) return err } func (e *elementHandleImpl) Type(value string, options ...ElementHandleTypeOptions) error { - _, err := e.channel.Send("type", map[string]any{ + var explicit *float64 + if len(options) == 1 { + explicit = options[0].Timeout + } + _, err := e.channel.SendWithTimeout("type", resolveTimeout(e.timeoutSettings(), explicit), map[string]any{ "text": value, }, options) return err @@ -249,7 +292,11 @@ func (e *elementHandleImpl) Focus() error { } func (e *elementHandleImpl) SelectText(options ...ElementHandleSelectTextOptions) error { - _, err := e.channel.Send("selectText", options) + var explicit *float64 + if len(options) == 1 { + explicit = options[0].Timeout + } + _, err := e.channel.SendWithTimeout("selectText", resolveTimeout(e.timeoutSettings(), explicit), options) return err } @@ -283,7 +330,11 @@ func (e *elementHandleImpl) Screenshot(options ...ElementHandleScreenshotOptions options[0].Mask = nil } } - data, err := e.channel.Send("screenshot", options, overrides) + var explicit *float64 + if len(options) == 1 { + explicit = options[0].Timeout + } + data, err := e.channel.SendWithTimeout("screenshot", resolveTimeout(e.timeoutSettings(), explicit), options, overrides) if err != nil { return nil, err } @@ -303,13 +354,21 @@ func (e *elementHandleImpl) Screenshot(options ...ElementHandleScreenshotOptions } func (e *elementHandleImpl) Tap(options ...ElementHandleTapOptions) error { - _, err := e.channel.Send("tap", options) + var explicit *float64 + if len(options) == 1 { + explicit = options[0].Timeout + } + _, err := e.channel.SendWithTimeout("tap", resolveTimeout(e.timeoutSettings(), explicit), options) return err } func (e *elementHandleImpl) SelectOption(values SelectOptionValues, options ...ElementHandleSelectOptionOptions) ([]string, error) { opts := convertSelectOptionSet(values) - selected, err := e.channel.Send("selectOption", opts, options) + var explicit *float64 + if len(options) == 1 { + explicit = options[0].Timeout + } + selected, err := e.channel.SendWithTimeout("selectOption", resolveTimeout(e.timeoutSettings(), explicit), opts, options) if err != nil { return nil, err } @@ -366,7 +425,11 @@ func (e *elementHandleImpl) IsVisible() (bool, error) { } func (e *elementHandleImpl) WaitForElementState(state ElementState, options ...ElementHandleWaitForElementStateOptions) error { - _, err := e.channel.Send("waitForElementState", map[string]any{ + var explicit *float64 + if len(options) == 1 { + explicit = options[0].Timeout + } + _, err := e.channel.SendWithTimeout("waitForElementState", resolveTimeout(e.timeoutSettings(), explicit), map[string]any{ "state": state, }, options) if err != nil { @@ -376,7 +439,11 @@ func (e *elementHandleImpl) WaitForElementState(state ElementState, options ...E } func (e *elementHandleImpl) WaitForSelector(selector string, options ...ElementHandleWaitForSelectorOptions) (ElementHandle, error) { - ch, err := e.channel.Send("waitForSelector", map[string]any{ + var explicit *float64 + if len(options) == 1 { + explicit = options[0].Timeout + } + ch, err := e.channel.SendWithTimeout("waitForSelector", resolveTimeout(e.timeoutSettings(), explicit), map[string]any{ "selector": selector, }, options) if err != nil { @@ -391,7 +458,11 @@ func (e *elementHandleImpl) WaitForSelector(selector string, options ...ElementH } func (e *elementHandleImpl) InputValue(options ...ElementHandleInputValueOptions) (string, error) { - result, err := e.channel.Send("inputValue", options) + // Timeout is deprecated and intentionally ignored for this immediate query. + if len(options) == 1 { + options[0].Timeout = nil + } + result, err := e.channel.SendWithTimeout("inputValue", Float(0), options) if result == nil { return "", err } @@ -399,13 +470,17 @@ func (e *elementHandleImpl) InputValue(options ...ElementHandleInputValueOptions } func (e *elementHandleImpl) SetChecked(checked bool, options ...ElementHandleSetCheckedOptions) error { + var explicit *float64 + if len(options) == 1 { + explicit = options[0].Timeout + } + timeout := resolveTimeout(e.timeoutSettings(), explicit) if checked { - _, err := e.channel.Send("check", options) - return err - } else { - _, err := e.channel.Send("uncheck", options) + _, err := e.channel.SendWithTimeout("check", timeout, options) return err } + _, err := e.channel.SendWithTimeout("uncheck", timeout, options) + return err } func newElementHandle(parent *channelOwner, objectType string, guid string, initializer map[string]any) *elementHandleImpl { diff --git a/fetch.go b/fetch.go index b6cfd4e2..6297022a 100644 --- a/fetch.go +++ b/fetch.go @@ -42,18 +42,25 @@ func (r *apiRequestImpl) NewContext(options ...APIRequestNewContextOptions) (API options[0].StorageState = storageState options[0].StorageStatePath = nil } - if options[0].Timeout != nil { - overrides["timeout"] = options[0].Timeout + // APIRequestContext storageState accepts only cookies/origins (protocol). + if options[0].StorageState != nil { + options[0].StorageState = sanitizeStorageStateForAPIRequest(options[0].StorageState) } } - + // Match JS/Python: newRequest itself is unbounded (kNoTimeout). The + // Timeout option is only the client default for subsequent fetches. + var defaultTimeout *float64 + if len(options) == 1 && options[0].Timeout != nil { + defaultTimeout = options[0].Timeout + options[0].Timeout = nil // do not serialize as params or metadata + } channel, err := r.channel.Send("newRequest", options, overrides) if err != nil { return nil, err } ctx := fromChannel(channel).(*apiRequestContextImpl) - if len(options) == 1 && options[0].Timeout != nil { - ctx.defaultTimeout = options[0].Timeout + if defaultTimeout != nil { + ctx.defaultTimeout = defaultTimeout } return ctx, nil } @@ -240,23 +247,24 @@ func (r *apiRequestContextImpl) innerFetch(url string, request Request, options overrides["params"] = serializeMapToNameValue(options[0].Params) options[0].Params = nil } - // Use the context-level default timeout when no per-request timeout is - // given. A standalone request context seeds r.defaultTimeout directly; a - // browser-context-owned request shares the context's timeoutSettings - // (mirroring upstream, where context.request._timeoutSettings is the - // context's instance), so SetDefaultTimeout reaches these fetches too. - if options[0].Timeout == nil { - if r.defaultTimeout != nil { - overrides["timeout"] = *r.defaultTimeout - } else if r.timeoutSettings != nil { - if dt := r.timeoutSettings.DefaultTimeout(); dt != nil { - overrides["timeout"] = *dt - } - } + // Resolve call timeout for metadata.timeout (Playwright 1.62+). + } + var fetchTimeout *float64 + if len(options) == 1 && options[0].Timeout != nil { + fetchTimeout = options[0].Timeout + } else if r.defaultTimeout != nil { + fetchTimeout = r.defaultTimeout + } else if r.timeoutSettings != nil { + if dt := r.timeoutSettings.DefaultTimeout(); dt != nil { + fetchTimeout = dt + } else { + fetchTimeout = Float(r.timeoutSettings.Timeout()) } + } else { + fetchTimeout = Float(defaultTimeout) } - response, err := r.channel.Send("fetch", options, overrides) + response, err := r.channel.SendWithTimeout("fetch", fetchTimeout, options, overrides) if err != nil { return nil, err } @@ -492,6 +500,39 @@ func (r *apiResponseImpl) fetchLog() ([]string, error) { return result, nil } +func (r *apiResponseImpl) Timing() *RequestTiming { + // Seed all fields with -1 (unavailable), then overlay server values. + // responseEnd is taken from responseEndTiming independently of timing, + // matching packages/playwright-core/src/client/fetch.ts in v1.62.1. + result := &RequestTiming{ + StartTime: -1, + DomainLookupStart: -1, + DomainLookupEnd: -1, + ConnectStart: -1, + SecureConnectionStart: -1, + ConnectEnd: -1, + RequestStart: -1, + ResponseStart: -1, + ResponseEnd: -1, + } + if timing, ok := r.initializer["timing"].(map[string]any); ok && timing != nil { + assignFloatIfPresent(timing, "startTime", &result.StartTime) + assignFloatIfPresent(timing, "domainLookupStart", &result.DomainLookupStart) + assignFloatIfPresent(timing, "domainLookupEnd", &result.DomainLookupEnd) + assignFloatIfPresent(timing, "connectStart", &result.ConnectStart) + assignFloatIfPresent(timing, "secureConnectionStart", &result.SecureConnectionStart) + assignFloatIfPresent(timing, "connectEnd", &result.ConnectEnd) + assignFloatIfPresent(timing, "requestStart", &result.RequestStart) + assignFloatIfPresent(timing, "responseStart", &result.ResponseStart) + } + if v, ok := r.initializer["responseEndTiming"]; ok && v != nil { + if f, isFloat := v.(float64); isFloat { + result.ResponseEnd = f + } + } + return result +} + func newAPIResponse(context *apiRequestContextImpl, initializer map[string]any) *apiResponseImpl { return &apiResponseImpl{ request: context, @@ -548,3 +589,16 @@ func (r *responseImpl) HttpVersion() (string, error) { } return result.(string), nil } + +// sanitizeStorageStateForAPIRequest returns a copy of state with only cookies +// and origins. packages/protocol/spec/playwright.yml does not accept credentials +// on APIRequestContext storageState. +func sanitizeStorageStateForAPIRequest(state *StorageState) *StorageState { + if state == nil { + return nil + } + return &StorageState{ + Cookies: state.Cookies, + Origins: state.Origins, + } +} diff --git a/fetch_storage_state_test.go b/fetch_storage_state_test.go new file mode 100644 index 00000000..270d554d --- /dev/null +++ b/fetch_storage_state_test.go @@ -0,0 +1,38 @@ +package playwright + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestSanitizeStorageStateForAPIRequestStripsOnlyCredentials(t *testing.T) { + state := &StorageState{ + Cookies: []Cookie{{ + Name: "session", + Value: "secret", + Domain: "example.com", + Path: "/", + }}, + Origins: []Origin{{ + Origin: "https://example.com", + LocalStorage: []NameValue{{ + Name: "token", + Value: "value", + }}, + }}, + Credentials: []VirtualCredential{{Id: "credential-id"}}, + } + + sanitized := sanitizeStorageStateForAPIRequest(state) + require.NotSame(t, state, sanitized) + require.Equal(t, state.Cookies, sanitized.Cookies) + require.Equal(t, state.Origins, sanitized.Origins) + require.Empty(t, sanitized.Credentials) + // Sanitizing the transport payload must not mutate the caller's state. + require.Len(t, state.Credentials, 1) +} + +func TestSanitizeStorageStateForAPIRequestAcceptsNil(t *testing.T) { + require.Nil(t, sanitizeStorageStateForAPIRequest(nil)) +} diff --git a/frame.go b/frame.go index 18972864..7d95640e 100644 --- a/frame.go +++ b/frame.go @@ -66,11 +66,11 @@ func (f *frameImpl) SetContent(content string, options ...FrameSetContentOptions overrides := map[string]any{ "html": content, } - // timeout is required in Playwright v1.57+ protocol - if len(options) == 0 || options[0].Timeout == nil { - overrides["timeout"] = f.page.timeoutSettings.NavigationTimeout() + var explicit *float64 + if len(options) == 1 { + explicit = options[0].Timeout } - _, err := f.channel.Send("setContent", overrides, options) + _, err := f.channel.SendWithTimeout("setContent", resolveNavigationTimeout(f.page.timeoutSettings, explicit), overrides, options) return err } @@ -86,11 +86,11 @@ func (f *frameImpl) Goto(url string, options ...FrameGotoOptions) (Response, err overrides := map[string]any{ "url": url, } - // timeout is required in Playwright v1.57+ protocol - if len(options) == 0 || options[0].Timeout == nil { - overrides["timeout"] = f.page.timeoutSettings.NavigationTimeout() + var explicit *float64 + if len(options) == 1 { + explicit = options[0].Timeout } - channel, err := f.channel.Send("goto", overrides, options) + channel, err := f.channel.SendWithTimeout("goto", resolveNavigationTimeout(f.page.timeoutSettings, explicit), overrides, options) if err != nil { return nil, fmt.Errorf("Frame.Goto %s: %w", url, err) } @@ -215,7 +215,13 @@ func (f *frameImpl) ExpectNavigation(cb func() error, options ...FrameExpectNavi if option.Timeout == nil { option.Timeout = Float(f.page.timeoutSettings.NavigationTimeout()) } - deadline := time.Now().Add(time.Duration(*option.Timeout) * time.Millisecond) + // A zero timeout disables the timeout. For positive values, use one deadline + // across both the navigation event and the requested load state. + var deadline *time.Time + if *option.Timeout > 0 { + d := time.Now().Add(time.Duration(*option.Timeout) * time.Millisecond) + deadline = &d + } var matcher *urlMatcher if option.URL != nil { matcher = newURLMatcher(option.URL, f.page.browserContext.options.BaseURL) @@ -246,12 +252,18 @@ func (f *frameImpl) ExpectNavigation(cb func() error, options ...FrameExpectNavi return nil, errors.New(errVal.(string)) } - t := time.Until(deadline).Milliseconds() - if t > 0 { - err = f.waitForLoadStateImpl(string(*option.WaitUntil), Float(float64(t)), nil) - if err != nil { - return nil, err + remaining := option.Timeout + if deadline != nil { + ms := float64(time.Until(*deadline).Milliseconds()) + // The waiter interprets zero as unlimited, so use the smallest positive + // timeout when the shared budget has just been exhausted. + if ms <= 0 { + ms = 1 } + remaining = Float(ms) + } + if err = f.waitForLoadStateImpl(string(*option.WaitUntil), remaining, nil); err != nil { + return nil, err } if event["newDocument"] != nil && event["newDocument"].(map[string]any)["request"] != nil { request := fromChannel(event["newDocument"].(map[string]any)["request"]).(*requestImpl) @@ -422,14 +434,22 @@ func (f *frameImpl) EvaluateHandle(expression string, options ...any) (JSHandle, } func (f *frameImpl) Click(selector string, options ...FrameClickOptions) error { - _, err := f.channel.Send("click", map[string]any{ + var explicit *float64 + if len(options) == 1 { + explicit = options[0].Timeout + } + _, err := f.channel.SendWithTimeout("click", resolveTimeout(f.page.timeoutSettings, explicit), map[string]any{ "selector": selector, }, options) return err } func (f *frameImpl) WaitForSelector(selector string, options ...FrameWaitForSelectorOptions) (ElementHandle, error) { - channel, err := f.channel.Send("waitForSelector", map[string]any{ + var explicit *float64 + if len(options) == 1 { + explicit = options[0].Timeout + } + channel, err := f.channel.SendWithTimeout("waitForSelector", resolveTimeout(f.page.timeoutSettings, explicit), map[string]any{ "selector": selector, }, options) if err != nil { @@ -443,7 +463,11 @@ func (f *frameImpl) WaitForSelector(selector string, options ...FrameWaitForSele } func (f *frameImpl) DispatchEvent(selector, typ string, eventInit any, options ...FrameDispatchEventOptions) error { - _, err := f.channel.Send("dispatchEvent", map[string]any{ + var explicit *float64 + if len(options) == 1 { + explicit = options[0].Timeout + } + _, err := f.channel.SendWithTimeout("dispatchEvent", resolveTimeout(f.page.timeoutSettings, explicit), map[string]any{ "selector": selector, "type": typ, "eventInit": serializeArgument(eventInit), @@ -452,7 +476,11 @@ func (f *frameImpl) DispatchEvent(selector, typ string, eventInit any, options . } func (f *frameImpl) InnerText(selector string, options ...FrameInnerTextOptions) (string, error) { - innerText, err := f.channel.Send("innerText", map[string]any{ + var explicit *float64 + if len(options) == 1 { + explicit = options[0].Timeout + } + innerText, err := f.channel.SendWithTimeout("innerText", resolveTimeout(f.page.timeoutSettings, explicit), map[string]any{ "selector": selector, }, options) if innerText == nil { @@ -462,7 +490,11 @@ func (f *frameImpl) InnerText(selector string, options ...FrameInnerTextOptions) } func (f *frameImpl) InnerHTML(selector string, options ...FrameInnerHTMLOptions) (string, error) { - innerHTML, err := f.channel.Send("innerHTML", map[string]any{ + var explicit *float64 + if len(options) == 1 { + explicit = options[0].Timeout + } + innerHTML, err := f.channel.SendWithTimeout("innerHTML", resolveTimeout(f.page.timeoutSettings, explicit), map[string]any{ "selector": selector, }, options) if innerHTML == nil { @@ -472,7 +504,11 @@ func (f *frameImpl) InnerHTML(selector string, options ...FrameInnerHTMLOptions) } func (f *frameImpl) GetAttribute(selector string, name string, options ...FrameGetAttributeOptions) (string, error) { - attribute, err := f.channel.Send("getAttribute", map[string]any{ + var explicit *float64 + if len(options) == 1 { + explicit = options[0].Timeout + } + attribute, err := f.channel.SendWithTimeout("getAttribute", resolveTimeout(f.page.timeoutSettings, explicit), map[string]any{ "selector": selector, "name": name, }, options) @@ -483,7 +519,11 @@ func (f *frameImpl) GetAttribute(selector string, name string, options ...FrameG } func (f *frameImpl) Hover(selector string, options ...FrameHoverOptions) error { - _, err := f.channel.Send("hover", map[string]any{ + var explicit *float64 + if len(options) == 1 { + explicit = options[0].Timeout + } + _, err := f.channel.SendWithTimeout("hover", resolveTimeout(f.page.timeoutSettings, explicit), map[string]any{ "selector": selector, }, options) return err @@ -499,18 +539,16 @@ func (f *frameImpl) SetInputFiles(selector string, files any, options ...FrameSe if len(options) == 1 { option = options[0] } - // timeout is required in Playwright v1.57+ protocol. Resolve the configured - // default (Page/BrowserContext.SetDefaultTimeout) instead of letting the - // serializer fall back to a hardcoded 30s, which would ignore that setting. - if option.Timeout == nil { - option.Timeout = Float(f.page.timeoutSettings.Timeout()) - } - _, err = f.channel.Send("setInputFiles", params, option) + _, err = f.channel.SendWithTimeout("setInputFiles", resolveTimeout(f.page.timeoutSettings, option.Timeout), params, option) return err } func (f *frameImpl) Type(selector, text string, options ...FrameTypeOptions) error { - _, err := f.channel.Send("type", map[string]any{ + var explicit *float64 + if len(options) == 1 { + explicit = options[0].Timeout + } + _, err := f.channel.SendWithTimeout("type", resolveTimeout(f.page.timeoutSettings, explicit), map[string]any{ "selector": selector, "text": text, }, options) @@ -518,7 +556,11 @@ func (f *frameImpl) Type(selector, text string, options ...FrameTypeOptions) err } func (f *frameImpl) Press(selector, key string, options ...FramePressOptions) error { - _, err := f.channel.Send("press", map[string]any{ + var explicit *float64 + if len(options) == 1 { + explicit = options[0].Timeout + } + _, err := f.channel.SendWithTimeout("press", resolveTimeout(f.page.timeoutSettings, explicit), map[string]any{ "selector": selector, "key": key, }, options) @@ -526,21 +568,31 @@ func (f *frameImpl) Press(selector, key string, options ...FramePressOptions) er } func (f *frameImpl) Check(selector string, options ...FrameCheckOptions) error { - _, err := f.channel.Send("check", map[string]any{ + var explicit *float64 + if len(options) == 1 { + explicit = options[0].Timeout + } + _, err := f.channel.SendWithTimeout("check", resolveTimeout(f.page.timeoutSettings, explicit), map[string]any{ "selector": selector, }, options) return err } func (f *frameImpl) Uncheck(selector string, options ...FrameUncheckOptions) error { - _, err := f.channel.Send("uncheck", map[string]any{ + var explicit *float64 + if len(options) == 1 { + explicit = options[0].Timeout + } + _, err := f.channel.SendWithTimeout("uncheck", resolveTimeout(f.page.timeoutSettings, explicit), map[string]any{ "selector": selector, }, options) return err } func (f *frameImpl) WaitForTimeout(timeout float64) { - time.Sleep(time.Duration(timeout) * time.Millisecond) + _, _ = f.channel.SendWithTimeout("waitForTimeout", Float(0), map[string]any{ + "waitTimeout": timeout, + }) } func (f *frameImpl) WaitForFunction(expression string, arg any, options ...FrameWaitForFunctionOptions) (JSHandle, error) { @@ -563,13 +615,7 @@ func (f *frameImpl) WaitForFunction(expression string, arg any, options ...Frame default: overrides["pollingInterval"] = option.Polling } - // timeout is required in Playwright v1.57+ protocol - if option.Timeout == nil { - overrides["timeout"] = f.page.timeoutSettings.Timeout() - } else { - overrides["timeout"] = option.Timeout - } - result, err := f.channel.Send("waitForFunction", overrides) + result, err := f.channel.SendWithTimeout("waitForFunction", resolveTimeout(f.page.timeoutSettings, option.Timeout), overrides) if err != nil { return nil, err } @@ -593,14 +639,22 @@ func (f *frameImpl) ChildFrames() []Frame { } func (f *frameImpl) Dblclick(selector string, options ...FrameDblclickOptions) error { - _, err := f.channel.Send("dblclick", map[string]any{ + var explicit *float64 + if len(options) == 1 { + explicit = options[0].Timeout + } + _, err := f.channel.SendWithTimeout("dblclick", resolveTimeout(f.page.timeoutSettings, explicit), map[string]any{ "selector": selector, }, options) return err } func (f *frameImpl) Fill(selector string, value string, options ...FrameFillOptions) error { - _, err := f.channel.Send("fill", map[string]any{ + var explicit *float64 + if len(options) == 1 { + explicit = options[0].Timeout + } + _, err := f.channel.SendWithTimeout("fill", resolveTimeout(f.page.timeoutSettings, explicit), map[string]any{ "selector": selector, "value": value, }, options) @@ -608,7 +662,11 @@ func (f *frameImpl) Fill(selector string, value string, options ...FrameFillOpti } func (f *frameImpl) Focus(selector string, options ...FrameFocusOptions) error { - _, err := f.channel.Send("focus", map[string]any{ + var explicit *float64 + if len(options) == 1 { + explicit = options[0].Timeout + } + _, err := f.channel.SendWithTimeout("focus", resolveTimeout(f.page.timeoutSettings, explicit), map[string]any{ "selector": selector, }, options) return err @@ -631,7 +689,11 @@ func (f *frameImpl) ParentFrame() Frame { } func (f *frameImpl) TextContent(selector string, options ...FrameTextContentOptions) (string, error) { - textContent, err := f.channel.Send("textContent", map[string]any{ + var explicit *float64 + if len(options) == 1 { + explicit = options[0].Timeout + } + textContent, err := f.channel.SendWithTimeout("textContent", resolveTimeout(f.page.timeoutSettings, explicit), map[string]any{ "selector": selector, }, options) if textContent == nil { @@ -641,7 +703,11 @@ func (f *frameImpl) TextContent(selector string, options ...FrameTextContentOpti } func (f *frameImpl) Tap(selector string, options ...FrameTapOptions) error { - _, err := f.channel.Send("tap", map[string]any{ + var explicit *float64 + if len(options) == 1 { + explicit = options[0].Timeout + } + _, err := f.channel.SendWithTimeout("tap", resolveTimeout(f.page.timeoutSettings, explicit), map[string]any{ "selector": selector, }, options) return err @@ -655,7 +721,11 @@ func (f *frameImpl) SelectOption(selector string, values SelectOptionValues, opt for k, v := range opts { m[k] = v } - selected, err := f.channel.Send("selectOption", m, options) + var explicit *float64 + if len(options) == 1 { + explicit = options[0].Timeout + } + selected, err := f.channel.SendWithTimeout("selectOption", resolveTimeout(f.page.timeoutSettings, explicit), m, options) if err != nil { return nil, err } @@ -664,7 +734,11 @@ func (f *frameImpl) SelectOption(selector string, values SelectOptionValues, opt } func (f *frameImpl) IsChecked(selector string, options ...FrameIsCheckedOptions) (bool, error) { - checked, err := f.channel.Send("isChecked", map[string]any{ + var explicit *float64 + if len(options) == 1 { + explicit = options[0].Timeout + } + checked, err := f.channel.SendWithTimeout("isChecked", resolveTimeout(f.page.timeoutSettings, explicit), map[string]any{ "selector": selector, }, options) if err != nil { @@ -674,7 +748,11 @@ func (f *frameImpl) IsChecked(selector string, options ...FrameIsCheckedOptions) } func (f *frameImpl) IsDisabled(selector string, options ...FrameIsDisabledOptions) (bool, error) { - disabled, err := f.channel.Send("isDisabled", map[string]any{ + var explicit *float64 + if len(options) == 1 { + explicit = options[0].Timeout + } + disabled, err := f.channel.SendWithTimeout("isDisabled", resolveTimeout(f.page.timeoutSettings, explicit), map[string]any{ "selector": selector, }, options) if err != nil { @@ -684,7 +762,11 @@ func (f *frameImpl) IsDisabled(selector string, options ...FrameIsDisabledOption } func (f *frameImpl) IsEditable(selector string, options ...FrameIsEditableOptions) (bool, error) { - editable, err := f.channel.Send("isEditable", map[string]any{ + var explicit *float64 + if len(options) == 1 { + explicit = options[0].Timeout + } + editable, err := f.channel.SendWithTimeout("isEditable", resolveTimeout(f.page.timeoutSettings, explicit), map[string]any{ "selector": selector, }, options) if err != nil { @@ -694,7 +776,11 @@ func (f *frameImpl) IsEditable(selector string, options ...FrameIsEditableOption } func (f *frameImpl) IsEnabled(selector string, options ...FrameIsEnabledOptions) (bool, error) { - enabled, err := f.channel.Send("isEnabled", map[string]any{ + var explicit *float64 + if len(options) == 1 { + explicit = options[0].Timeout + } + enabled, err := f.channel.SendWithTimeout("isEnabled", resolveTimeout(f.page.timeoutSettings, explicit), map[string]any{ "selector": selector, }, options) if err != nil { @@ -704,7 +790,11 @@ func (f *frameImpl) IsEnabled(selector string, options ...FrameIsEnabledOptions) } func (f *frameImpl) IsHidden(selector string, options ...FrameIsHiddenOptions) (bool, error) { - hidden, err := f.channel.Send("isHidden", map[string]any{ + // Timeout is deprecated and intentionally ignored for this immediate query. + if len(options) == 1 { + options[0].Timeout = nil + } + hidden, err := f.channel.SendWithTimeout("isHidden", Float(0), map[string]any{ "selector": selector, }, options) if err != nil { @@ -714,7 +804,11 @@ func (f *frameImpl) IsHidden(selector string, options ...FrameIsHiddenOptions) ( } func (f *frameImpl) IsVisible(selector string, options ...FrameIsVisibleOptions) (bool, error) { - visible, err := f.channel.Send("isVisible", map[string]any{ + // Timeout is deprecated and intentionally ignored for this immediate query. + if len(options) == 1 { + options[0].Timeout = nil + } + visible, err := f.channel.SendWithTimeout("isVisible", Float(0), map[string]any{ "selector": selector, }, options) if err != nil { @@ -724,7 +818,11 @@ func (f *frameImpl) IsVisible(selector string, options ...FrameIsVisibleOptions) } func (f *frameImpl) InputValue(selector string, options ...FrameInputValueOptions) (string, error) { - value, err := f.channel.Send("inputValue", map[string]any{ + var explicit *float64 + if len(options) == 1 { + explicit = options[0].Timeout + } + value, err := f.channel.SendWithTimeout("inputValue", resolveTimeout(f.page.timeoutSettings, explicit), map[string]any{ "selector": selector, }, options) if value == nil { @@ -734,7 +832,11 @@ func (f *frameImpl) InputValue(selector string, options ...FrameInputValueOption } func (f *frameImpl) DragAndDrop(source, target string, options ...FrameDragAndDropOptions) error { - _, err := f.channel.Send("dragAndDrop", map[string]any{ + var explicit *float64 + if len(options) == 1 { + explicit = options[0].Timeout + } + _, err := f.channel.SendWithTimeout("dragAndDrop", resolveTimeout(f.page.timeoutSettings, explicit), map[string]any{ "source": source, "target": target, }, options) @@ -742,17 +844,21 @@ func (f *frameImpl) DragAndDrop(source, target string, options ...FrameDragAndDr } func (f *frameImpl) SetChecked(selector string, checked bool, options ...FrameSetCheckedOptions) error { + var explicit *float64 + if len(options) == 1 { + explicit = options[0].Timeout + } + timeout := resolveTimeout(f.page.timeoutSettings, explicit) if checked { - _, err := f.channel.Send("check", map[string]any{ - "selector": selector, - }, options) - return err - } else { - _, err := f.channel.Send("uncheck", map[string]any{ + _, err := f.channel.SendWithTimeout("check", timeout, map[string]any{ "selector": selector, }, options) return err } + _, err := f.channel.SendWithTimeout("uncheck", timeout, map[string]any{ + "selector": selector, + }, options) + return err } func (f *frameImpl) Locator(selector string, options ...FrameLocatorOptions) Locator { diff --git a/generated-enums.go b/generated-enums.go index 36dd1b3c..17047887 100644 --- a/generated-enums.go +++ b/generated-enums.go @@ -261,6 +261,18 @@ var ( UnrouteBehaviorDefault = getUnrouteBehavior("default") ) +func getScrollMode(in string) *ScrollMode { + v := ScrollMode(in) + return &v +} + +type ScrollMode string + +var ( + ScrollModeAuto *ScrollMode = getScrollMode("auto") + ScrollModeNone = getScrollMode("none") +) + func getMouseButton(in string) *MouseButton { v := MouseButton(in) return &v @@ -335,6 +347,7 @@ type ScreenshotType string var ( ScreenshotTypePng *ScreenshotType = getScreenshotType("png") ScreenshotTypeJpeg = getScreenshotType("jpeg") + ScreenshotTypeWebp = getScreenshotType("webp") ) func getWaitForSelectorState(in string) *WaitForSelectorState { diff --git a/generated-interfaces.go b/generated-interfaces.go index 4b5f70bd..2fbdd5a0 100644 --- a/generated-interfaces.go +++ b/generated-interfaces.go @@ -131,6 +131,15 @@ type APIResponse interface { // Returns the text representation of response body. Text() (string, error) + // Returns resource timing information for given response. For redirected requests, returns the information for the + // last request in the redirect chain. When the response is served [from the HAR file], + // timing information is not available and all the values are -1. Find more information at + // [Resource Timing API]. + // + // [from the HAR file]: https://playwright.dev/docs/mock#replaying-from-har + // [Resource Timing API]: https://developer.mozilla.org/en-US/docs/Web/API/PerformanceResourceTiming + Timing() *RequestTiming + // Contains the URL of the response. URL() string } @@ -511,11 +520,13 @@ type BrowserContext interface { // offline: Whether to emulate network being offline for the browser context. SetOffline(offline bool) error - // Returns storage state for this browser context, contains current cookies, local storage snapshot and IndexedDB - // snapshot. + // Returns storage state for this browser context, contains current cookies, local storage snapshot, IndexedDB + // snapshot and virtual WebAuthn credentials. StorageState(options ...BrowserContextStorageStateOptions) (*StorageState, error) - // Clears the existing cookies, local storage and IndexedDB entries for all origins and sets the new storage state. + // Clears the existing cookies, local storage, IndexedDB entries and virtual WebAuthn credentials, and sets the new + // storage state. When the storage state contains credentials, the virtual WebAuthn authenticator is installed + // (equivalent to [Credentials.Install]), preventing all real authenticators from working in this context. // // storageStatePath: Populates context with given storage state. This option can be used to initialize context with logged-in // information obtained via [BrowserContext.StorageState]. Path to the file with saved storage state. @@ -575,6 +586,9 @@ type BrowserType interface { // **NOTE** This connection is significantly lower fidelity than the Playwright protocol connection via // [BrowserType.Connect]. If you are experiencing issues or attempting to use advanced functionality, you probably // want to use [BrowserType.Connect]. + // **NOTE** Playwright maintains a curated list of arguments for launching the browser. If you launch the browser + // without Playwright and do not pass the exact same arguments, some of Playwright functionality may be broken upon + // connecting to the browser. // // endpointURL: A CDP websocket endpoint or http url to connect to. For example `http://localhost:9222/` or // `ws://127.0.0.1:9222/devtools/browser/387adf4c-243f-4051-a181-46798f4a46f4`. @@ -683,7 +697,7 @@ type Clock interface { // Only fires due timers at most once. This is equivalent to user closing the laptop lid for a while and reopening it // at the specified time and pausing. // - // time: Time to pause at. + // time: Time to pause at. Numeric values are Unix time in milliseconds. PauseAt(time any) error // Resumes timers. Once this method is called, time resumes flowing, timers are fired as usual. @@ -693,7 +707,7 @@ type Clock interface { // Use this method for simple scenarios where you only need to test with a predefined time. For more advanced // scenarios, use [Clock.Install] instead. Read docs on [clock emulation] to learn more. // - // time: Time to be set. + // time: Time to be set in milliseconds. // // [clock emulation]: https://playwright.dev/docs/clock SetFixedTime(time any) error @@ -701,7 +715,7 @@ type Clock interface { // Sets system time, but does not trigger any timers. Use this to test how the web page reacts to a time shift, for // example switching from summer to winter time, or changing time zones. // - // time: Time to be set. + // time: Time to be set in milliseconds. SetSystemTime(time any) error } @@ -738,10 +752,14 @@ type ConsoleMessage interface { // `Credentials` is a virtual WebAuthn authenticator scoped to a [BrowserContext]. It lets tests register passkeys and // answer `navigator.credentials.create()` / `navigator.credentials.get()` ceremonies in the page, without a real // authenticator or hardware security key. -// There are two common ways to use it: +// There are three common ways to use it: // **Usage: seed a known credential** -// **Usage: capture a passkey, then reuse it** +// **Usage: capture a credential, then reuse it** +// **Usage: save credentials in the storage state, restore later** +// See [authentication guide] for examples of using saving and resotring the storage state. // **Defaults** +// +// [authentication guide]: https://playwright.dev/docs/auth type Credentials interface { // Installs the virtual WebAuthn authenticator into the context, overriding `navigator.credentials.create()` and // `navigator.credentials.get()` in all current and future pages. Call this before the page first touches @@ -3040,6 +3058,19 @@ type Locator interface { // “[object Object]” milliseconds until the condition is met. WaitFor(options ...LocatorWaitForOptions) error + // Returns when “[object Object]” returns a truthy value, called with the matching element as a first argument, and + // “[object Object]” as a second argument. + // This is a generic way to wait for an element to reach a custom condition without asserting it. The locator is + // re-resolved on each retry, so it tolerates the element being re-rendered while waiting. + // If “[object Object]” returns a [Promise], this method will wait for the promise to resolve before checking its + // value. + // If “[object Object]” throws or rejects, this method throws. + // + // 1. expression: JavaScript expression to be evaluated in the browser context. If the expression evaluates to a function, the + // function is automatically invoked. + // 2. arg: Optional argument to pass to “[object Object]”. + WaitForFunction(expression string, arg any, options ...LocatorWaitForFunctionOptions) error + Err() error } @@ -3698,11 +3729,21 @@ type Page interface { // Returns the main resource response. In case of multiple redirects, the navigation will resolve with the response of // the last redirect. If cannot go back, returns `null`. // Navigate to the previous page in history. + // **NOTE** **Testing Back/Forward Cache (BFCache) is not supported.** By default, Playwright disables the + // Back/Forward Cache across all browsers. Even if explicitly enabled, Playwright's internal state relies on + // network-level navigation events. Because BFCache restores unfreeze the DOM without firing these events, using + // `page.goBack()` or `page.goForward()` to trigger a BFCache restore will result in timeouts and a desynchronized + // `Page` state. GoBack(options ...PageGoBackOptions) (Response, error) // Returns the main resource response. In case of multiple redirects, the navigation will resolve with the response of // the last redirect. If cannot go forward, returns `null`. // Navigate to the next page in history. + // **NOTE** **Testing Back/Forward Cache (BFCache) is not supported.** By default, Playwright disables the + // Back/Forward Cache across all browsers. Even if explicitly enabled, Playwright's internal state relies on + // network-level navigation events. Because BFCache restores unfreeze the DOM without firing these events, using + // `page.goBack()` or `page.goForward()` to trigger a BFCache restore will result in timeouts and a desynchronized + // `Page` state. GoForward(options ...PageGoForwardOptions) (Response, error) // Request the page to perform garbage collection. Note that there is no guarantee that all unreachable objects will diff --git a/generated-structs.go b/generated-structs.go index 652511f2..05ab65b5 100644 --- a/generated-structs.go +++ b/generated-structs.go @@ -332,6 +332,9 @@ type APIRequestContextPutOptions struct { type StorageState struct { Cookies []Cookie `json:"cookies"` Origins []Origin `json:"origins"` + // Virtual WebAuthn credentials. BrowserContext captures and restores them; APIRequestContext ignores this field and + // never populates it. + Credentials []VirtualCredential `json:"credentials"` } type APIRequestContextStorageStateOptions struct { @@ -371,6 +374,36 @@ type ResponseServerAddrResult struct { Port int `json:"port"` } +type RequestTiming struct { + // Request start time in milliseconds elapsed since January 1, 1970 00:00:00 UTC + StartTime float64 `json:"startTime"` + // Time immediately before the client starts the domain name lookup for the resource. The value is given in + // milliseconds relative to `startTime`, -1 if not available. + DomainLookupStart float64 `json:"domainLookupStart"` + // Time immediately after the client ends the domain name lookup for the resource. The value is given in milliseconds + // relative to `startTime`, -1 if not available. + DomainLookupEnd float64 `json:"domainLookupEnd"` + // Time immediately before the client starts establishing the connection to the server to retrieve the resource. The + // value is given in milliseconds relative to `startTime`, -1 if not available. + ConnectStart float64 `json:"connectStart"` + // Time immediately before the client starts the handshake process to secure the current connection. The value is + // given in milliseconds relative to `startTime`, -1 if not available. + SecureConnectionStart float64 `json:"secureConnectionStart"` + // Time immediately after the client establishes the connection to the server to retrieve the resource. The value is + // given in milliseconds relative to `startTime`, -1 if not available. + ConnectEnd float64 `json:"connectEnd"` + // Time immediately before the client starts requesting the resource from the server, cache, or local resource. The + // value is given in milliseconds relative to `startTime`, -1 if not available. + RequestStart float64 `json:"requestStart"` + // Time immediately after the client receives the first byte of the response from the server, cache, or local + // resource. The value is given in milliseconds relative to `startTime`, -1 if not available. + ResponseStart float64 `json:"responseStart"` + // Time immediately after the client receives the last byte of the resource or immediately before the transport + // connection is closed, whichever comes first. The value is given in milliseconds relative to `startTime`, -1 if not + // available. + ResponseEnd float64 `json:"responseEnd"` +} + type BrowserCloseOptions struct { // The reason to be reported to the operations interrupted by the browser closure. Reason *string `json:"reason"` @@ -805,6 +838,12 @@ type Geolocation struct { } type BrowserContextStorageStateOptions struct { + // Set to `true` to include the context's virtual WebAuthn [BrowserContext.Credentials] (passkeys) in the storage + // state snapshot. The captured credentials carry their private keys, so they can be re-seeded into a later context + // via the “[object Object]” option or [BrowserContext.SetStorageState]. Note that restoring the storage state that + // contains credentials will automatically install the virtual WebAuthn authenticator (see [Credentials.Install]), and + // prevent all real authenticators from working in this context. + Credentials *bool `json:"credentials"` // Set to `true` to include [IndexedDB] in the storage // state snapshot. If your application uses IndexedDB to store authentication tokens, like Firebase Authentication, // enable this. @@ -1184,7 +1223,7 @@ type BrowserTypeLaunchPersistentContextOptions struct { } type ClockInstallOptions struct { - // Time to initialize with, current system time by default. + // Time to initialize with, current system time by default. Numeric values are Unix time in milliseconds. Time any `json:"time"` } @@ -1266,6 +1305,11 @@ type ElementHandleCheckOptions struct { // A point to use relative to the top-left corner of element padding box. If not specified, uses some visible point of // the element. Position *Position `json:"position"` + // Controls whether Playwright scrolls the element into view before performing the action. Defaults to `"auto"`, which + // scrolls the element into view when necessary, including scrolling nested scrollable containers. When set to + // `"none"`, Playwright does not scroll the element and the action fails if the element is not already in the + // viewport. This is useful to assert that an element is reachable by the user without additional scrolling. + Scroll *ScrollMode `json:"scroll"` // Maximum time in milliseconds. Defaults to `30000` (30 seconds). Pass `0` to disable timeout. The default value can // be changed by using the [BrowserContext.SetDefaultTimeout] or [Page.SetDefaultTimeout] methods. Timeout *float64 `json:"timeout"` @@ -1300,6 +1344,11 @@ type ElementHandleClickOptions struct { // A point to use relative to the top-left corner of element padding box. If not specified, uses some visible point of // the element. Position *Position `json:"position"` + // Controls whether Playwright scrolls the element into view before performing the action. Defaults to `"auto"`, which + // scrolls the element into view when necessary, including scrolling nested scrollable containers. When set to + // `"none"`, Playwright does not scroll the element and the action fails if the element is not already in the + // viewport. This is useful to assert that an element is reachable by the user without additional scrolling. + Scroll *ScrollMode `json:"scroll"` // Defaults to 1. Sends `n` interpolated `mousemove` events to represent travel between Playwright's current cursor // position and the provided destination. When set to 1, emits a single `mousemove` event at the destination location. Steps *int `json:"steps"` @@ -1333,6 +1382,11 @@ type ElementHandleDblclickOptions struct { // A point to use relative to the top-left corner of element padding box. If not specified, uses some visible point of // the element. Position *Position `json:"position"` + // Controls whether Playwright scrolls the element into view before performing the action. Defaults to `"auto"`, which + // scrolls the element into view when necessary, including scrolling nested scrollable containers. When set to + // `"none"`, Playwright does not scroll the element and the action fails if the element is not already in the + // viewport. This is useful to assert that an element is reachable by the user without additional scrolling. + Scroll *ScrollMode `json:"scroll"` // Defaults to 1. Sends `n` interpolated `mousemove` events to represent travel between Playwright's current cursor // position and the provided destination. When set to 1, emits a single `mousemove` event at the destination location. Steps *int `json:"steps"` @@ -1376,6 +1430,11 @@ type ElementHandleHoverOptions struct { // A point to use relative to the top-left corner of element padding box. If not specified, uses some visible point of // the element. Position *Position `json:"position"` + // Controls whether Playwright scrolls the element into view before performing the action. Defaults to `"auto"`, which + // scrolls the element into view when necessary, including scrolling nested scrollable containers. When set to + // `"none"`, Playwright does not scroll the element and the action fails if the element is not already in the + // viewport. This is useful to assert that an element is reachable by the user without additional scrolling. + Scroll *ScrollMode `json:"scroll"` // Maximum time in milliseconds. Defaults to `30000` (30 seconds). Pass `0` to disable timeout. The default value can // be changed by using the [BrowserContext.SetDefaultTimeout] or [Page.SetDefaultTimeout] methods. Timeout *float64 `json:"timeout"` @@ -1387,8 +1446,8 @@ type ElementHandleHoverOptions struct { } type ElementHandleInputValueOptions struct { - // Maximum time in milliseconds. Defaults to `30000` (30 seconds). Pass `0` to disable timeout. The default value can - // be changed by using the [BrowserContext.SetDefaultTimeout] or [Page.SetDefaultTimeout] methods. + // + // Deprecated: This option is ignored. The value is returned immediately. Timeout *float64 `json:"timeout"` } @@ -1435,7 +1494,8 @@ type ElementHandleScreenshotOptions struct { // is a relative path, then it is resolved relative to the current working directory. If no path is provided, the // image won't be saved to the disk. Path *string `json:"path"` - // The quality of the image, between 0-100. Not applicable to `png` images. + // The quality of the image, between 0-100. Not applicable to `png` images. For `jpeg` the default is `80`. For + // `webp`, a quality of `100` (the default) produces a lossless image, while lower values use lossy compression. Quality *int `json:"quality"` // When set to `"css"`, screenshot will have a single pixel per each css pixel on the page. For high-dpi devices, this // will keep screenshots small. Using `"device"` option will produce a single pixel per each device pixel, so @@ -1495,6 +1555,11 @@ type ElementHandleSetCheckedOptions struct { // A point to use relative to the top-left corner of element padding box. If not specified, uses some visible point of // the element. Position *Position `json:"position"` + // Controls whether Playwright scrolls the element into view before performing the action. Defaults to `"auto"`, which + // scrolls the element into view when necessary, including scrolling nested scrollable containers. When set to + // `"none"`, Playwright does not scroll the element and the action fails if the element is not already in the + // viewport. This is useful to assert that an element is reachable by the user without additional scrolling. + Scroll *ScrollMode `json:"scroll"` // Maximum time in milliseconds. Defaults to `30000` (30 seconds). Pass `0` to disable timeout. The default value can // be changed by using the [BrowserContext.SetDefaultTimeout] or [Page.SetDefaultTimeout] methods. Timeout *float64 `json:"timeout"` @@ -1531,6 +1596,11 @@ type ElementHandleTapOptions struct { // A point to use relative to the top-left corner of element padding box. If not specified, uses some visible point of // the element. Position *Position `json:"position"` + // Controls whether Playwright scrolls the element into view before performing the action. Defaults to `"auto"`, which + // scrolls the element into view when necessary, including scrolling nested scrollable containers. When set to + // `"none"`, Playwright does not scroll the element and the action fails if the element is not already in the + // viewport. This is useful to assert that an element is reachable by the user without additional scrolling. + Scroll *ScrollMode `json:"scroll"` // Maximum time in milliseconds. Defaults to `30000` (30 seconds). Pass `0` to disable timeout. The default value can // be changed by using the [BrowserContext.SetDefaultTimeout] or [Page.SetDefaultTimeout] methods. Timeout *float64 `json:"timeout"` @@ -1565,6 +1635,11 @@ type ElementHandleUncheckOptions struct { // A point to use relative to the top-left corner of element padding box. If not specified, uses some visible point of // the element. Position *Position `json:"position"` + // Controls whether Playwright scrolls the element into view before performing the action. Defaults to `"auto"`, which + // scrolls the element into view when necessary, including scrolling nested scrollable containers. When set to + // `"none"`, Playwright does not scroll the element and the action fails if the element is not already in the + // viewport. This is useful to assert that an element is reachable by the user without additional scrolling. + Scroll *ScrollMode `json:"scroll"` // Maximum time in milliseconds. Defaults to `30000` (30 seconds). Pass `0` to disable timeout. The default value can // be changed by using the [BrowserContext.SetDefaultTimeout] or [Page.SetDefaultTimeout] methods. Timeout *float64 `json:"timeout"` @@ -1645,6 +1720,11 @@ type FrameCheckOptions struct { // A point to use relative to the top-left corner of element padding box. If not specified, uses some visible point of // the element. Position *Position `json:"position"` + // Controls whether Playwright scrolls the element into view before performing the action. Defaults to `"auto"`, which + // scrolls the element into view when necessary, including scrolling nested scrollable containers. When set to + // `"none"`, Playwright does not scroll the element and the action fails if the element is not already in the + // viewport. This is useful to assert that an element is reachable by the user without additional scrolling. + Scroll *ScrollMode `json:"scroll"` // When true, the call requires selector to resolve to a single element. If given selector resolves to more than one // element, the call throws an exception. Strict *bool `json:"strict"` @@ -1682,6 +1762,11 @@ type FrameClickOptions struct { // A point to use relative to the top-left corner of element padding box. If not specified, uses some visible point of // the element. Position *Position `json:"position"` + // Controls whether Playwright scrolls the element into view before performing the action. Defaults to `"auto"`, which + // scrolls the element into view when necessary, including scrolling nested scrollable containers. When set to + // `"none"`, Playwright does not scroll the element and the action fails if the element is not already in the + // viewport. This is useful to assert that an element is reachable by the user without additional scrolling. + Scroll *ScrollMode `json:"scroll"` // Defaults to 1. Sends `n` interpolated `mousemove` events to represent travel between Playwright's current cursor // position and the provided destination. When set to 1, emits a single `mousemove` event at the destination location. Steps *int `json:"steps"` @@ -1720,6 +1805,11 @@ type FrameDblclickOptions struct { // A point to use relative to the top-left corner of element padding box. If not specified, uses some visible point of // the element. Position *Position `json:"position"` + // Controls whether Playwright scrolls the element into view before performing the action. Defaults to `"auto"`, which + // scrolls the element into view when necessary, including scrolling nested scrollable containers. When set to + // `"none"`, Playwright does not scroll the element and the action fails if the element is not already in the + // viewport. This is useful to assert that an element is reachable by the user without additional scrolling. + Scroll *ScrollMode `json:"scroll"` // When true, the call requires selector to resolve to a single element. If given selector resolves to more than one // element, the call throws an exception. Strict *bool `json:"strict"` @@ -1753,6 +1843,11 @@ type FrameDragAndDropOptions struct { // // Deprecated: This option has no effect. NoWaitAfter *bool `json:"noWaitAfter"` + // Controls whether Playwright scrolls the element into view before performing the action. Defaults to `"auto"`, which + // scrolls the element into view when necessary, including scrolling nested scrollable containers. When set to + // `"none"`, Playwright does not scroll the element and the action fails if the element is not already in the + // viewport. This is useful to assert that an element is reachable by the user without additional scrolling. + Scroll *ScrollMode `json:"scroll"` // Clicks on the source element at this point relative to the top-left corner of the element's padding box. If not // specified, some visible point of the element is used. SourcePosition *Position `json:"sourcePosition"` @@ -1939,6 +2034,11 @@ type FrameHoverOptions struct { // A point to use relative to the top-left corner of element padding box. If not specified, uses some visible point of // the element. Position *Position `json:"position"` + // Controls whether Playwright scrolls the element into view before performing the action. Defaults to `"auto"`, which + // scrolls the element into view when necessary, including scrolling nested scrollable containers. When set to + // `"none"`, Playwright does not scroll the element and the action fails if the element is not already in the + // viewport. This is useful to assert that an element is reachable by the user without additional scrolling. + Scroll *ScrollMode `json:"scroll"` // When true, the call requires selector to resolve to a single element. If given selector resolves to more than one // element, the call throws an exception. Strict *bool `json:"strict"` @@ -2109,6 +2209,11 @@ type FrameSetCheckedOptions struct { // A point to use relative to the top-left corner of element padding box. If not specified, uses some visible point of // the element. Position *Position `json:"position"` + // Controls whether Playwright scrolls the element into view before performing the action. Defaults to `"auto"`, which + // scrolls the element into view when necessary, including scrolling nested scrollable containers. When set to + // `"none"`, Playwright does not scroll the element and the action fails if the element is not already in the + // viewport. This is useful to assert that an element is reachable by the user without additional scrolling. + Scroll *ScrollMode `json:"scroll"` // When true, the call requires selector to resolve to a single element. If given selector resolves to more than one // element, the call throws an exception. Strict *bool `json:"strict"` @@ -2166,6 +2271,11 @@ type FrameTapOptions struct { // A point to use relative to the top-left corner of element padding box. If not specified, uses some visible point of // the element. Position *Position `json:"position"` + // Controls whether Playwright scrolls the element into view before performing the action. Defaults to `"auto"`, which + // scrolls the element into view when necessary, including scrolling nested scrollable containers. When set to + // `"none"`, Playwright does not scroll the element and the action fails if the element is not already in the + // viewport. This is useful to assert that an element is reachable by the user without additional scrolling. + Scroll *ScrollMode `json:"scroll"` // When true, the call requires selector to resolve to a single element. If given selector resolves to more than one // element, the call throws an exception. Strict *bool `json:"strict"` @@ -2217,6 +2327,11 @@ type FrameUncheckOptions struct { // A point to use relative to the top-left corner of element padding box. If not specified, uses some visible point of // the element. Position *Position `json:"position"` + // Controls whether Playwright scrolls the element into view before performing the action. Defaults to `"auto"`, which + // scrolls the element into view when necessary, including scrolling nested scrollable containers. When set to + // `"none"`, Playwright does not scroll the element and the action fails if the element is not already in the + // viewport. This is useful to assert that an element is reachable by the user without additional scrolling. + Scroll *ScrollMode `json:"scroll"` // When true, the call requires selector to resolve to a single element. If given selector resolves to more than one // element, the call throws an exception. Strict *bool `json:"strict"` @@ -2468,6 +2583,11 @@ type LocatorCheckOptions struct { // A point to use relative to the top-left corner of element padding box. If not specified, uses some visible point of // the element. Position *Position `json:"position"` + // Controls whether Playwright scrolls the element into view before performing the action. Defaults to `"auto"`, which + // scrolls the element into view when necessary, including scrolling nested scrollable containers. When set to + // `"none"`, Playwright does not scroll the element and the action fails if the element is not already in the + // viewport. This is useful to assert that an element is reachable by the user without additional scrolling. + Scroll *ScrollMode `json:"scroll"` // Maximum time in milliseconds. Defaults to `30000` (30 seconds). Pass `0` to disable timeout. The default value can // be changed by using the [BrowserContext.SetDefaultTimeout] or [Page.SetDefaultTimeout] methods. Timeout *float64 `json:"timeout"` @@ -2516,6 +2636,11 @@ type LocatorClickOptions struct { // A point to use relative to the top-left corner of element padding box. If not specified, uses some visible point of // the element. Position *Position `json:"position"` + // Controls whether Playwright scrolls the element into view before performing the action. Defaults to `"auto"`, which + // scrolls the element into view when necessary, including scrolling nested scrollable containers. When set to + // `"none"`, Playwright does not scroll the element and the action fails if the element is not already in the + // viewport. This is useful to assert that an element is reachable by the user without additional scrolling. + Scroll *ScrollMode `json:"scroll"` // Defaults to 1. Sends `n` interpolated `mousemove` events to represent travel between Playwright's current cursor // position and the provided destination. When set to 1, emits a single `mousemove` event at the destination location. Steps *int `json:"steps"` @@ -2551,6 +2676,11 @@ type LocatorDblclickOptions struct { // A point to use relative to the top-left corner of element padding box. If not specified, uses some visible point of // the element. Position *Position `json:"position"` + // Controls whether Playwright scrolls the element into view before performing the action. Defaults to `"auto"`, which + // scrolls the element into view when necessary, including scrolling nested scrollable containers. When set to + // `"none"`, Playwright does not scroll the element and the action fails if the element is not already in the + // viewport. This is useful to assert that an element is reachable by the user without additional scrolling. + Scroll *ScrollMode `json:"scroll"` // Defaults to 1. Sends `n` interpolated `mousemove` events to represent travel between Playwright's current cursor // position and the provided destination. When set to 1, emits a single `mousemove` event at the destination location. Steps *int `json:"steps"` @@ -2581,6 +2711,11 @@ type LocatorDragToOptions struct { // // Deprecated: This option has no effect. NoWaitAfter *bool `json:"noWaitAfter"` + // Controls whether Playwright scrolls the element into view before performing the action. Defaults to `"auto"`, which + // scrolls the element into view when necessary, including scrolling nested scrollable containers. When set to + // `"none"`, Playwright does not scroll the element and the action fails if the element is not already in the + // viewport. This is useful to assert that an element is reachable by the user without additional scrolling. + Scroll *ScrollMode `json:"scroll"` // Clicks on the source element at this point relative to the top-left corner of the element's padding box. If not // specified, some visible point of the element is used. SourcePosition *Position `json:"sourcePosition"` @@ -2787,6 +2922,11 @@ type LocatorHoverOptions struct { // A point to use relative to the top-left corner of element padding box. If not specified, uses some visible point of // the element. Position *Position `json:"position"` + // Controls whether Playwright scrolls the element into view before performing the action. Defaults to `"auto"`, which + // scrolls the element into view when necessary, including scrolling nested scrollable containers. When set to + // `"none"`, Playwright does not scroll the element and the action fails if the element is not already in the + // viewport. This is useful to assert that an element is reachable by the user without additional scrolling. + Scroll *ScrollMode `json:"scroll"` // Maximum time in milliseconds. Defaults to `30000` (30 seconds). Pass `0` to disable timeout. The default value can // be changed by using the [BrowserContext.SetDefaultTimeout] or [Page.SetDefaultTimeout] methods. Timeout *float64 `json:"timeout"` @@ -2930,7 +3070,8 @@ type LocatorScreenshotOptions struct { // is a relative path, then it is resolved relative to the current working directory. If no path is provided, the // image won't be saved to the disk. Path *string `json:"path"` - // The quality of the image, between 0-100. Not applicable to `png` images. + // The quality of the image, between 0-100. Not applicable to `png` images. For `jpeg` the default is `80`. For + // `webp`, a quality of `100` (the default) produces a lossless image, while lower values use lossy compression. Quality *int `json:"quality"` // When set to `"css"`, screenshot will have a single pixel per each css pixel on the page. For high-dpi devices, this // will keep screenshots small. Using `"device"` option will produce a single pixel per each device pixel, so @@ -2990,6 +3131,11 @@ type LocatorSetCheckedOptions struct { // A point to use relative to the top-left corner of element padding box. If not specified, uses some visible point of // the element. Position *Position `json:"position"` + // Controls whether Playwright scrolls the element into view before performing the action. Defaults to `"auto"`, which + // scrolls the element into view when necessary, including scrolling nested scrollable containers. When set to + // `"none"`, Playwright does not scroll the element and the action fails if the element is not already in the + // viewport. This is useful to assert that an element is reachable by the user without additional scrolling. + Scroll *ScrollMode `json:"scroll"` // Maximum time in milliseconds. Defaults to `30000` (30 seconds). Pass `0` to disable timeout. The default value can // be changed by using the [BrowserContext.SetDefaultTimeout] or [Page.SetDefaultTimeout] methods. Timeout *float64 `json:"timeout"` @@ -3026,6 +3172,11 @@ type LocatorTapOptions struct { // A point to use relative to the top-left corner of element padding box. If not specified, uses some visible point of // the element. Position *Position `json:"position"` + // Controls whether Playwright scrolls the element into view before performing the action. Defaults to `"auto"`, which + // scrolls the element into view when necessary, including scrolling nested scrollable containers. When set to + // `"none"`, Playwright does not scroll the element and the action fails if the element is not already in the + // viewport. This is useful to assert that an element is reachable by the user without additional scrolling. + Scroll *ScrollMode `json:"scroll"` // Maximum time in milliseconds. Defaults to `30000` (30 seconds). Pass `0` to disable timeout. The default value can // be changed by using the [BrowserContext.SetDefaultTimeout] or [Page.SetDefaultTimeout] methods. Timeout *float64 `json:"timeout"` @@ -3068,6 +3219,11 @@ type LocatorUncheckOptions struct { // A point to use relative to the top-left corner of element padding box. If not specified, uses some visible point of // the element. Position *Position `json:"position"` + // Controls whether Playwright scrolls the element into view before performing the action. Defaults to `"auto"`, which + // scrolls the element into view when necessary, including scrolling nested scrollable containers. When set to + // `"none"`, Playwright does not scroll the element and the action fails if the element is not already in the + // viewport. This is useful to assert that an element is reachable by the user without additional scrolling. + Scroll *ScrollMode `json:"scroll"` // Maximum time in milliseconds. Defaults to `30000` (30 seconds). Pass `0` to disable timeout. The default value can // be changed by using the [BrowserContext.SetDefaultTimeout] or [Page.SetDefaultTimeout] methods. Timeout *float64 `json:"timeout"` @@ -3092,6 +3248,12 @@ type LocatorWaitForOptions struct { Timeout *float64 `json:"timeout"` } +type LocatorWaitForFunctionOptions struct { + // Maximum time to wait for in milliseconds. Defaults to `30000` (30 seconds). Pass `0` to disable timeout. The + // default value can be changed by using the [BrowserContext.SetDefaultTimeout] or [Page.SetDefaultTimeout] methods. + Timeout *float64 `json:"timeout"` +} + type LocatorAssertionsToBeAttachedOptions struct { Attached *bool `json:"attached"` // Time to retry the assertion for in milliseconds. Defaults to `5000`. @@ -3332,6 +3494,11 @@ type PageCheckOptions struct { // A point to use relative to the top-left corner of element padding box. If not specified, uses some visible point of // the element. Position *Position `json:"position"` + // Controls whether Playwright scrolls the element into view before performing the action. Defaults to `"auto"`, which + // scrolls the element into view when necessary, including scrolling nested scrollable containers. When set to + // `"none"`, Playwright does not scroll the element and the action fails if the element is not already in the + // viewport. This is useful to assert that an element is reachable by the user without additional scrolling. + Scroll *ScrollMode `json:"scroll"` // When true, the call requires selector to resolve to a single element. If given selector resolves to more than one // element, the call throws an exception. Strict *bool `json:"strict"` @@ -3369,6 +3536,11 @@ type PageClickOptions struct { // A point to use relative to the top-left corner of element padding box. If not specified, uses some visible point of // the element. Position *Position `json:"position"` + // Controls whether Playwright scrolls the element into view before performing the action. Defaults to `"auto"`, which + // scrolls the element into view when necessary, including scrolling nested scrollable containers. When set to + // `"none"`, Playwright does not scroll the element and the action fails if the element is not already in the + // viewport. This is useful to assert that an element is reachable by the user without additional scrolling. + Scroll *ScrollMode `json:"scroll"` // Defaults to 1. Sends `n` interpolated `mousemove` events to represent travel between Playwright's current cursor // position and the provided destination. When set to 1, emits a single `mousemove` event at the destination location. Steps *int `json:"steps"` @@ -3417,6 +3589,11 @@ type PageDblclickOptions struct { // A point to use relative to the top-left corner of element padding box. If not specified, uses some visible point of // the element. Position *Position `json:"position"` + // Controls whether Playwright scrolls the element into view before performing the action. Defaults to `"auto"`, which + // scrolls the element into view when necessary, including scrolling nested scrollable containers. When set to + // `"none"`, Playwright does not scroll the element and the action fails if the element is not already in the + // viewport. This is useful to assert that an element is reachable by the user without additional scrolling. + Scroll *ScrollMode `json:"scroll"` // When true, the call requires selector to resolve to a single element. If given selector resolves to more than one // element, the call throws an exception. Strict *bool `json:"strict"` @@ -3450,6 +3627,11 @@ type PageDragAndDropOptions struct { // // Deprecated: This option has no effect. NoWaitAfter *bool `json:"noWaitAfter"` + // Controls whether Playwright scrolls the element into view before performing the action. Defaults to `"auto"`, which + // scrolls the element into view when necessary, including scrolling nested scrollable containers. When set to + // `"none"`, Playwright does not scroll the element and the action fails if the element is not already in the + // viewport. This is useful to assert that an element is reachable by the user without additional scrolling. + Scroll *ScrollMode `json:"scroll"` // Clicks on the source element at this point relative to the top-left corner of the element's padding box. If not // specified, some visible point of the element is used. SourcePosition *Position `json:"sourcePosition"` @@ -3694,6 +3876,11 @@ type PageHoverOptions struct { // A point to use relative to the top-left corner of element padding box. If not specified, uses some visible point of // the element. Position *Position `json:"position"` + // Controls whether Playwright scrolls the element into view before performing the action. Defaults to `"auto"`, which + // scrolls the element into view when necessary, including scrolling nested scrollable containers. When set to + // `"none"`, Playwright does not scroll the element and the action fails if the element is not already in the + // viewport. This is useful to assert that an element is reachable by the user without additional scrolling. + Scroll *ScrollMode `json:"scroll"` // When true, the call requires selector to resolve to a single element. If given selector resolves to more than one // element, the call throws an exception. Strict *bool `json:"strict"` @@ -3960,7 +4147,8 @@ type PageScreenshotOptions struct { // is a relative path, then it is resolved relative to the current working directory. If no path is provided, the // image won't be saved to the disk. Path *string `json:"path"` - // The quality of the image, between 0-100. Not applicable to `png` images. + // The quality of the image, between 0-100. Not applicable to `png` images. For `jpeg` the default is `80`. For + // `webp`, a quality of `100` (the default) produces a lossless image, while lower values use lossy compression. Quality *int `json:"quality"` // When set to `"css"`, screenshot will have a single pixel per each css pixel on the page. For high-dpi devices, this // will keep screenshots small. Using `"device"` option will produce a single pixel per each device pixel, so @@ -4007,6 +4195,11 @@ type PageSetCheckedOptions struct { // A point to use relative to the top-left corner of element padding box. If not specified, uses some visible point of // the element. Position *Position `json:"position"` + // Controls whether Playwright scrolls the element into view before performing the action. Defaults to `"auto"`, which + // scrolls the element into view when necessary, including scrolling nested scrollable containers. When set to + // `"none"`, Playwright does not scroll the element and the action fails if the element is not already in the + // viewport. This is useful to assert that an element is reachable by the user without additional scrolling. + Scroll *ScrollMode `json:"scroll"` // When true, the call requires selector to resolve to a single element. If given selector resolves to more than one // element, the call throws an exception. Strict *bool `json:"strict"` @@ -4082,6 +4275,11 @@ type PageTapOptions struct { // A point to use relative to the top-left corner of element padding box. If not specified, uses some visible point of // the element. Position *Position `json:"position"` + // Controls whether Playwright scrolls the element into view before performing the action. Defaults to `"auto"`, which + // scrolls the element into view when necessary, including scrolling nested scrollable containers. When set to + // `"none"`, Playwright does not scroll the element and the action fails if the element is not already in the + // viewport. This is useful to assert that an element is reachable by the user without additional scrolling. + Scroll *ScrollMode `json:"scroll"` // When true, the call requires selector to resolve to a single element. If given selector resolves to more than one // element, the call throws an exception. Strict *bool `json:"strict"` @@ -4133,6 +4331,11 @@ type PageUncheckOptions struct { // A point to use relative to the top-left corner of element padding box. If not specified, uses some visible point of // the element. Position *Position `json:"position"` + // Controls whether Playwright scrolls the element into view before performing the action. Defaults to `"auto"`, which + // scrolls the element into view when necessary, including scrolling nested scrollable containers. When set to + // `"none"`, Playwright does not scroll the element and the action fails if the element is not already in the + // viewport. This is useful to assert that an element is reachable by the user without additional scrolling. + Scroll *ScrollMode `json:"scroll"` // When true, the call requires selector to resolve to a single element. If given selector resolves to more than one // element, the call throws an exception. Strict *bool `json:"strict"` @@ -4353,36 +4556,6 @@ type RequestSizesResult struct { ResponseHeadersSize int `json:"responseHeadersSize"` } -type RequestTiming struct { - // Request start time in milliseconds elapsed since January 1, 1970 00:00:00 UTC - StartTime float64 `json:"startTime"` - // Time immediately before the browser starts the domain name lookup for the resource. The value is given in - // milliseconds relative to `startTime`, -1 if not available. - DomainLookupStart float64 `json:"domainLookupStart"` - // Time immediately after the browser starts the domain name lookup for the resource. The value is given in - // milliseconds relative to `startTime`, -1 if not available. - DomainLookupEnd float64 `json:"domainLookupEnd"` - // Time immediately before the user agent starts establishing the connection to the server to retrieve the resource. - // The value is given in milliseconds relative to `startTime`, -1 if not available. - ConnectStart float64 `json:"connectStart"` - // Time immediately before the browser starts the handshake process to secure the current connection. The value is - // given in milliseconds relative to `startTime`, -1 if not available. - SecureConnectionStart float64 `json:"secureConnectionStart"` - // Time immediately before the user agent starts establishing the connection to the server to retrieve the resource. - // The value is given in milliseconds relative to `startTime`, -1 if not available. - ConnectEnd float64 `json:"connectEnd"` - // Time immediately before the browser starts requesting the resource from the server, cache, or local resource. The - // value is given in milliseconds relative to `startTime`, -1 if not available. - RequestStart float64 `json:"requestStart"` - // Time immediately after the browser receives the first byte of the response from the server, cache, or local - // resource. The value is given in milliseconds relative to `startTime`, -1 if not available. - ResponseStart float64 `json:"responseStart"` - // Time immediately after the browser receives the last byte of the resource or immediately before the transport - // connection is closed, whichever comes first. The value is given in milliseconds relative to `startTime`, -1 if not - // available. - ResponseEnd float64 `json:"responseEnd"` -} - type RouteContinueOptions struct { // If set changes the request HTTP headers. Header values will be converted to a string. Headers map[string]string `json:"headers"` @@ -4646,6 +4819,8 @@ type OptionalStorageState struct { Cookies []OptionalCookie `json:"cookies"` // localStorage to set for context Origins []Origin `json:"origins"` + // Virtual WebAuthn credentials to seed into the context. + Credentials []VirtualCredential `json:"credentials"` } type PausedDetailLocation struct { @@ -4694,4 +4869,7 @@ type ShowAction struct { Position *AnnotatePosition `json:"position"` // Font size of the action title in pixels. Defaults to `24`. FontSize *int `json:"fontSize"` + // Cursor decoration shown for pointer actions. `"pointer"` (the default) renders a mouse pointer that animates from + // the previous action point to the next one. `"none"` disables the cursor decoration. + Cursor *ScreencastCursor `json:"cursor"` } diff --git a/helpers.go b/helpers.go index 13954594..6ee481a7 100644 --- a/helpers.go +++ b/helpers.go @@ -68,13 +68,9 @@ func transformStructIntoMapIfNeeded(inStruct any) map[string]any { if key == "" { key = fi.Name } - // Special handling for timeout field: provide default value when nil - // This is required in Playwright v1.57+ protocol where timeout is no longer optional - if key == "timeout" && skipFieldSerialization(v.Field(i)) { - out[key] = float64(30000) // default 30s - continue - } // Skip the values when the field is a pointer (like *string) and nil. + // Call timeouts are no longer injected here: since Playwright 1.62 + // they travel in metadata.timeout via channel.SendWithTimeout. if fi.IsExported() && !skipFieldSerialization(v.Field(i)) { // We use the JSON struct fields for getting the original names // out of the field. @@ -118,16 +114,6 @@ func transformOptions(options ...any) map[string]any { v := reflect.ValueOf(option) if v.Kind() == reflect.Slice { if v.Len() == 0 { - // Check if the slice element type has a Timeout field and add default if so - // This is required in Playwright v1.57+ protocol where timeout is no longer optional - elemType := v.Type().Elem() - if elemType.Kind() == reflect.Struct { - if _, hasTimeout := elemType.FieldByName("Timeout"); hasTimeout { - if base["timeout"] == nil { - base["timeout"] = float64(30000) // default 30s - } - } - } return base } option = v.Index(0).Interface() @@ -440,6 +426,30 @@ func newTimeoutSettings(parent *timeoutSettings) *timeoutSettings { } } +// resolveTimeout returns the explicit call timeout when set (including zero) +// or a pointer to the configured default. Used to populate metadata.timeout. +func resolveTimeout(settings *timeoutSettings, explicit *float64) *float64 { + if explicit != nil { + return explicit + } + if settings == nil { + return Float(defaultTimeout) + } + return Float(settings.Timeout()) +} + +// resolveNavigationTimeout returns the explicit navigation timeout when set +// (including zero) or a pointer to the configured navigation default. +func resolveNavigationTimeout(settings *timeoutSettings, explicit *float64) *float64 { + if explicit != nil { + return explicit + } + if settings == nil { + return Float(defaultTimeout) + } + return Float(settings.NavigationTimeout()) +} + // SelectOptionValues is the option struct for ElementHandle.Select() etc. type SelectOptionValues struct { ValuesOrLabels *[]string diff --git a/locator.go b/locator.go index 8be76360..95035f1e 100644 --- a/locator.go +++ b/locator.go @@ -154,12 +154,11 @@ func (l *locatorImpl) Blur(options ...LocatorBlurOptions) error { "selector": l.selector, "strict": true, } - if len(options) == 1 && options[0].Timeout != nil { - params["timeout"] = options[0].Timeout - } else { - params["timeout"] = float64(30000) // default 30s, required in Playwright v1.57+ + var explicit *float64 + if len(options) == 1 { + explicit = options[0].Timeout } - _, err := l.frame.channel.Send("blur", params) + _, err := l.frame.channel.SendWithTimeout("blur", resolveTimeout(l.frame.page.timeoutSettings, explicit), params) return err } @@ -171,7 +170,12 @@ func (l *locatorImpl) AriaSnapshot(options ...LocatorAriaSnapshotOptions) (strin if len(options) == 1 { option = options[0] } - ret, err := l.frame.channel.Send("ariaSnapshot", option, + var explicit *float64 + if option.Timeout != nil { + explicit = option.Timeout + } + // ariaSnapshot may use LocatorAriaSnapshotOptions + ret, err := l.frame.channel.SendWithTimeout("ariaSnapshot", resolveTimeout(l.frame.page.timeoutSettings, explicit), option, map[string]any{"selector": l.selector}) if err != nil { return "", err @@ -335,14 +339,12 @@ func (l *locatorImpl) Drop(payload Payload, options ...LocatorDropOptions) error if options[0].Position != nil { params["position"] = options[0].Position } - if options[0].Timeout != nil { - params["timeout"] = options[0].Timeout - } } - if _, ok := params["timeout"]; !ok { - params["timeout"] = float64(30000) // default 30s, required in Playwright v1.57+ + var dropTimeout *float64 + if len(options) == 1 { + dropTimeout = options[0].Timeout } - _, err := l.frame.channel.Send("drop", params) + _, err := l.frame.channel.SendWithTimeout("drop", resolveTimeout(l.frame.page.timeoutSettings, dropTimeout), params) return err } @@ -961,12 +963,12 @@ func (l *locatorImpl) withElement( if len(options) == 1 { option.Timeout = options[0].Timeout } - // Mirror upstream `_withElement`: when an explicit timeout is provided, the - // total budget for waitForSelector plus the inner action is bounded by that - // single timeout. Compute a deadline up front and hand the inner action the - // remaining budget instead of repeating the full timeout. + // Mirror upstream `_withElement`: resolve the effective timeout once and use + // one budget for waitForSelector plus the inner action. A zero timeout means + // unlimited and must remain zero for both stages. + option.Timeout = resolveTimeout(l.frame.page.timeoutSettings, option.Timeout) var deadline *time.Time - if option.Timeout != nil { + if *option.Timeout > 0 { d := time.Now().Add(time.Duration(*option.Timeout) * time.Millisecond) deadline = &d } @@ -975,7 +977,7 @@ func (l *locatorImpl) withElement( return nil, err } - var remaining *float64 + remaining := option.Timeout if deadline != nil { ms := float64(time.Until(*deadline).Milliseconds()) // Floor at 1ms, not 0: the protocol treats timeout 0 as "disable timeout" @@ -996,6 +998,28 @@ func (l *locatorImpl) withElement( return result, nil } +func (l *locatorImpl) WaitForFunction(expression string, arg any, options ...LocatorWaitForFunctionOptions) error { + if l.err != nil { + return l.err + } + var explicit *float64 + if len(options) == 1 { + explicit = options[0].Timeout + } + params := map[string]any{ + "selector": l.selector, + "strict": true, + "expression": expression, + "arg": serializeArgument(arg), + } + _, err := l.frame.channel.SendWithTimeout( + "waitForFunction", + resolveTimeout(l.frame.page.timeoutSettings, explicit), + params, + ) + return err +} + func (l *locatorImpl) expect(expression string, options frameExpectOptions) (*frameExpectResult, error) { if l.err != nil { return nil, l.err @@ -1008,7 +1032,7 @@ func (l *locatorImpl) expect(expression string, options frameExpectOptions) (*fr overrides["expectedValue"] = serializeArgument(options.ExpectedValue) options.ExpectedValue = nil } - _, err := l.frame.channel.SendReturnAsDict("expect", options, overrides) + _, err := l.frame.channel.SendReturnAsDictWithTimeout("expect", options.Timeout, options, overrides) if err != nil { // Since v1.61 a failed assertion is reported as a server error carrying // structured errorDetails rather than a `{ matches: false }` result. diff --git a/mime_types.go b/mime_types.go index 3b425591..97bab8ef 100644 --- a/mime_types.go +++ b/mime_types.go @@ -81,6 +81,8 @@ func determineScreenshotType(path *string, typ *ScreenshotType) (*ScreenshotType return ScreenshotTypePng, nil case "image/jpeg": return ScreenshotTypeJpeg, nil + case "image/webp": + return ScreenshotTypeWebp, nil default: return nil, fmt.Errorf("path: unsupported mime type %q", mimeType) } diff --git a/page.go b/page.go index b6e4d1fb..387ea8c1 100644 --- a/page.go +++ b/page.go @@ -379,24 +379,12 @@ func (p *pageImpl) Goto(url string, options ...PageGotoOptions) (Response, error return p.mainFrame.Goto(url) } -// navigationTimeoutOverride returns the channel overrides that resolve the -// configured navigation timeout when the caller supplied no per-call timeout. -// The protocol requires a timeout on the navigation methods, so without this the -// serializer would inject a hardcoded 30s instead of honoring SetDefaultNavigationTimeout. -func (p *pageImpl) navigationTimeoutOverride(timeout *float64) map[string]any { - overrides := map[string]any{} - if timeout == nil { - overrides["timeout"] = p.timeoutSettings.NavigationTimeout() - } - return overrides -} - func (p *pageImpl) Reload(options ...PageReloadOptions) (Response, error) { var timeout *float64 if len(options) == 1 { timeout = options[0].Timeout } - channel, err := p.channel.Send("reload", options, p.navigationTimeoutOverride(timeout)) + channel, err := p.channel.SendWithTimeout("reload", resolveNavigationTimeout(p.timeoutSettings, timeout), options) if err != nil { return nil, err } @@ -419,7 +407,7 @@ func (p *pageImpl) GoBack(options ...PageGoBackOptions) (Response, error) { if len(options) == 1 { timeout = options[0].Timeout } - channel, err := p.channel.Send("goBack", options, p.navigationTimeoutOverride(timeout)) + channel, err := p.channel.SendWithTimeout("goBack", resolveNavigationTimeout(p.timeoutSettings, timeout), options) if err != nil { return nil, err } @@ -436,7 +424,7 @@ func (p *pageImpl) GoForward(options ...PageGoForwardOptions) (Response, error) if len(options) == 1 { timeout = options[0].Timeout } - channel, err := p.channel.Send("goForward", options, p.navigationTimeoutOverride(timeout)) + channel, err := p.channel.SendWithTimeout("goForward", resolveNavigationTimeout(p.timeoutSettings, timeout), options) if err != nil { return nil, err } @@ -542,7 +530,11 @@ func (p *pageImpl) Screenshot(options ...PageScreenshotOptions) ([]byte, error) overrides["mask"] = masks } } - data, err := p.channel.Send("screenshot", options, overrides) + var screenshotTimeout *float64 + if len(options) == 1 { + screenshotTimeout = options[0].Timeout + } + data, err := p.channel.SendWithTimeout("screenshot", resolveTimeout(p.timeoutSettings, screenshotTimeout), options, overrides) if err != nil { return nil, err } @@ -1549,7 +1541,11 @@ func (p *pageImpl) updateWebSocketInterceptionPatterns() error { } func (p *pageImpl) AriaSnapshot(options ...PageAriaSnapshotOptions) (string, error) { - result, err := p.mainFrame.(*frameImpl).channel.Send("ariaSnapshot", options) + var ariaTimeout *float64 + if len(options) == 1 { + ariaTimeout = options[0].Timeout + } + result, err := p.mainFrame.(*frameImpl).channel.SendWithTimeout("ariaSnapshot", resolveTimeout(p.timeoutSettings, ariaTimeout), options) if err != nil { return "", err } diff --git a/page_assertions.go b/page_assertions.go index 976b5ab4..10e00296 100644 --- a/page_assertions.go +++ b/page_assertions.go @@ -50,7 +50,7 @@ func (pa *pageAssertionsImpl) expectOnFrame( matches bool log []string ) - _, err := frame.channel.SendReturnAsDict("expect", options, overrides) + _, err := frame.channel.SendReturnAsDictWithTimeout("expect", options.Timeout, options, overrides) if err != nil { // Since v1.61 a failed assertion is reported as a server error carrying // structured errorDetails rather than a `{ matches: false }` result. diff --git a/patches/main.patch b/patches/main.patch index ee04690f..bd4f650e 100644 --- a/patches/main.patch +++ b/patches/main.patch @@ -1,5 +1,5 @@ diff --git a/docs/src/api/class-apirequest.md b/docs/src/api/class-apirequest.md -index d27e22b03..fd06a0784 100644 +index d27e22b03..d84e55191 100644 --- a/docs/src/api/class-apirequest.md +++ b/docs/src/api/class-apirequest.md @@ -62,7 +62,7 @@ Methods like [`method: APIRequestContext.get`] take the base URL into considerat @@ -11,7 +11,7 @@ index d27e22b03..fd06a0784 100644 - `storageState` <[path]|[Object]> - `cookies` <[Array]<[Object]>> - `name` <[string]> -@@ -73,6 +73,7 @@ Methods like [`method: APIRequestContext.get`] take the base URL into considerat +@@ -73,11 +73,19 @@ Methods like [`method: APIRequestContext.get`] take the base URL into considerat - `httpOnly` <[boolean]> - `secure` <[boolean]> - `sameSite` <[SameSiteAttribute]<"Strict"|"Lax"|"None">> @@ -19,8 +19,20 @@ index d27e22b03..fd06a0784 100644 - `origins` <[Array]<[Object]>> - `origin` <[string]> - `localStorage` <[Array]<[Object]>> + - `name` <[string]> + - `value` <[string]> ++ - `credentials` ?<[Array]<[Object]>> Virtual WebAuthn credentials. BrowserContext captures and restores them; APIRequestContext ignores this field. ++ * alias: VirtualCredential ++ - `id` <[string]> Base64url-encoded credential id. ++ - `rpId` <[string]> Relying party id. ++ - `userHandle` <[string]> Base64url-encoded user handle. ++ - `privateKey` <[string]> Base64url-encoded PKCS#8 (DER) private key. ++ - `publicKey` <[string]> Base64url-encoded SPKI (DER) public key. + + Populates context with given storage state. This option can be used to initialize context with logged-in information + obtained via [`method: BrowserContext.storageState`] or [`method: APIRequestContext.storageState`]. Either a path to the diff --git a/docs/src/api/class-apirequestcontext.md b/docs/src/api/class-apirequestcontext.md -index 2ddc3815f..6e80f44c0 100644 +index 28399a0b0..f0f8361cd 100644 --- a/docs/src/api/class-apirequestcontext.md +++ b/docs/src/api/class-apirequestcontext.md @@ -163,6 +163,9 @@ context cookies from the response. The method will automatically follow redirect @@ -53,7 +65,7 @@ index 2ddc3815f..6e80f44c0 100644 ### option: APIRequestContext.delete.timeout = %%-js-python-csharp-fetch-option-timeout-%% * since: v1.16 -@@ -328,7 +337,7 @@ Target URL or Request to get all parameters from. +@@ -330,7 +339,7 @@ Target URL or Request to get all parameters from. ### option: APIRequestContext.fetch.method * since: v1.16 @@ -62,7 +74,7 @@ index 2ddc3815f..6e80f44c0 100644 - `method` <[string]> If set changes the fetch method (e.g. [PUT](https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods/PUT) or -@@ -340,6 +349,9 @@ If set changes the fetch method (e.g. [PUT](https://developer.mozilla.org/en-US/ +@@ -342,6 +351,9 @@ If set changes the fetch method (e.g. [PUT](https://developer.mozilla.org/en-US/ ### option: APIRequestContext.fetch.data = %%-js-python-csharp-fetch-option-data-%% * since: v1.16 @@ -72,7 +84,7 @@ index 2ddc3815f..6e80f44c0 100644 ### option: APIRequestContext.fetch.form = %%-js-fetch-option-form-%% * since: v1.16 -@@ -349,6 +361,9 @@ If set changes the fetch method (e.g. [PUT](https://developer.mozilla.org/en-US/ +@@ -351,6 +363,9 @@ If set changes the fetch method (e.g. [PUT](https://developer.mozilla.org/en-US/ ### option: APIRequestContext.fetch.form = %%-csharp-fetch-option-form-%% * since: v1.16 @@ -82,7 +94,7 @@ index 2ddc3815f..6e80f44c0 100644 ### option: APIRequestContext.fetch.multipart = %%-js-fetch-option-multipart-%% * since: v1.16 -@@ -358,6 +373,9 @@ If set changes the fetch method (e.g. [PUT](https://developer.mozilla.org/en-US/ +@@ -360,6 +375,9 @@ If set changes the fetch method (e.g. [PUT](https://developer.mozilla.org/en-US/ ### option: APIRequestContext.fetch.multipart = %%-csharp-fetch-option-multipart-%% * since: v1.16 @@ -92,7 +104,7 @@ index 2ddc3815f..6e80f44c0 100644 ### option: APIRequestContext.fetch.timeout = %%-js-python-csharp-fetch-option-timeout-%% * since: v1.16 -@@ -453,6 +471,9 @@ await request.GetAsync("https://example.com/api/getText", new() { Params = query +@@ -457,6 +475,9 @@ await request.GetAsync("https://example.com/api/getText", new() { Params = query ### option: APIRequestContext.get.data = %%-js-python-csharp-fetch-option-data-%% * since: v1.26 @@ -102,7 +114,7 @@ index 2ddc3815f..6e80f44c0 100644 ### option: APIRequestContext.get.form = %%-js-fetch-option-form-%% * since: v1.26 -@@ -462,6 +483,9 @@ await request.GetAsync("https://example.com/api/getText", new() { Params = query +@@ -466,6 +487,9 @@ await request.GetAsync("https://example.com/api/getText", new() { Params = query ### option: APIRequestContext.get.form = %%-csharp-fetch-option-form-%% * since: v1.26 @@ -112,7 +124,7 @@ index 2ddc3815f..6e80f44c0 100644 ### option: APIRequestContext.get.multipart = %%-js-fetch-option-multipart-%% * since: v1.26 -@@ -471,6 +495,9 @@ await request.GetAsync("https://example.com/api/getText", new() { Params = query +@@ -475,6 +499,9 @@ await request.GetAsync("https://example.com/api/getText", new() { Params = query ### option: APIRequestContext.get.multipart = %%-csharp-fetch-option-multipart-%% * since: v1.26 @@ -122,7 +134,7 @@ index 2ddc3815f..6e80f44c0 100644 ### option: APIRequestContext.get.timeout = %%-js-python-csharp-fetch-option-timeout-%% * since: v1.16 -@@ -518,6 +545,9 @@ context cookies from the response. The method will automatically follow redirect +@@ -524,6 +551,9 @@ context cookies from the response. The method will automatically follow redirect ### option: APIRequestContext.head.data = %%-js-python-csharp-fetch-option-data-%% * since: v1.26 @@ -132,7 +144,7 @@ index 2ddc3815f..6e80f44c0 100644 ### option: APIRequestContext.head.form = %%-python-fetch-option-form-%% * since: v1.26 -@@ -527,6 +557,9 @@ context cookies from the response. The method will automatically follow redirect +@@ -533,6 +563,9 @@ context cookies from the response. The method will automatically follow redirect ### option: APIRequestContext.head.form = %%-csharp-fetch-option-form-%% * since: v1.26 @@ -142,7 +154,7 @@ index 2ddc3815f..6e80f44c0 100644 ### option: APIRequestContext.head.multipart = %%-js-fetch-option-multipart-%% * since: v1.26 -@@ -536,6 +569,9 @@ context cookies from the response. The method will automatically follow redirect +@@ -542,6 +575,9 @@ context cookies from the response. The method will automatically follow redirect ### option: APIRequestContext.head.multipart = %%-csharp-fetch-option-multipart-%% * since: v1.26 @@ -152,7 +164,7 @@ index 2ddc3815f..6e80f44c0 100644 ### option: APIRequestContext.head.timeout = %%-js-python-csharp-fetch-option-timeout-%% * since: v1.16 -@@ -583,6 +619,9 @@ context cookies from the response. The method will automatically follow redirect +@@ -591,6 +627,9 @@ context cookies from the response. The method will automatically follow redirect ### option: APIRequestContext.patch.data = %%-js-python-csharp-fetch-option-data-%% * since: v1.16 @@ -162,7 +174,7 @@ index 2ddc3815f..6e80f44c0 100644 ### option: APIRequestContext.patch.form = %%-js-fetch-option-form-%% * since: v1.16 -@@ -592,6 +631,9 @@ context cookies from the response. The method will automatically follow redirect +@@ -600,6 +639,9 @@ context cookies from the response. The method will automatically follow redirect ### option: APIRequestContext.patch.form = %%-csharp-fetch-option-form-%% * since: v1.16 @@ -172,7 +184,7 @@ index 2ddc3815f..6e80f44c0 100644 ### option: APIRequestContext.patch.multipart = %%-js-fetch-option-multipart-%% * since: v1.16 -@@ -601,6 +643,9 @@ context cookies from the response. The method will automatically follow redirect +@@ -609,6 +651,9 @@ context cookies from the response. The method will automatically follow redirect ### option: APIRequestContext.patch.multipart = %%-csharp-fetch-option-multipart-%% * since: v1.16 @@ -182,7 +194,7 @@ index 2ddc3815f..6e80f44c0 100644 ### option: APIRequestContext.patch.timeout = %%-js-python-csharp-fetch-option-timeout-%% * since: v1.16 -@@ -769,6 +814,9 @@ await request.PostAsync("https://example.com/api/uploadScript", new() { Multipar +@@ -779,6 +824,9 @@ await request.PostAsync("https://example.com/api/uploadScript", new() { Multipar ### option: APIRequestContext.post.data = %%-js-python-csharp-fetch-option-data-%% * since: v1.16 @@ -192,7 +204,7 @@ index 2ddc3815f..6e80f44c0 100644 ### option: APIRequestContext.post.form = %%-js-fetch-option-form-%% * since: v1.16 -@@ -778,6 +826,9 @@ await request.PostAsync("https://example.com/api/uploadScript", new() { Multipar +@@ -788,6 +836,9 @@ await request.PostAsync("https://example.com/api/uploadScript", new() { Multipar ### option: APIRequestContext.post.form = %%-csharp-fetch-option-form-%% * since: v1.16 @@ -202,7 +214,7 @@ index 2ddc3815f..6e80f44c0 100644 ### option: APIRequestContext.post.multipart = %%-js-fetch-option-multipart-%% * since: v1.16 -@@ -787,6 +838,9 @@ await request.PostAsync("https://example.com/api/uploadScript", new() { Multipar +@@ -797,6 +848,9 @@ await request.PostAsync("https://example.com/api/uploadScript", new() { Multipar ### option: APIRequestContext.post.multipart = %%-csharp-fetch-option-multipart-%% * since: v1.16 @@ -212,7 +224,7 @@ index 2ddc3815f..6e80f44c0 100644 ### option: APIRequestContext.post.timeout = %%-js-python-csharp-fetch-option-timeout-%% * since: v1.16 -@@ -834,6 +888,9 @@ context cookies from the response. The method will automatically follow redirect +@@ -846,6 +900,9 @@ context cookies from the response. The method will automatically follow redirect ### option: APIRequestContext.put.data = %%-js-python-csharp-fetch-option-data-%% * since: v1.16 @@ -222,7 +234,7 @@ index 2ddc3815f..6e80f44c0 100644 ### option: APIRequestContext.put.form = %%-python-fetch-option-form-%% * since: v1.16 -@@ -843,6 +900,9 @@ context cookies from the response. The method will automatically follow redirect +@@ -855,6 +912,9 @@ context cookies from the response. The method will automatically follow redirect ### option: APIRequestContext.put.form = %%-csharp-fetch-option-form-%% * since: v1.16 @@ -232,7 +244,7 @@ index 2ddc3815f..6e80f44c0 100644 ### option: APIRequestContext.put.multipart = %%-js-fetch-option-multipart-%% * since: v1.16 -@@ -852,6 +912,9 @@ context cookies from the response. The method will automatically follow redirect +@@ -864,6 +924,9 @@ context cookies from the response. The method will automatically follow redirect ### option: APIRequestContext.put.multipart = %%-csharp-fetch-option-multipart-%% * since: v1.16 @@ -242,7 +254,7 @@ index 2ddc3815f..6e80f44c0 100644 ### option: APIRequestContext.put.timeout = %%-js-python-csharp-fetch-option-timeout-%% * since: v1.16 -@@ -879,6 +942,7 @@ context cookies from the response. The method will automatically follow redirect +@@ -893,11 +956,19 @@ context cookies from the response. The method will automatically follow redirect - `httpOnly` <[boolean]> - `secure` <[boolean]> - `sameSite` <[SameSiteAttribute]<"Strict"|"Lax"|"None">> @@ -250,7 +262,19 @@ index 2ddc3815f..6e80f44c0 100644 - `origins` <[Array]<[Object]>> - `origin` <[string]> - `localStorage` <[Array]<[Object]>> -@@ -897,6 +961,7 @@ Returns storage state for this request context, contains current cookies and loc + - `name` <[string]> + - `value` <[string]> ++ - `credentials` ?<[Array]<[Object]>> Virtual WebAuthn credentials. BrowserContext captures and restores them; APIRequestContext ignores this field and never populates it. ++ * alias: VirtualCredential ++ - `id` <[string]> Base64url-encoded credential id. ++ - `rpId` <[string]> Relying party id. ++ - `userHandle` <[string]> Base64url-encoded user handle. ++ - `privateKey` <[string]> Base64url-encoded PKCS#8 (DER) private key. ++ - `publicKey` <[string]> Base64url-encoded SPKI (DER) public key. + + Returns storage state for this request context, contains current cookies and local storage snapshot if it was passed to the constructor. + +@@ -911,6 +982,7 @@ Returns storage state for this request context, contains current cookies and loc ### option: APIRequestContext.storageState.indexedDB * since: v1.51 @@ -259,7 +283,7 @@ index 2ddc3815f..6e80f44c0 100644 Set to `true` to include IndexedDB in the storage state snapshot. diff --git a/docs/src/api/class-apiresponse.md b/docs/src/api/class-apiresponse.md -index 48ca44eb8..6fc5d7d2c 100644 +index ae1f3de44..df0a55847 100644 --- a/docs/src/api/class-apiresponse.md +++ b/docs/src/api/class-apiresponse.md @@ -68,7 +68,7 @@ Headers with multiple entries, such as `Set-Cookie`, appear in the array multipl @@ -346,7 +370,7 @@ index 2c07e98c5..9dac60a4b 100644 :::note diff --git a/docs/src/api/class-browsercontext.md b/docs/src/api/class-browsercontext.md -index c5e504b48..1d968c7d9 100644 +index f5c2cbc95..29118b30b 100644 --- a/docs/src/api/class-browsercontext.md +++ b/docs/src/api/class-browsercontext.md @@ -440,7 +440,7 @@ The order of evaluation of multiple scripts installed via [`method: BrowserConte @@ -358,7 +382,7 @@ index c5e504b48..1d968c7d9 100644 - `script` <[function]|[string]|[Object]> - `path` ?<[path]> Path to the JavaScript file. If `path` is a relative path, then it is resolved relative to the current working directory. Optional. -@@ -1237,7 +1237,7 @@ A glob pattern, regex pattern, URL pattern, or predicate that receives a [URL] t +@@ -1240,7 +1240,7 @@ A glob pattern, regex pattern, URL pattern, or predicate that receives a [URL] t ### param: BrowserContext.route.url * since: v1.8 @@ -367,16 +391,16 @@ index c5e504b48..1d968c7d9 100644 - `url` <[string]|[RegExp]|[function]\([URL]\):[boolean]> A glob pattern, regex pattern, or predicate that receives a [URL] to match during routing. If [`option: Browser.newContext.baseURL`] is set in the context options and the provided URL is a string that does not start with `*`, it is resolved using the [`new URL()`](https://developer.mozilla.org/en-US/docs/Web/API/URL/URL) constructor. -@@ -1251,7 +1251,7 @@ handler function to route the request. +@@ -1254,7 +1254,7 @@ handler function to route the request. ### param: BrowserContext.route.handler * since: v1.8 -* langs: csharp, java -+* langs: csharp, java,go ++* langs: csharp, java, go - `handler` <[function]\([Route]\)> handler function to route the request. -@@ -1394,7 +1394,7 @@ Handler function to route the WebSocket. +@@ -1397,7 +1397,7 @@ Handler function to route the WebSocket. ### param: BrowserContext.routeWebSocket.handler * since: v1.48 @@ -385,7 +409,7 @@ index c5e504b48..1d968c7d9 100644 - `handler` <[function]\([WebSocketRoute]\)> Handler function to route the WebSocket. -@@ -1402,7 +1402,7 @@ Handler function to route the WebSocket. +@@ -1405,7 +1405,7 @@ Handler function to route the WebSocket. ## method: BrowserContext.serviceWorkers * since: v1.11 @@ -394,7 +418,7 @@ index c5e504b48..1d968c7d9 100644 - returns: <[Array]<[Worker]>> :::note -@@ -1543,6 +1543,7 @@ Whether to emulate network being offline for the browser context. +@@ -1546,11 +1546,19 @@ Whether to emulate network being offline for the browser context. - `httpOnly` <[boolean]> - `secure` <[boolean]> - `sameSite` <[SameSiteAttribute]<"Strict"|"Lax"|"None">> @@ -402,7 +426,19 @@ index c5e504b48..1d968c7d9 100644 - `origins` <[Array]<[Object]>> - `origin` <[string]> - `localStorage` <[Array]<[Object]>> -@@ -1561,6 +1562,7 @@ Returns storage state for this browser context, contains current cookies, local + - `name` <[string]> + - `value` <[string]> ++ - `credentials` ?<[Array]<[Object]>> Virtual WebAuthn credentials when [`option: BrowserContext.storageState.credentials`] is set. ++ * alias: VirtualCredential ++ - `id` <[string]> Base64url-encoded credential id. ++ - `rpId` <[string]> Relying party id. ++ - `userHandle` <[string]> Base64url-encoded user handle. ++ - `privateKey` <[string]> Base64url-encoded PKCS#8 (DER) private key. ++ - `publicKey` <[string]> Base64url-encoded SPKI (DER) public key. + + Returns storage state for this browser context, contains current cookies, local storage snapshot, IndexedDB snapshot and virtual WebAuthn credentials. + +@@ -1564,6 +1572,7 @@ Returns storage state for this browser context, contains current cookies, local ### option: BrowserContext.storageState.indexedDB * since: v1.51 @@ -410,7 +446,7 @@ index c5e504b48..1d968c7d9 100644 - `indexedDB` ? Set to `true` to include [IndexedDB](https://developer.mozilla.org/en-US/docs/Web/API/IndexedDB_API) in the storage state snapshot. -@@ -1634,7 +1636,7 @@ A glob pattern, regex pattern, URL pattern, or predicate receiving [URL] used to +@@ -1648,7 +1657,7 @@ A glob pattern, regex pattern, URL pattern, or predicate receiving [URL] used to ### param: BrowserContext.unroute.url * since: v1.8 @@ -419,7 +455,7 @@ index c5e504b48..1d968c7d9 100644 - `url` <[string]|[RegExp]|[function]\([URL]\):[boolean]> A glob pattern, regex pattern, or predicate receiving [URL] used to register a routing with -@@ -1647,6 +1649,13 @@ A glob pattern, regex pattern, or predicate receiving [URL] used to register a r +@@ -1661,6 +1670,13 @@ A glob pattern, regex pattern, or predicate receiving [URL] used to register a r Optional handler function used to register a routing with [`method: BrowserContext.route`]. @@ -433,7 +469,7 @@ index c5e504b48..1d968c7d9 100644 ### param: BrowserContext.unroute.handler * since: v1.8 * langs: csharp, java -@@ -1688,7 +1697,8 @@ Condition to wait for. +@@ -1702,7 +1718,8 @@ Condition to wait for. ## async method: BrowserContext.waitForConsoleMessage * since: v1.34 @@ -443,7 +479,7 @@ index c5e504b48..1d968c7d9 100644 - alias-python: expect_console_message - alias-csharp: RunAndWaitForConsoleMessage - returns: <[ConsoleMessage]> -@@ -1719,7 +1729,8 @@ Receives the [ConsoleMessage] object and resolves to truthy value when the waiti +@@ -1752,7 +1769,8 @@ Receives the [ConsoleMessage] object and resolves to truthy value when the waiti ## async method: BrowserContext.waitForEvent * since: v1.8 @@ -453,7 +489,7 @@ index c5e504b48..1d968c7d9 100644 - alias-python: expect_event - returns: <[any]> -@@ -1785,7 +1796,8 @@ Either a predicate that receives an event or an options object. Optional. +@@ -1819,7 +1837,8 @@ Either a predicate that receives an event or an options object. Optional. ## async method: BrowserContext.waitForPage * since: v1.9 @@ -463,7 +499,7 @@ index c5e504b48..1d968c7d9 100644 - alias-python: expect_page - alias-csharp: RunAndWaitForPage - returns: <[Page]> -@@ -1804,7 +1816,7 @@ Will throw an error if the context closes before new [Page] is created. +@@ -1856,7 +1875,7 @@ print(new_page.title()) ### option: BrowserContext.waitForPage.predicate * since: v1.9 @@ -472,7 +508,7 @@ index c5e504b48..1d968c7d9 100644 - `predicate` <[function]\([Page]\):[boolean]> Receives the [Page] object and resolves to truthy value when the waiting should resolve. -@@ -1817,7 +1829,8 @@ Receives the [Page] object and resolves to truthy value when the waiting should +@@ -1870,7 +1889,8 @@ Receives the [Page] object and resolves to truthy value when the waiting should ## async method: BrowserContext.waitForEvent2 * since: v1.8 @@ -483,7 +519,7 @@ index c5e504b48..1d968c7d9 100644 - returns: <[any]> diff --git a/docs/src/api/class-cdpsession.md b/docs/src/api/class-cdpsession.md -index d14b51a86..4e0959473 100644 +index 1d581ffe1..4e11d28f1 100644 --- a/docs/src/api/class-cdpsession.md +++ b/docs/src/api/class-cdpsession.md @@ -126,7 +126,7 @@ Optional method parameters. @@ -496,44 +532,54 @@ index d14b51a86..4e0959473 100644 - `params` ?<[Map]> diff --git a/docs/src/api/class-clock.md b/docs/src/api/class-clock.md -index e5610b424..748b93b68 100644 +index 233531f2d..c39a6d756 100644 --- a/docs/src/api/class-clock.md +++ b/docs/src/api/class-clock.md -@@ -71,7 +71,7 @@ Fake timers are used to manually control the flow of time in tests. They allow y - Time to initialize with, current system time by default. +@@ -64,11 +64,11 @@ Install fake implementations for the following time-related functions: + Fake timers are used to manually control the flow of time in tests. They allow you to advance time, fire timers, and control the behavior of time-dependent functions. See [`method: Clock.runFor`] and [`method: Clock.fastForward`] for more information. ### option: Clock.install.time --* langs: python -+* langs: python, go +-* langs: js, java ++* langs: js, java, go * since: v1.45 - - `time` <[float]|[string]|[Date]> + - `time` <[long]|[string]|[Date]> + +-Time to initialize with, current system time by default. ++Time to initialize with, current system time by default. Numeric values are Unix time in milliseconds. -@@ -204,7 +204,7 @@ page.clock().pauseAt(format.parse("2024-12-10T10:00:00")); - Time to pause at. + ### option: Clock.install.time + * langs: python +@@ -197,11 +197,11 @@ page.clock().pauseAt(format.parse("2024-12-10T10:00:00")); + ``` ### param: Clock.pauseAt.time --* langs: python -+* langs: python, go +-* langs: js, java ++* langs: js, java, go * since: v1.45 - - `time` <[float]|[string]|[Date]> + - `time` <[long]|[string]|[Date]> + +-Time to pause at. ++Time to pause at. Numeric values are Unix time in milliseconds. -@@ -270,7 +270,7 @@ await page.Clock.SetFixedTimeAsync("2020-02-02"); - Time to be set in milliseconds. + ### param: Clock.pauseAt.time + * langs: python +@@ -263,7 +263,7 @@ await page.Clock.SetFixedTimeAsync("2020-02-02"); + ``` ### param: Clock.setFixedTime.time --* langs: python -+* langs: python, go +-* langs: js, java ++* langs: js, java, go * since: v1.45 - - `time` <[float]|[string]|[Date]> + - `time` <[long]|[string]|[Date]> -@@ -328,7 +328,7 @@ await page.Clock.SetSystemTimeAsync("2020-02-02"); - Time to be set in milliseconds. +@@ -321,7 +321,7 @@ await page.Clock.SetSystemTimeAsync("2020-02-02"); + ``` ### param: Clock.setSystemTime.time --* langs: python -+* langs: python, go +-* langs: js, java ++* langs: js, java, go * since: v1.45 - - `time` <[float]|[string]|[Date]> + - `time` <[long]|[string]|[Date]> diff --git a/docs/src/api/class-consolemessage.md b/docs/src/api/class-consolemessage.md index 020d2efba..9e15e8223 100644 @@ -572,10 +618,10 @@ index 020d2efba..9e15e8223 100644 One of the following values: `'log'`, `'debug'`, `'info'`, `'error'`, `'warning'`, `'dir'`, `'dirxml'`, `'table'`, diff --git a/docs/src/api/class-frame.md b/docs/src/api/class-frame.md -index c73aa88e7..413adb658 100644 +index 6a1242084..ef11e5e30 100644 --- a/docs/src/api/class-frame.md +++ b/docs/src/api/class-frame.md -@@ -283,6 +283,9 @@ When all steps combined have not finished during the specified [`option: timeout +@@ -293,6 +293,9 @@ When all steps combined have not finished during the specified [`option: timeout ### option: Frame.click.trial = %%-input-trial-with-modifiers-%% * since: v1.11 @@ -585,7 +631,7 @@ index c73aa88e7..413adb658 100644 ## async method: Frame.content * since: v1.8 - returns: <[string]> -@@ -1951,7 +1954,7 @@ await page.MainFrame.WaitForFunctionAsync("selector => !!document.querySelector( +@@ -2033,7 +2036,7 @@ await page.MainFrame.WaitForFunctionAsync("selector => !!document.querySelector( Optional argument to pass to [`param: expression`]. @@ -594,7 +640,7 @@ index c73aa88e7..413adb658 100644 * since: v1.8 ### option: Frame.waitForFunction.polling = %%-csharp-java-wait-for-function-polling-%% -@@ -2003,6 +2006,11 @@ await frame.WaitForLoadStateAsync(); // Defaults to LoadState.Load +@@ -2087,6 +2090,11 @@ await frame.WaitForLoadStateAsync(); // Defaults to LoadState.Load ``` ### param: Frame.waitForLoadState.state = %%-wait-for-load-state-state-%% @@ -606,7 +652,7 @@ index c73aa88e7..413adb658 100644 * since: v1.8 ### option: Frame.waitForLoadState.timeout = %%-navigation-timeout-%% -@@ -2015,6 +2023,7 @@ await frame.WaitForLoadStateAsync(); // Defaults to LoadState.Load +@@ -2100,6 +2108,7 @@ await frame.WaitForLoadStateAsync(); // Defaults to LoadState.Load * since: v1.8 * deprecated: This method is inherently racy, please use [`method: Frame.waitForURL`] instead. * langs: @@ -615,10 +661,10 @@ index c73aa88e7..413adb658 100644 * alias-csharp: RunAndWaitForNavigation - returns: <[null]|[Response]> diff --git a/docs/src/api/class-locator.md b/docs/src/api/class-locator.md -index 59a98671a..0ce1f8c95 100644 +index c252b318f..d5a216439 100644 --- a/docs/src/api/class-locator.md +++ b/docs/src/api/class-locator.md -@@ -655,7 +655,7 @@ Locator description. +@@ -678,7 +678,7 @@ Locator description. ## method: Locator.description * since: v1.57 @@ -627,7 +673,7 @@ index 59a98671a..0ce1f8c95 100644 - returns: <[null]|[string]> Returns locator description previously set with [`method: Locator.describe`]. Returns `null` if no custom description has been set. -@@ -1091,7 +1091,7 @@ Optional argument to pass to [`param: expression`]. +@@ -1128,7 +1128,7 @@ Optional argument to pass to [`param: expression`]. ### option: Locator.evaluate.timeout * since: v1.14 @@ -636,7 +682,7 @@ index 59a98671a..0ce1f8c95 100644 - `timeout` <[float]> Maximum time in milliseconds to wait for the locator before evaluating. Note that after locator is resolved, evaluation itself is not limited by the timeout. Defaults to `30000` (30 seconds). Pass `0` to disable timeout. -@@ -1188,7 +1188,7 @@ Optional argument to pass to [`param: expression`]. +@@ -1230,7 +1230,7 @@ Optional argument to pass to [`param: expression`]. ### option: Locator.evaluateHandle.timeout * since: v1.14 @@ -646,7 +692,7 @@ index 59a98671a..0ce1f8c95 100644 Maximum time in milliseconds to wait for the locator before evaluating. Note that after locator is resolved, evaluation itself is not limited by the timeout. Defaults to `30000` (30 seconds). Pass `0` to disable timeout. diff --git a/docs/src/api/class-locatorassertions.md b/docs/src/api/class-locatorassertions.md -index dcfafda26..3940e6633 100644 +index 200c4ec20..44b19148b 100644 --- a/docs/src/api/class-locatorassertions.md +++ b/docs/src/api/class-locatorassertions.md @@ -67,7 +67,7 @@ public class ExampleTests : PageTest @@ -658,7 +704,7 @@ index dcfafda26..3940e6633 100644 - returns: <[LocatorAssertions]> Makes the assertion check for the opposite condition. -@@ -1287,7 +1287,7 @@ Expected substring or RegExp or a list of those. +@@ -1327,7 +1327,7 @@ Expected substring or RegExp or a list of those. ### param: LocatorAssertions.toContainText.expected * since: v1.18 @@ -667,7 +713,7 @@ index dcfafda26..3940e6633 100644 - `expected` <[string]|[RegExp]|[Array]<[string]>|[Array]<[RegExp]>|[Array]<[string]|[RegExp]>> Expected substring or RegExp or a list of those. -@@ -1631,7 +1631,7 @@ Expected class or RegExp or a list of those. +@@ -1700,7 +1700,7 @@ Expected class or RegExp or a list of those. ### param: LocatorAssertions.toHaveClass.expected * since: v1.18 @@ -676,7 +722,7 @@ index dcfafda26..3940e6633 100644 - `expected` <[string]|[RegExp]|[Array]<[string]>|[Array]<[RegExp]>|[Array]<[string]|[RegExp]>> Expected class or RegExp or a list of those. -@@ -2164,7 +2164,7 @@ Expected string or RegExp or a list of those. +@@ -2262,7 +2262,7 @@ Expected string or RegExp or a list of those. ### param: LocatorAssertions.toHaveText.expected * since: v1.18 @@ -685,7 +731,7 @@ index dcfafda26..3940e6633 100644 - `expected` <[string]|[RegExp]|[Array]<[string]>|[Array]<[RegExp]>|[Array]<[string]|[RegExp]>> Expected string or RegExp or a list of those. -@@ -2298,7 +2298,7 @@ await Expect(locator).ToHaveValuesAsync(new Regex[] { new Regex("R"), new Regex( +@@ -2402,7 +2402,7 @@ await Expect(locator).ToHaveValuesAsync(new Regex[] { new Regex("R"), new Regex( ### param: LocatorAssertions.toHaveValues.values * since: v1.23 @@ -695,7 +741,7 @@ index dcfafda26..3940e6633 100644 Expected options currently selected. diff --git a/docs/src/api/class-page.md b/docs/src/api/class-page.md -index 606bab5d3..d8e7f9a8e 100644 +index 7771df0ee..e0082380c 100644 --- a/docs/src/api/class-page.md +++ b/docs/src/api/class-page.md @@ -615,7 +615,7 @@ The order of evaluation of multiple scripts installed via [`method: BrowserConte @@ -707,7 +753,7 @@ index 606bab5d3..d8e7f9a8e 100644 - `script` <[function]|[string]|[Object]> - `path` ?<[path]> Path to the JavaScript file. If `path` is a relative path, then it is resolved relative to the current working directory. Optional. -@@ -717,6 +717,7 @@ Brings page to front (activates tab). +@@ -720,6 +720,7 @@ Brings page to front (activates tab). ## async method: Page.cancelPickLocator * since: v1.59 @@ -715,7 +761,7 @@ index 606bab5d3..d8e7f9a8e 100644 Cancels an ongoing [`method: Page.pickLocator`] call by deactivating pick locator mode. If no pick locator mode is active, this method is a no-op. -@@ -815,6 +816,9 @@ When all steps combined have not finished during the specified [`option: timeout +@@ -828,6 +829,9 @@ When all steps combined have not finished during the specified [`option: timeout ### option: Page.click.trial = %%-input-trial-with-modifiers-%% * since: v1.11 @@ -725,7 +771,7 @@ index 606bab5d3..d8e7f9a8e 100644 ## async method: Page.close * since: v1.8 -@@ -1270,6 +1274,14 @@ Passing `null` disables CSS media emulation. +@@ -1295,6 +1299,14 @@ Passing `null` disables CSS media emulation. Changes the CSS media type of the page. The only allowed values are `'Screen'`, `'Print'` and `'Null'`. Passing `'Null'` disables CSS media emulation. @@ -740,7 +786,7 @@ index 606bab5d3..d8e7f9a8e 100644 ### option: Page.emulateMedia.colorScheme * since: v1.9 * langs: js, java -@@ -1286,6 +1298,14 @@ Emulates [prefers-colors-scheme](https://developer.mozilla.org/en-US/docs/Web/CS +@@ -1311,6 +1323,14 @@ Emulates [prefers-colors-scheme](https://developer.mozilla.org/en-US/docs/Web/CS Emulates [prefers-colors-scheme](https://developer.mozilla.org/en-US/docs/Web/CSS/@media/prefers-color-scheme) media feature, supported values are `'light'` and `'dark'`. Passing `'Null'` disables color scheme emulation. `'no-preference'` is deprecated. @@ -755,7 +801,7 @@ index 606bab5d3..d8e7f9a8e 100644 ### option: Page.emulateMedia.reducedMotion * since: v1.12 * langs: js, java -@@ -1300,6 +1320,13 @@ Emulates `'prefers-reduced-motion'` media feature, supported values are `'reduce +@@ -1325,6 +1345,13 @@ Emulates `'prefers-reduced-motion'` media feature, supported values are `'reduce Emulates `'prefers-reduced-motion'` media feature, supported values are `'reduce'`, `'no-preference'`. Passing `null` disables reduced motion emulation. @@ -769,7 +815,7 @@ index 606bab5d3..d8e7f9a8e 100644 ### option: Page.emulateMedia.forcedColors * since: v1.15 * langs: js, java -@@ -1312,6 +1339,13 @@ Emulates `'forced-colors'` media feature, supported values are `'active'` and `' +@@ -1337,6 +1364,13 @@ Emulates `'forced-colors'` media feature, supported values are `'active'` and `' * langs: csharp, python - `forcedColors` <[ForcedColors]<"active"|"none"|"null">> @@ -783,7 +829,7 @@ index 606bab5d3..d8e7f9a8e 100644 ### option: Page.emulateMedia.contrast * since: v1.51 * langs: js, java -@@ -1324,6 +1358,13 @@ Emulates `'prefers-contrast'` media feature, supported values are `'no-preferenc +@@ -1349,6 +1383,13 @@ Emulates `'prefers-contrast'` media feature, supported values are `'no-preferenc * langs: csharp, python - `contrast` <[Contrast]<"no-preference"|"more"|"null">> @@ -797,7 +843,7 @@ index 606bab5d3..d8e7f9a8e 100644 ## async method: Page.evalOnSelector * since: v1.9 * discouraged: This method does not wait for the element to pass actionability -@@ -2139,14 +2180,14 @@ Frame name specified in the `iframe`'s `name` attribute. +@@ -2174,14 +2215,14 @@ Frame name specified in the `iframe`'s `name` attribute. ### option: Page.frame.name * since: v1.8 @@ -814,7 +860,7 @@ index 606bab5d3..d8e7f9a8e 100644 - `url` ?<[string]|[RegExp]|[function]\([URL]\):[boolean]> A glob pattern, regex pattern or predicate receiving frame's `url` as a [URL] object. Optional. -@@ -2740,7 +2781,7 @@ Returns up to (currently) 200 last page errors from this page. See [`event: Page +@@ -2812,7 +2853,7 @@ Returns up to (currently) 200 last page errors from this page. See [`event: Page ## async method: Page.pageErrors * since: v1.56 @@ -823,7 +869,7 @@ index 606bab5d3..d8e7f9a8e 100644 - returns: <[Array]<[string]>> Returns up to (currently) 200 last page errors from this page. See [`event: Page.pageError`] for more details. -@@ -2994,7 +3035,7 @@ Paper width, accepts values labeled with units. +@@ -3066,7 +3107,7 @@ Paper width, accepts values labeled with units. ### option: Page.pdf.width * since: v1.8 @@ -832,7 +878,7 @@ index 606bab5d3..d8e7f9a8e 100644 - `width` <[string]> Paper width, accepts values labeled with units. -@@ -3008,7 +3049,7 @@ Paper height, accepts values labeled with units. +@@ -3080,7 +3121,7 @@ Paper height, accepts values labeled with units. ### option: Page.pdf.height * since: v1.8 @@ -841,7 +887,7 @@ index 606bab5d3..d8e7f9a8e 100644 - `height` <[string]> Paper height, accepts values labeled with units. -@@ -3026,7 +3067,7 @@ Paper margins, defaults to none. +@@ -3098,7 +3139,7 @@ Paper margins, defaults to none. ### option: Page.pdf.margin * since: v1.8 @@ -850,7 +896,7 @@ index 606bab5d3..d8e7f9a8e 100644 - `margin` <[Object]> * alias-java: Margin - `top` ?<[string]> Top margin, accepts values labeled with units. Defaults to `0`. -@@ -3058,6 +3099,7 @@ Whether or not to embed the document outline into the PDF. Defaults to `false`. +@@ -3130,6 +3171,7 @@ Whether or not to embed the document outline into the PDF. Defaults to `false`. ## async method: Page.pickLocator * since: v1.59 @@ -858,7 +904,7 @@ index 606bab5d3..d8e7f9a8e 100644 - returns: <[Locator]> Enters pick locator mode where hovering over page elements highlights them and shows the corresponding locator. -@@ -3493,7 +3535,7 @@ Function that should be run once [`param: locator`] appears. This function shoul +@@ -3567,7 +3609,7 @@ Function that should be run once [`param: locator`] appears. This function shoul Function that should be run once [`param: locator`] appears. This function should get rid of the element that blocks actions like click. ### param: Page.addLocatorHandler.handler @@ -867,7 +913,7 @@ index 606bab5d3..d8e7f9a8e 100644 * since: v1.42 - `handler` <[function]\([Locator]\)> -@@ -3738,7 +3780,7 @@ A glob pattern, regex pattern, URL pattern, or predicate that receives a [URL] t +@@ -3814,7 +3856,7 @@ A glob pattern, regex pattern, URL pattern, or predicate that receives a [URL] t ### param: Page.route.url * since: v1.8 @@ -876,7 +922,7 @@ index 606bab5d3..d8e7f9a8e 100644 - `url` <[string]|[RegExp]|[function]\([URL]\):[boolean]> A glob pattern, regex pattern, or predicate that receives a [URL] to match during routing. If [`option: Browser.newContext.baseURL`] is set in the context options and the provided URL is a string that does not start with `*`, it is resolved using the [`new URL()`](https://developer.mozilla.org/en-US/docs/Web/API/URL/URL) constructor. -@@ -3750,6 +3792,13 @@ A glob pattern, regex pattern, or predicate that receives a [URL] to match durin +@@ -3826,6 +3868,13 @@ A glob pattern, regex pattern, or predicate that receives a [URL] to match durin handler function to route the request. @@ -890,7 +936,7 @@ index 606bab5d3..d8e7f9a8e 100644 ### param: Page.route.handler * since: v1.8 * langs: csharp, java -@@ -3878,7 +3927,7 @@ Only WebSockets with the url matching this pattern will be routed. A string patt +@@ -3954,7 +4003,7 @@ Only WebSockets with the url matching this pattern will be routed. A string patt ### param: Page.routeWebSocket.url * since: v1.48 @@ -899,7 +945,7 @@ index 606bab5d3..d8e7f9a8e 100644 - `url` <[string]|[RegExp]|[function]\([URL]\):[boolean]> Only WebSockets with the url matching this pattern will be routed. A string pattern can be relative to the [`option: Browser.newContext.baseURL`] context option. -@@ -3892,7 +3941,7 @@ Handler function to route the WebSocket. +@@ -3968,7 +4017,7 @@ Handler function to route the WebSocket. ### param: Page.routeWebSocket.handler * since: v1.48 @@ -908,7 +954,7 @@ index 606bab5d3..d8e7f9a8e 100644 - `handler` <[function]\([WebSocketRoute]\)> Handler function to route the WebSocket. -@@ -4239,14 +4288,14 @@ await page.GotoAsync("https://www.microsoft.com"); +@@ -4328,14 +4377,14 @@ await page.GotoAsync("https://www.microsoft.com"); ### param: Page.setViewportSize.width * since: v1.10 @@ -925,7 +971,7 @@ index 606bab5d3..d8e7f9a8e 100644 - `height` <[int]> Page height in pixels. -@@ -4461,7 +4510,7 @@ A glob pattern, regex pattern, URL pattern, or predicate receiving [URL] to matc +@@ -4566,7 +4615,7 @@ A glob pattern, regex pattern, URL pattern, or predicate receiving [URL] to matc ### param: Page.unroute.url * since: v1.8 @@ -934,7 +980,7 @@ index 606bab5d3..d8e7f9a8e 100644 - `url` <[string]|[RegExp]|[function]\([URL]\):[boolean]> A glob pattern, regex pattern, or predicate receiving [URL] to match while routing. -@@ -4473,6 +4522,13 @@ A glob pattern, regex pattern, or predicate receiving [URL] to match while routi +@@ -4578,6 +4627,13 @@ A glob pattern, regex pattern, or predicate receiving [URL] to match while routi Optional handler function to route the request. @@ -948,7 +994,7 @@ index 606bab5d3..d8e7f9a8e 100644 ### param: Page.unroute.handler * since: v1.8 * langs: csharp, java -@@ -4513,7 +4569,8 @@ Performs action and waits for the Page to close. +@@ -4619,7 +4675,8 @@ Performs action and waits for the Page to close. ## async method: Page.waitForConsoleMessage * since: v1.9 @@ -958,7 +1004,7 @@ index 606bab5d3..d8e7f9a8e 100644 - alias-python: expect_console_message - alias-csharp: RunAndWaitForConsoleMessage - returns: <[ConsoleMessage]> -@@ -4544,7 +4601,8 @@ Receives the [ConsoleMessage] object and resolves to truthy value when the waiti +@@ -4669,7 +4726,8 @@ Receives the [ConsoleMessage] object and resolves to truthy value when the waiti ## async method: Page.waitForDownload * since: v1.9 @@ -968,7 +1014,7 @@ index 606bab5d3..d8e7f9a8e 100644 - alias-python: expect_download - alias-csharp: RunAndWaitForDownload - returns: <[Download]> -@@ -4575,7 +4633,8 @@ Receives the [Download] object and resolves to truthy value when the waiting sho +@@ -4719,7 +4777,8 @@ Receives the [Download] object and resolves to truthy value when the waiting sho ## async method: Page.waitForEvent * since: v1.8 @@ -978,7 +1024,7 @@ index 606bab5d3..d8e7f9a8e 100644 - alias-python: expect_event - returns: <[any]> -@@ -4628,7 +4687,8 @@ Either a predicate that receives an event or an options object. Optional. +@@ -4773,7 +4832,8 @@ Either a predicate that receives an event or an options object. Optional. ## async method: Page.waitForFileChooser * since: v1.9 @@ -988,7 +1034,7 @@ index 606bab5d3..d8e7f9a8e 100644 - alias-python: expect_file_chooser - alias-csharp: RunAndWaitForFileChooser - returns: <[FileChooser]> -@@ -4786,7 +4846,7 @@ await page.WaitForFunctionAsync("selector => !!document.querySelector(selector)" +@@ -4950,7 +5010,7 @@ await page.WaitForFunctionAsync("selector => !!document.querySelector(selector)" Optional argument to pass to [`param: expression`]. @@ -997,7 +1043,7 @@ index 606bab5d3..d8e7f9a8e 100644 * since: v1.8 ### option: Page.waitForFunction.polling = %%-csharp-java-wait-for-function-polling-%% -@@ -4883,6 +4943,11 @@ Console.WriteLine(await popup.TitleAsync()); // popup is ready to use. +@@ -5049,6 +5109,11 @@ Console.WriteLine(await popup.TitleAsync()); // popup is ready to use. ``` ### param: Page.waitForLoadState.state = %%-wait-for-load-state-state-%% @@ -1009,7 +1055,7 @@ index 606bab5d3..d8e7f9a8e 100644 * since: v1.8 ### option: Page.waitForLoadState.timeout = %%-navigation-timeout-%% -@@ -4895,6 +4960,7 @@ Console.WriteLine(await popup.TitleAsync()); // popup is ready to use. +@@ -5062,6 +5127,7 @@ Console.WriteLine(await popup.TitleAsync()); // popup is ready to use. * since: v1.8 * deprecated: This method is inherently racy, please use [`method: Page.waitForURL`] instead. * langs: @@ -1017,7 +1063,7 @@ index 606bab5d3..d8e7f9a8e 100644 * alias-python: expect_navigation * alias-csharp: RunAndWaitForNavigation - returns: <[null]|[Response]> -@@ -4982,7 +5048,8 @@ a navigation. +@@ -5150,7 +5216,8 @@ a navigation. ## async method: Page.waitForPopup * since: v1.9 @@ -1027,7 +1073,7 @@ index 606bab5d3..d8e7f9a8e 100644 - alias-python: expect_popup - alias-csharp: RunAndWaitForPopup - returns: <[Page]> -@@ -5014,6 +5081,7 @@ Receives the [Page] object and resolves to truthy value when the waiting should +@@ -5201,6 +5268,7 @@ Receives the [Page] object and resolves to truthy value when the waiting should ## async method: Page.waitForRequest * since: v1.8 * langs: @@ -1035,7 +1081,7 @@ index 606bab5d3..d8e7f9a8e 100644 * alias-python: expect_request * alias-csharp: RunAndWaitForRequest - returns: <[Request]> -@@ -5121,7 +5189,8 @@ changed by using the [`method: Page.setDefaultTimeout`] method. +@@ -5310,7 +5378,8 @@ changed by using the [`method: Page.setDefaultTimeout`] method. ## async method: Page.waitForRequestFinished * since: v1.12 @@ -1045,7 +1091,7 @@ index 606bab5d3..d8e7f9a8e 100644 - alias-python: expect_request_finished - alias-csharp: RunAndWaitForRequestFinished - returns: <[Request]> -@@ -5153,6 +5222,7 @@ Receives the [Request] object and resolves to truthy value when the waiting shou +@@ -5361,6 +5430,7 @@ Receives the [Request] object and resolves to truthy value when the waiting shou ## async method: Page.waitForResponse * since: v1.8 * langs: @@ -1053,7 +1099,7 @@ index 606bab5d3..d8e7f9a8e 100644 * alias-python: expect_response * alias-csharp: RunAndWaitForResponse - returns: <[Response]> -@@ -5519,7 +5589,8 @@ await page.WaitForURLAsync("**/target.html"); +@@ -5732,7 +5802,8 @@ await page.WaitForURLAsync("**/target.html"); ## async method: Page.waitForWebSocket * since: v1.9 @@ -1063,7 +1109,7 @@ index 606bab5d3..d8e7f9a8e 100644 - alias-python: expect_websocket - alias-csharp: RunAndWaitForWebSocket - returns: <[WebSocket]> -@@ -5550,7 +5621,8 @@ Receives the [WebSocket] object and resolves to truthy value when the waiting sh +@@ -5782,7 +5853,8 @@ Receives the [WebSocket] object and resolves to truthy value when the waiting sh ## async method: Page.waitForWorker * since: v1.9 @@ -1073,7 +1119,7 @@ index 606bab5d3..d8e7f9a8e 100644 - alias-python: expect_worker - alias-csharp: RunAndWaitForWorker - returns: <[Worker]> -@@ -5592,7 +5664,8 @@ This does not contain ServiceWorkers +@@ -5843,7 +5915,8 @@ This does not contain ServiceWorkers ## async method: Page.waitForEvent2 * since: v1.8 @@ -1084,7 +1130,7 @@ index 606bab5d3..d8e7f9a8e 100644 - returns: <[any]> diff --git a/docs/src/api/class-pageassertions.md b/docs/src/api/class-pageassertions.md -index ce5de2f09..fee049b7b 100644 +index 22b1b95bf..3c34117a0 100644 --- a/docs/src/api/class-pageassertions.md +++ b/docs/src/api/class-pageassertions.md @@ -69,7 +69,7 @@ public class ExampleTests : PageTest @@ -1096,7 +1142,7 @@ index ce5de2f09..fee049b7b 100644 - returns: <[PageAssertions]> Makes the assertion check for the opposite condition. -@@ -449,7 +449,7 @@ When [`option: Browser.newContext.baseURL`] is provided via the context options +@@ -469,7 +469,7 @@ When [`option: Browser.newContext.baseURL`] is provided via the context options ### param: PageAssertions.toHaveURL.urlOrRegExp * since: v1.18 @@ -1141,7 +1187,7 @@ index aa4e0a42a..2b822104e 100644 Creates a [PageAssertions] object for the given [Page]. diff --git a/docs/src/api/class-request.md b/docs/src/api/class-request.md -index a37a5d595..9e091dd83 100644 +index 6ae84828f..b63e28351 100644 --- a/docs/src/api/class-request.md +++ b/docs/src/api/class-request.md @@ -128,6 +128,13 @@ Headers with multiple entries, such as `Set-Cookie`, appear in the array multipl @@ -1205,7 +1251,7 @@ index 555d2210c..4e7ecef57 100644 Returns the JSON representation of response body. diff --git a/docs/src/api/class-route.md b/docs/src/api/class-route.md -index 5bbe4b3f8..b7c9624c1 100644 +index ccccbe556..dd353a427 100644 --- a/docs/src/api/class-route.md +++ b/docs/src/api/class-route.md @@ -134,7 +134,7 @@ If set changes the post data of request. @@ -1226,7 +1272,7 @@ index 5bbe4b3f8..b7c9624c1 100644 - `postData` <[string]|[Buffer]> If set changes the post data of request. -@@ -544,7 +544,7 @@ and `content-type` header will be set to `application/json` if not explicitly se +@@ -546,7 +546,7 @@ and `content-type` header will be set to `application/json` if not explicitly se set to `application/octet-stream` if not explicitly set. ### option: Route.fetch.postData @@ -1235,7 +1281,7 @@ index 5bbe4b3f8..b7c9624c1 100644 * since: v1.29 - `postData` <[string]|[Buffer]> -@@ -657,7 +657,7 @@ If set, equals to setting `Content-Type` response header. +@@ -659,7 +659,7 @@ If set, equals to setting `Content-Type` response header. ### option: Route.fulfill.body * since: v1.8 @@ -1280,7 +1326,7 @@ index 256ae513a..f65ebbe13 100644 Only used together with `content: 'attach'`. When set, response bodies are placed in this directory instead of next to diff --git a/docs/src/api/class-websocket.md b/docs/src/api/class-websocket.md -index e33740eac..d89648d91 100644 +index d242d6e67..f82be2043 100644 --- a/docs/src/api/class-websocket.md +++ b/docs/src/api/class-websocket.md @@ -18,6 +18,13 @@ Fired when the websocket closes. @@ -1321,7 +1367,7 @@ index e33740eac..d89648d91 100644 - alias-python: expect_event - returns: <[any]> -@@ -142,7 +157,8 @@ Receives the [WebSocketFrame] object and resolves to truthy value when the waiti +@@ -163,7 +178,8 @@ Receives the [WebSocketFrame] object and resolves to truthy value when the waiti ## async method: WebSocket.waitForEvent2 * since: v1.8 @@ -1359,7 +1405,7 @@ index c4c8dfc0b..f04bca717 100644 * since: v1.48 * langs: csharp, java diff --git a/docs/src/api/params.md b/docs/src/api/params.md -index 118b38b28..cb8a6b818 100644 +index 47fe73082..d33c80ec0 100644 --- a/docs/src/api/params.md +++ b/docs/src/api/params.md @@ -8,7 +8,7 @@ When to consider operation succeeded, defaults to `load`. Events can be either: @@ -1389,7 +1435,7 @@ index 118b38b28..cb8a6b818 100644 - `timeout` <[float]> Maximum time in milliseconds. Defaults to `30000` (30 seconds). Pass `0` to disable timeout. The default value can be changed by -@@ -216,8 +216,8 @@ Defaults to `'visible'`. Can be either: +@@ -224,8 +224,8 @@ Defaults to `'visible'`. Can be either: * `'hidden'` - wait for element to be either detached from DOM, or have an empty bounding box or `visibility:hidden`. This is opposite to the `'visible'` option. @@ -1400,7 +1446,7 @@ index 118b38b28..cb8a6b818 100644 - `polling` <[float]|"raf"> If [`option: polling`] is `'raf'`, then [`param: expression`] is constantly executed in `requestAnimationFrame` -@@ -238,14 +238,14 @@ If `true`, Playwright does not pass its own configurations args and only uses th +@@ -246,14 +246,14 @@ If `true`, Playwright does not pass its own configurations args and only uses th array is given, then filters out the given default arguments. Dangerous option; use with care. Defaults to `false`. ## csharp-java-browser-option-ignoredefaultargs @@ -1417,7 +1463,7 @@ index 118b38b28..cb8a6b818 100644 - `ignoreAllDefaultArgs` <[boolean]> If `true`, Playwright does not pass its own configurations args and only uses the ones from [`option: args`]. -@@ -269,7 +269,7 @@ Network proxy settings. +@@ -277,7 +277,7 @@ Network proxy settings. - `env` <[Object]<[string], [string]|[undefined]>> ## csharp-java-browser-option-env @@ -1426,7 +1472,7 @@ index 118b38b28..cb8a6b818 100644 - `env` <[Object]<[string], [string]>> Specify environment variables that will be visible to the browser. Defaults to `process.env`. -@@ -302,6 +302,30 @@ Learn more about [storage state and auth](../auth.md). +@@ -310,6 +310,37 @@ Learn more about [storage state and auth](../auth.md). Populates context with given storage state. This option can be used to initialize context with logged-in information obtained via [`method: BrowserContext.storageState`]. @@ -1449,6 +1495,13 @@ index 118b38b28..cb8a6b818 100644 + - `localStorage` <[Array]<[Object]>> + - `name` <[string]> + - `value` <[string]> ++ - `credentials` ?<[Array]<[Object]>> Virtual WebAuthn credentials to seed into the context. ++ * alias: VirtualCredential ++ - `id` <[string]> Base64url-encoded credential id. ++ - `rpId` <[string]> Relying party id. ++ - `userHandle` <[string]> Base64url-encoded user handle. ++ - `privateKey` <[string]> Base64url-encoded PKCS#8 (DER) private key. ++ - `publicKey` <[string]> Base64url-encoded SPKI (DER) public key. + +Learn more about [storage state and auth](../auth.md). + @@ -1457,7 +1510,7 @@ index 118b38b28..cb8a6b818 100644 ## csharp-java-context-option-storage-state * langs: csharp, java - `storageState` <[string]> -@@ -310,7 +334,7 @@ Populates context with given storage state. This option can be used to initializ +@@ -318,7 +349,7 @@ Populates context with given storage state. This option can be used to initializ obtained via [`method: BrowserContext.storageState`]. ## csharp-java-context-option-storage-state-path @@ -1466,7 +1519,7 @@ index 118b38b28..cb8a6b818 100644 - `storageStatePath` <[path]> Populates context with given storage state. This option can be used to initialize context with logged-in information -@@ -441,7 +465,7 @@ Query parameters to be sent with the URL. +@@ -449,7 +480,7 @@ Query parameters to be sent with the URL. Query parameters to be sent with the URL. ## csharp-fetch-option-params @@ -1475,7 +1528,7 @@ index 118b38b28..cb8a6b818 100644 - `params` <[Object]<[string], [Serializable]>> Query parameters to be sent with the URL. -@@ -459,19 +483,19 @@ Query parameters to be sent with the URL. +@@ -467,19 +498,19 @@ Query parameters to be sent with the URL. Optional request parameters. ## js-python-csharp-fetch-option-headers @@ -1498,7 +1551,7 @@ index 118b38b28..cb8a6b818 100644 - `failOnStatusCode` <[boolean]> Whether to throw on response codes other than 2xx and 3xx. By default response object is returned -@@ -503,6 +527,14 @@ unless explicitly provided. +@@ -511,6 +542,14 @@ unless explicitly provided. An instance of [FormData] can be created via [`method: APIRequestContext.createFormData`]. @@ -1513,7 +1566,7 @@ index 118b38b28..cb8a6b818 100644 ## js-fetch-option-multipart * langs: js - `multipart` <[FormData]|[Object]<[string], [string]|[float]|[boolean]|[ReadStream]|[Object]>> -@@ -537,6 +569,15 @@ unless explicitly provided. File values can be passed as file-like object contai +@@ -545,6 +584,15 @@ unless explicitly provided. File values can be passed as file-like object contai An instance of [FormData] can be created via [`method: APIRequestContext.createFormData`]. @@ -1529,7 +1582,7 @@ index 118b38b28..cb8a6b818 100644 ## js-python-csharp-fetch-option-data * langs: js, python, csharp - `data` <[string]|[Buffer]|[Serializable]> -@@ -545,21 +586,29 @@ Allows to set post data of the request. If the data parameter is an object, it w +@@ -553,21 +601,29 @@ Allows to set post data of the request. If the data parameter is an object, it w and `content-type` header will be set to `application/json` if not explicitly set. Otherwise the `content-type` header will be set to `application/octet-stream` if not explicitly set. @@ -1562,7 +1615,7 @@ index 118b38b28..cb8a6b818 100644 - `maxRetries` <[int]> Maximum number of times network errors should be retried. Currently only `ECONNRESET` error is retried. Does not retry based on HTTP response codes. An error will be thrown if the limit is exceeded. Defaults to `0` - no retries. -@@ -601,7 +650,7 @@ Function to be evaluated in the worker context. +@@ -621,7 +677,7 @@ Function to be evaluated in the worker context. Function to be evaluated in the main Electron process. ## python-context-option-viewport @@ -1571,7 +1624,7 @@ index 118b38b28..cb8a6b818 100644 - `viewport` <[null]|[Object]> - `width` <[int]> page width in pixels. - `height` <[int]> page height in pixels. -@@ -609,7 +658,7 @@ Function to be evaluated in the main Electron process. +@@ -629,7 +685,7 @@ Function to be evaluated in the main Electron process. Sets a consistent viewport for each page. Defaults to an 1280x720 viewport. `no_viewport` disables the fixed viewport. Learn more about [viewport emulation](../emulation.md#viewport). ## python-context-option-no-viewport @@ -1580,7 +1633,7 @@ index 118b38b28..cb8a6b818 100644 - `noViewport` <[boolean]> Does not enforce fixed viewport, allows resizing window in the headed mode. -@@ -722,6 +771,13 @@ Emulates [prefers-colors-scheme](https://developer.mozilla.org/en-US/docs/Web/CS +@@ -742,6 +798,13 @@ Emulates [prefers-colors-scheme](https://developer.mozilla.org/en-US/docs/Web/CS Emulates [prefers-colors-scheme](https://developer.mozilla.org/en-US/docs/Web/CSS/@media/prefers-color-scheme) media feature, supported values are `'light'` and `'dark'`. See [`method: Page.emulateMedia`] for more details. Passing `'null'` resets emulation to system defaults. Defaults to `'light'`. @@ -1594,7 +1647,7 @@ index 118b38b28..cb8a6b818 100644 ## context-option-reducedMotion * langs: js, java - `reducedMotion` > -@@ -734,6 +790,12 @@ Emulates `'prefers-reduced-motion'` media feature, supported values are `'reduce +@@ -754,6 +817,12 @@ Emulates `'prefers-reduced-motion'` media feature, supported values are `'reduce Emulates `'prefers-reduced-motion'` media feature, supported values are `'reduce'`, `'no-preference'`. See [`method: Page.emulateMedia`] for more details. Passing `'null'` resets emulation to system defaults. Defaults to `'no-preference'`. @@ -1607,7 +1660,7 @@ index 118b38b28..cb8a6b818 100644 ## context-option-forcedColors * langs: js, java - `forcedColors` > -@@ -741,10 +803,10 @@ Emulates `'prefers-reduced-motion'` media feature, supported values are `'reduce +@@ -761,10 +830,10 @@ Emulates `'prefers-reduced-motion'` media feature, supported values are `'reduce Emulates `'forced-colors'` media feature, supported values are `'active'`, `'none'`. See [`method: Page.emulateMedia`] for more details. Passing `null` resets emulation to system defaults. Defaults to `'none'`. ## context-option-forcedColors-csharp-python @@ -1621,7 +1674,7 @@ index 118b38b28..cb8a6b818 100644 ## context-option-contrast * langs: js, java -@@ -758,6 +820,12 @@ Emulates `'prefers-contrast'` media feature, supported values are `'no-preferenc +@@ -778,6 +847,12 @@ Emulates `'prefers-contrast'` media feature, supported values are `'no-preferenc Emulates `'prefers-contrast'` media feature, supported values are `'no-preference'`, `'more'`. See [`method: Page.emulateMedia`] for more details. Passing `'null'` resets emulation to system defaults. Defaults to `'no-preference'`. @@ -1634,7 +1687,7 @@ index 118b38b28..cb8a6b818 100644 ## context-option-logger * langs: js * deprecated: The logs received by the logger are incomplete. Please use tracing instead. -@@ -780,7 +848,7 @@ specified, the HAR is not recorded. Make sure to await [`method: BrowserContext. +@@ -800,7 +875,7 @@ specified, the HAR is not recorded. Make sure to await [`method: BrowserContext. saved. ## context-option-recordhar-path @@ -1643,7 +1696,7 @@ index 118b38b28..cb8a6b818 100644 - alias-python: record_har_path - `recordHarPath` <[path]> -@@ -789,33 +857,33 @@ specified HAR file on the filesystem. If not specified, the HAR is not recorded. +@@ -809,33 +884,33 @@ specified HAR file on the filesystem. If not specified, the HAR is not recorded. call [`method: BrowserContext.close`] for the HAR to be saved. ## context-option-recordhar-omit-content @@ -1682,7 +1735,7 @@ index 118b38b28..cb8a6b818 100644 - `recordVideo` <[Object]> - `dir` ?<[path]> Path to the directory to put videos into. If not specified, the videos will be stored in `artifactsDir` (see [`method: BrowserType.launch`] options). - `size` ?<[Object]> Optional dimensions of the recorded videos. If not specified the size will be equal to `viewport` -@@ -888,7 +956,7 @@ Specifies whether to wait for already running listeners and what to do if they t +@@ -909,7 +984,7 @@ Specifies whether to wait for already running listeners and what to do if they t * `'ignoreErrors'` - do not wait for current listener calls (if any) to finish, all errors thrown by the listeners after removal are silently caught ## unroute-all-options-behavior @@ -1691,7 +1744,7 @@ index 118b38b28..cb8a6b818 100644 * since: v1.41 - `behavior` <[UnrouteBehavior]<"wait"|"ignoreErrors"|"default">> -@@ -899,7 +967,7 @@ Specifies whether to wait for already running handlers and what to do if they th +@@ -920,7 +995,7 @@ Specifies whether to wait for already running handlers and what to do if they th ## select-options-values @@ -1700,7 +1753,7 @@ index 118b38b28..cb8a6b818 100644 - `values` <[null]|[string]|[ElementHandle]|[Array]<[string]>|[Object]|[Array]<[ElementHandle]>|[Array]<[Object]>> * alias-java: SelectOption - `value` ?<[string]> Matches by `option.value`. Optional. -@@ -919,7 +987,7 @@ the parameter is a string without wildcard characters, the method will wait for +@@ -940,7 +1015,7 @@ the parameter is a string without wildcard characters, the method will wait for equal to the string. ## python-csharp-java-wait-for-navigation-url @@ -1709,7 +1762,7 @@ index 118b38b28..cb8a6b818 100644 - `url` <[string]|[RegExp]|[function]\([URL]\):[boolean]> A glob pattern, regex pattern, or predicate receiving [URL] to match while waiting for the navigation. Note that if -@@ -927,7 +995,7 @@ the parameter is a string without wildcard characters, the method will wait for +@@ -948,7 +1023,7 @@ the parameter is a string without wildcard characters, the method will wait for equal to the string. ## wait-for-event-event @@ -1718,7 +1771,7 @@ index 118b38b28..cb8a6b818 100644 - `event` <[string]> Event name, same one typically passed into `*.on(event)`. -@@ -985,7 +1053,7 @@ only the first option matching one of the passed options is selected. Optional. +@@ -1006,7 +1081,7 @@ only the first option matching one of the passed options is selected. Optional. Receives the event data and resolves to truthy value when the waiting should resolve. ## wait-for-event-timeout @@ -1727,8 +1780,8 @@ index 118b38b28..cb8a6b818 100644 - `timeout` <[float]> Maximum time to wait for in milliseconds. Defaults to `30000` (30 seconds). Pass `0` to disable timeout. -@@ -1005,7 +1073,7 @@ using the [`method: AndroidDevice.setDefaultTimeout`] method. - Time to retry the assertion for in milliseconds. Defaults to `timeout` in `TestConfig.expect`. +@@ -1052,7 +1127,7 @@ is aborted while the assertion is retrying, or is already aborted before the ass + the assertion fails without retrying further. ## csharp-java-python-assertions-timeout -* langs: java, python, csharp @@ -1736,7 +1789,7 @@ index 118b38b28..cb8a6b818 100644 - `timeout` <[float]> Time to retry the assertion for in milliseconds. Defaults to `5000`. -@@ -1059,12 +1127,15 @@ between the same pixel in compared images, between zero (strict) and one (lax), +@@ -1106,12 +1181,15 @@ between the same pixel in compared images, between zero (strict) and one (lax), - %%-context-option-httpcredentials-%% - %%-context-option-colorscheme-%% - %%-context-option-colorscheme-csharp-python-%% @@ -1752,7 +1805,7 @@ index 118b38b28..cb8a6b818 100644 - %%-context-option-logger-%% - %%-context-option-recordhar-%% - %%-context-option-recordhar-path-%% -@@ -1149,7 +1220,7 @@ Firefox user preferences. Learn more about the Firefox user preferences at +@@ -1196,7 +1274,7 @@ Firefox user preferences. Learn more about the Firefox user preferences at You can also provide a path to a custom [`policies.json` file](https://mozilla.github.io/policy-templates/) via `PLAYWRIGHT_FIREFOX_POLICIES_JSON` environment variable. ## csharp-java-browser-option-firefoxuserprefs @@ -1763,10 +1816,10 @@ index 118b38b28..cb8a6b818 100644 Firefox user preferences. Learn more about the Firefox user preferences at diff --git a/utils/doclint/generateGoApi.js b/utils/doclint/generateGoApi.js new file mode 100644 -index 000000000..0718831f4 +index 000000000..8aa579b00 --- /dev/null +++ b/utils/doclint/generateGoApi.js -@@ -0,0 +1,891 @@ +@@ -0,0 +1,897 @@ +/** + * Copyright (c) Microsoft Corporation. + * @@ -2142,6 +2195,12 @@ index 000000000..0718831f4 + // method-derived names Create/Get, so both share one struct. + if (parent.name === 'Credentials' && (attemptedName === 'Create' || attemptedName === 'Get')) + attemptedName = 'VirtualCredential'; ++ // StorageState/OptionalStorageState.credentials: reuse VirtualCredential ++ // instead of generating a duplicate Credential type from the array name. ++ // Parent may be StorageState, OptionalStorageState, or a nested options type ++ // like BrowserNewContextOptionsOptionalStorageState when shapes first conflict. ++ if (attemptedName === 'Credential' && /StorageState$/.test(parent.name)) ++ attemptedName = 'VirtualCredential'; + // WebStorage.Items: use the documented `* alias: WebStorageItem` over generic `Item`. + if (parent.name === 'WebStorage' && attemptedName === 'Item') + attemptedName = 'WebStorageItem'; diff --git a/playwright b/playwright index 39e3553a..26a9e470 160000 --- a/playwright +++ b/playwright @@ -1 +1 @@ -Subproject commit 39e3553a4f283a41134d75d7e404484bd9e6865a +Subproject commit 26a9e470a7b3c7822084b09fb7f13902c5f37b51 diff --git a/run.go b/run.go index 66cc08dc..bcddc089 100644 --- a/run.go +++ b/run.go @@ -19,11 +19,11 @@ import ( ) const ( - playwrightCliVersion = "1.61.1" + playwrightCliVersion = "1.62.1" // nodeVersion is the Node.js runtime downloaded alongside the driver when no - // PLAYWRIGHT_NODEJS_PATH is provided. It is kept in line with the Node.js - // version upstream Playwright bundles in its own driver. - nodeVersion = "24.18.0" + // PLAYWRIGHT_NODEJS_PATH is provided. It is kept aligned with the Node.js + // version used by the official Playwright bindings. + nodeVersion = "24.19.0" // defaultNpmRegistry serves the platform-independent playwright-core package. // Override with the PLAYWRIGHT_GO_NPM_REGISTRY environment variable. @@ -71,9 +71,11 @@ func getDefaultCacheDirectory() (string, error) { } func (d *PlaywrightDriver) isUpToDateDriver() (bool, error) { - if _, err := os.Stat(d.options.DriverDirectory); os.IsNotExist(err) { - if err := os.MkdirAll(d.options.DriverDirectory, 0o777); err != nil { - return false, fmt.Errorf("could not create driver directory: %w", err) + if os.Getenv("PLAYWRIGHT_CLI_PATH") == "" { + if _, err := os.Stat(d.options.DriverDirectory); os.IsNotExist(err) { + if err := os.MkdirAll(d.options.DriverDirectory, 0o777); err != nil { + return false, fmt.Errorf("could not create driver directory: %w", err) + } } } if _, err := os.Stat(getDriverCliJs(d.options.DriverDirectory)); os.IsNotExist(err) { @@ -146,11 +148,20 @@ func (d *PlaywrightDriver) Uninstall() error { // When PLAYWRIGHT_NODEJS_PATH is set the Node.js download is skipped and the // preinstalled Node.js is used instead, which also covers platforms for which // nodejs.org has no prebuilt binary (e.g. linux/arm). +// When PLAYWRIGHT_CLI_PATH is set, that externally managed CLI is validated but +// is not downloaded, patched, or required to use the DriverDirectory layout. func (d *PlaywrightDriver) DownloadDriver() error { + externalCLIPath := os.Getenv("PLAYWRIGHT_CLI_PATH") up2Date, err := d.isUpToDateDriver() if err != nil { return err } + if externalCLIPath != "" { + if !up2Date { + return fmt.Errorf("PLAYWRIGHT_CLI_PATH %q does not exist", externalCLIPath) + } + return nil + } if up2Date { return d.patchDriverBundle() } @@ -255,38 +266,31 @@ func (d *PlaywrightDriver) patchDriverBundle() error { coreBundlePath := filepath.Join(d.options.DriverDirectory, "package", "lib", "coreBundle.js") data, err := os.ReadFile(coreBundlePath) if err != nil { - if os.IsNotExist(err) { - return nil - } return fmt.Errorf("could not read driver bundle: %w", err) } - replacements := map[string]string{ - "pageError.location.url": `pageError.location?.url || ""`, - "pageError.location.lineNumber": "pageError.location?.lineNumber || 0", - "pageError.location.columnNumber": "pageError.location?.columnNumber || 0", + replacements := []struct { + original string + patched string + }{ + {"pageError.location.url", `pageError.location?.url || ""`}, + {"pageError.location.lineNumber", "pageError.location?.lineNumber || 0"}, + {"pageError.location.columnNumber", "pageError.location?.columnNumber || 0"}, } changed := false - for original, patched := range replacements { - originalBytes := []byte(original) - patchedBytes := []byte(patched) + for _, replacement := range replacements { + originalBytes := []byte(replacement.original) + patchedBytes := []byte(replacement.patched) + if !bytes.Contains(data, originalBytes) && !bytes.Contains(data, patchedBytes) { + return fmt.Errorf("could not patch driver bundle: expected pageError location pattern %q or %q", replacement.original, replacement.patched) + } if bytes.Contains(data, originalBytes) { data = bytes.ReplaceAll(data, originalBytes, patchedBytes) changed = true } } if !changed { - alreadyPatched := true - for _, patched := range replacements { - if !bytes.Contains(data, []byte(patched)) { - alreadyPatched = false - break - } - } - if alreadyPatched { - return nil - } - return fmt.Errorf("could not patch driver bundle: pageError location pattern not found") + return nil } if err := os.WriteFile(coreBundlePath, data, 0o644); err != nil { return fmt.Errorf("could not write patched driver bundle: %w", err) diff --git a/run_test.go b/run_test.go index 8c16079c..8b862111 100644 --- a/run_test.go +++ b/run_test.go @@ -7,16 +7,36 @@ import ( "net/http" "net/http/httptest" "os" + "os/exec" "path/filepath" "runtime" "strings" "sync" + "sync/atomic" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) +const ( + testDriverHelperEnv = "GO_WANT_PLAYWRIGHT_DRIVER_HELPER" + testDriverVersionEnv = "PLAYWRIGHT_GO_TEST_DRIVER_VERSION" +) + +func TestMain(m *testing.M) { + if os.Getenv(testDriverHelperEnv) != "1" { + os.Exit(m.Run()) + } + version := os.Getenv(testDriverVersionEnv) + validArgs := len(os.Args) >= 3 && filepath.Base(os.Args[len(os.Args)-2]) == "cli.js" && os.Args[len(os.Args)-1] == "--version" + if version == "" || !validArgs { + os.Exit(2) + } + fmt.Printf("Version %s\n", version) + os.Exit(0) +} + func TestRunOptionsRedirectStderr(t *testing.T) { r, w := io.Pipe() var output string @@ -83,6 +103,11 @@ func TestRunOptions_OnlyInstallShell(t *testing.T) { } func TestDriverInstall(t *testing.T) { + if _, err := nodePlatformSuffix(); err != nil { + t.Skipf("bundled Node.js is not available on this platform: %v", err) + } + t.Setenv("PLAYWRIGHT_NODEJS_PATH", "") + t.Setenv("PLAYWRIGHT_CLI_PATH", "") driverPath := t.TempDir() driver, err := NewDriver(&RunOptions{ DriverDirectory: driverPath, @@ -93,15 +118,12 @@ func TestDriverInstall(t *testing.T) { t.Fatalf("could not start driver: %v", err) } browserPath := t.TempDir() - err = os.Setenv("PLAYWRIGHT_BROWSERS_PATH", browserPath) - if err != nil { - t.Fatalf("could not set PLAYWRIGHT_BROWSERS_PATH: %v", err) - } - defer os.Unsetenv("PLAYWRIGHT_BROWSERS_PATH") //nolint:errcheck + t.Setenv("PLAYWRIGHT_BROWSERS_PATH", browserPath) err = driver.Install() if err != nil { t.Fatalf("could not install driver: %v", err) } + requireDriverPackageArtifacts(t, driver, driverPath) err = driver.Uninstall() if err != nil { t.Fatalf("could not uninstall driver: %v", err) @@ -144,7 +166,8 @@ func TestNodePlatformSuffix(t *testing.T) { } } -func TestPatchDriverBundleAllowsMissingPageErrorLocation(t *testing.T) { +func TestPatchDriverBundleMakesPageErrorLocationOptional(t *testing.T) { + t.Setenv("PLAYWRIGHT_CLI_PATH", "") driverPath := t.TempDir() bundlePath := filepath.Join(driverPath, "package", "lib", "coreBundle.js") require.NoError(t, os.MkdirAll(filepath.Dir(bundlePath), 0o755)) @@ -166,6 +189,146 @@ column: pageError.location.columnNumber require.Contains(t, string(data), `pageError.location?.columnNumber || 0`) } +func TestPatchDriverBundleAcceptsMixedOriginalAndPatchedPatterns(t *testing.T) { + t.Setenv("PLAYWRIGHT_CLI_PATH", "") + driverPath := t.TempDir() + bundlePath := filepath.Join(driverPath, "package", "lib", "coreBundle.js") + require.NoError(t, os.MkdirAll(filepath.Dir(bundlePath), 0o755)) + require.NoError(t, os.WriteFile(bundlePath, []byte(`location:{ + url:pageError.location?.url || "", + line: pageError.location.lineNumber, + column: pageError.location?.columnNumber || 0 +}`), 0o644)) + + driver, err := NewDriver(&RunOptions{DriverDirectory: driverPath}) + require.NoError(t, err) + require.NoError(t, driver.patchDriverBundle()) + + data, err := os.ReadFile(bundlePath) + require.NoError(t, err) + require.Contains(t, string(data), `pageError.location?.url || ""`) + require.Contains(t, string(data), `pageError.location?.lineNumber || 0`) + require.Contains(t, string(data), `pageError.location?.columnNumber || 0`) +} + +func TestPatchDriverBundleRequiresCoreBundle(t *testing.T) { + t.Setenv("PLAYWRIGHT_CLI_PATH", "") + driver, err := NewDriver(&RunOptions{DriverDirectory: t.TempDir()}) + require.NoError(t, err) + + err = driver.patchDriverBundle() + require.Error(t, err) + require.ErrorContains(t, err, "could not read driver bundle") +} + +func TestPatchDriverBundleRejectsPartialPatternMismatch(t *testing.T) { + t.Setenv("PLAYWRIGHT_CLI_PATH", "") + driverPath := t.TempDir() + bundlePath := filepath.Join(driverPath, "package", "lib", "coreBundle.js") + require.NoError(t, os.MkdirAll(filepath.Dir(bundlePath), 0o755)) + original := []byte(`location:{ + url:pageError.location.url, + line: pageError.location.lineNumber +}`) + require.NoError(t, os.WriteFile(bundlePath, original, 0o644)) + + driver, err := NewDriver(&RunOptions{DriverDirectory: driverPath}) + require.NoError(t, err) + err = driver.patchDriverBundle() + require.Error(t, err) + require.ErrorContains(t, err, "pageError.location.columnNumber") + + data, readErr := os.ReadFile(bundlePath) + require.NoError(t, readErr) + require.Equal(t, original, data, "an incompatible bundle must not be partially rewritten") +} + +func TestDownloadDriverExternalCLIDoesNotRequireManagedBundle(t *testing.T) { + driverPath := filepath.Join(t.TempDir(), "driver-cache") + externalCLI := filepath.Join(t.TempDir(), "cli.js") + require.NoError(t, os.WriteFile(externalCLI, []byte("// externally managed"), 0o644)) + configureTestDriverRuntime(t, playwrightCliVersion) + t.Setenv("PLAYWRIGHT_CLI_PATH", externalCLI) + + var registryRequests atomic.Int32 + registry := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + registryRequests.Add(1) + http.Error(w, "unexpected download", http.StatusInternalServerError) + })) + defer registry.Close() + t.Setenv("PLAYWRIGHT_GO_NPM_REGISTRY", registry.URL) + + driver, err := NewDriver(&RunOptions{DriverDirectory: driverPath}) + require.NoError(t, err) + require.NoError(t, driver.DownloadDriver()) + require.Zero(t, registryRequests.Load()) + _, err = os.Stat(driverPath) + require.ErrorIs(t, err, os.ErrNotExist) +} + +func TestDownloadDriverMissingExternalCLIFailsWithoutDownload(t *testing.T) { + missingCLI := filepath.Join(t.TempDir(), "missing-cli.js") + t.Setenv("PLAYWRIGHT_CLI_PATH", missingCLI) + + var registryRequests atomic.Int32 + registry := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + registryRequests.Add(1) + http.Error(w, "unexpected download", http.StatusInternalServerError) + })) + defer registry.Close() + t.Setenv("PLAYWRIGHT_GO_NPM_REGISTRY", registry.URL) + + driver, err := NewDriver(&RunOptions{DriverDirectory: t.TempDir()}) + require.NoError(t, err) + err = driver.DownloadDriver() + require.EqualError(t, err, fmt.Sprintf("PLAYWRIGHT_CLI_PATH %q does not exist", missingCLI)) + require.Zero(t, registryRequests.Load()) +} + +func TestDownloadDriverExternalCLIWrongVersionFailsWithoutDownload(t *testing.T) { + externalCLI := filepath.Join(t.TempDir(), "cli.js") + require.NoError(t, os.WriteFile(externalCLI, []byte("// externally managed"), 0o644)) + configureTestDriverRuntime(t, "1.61.1") + t.Setenv("PLAYWRIGHT_CLI_PATH", externalCLI) + + var registryRequests atomic.Int32 + registry := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + registryRequests.Add(1) + http.Error(w, "unexpected download", http.StatusInternalServerError) + })) + defer registry.Close() + t.Setenv("PLAYWRIGHT_GO_NPM_REGISTRY", registry.URL) + + driver, err := NewDriver(&RunOptions{DriverDirectory: t.TempDir()}) + require.NoError(t, err) + err = driver.DownloadDriver() + require.ErrorContains(t, err, "driver exists but version not "+playwrightCliVersion) + require.Zero(t, registryRequests.Load()) +} + +func TestDownloadDriverManagedCLIRequiresCoreBundle(t *testing.T) { + driverPath := t.TempDir() + cliPath := filepath.Join(driverPath, "package", "cli.js") + require.NoError(t, os.MkdirAll(filepath.Dir(cliPath), 0o755)) + require.NoError(t, os.WriteFile(cliPath, []byte("// managed"), 0o644)) + configureTestDriverRuntime(t, playwrightCliVersion) + t.Setenv("PLAYWRIGHT_CLI_PATH", "") + + driver, err := NewDriver(&RunOptions{DriverDirectory: driverPath}) + require.NoError(t, err) + err = driver.DownloadDriver() + require.ErrorContains(t, err, "could not read driver bundle") +} + +func configureTestDriverRuntime(t *testing.T, version string) { + t.Helper() + testExecutable, err := os.Executable() + require.NoError(t, err) + t.Setenv("PLAYWRIGHT_NODEJS_PATH", testExecutable) + t.Setenv(testDriverHelperEnv, "1") + t.Setenv(testDriverVersionEnv, version) +} + func TestShouldNotHangWhenPlaywrightUnexpectedExit(t *testing.T) { if getBrowserName() != "chromium" { t.Skip("chromium only") @@ -256,3 +419,23 @@ func readIOAsyncTilEOF(t *testing.T, r *io.PipeReader, wg *sync.WaitGroup, outpu _ = r.Close() }() } + +func requireDriverPackageArtifacts(t *testing.T, driver *PlaywrightDriver, driverPath string) { + t.Helper() + cliPath := filepath.Join(driverPath, "package", "cli.js") + coreBundlePath := filepath.Join(driverPath, "package", "lib", "coreBundle.js") + codecPath := filepath.Join(driverPath, "package", "lib", "webp_codec.wasm") + for _, expectedPath := range []string{cliPath, coreBundlePath, codecPath} { + info, statErr := os.Stat(expectedPath) + require.NoError(t, statErr, "installed driver must contain %s", expectedPath) + require.False(t, info.IsDir(), "installed driver artifact must be a file: %s", expectedPath) + } + + nodeOutput, err := exec.Command(getNodeExecutable(driverPath), "--version").CombinedOutput() + require.NoError(t, err, "could not execute installed Node.js: %s", nodeOutput) + require.Equal(t, "v"+nodeVersion, strings.TrimSpace(string(nodeOutput))) + + cliOutput, err := driver.Command("--version").CombinedOutput() + require.NoError(t, err, "could not execute installed Playwright CLI: %s", cliOutput) + require.Equal(t, "Version "+playwrightCliVersion, strings.TrimSpace(string(cliOutput))) +} diff --git a/screencast.go b/screencast.go index 6238242f..4f12b1d4 100644 --- a/screencast.go +++ b/screencast.go @@ -33,24 +33,36 @@ func (s *screencastImpl) Start(options ...ScreencastStartOptions) error { if !s.listening { s.listening = true s.page.channel.On("screencastFrame", func(params map[string]any) { + frameID, hasFrameID := params["frameId"].(float64) s.mu.Lock() onFrame := s.onFrame s.mu.Unlock() - if onFrame == nil { - return - } - data, _ := base64.StdEncoding.DecodeString(params["data"].(string)) - frame := OnFrame{Data: data} - if ts, ok := params["timestamp"].(float64); ok { - frame.Timestamp = ts - } - if vw, ok := params["viewportWidth"].(float64); ok { - frame.ViewportWidth = int(vw) - } - if vh, ok := params["viewportHeight"].(float64); ok { - frame.ViewportHeight = int(vh) - } - onFrame(frame) + s.page.channel.CreateTask(func() { + // The server applies backpressure until every valid frame is + // acknowledged, including when no handler is active or the + // handler panics or exits its goroutine. + if hasFrameID { + defer s.page.channel.SendNoReplyInternalWithTimeout("screencastFrameAck", Float(0), map[string]any{ + "frameId": frameID, + }) + } + if onFrame == nil { + return + } + dataStr, _ := params["data"].(string) + data, _ := base64.StdEncoding.DecodeString(dataStr) + frame := OnFrame{Data: data} + if ts, ok := params["timestamp"].(float64); ok { + frame.Timestamp = ts + } + if vw, ok := params["viewportWidth"].(float64); ok { + frame.ViewportWidth = int(vw) + } + if vh, ok := params["viewportHeight"].(float64); ok { + frame.ViewportHeight = int(vh) + } + onFrame(frame) + }) }) } overrides["sendFrames"] = true diff --git a/scroll_api_test.go b/scroll_api_test.go new file mode 100644 index 00000000..7af9b010 --- /dev/null +++ b/scroll_api_test.go @@ -0,0 +1,52 @@ +package playwright + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestAllActionOptionsSerializeScrollMode(t *testing.T) { + options := []any{ + ElementHandleCheckOptions{Scroll: ScrollModeAuto}, + ElementHandleClickOptions{Scroll: ScrollModeAuto}, + ElementHandleDblclickOptions{Scroll: ScrollModeAuto}, + ElementHandleHoverOptions{Scroll: ScrollModeAuto}, + ElementHandleSetCheckedOptions{Scroll: ScrollModeAuto}, + ElementHandleTapOptions{Scroll: ScrollModeAuto}, + ElementHandleUncheckOptions{Scroll: ScrollModeAuto}, + FrameCheckOptions{Scroll: ScrollModeAuto}, + FrameClickOptions{Scroll: ScrollModeAuto}, + FrameDblclickOptions{Scroll: ScrollModeAuto}, + FrameDragAndDropOptions{Scroll: ScrollModeAuto}, + FrameHoverOptions{Scroll: ScrollModeAuto}, + FrameSetCheckedOptions{Scroll: ScrollModeAuto}, + FrameTapOptions{Scroll: ScrollModeAuto}, + FrameUncheckOptions{Scroll: ScrollModeAuto}, + LocatorCheckOptions{Scroll: ScrollModeAuto}, + LocatorClickOptions{Scroll: ScrollModeAuto}, + LocatorDblclickOptions{Scroll: ScrollModeAuto}, + LocatorDragToOptions{Scroll: ScrollModeAuto}, + LocatorHoverOptions{Scroll: ScrollModeAuto}, + LocatorSetCheckedOptions{Scroll: ScrollModeAuto}, + LocatorTapOptions{Scroll: ScrollModeAuto}, + LocatorUncheckOptions{Scroll: ScrollModeAuto}, + PageCheckOptions{Scroll: ScrollModeAuto}, + PageClickOptions{Scroll: ScrollModeAuto}, + PageDblclickOptions{Scroll: ScrollModeAuto}, + PageDragAndDropOptions{Scroll: ScrollModeAuto}, + PageHoverOptions{Scroll: ScrollModeAuto}, + PageSetCheckedOptions{Scroll: ScrollModeAuto}, + PageTapOptions{Scroll: ScrollModeAuto}, + PageUncheckOptions{Scroll: ScrollModeAuto}, + } + + for _, option := range options { + encoded, err := json.Marshal(option) + require.NoError(t, err) + var payload map[string]any + require.NoError(t, json.Unmarshal(encoded, &payload)) + require.Equal(t, "auto", payload["scroll"]) + } +} diff --git a/tests/browser_context_credentials_test.go b/tests/browser_context_credentials_test.go index e826f853..261f267d 100644 --- a/tests/browser_context_credentials_test.go +++ b/tests/browser_context_credentials_test.go @@ -1,12 +1,120 @@ package playwright_test import ( + "path/filepath" "testing" "github.com/mxschmitt/playwright-go" "github.com/stretchr/testify/require" ) +const webAuthnRPID = "localhost" + +const webAuthnAuthenticateScript = `async ({ rpId, credentialId }) => { + const b64URLToBytes = value => { + let padded = value.replace(/-/g, '+').replace(/_/g, '/'); + while (padded.length % 4) + padded += '='; + const binary = atob(padded); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; ++i) + bytes[i] = binary.charCodeAt(i); + return bytes; + }; + const credential = await navigator.credentials.get({ + publicKey: { + challenge: crypto.getRandomValues(new Uint8Array(32)), + rpId, + allowCredentials: [{ type: 'public-key', id: b64URLToBytes(credentialId) }], + userVerification: 'preferred', + }, + }); + const response = credential.response; + return { + id: credential.id, + type: credential.type, + hasClientData: response.clientDataJSON.byteLength > 0, + hasAuthData: response.authenticatorData.byteLength > 0, + hasSignature: response.signature.byteLength > 0, + hasUserPresentAndVerified: (new Uint8Array(response.authenticatorData)[32] & 0x05) === 0x05, + }; +}` + +const webAuthnAuthenticateErrorScript = `async ({ rpId, credentialId }) => { + const b64URLToBytes = value => { + let padded = value.replace(/-/g, '+').replace(/_/g, '/'); + while (padded.length % 4) + padded += '='; + const binary = atob(padded); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; ++i) + bytes[i] = binary.charCodeAt(i); + return bytes; + }; + try { + await navigator.credentials.get({ + publicKey: { + challenge: crypto.getRandomValues(new Uint8Array(32)), + rpId, + allowCredentials: [{ type: 'public-key', id: b64URLToBytes(credentialId) }], + }, + }); + return 'no-error'; + } catch (error) { + return error.name; + } +}` + +const webAuthnCreateScript = `async ({ rpId }) => { + const credential = await navigator.credentials.create({ + publicKey: { + challenge: crypto.getRandomValues(new Uint8Array(32)), + rp: { id: rpId, name: 'Test RP' }, + user: { id: new Uint8Array([1, 2, 3, 4]), name: 'u', displayName: 'User' }, + pubKeyCredParams: [{ type: 'public-key', alg: -7 }], + authenticatorSelection: { residentKey: 'required', userVerification: 'preferred' }, + }, + }); + return credential.id; +}` + +const webAuthnDiscoverableCredentialScript = `async ({ rpId }) => { + const credential = await navigator.credentials.get({ + publicKey: { + challenge: crypto.getRandomValues(new Uint8Array(32)), + rpId, + userVerification: 'preferred', + }, + }); + return credential.id; +}` + +func newWebAuthnPage(t *testing.T, ctx playwright.BrowserContext) playwright.Page { + t.Helper() + p, err := ctx.NewPage() + require.NoError(t, err) + _, err = p.Goto(server.CROSS_PROCESS_PREFIX + "/empty.html") + require.NoError(t, err) + return p +} + +func requireWebAuthnAuthentication(t *testing.T, p playwright.Page, rpID, credentialID string) { + t.Helper() + value, err := p.Evaluate(webAuthnAuthenticateScript, map[string]any{ + "rpId": rpID, + "credentialId": credentialID, + }) + require.NoError(t, err) + result, ok := value.(map[string]any) + require.True(t, ok, "expected WebAuthn authentication result object, got %T", value) + require.Equal(t, credentialID, result["id"]) + require.Equal(t, "public-key", result["type"]) + require.Equal(t, true, result["hasClientData"]) + require.Equal(t, true, result["hasAuthData"]) + require.Equal(t, true, result["hasSignature"]) + require.Equal(t, true, result["hasUserPresentAndVerified"]) +} + func TestBrowserContextExposesCredentialsProperty(t *testing.T) { BeforeEach(t) @@ -42,3 +150,220 @@ func TestBrowserContextInstallCreateGetDeleteCredentials(t *testing.T) { require.NoError(t, err) require.Empty(t, list) } + +func TestBrowserContextCredentialsDoNotInterceptBeforeInstall(t *testing.T) { + BeforeEach(t) + + _, err := context.Credentials().Create(webAuthnRPID) + require.NoError(t, err) + _, err = page.Goto(server.CROSS_PROCESS_PREFIX + "/empty.html") + require.NoError(t, err) + installed, err := page.Evaluate(`() => globalThis.__pwWebAuthnInstalled === true`) + require.NoError(t, err) + require.Equal(t, false, installed) +} + +func TestBrowserContextCredentialsSeedKnownCredentialAndAuthenticate(t *testing.T) { + BeforeEach(t) + + source, err := browser.NewContext() + require.NoError(t, err) + defer source.Close() //nolint:errcheck + known, err := source.Credentials().Create(webAuthnRPID) + require.NoError(t, err) + + target, err := browser.NewContext() + require.NoError(t, err) + defer target.Close() //nolint:errcheck + _, err = target.Credentials().Create(known.RpId, playwright.CredentialsCreateOptions{ + Id: playwright.String(known.Id), + UserHandle: playwright.String(known.UserHandle), + PrivateKey: playwright.String(known.PrivateKey), + PublicKey: playwright.String(known.PublicKey), + }) + require.NoError(t, err) + require.NoError(t, target.Credentials().Install()) + targetPage := newWebAuthnPage(t, target) + requireWebAuthnAuthentication(t, targetPage, known.RpId, known.Id) + + require.NoError(t, target.Credentials().Delete(known.Id)) + credentials, err := target.Credentials().Get() + require.NoError(t, err) + require.Empty(t, credentials) + failure, err := targetPage.Evaluate(webAuthnAuthenticateErrorScript, map[string]any{ + "rpId": known.RpId, + "credentialId": known.Id, + }) + require.NoError(t, err) + require.Equal(t, "NotAllowedError", failure) +} + +func TestBrowserContextCredentialsCapturePageCredentialAndReuseIt(t *testing.T) { + BeforeEach(t) + + setupContext, err := browser.NewContext() + require.NoError(t, err) + defer setupContext.Close() //nolint:errcheck + require.NoError(t, setupContext.Credentials().Install()) + setupPage := newWebAuthnPage(t, setupContext) + createdID, err := setupPage.Evaluate(webAuthnCreateScript, map[string]any{"rpId": webAuthnRPID}) + require.NoError(t, err) + createdIDString, ok := createdID.(string) + require.True(t, ok, "expected a base64url credential id, got %T", createdID) + + captured, err := setupContext.Credentials().Get(playwright.CredentialsGetOptions{RpId: playwright.String(webAuthnRPID)}) + require.NoError(t, err) + require.Len(t, captured, 1) + require.Equal(t, createdIDString, captured[0].Id) + require.Regexp(t, `^[A-Za-z0-9_-]+$`, captured[0].PrivateKey) + require.Regexp(t, `^[A-Za-z0-9_-]+$`, captured[0].PublicKey) + + target, err := browser.NewContext() + require.NoError(t, err) + defer target.Close() //nolint:errcheck + _, err = target.Credentials().Create(captured[0].RpId, playwright.CredentialsCreateOptions{ + Id: playwright.String(captured[0].Id), + UserHandle: playwright.String(captured[0].UserHandle), + PrivateKey: playwright.String(captured[0].PrivateKey), + PublicKey: playwright.String(captured[0].PublicKey), + }) + require.NoError(t, err) + require.NoError(t, target.Credentials().Install()) + targetPage := newWebAuthnPage(t, target) + gotID, err := targetPage.Evaluate(webAuthnDiscoverableCredentialScript, map[string]any{"rpId": webAuthnRPID}) + require.NoError(t, err) + require.Equal(t, createdIDString, gotID) +} + +func TestBrowserContextCredentialsStorageStateReusesPageCredential(t *testing.T) { + BeforeEach(t) + + setupContext, err := browser.NewContext() + require.NoError(t, err) + defer setupContext.Close() //nolint:errcheck + require.NoError(t, setupContext.Credentials().Install()) + setupPage := newWebAuthnPage(t, setupContext) + createdID, err := setupPage.Evaluate(webAuthnCreateScript, map[string]any{"rpId": webAuthnRPID}) + require.NoError(t, err) + createdIDString, ok := createdID.(string) + require.True(t, ok, "expected a base64url credential id, got %T", createdID) + + state, err := setupContext.StorageState(playwright.BrowserContextStorageStateOptions{ + Credentials: playwright.Bool(true), + }) + require.NoError(t, err) + require.Len(t, state.Credentials, 1) + require.Equal(t, createdIDString, state.Credentials[0].Id) + + restored, err := browser.NewContext(playwright.BrowserNewContextOptions{ + StorageState: state.ToOptionalStorageState(), + }) + require.NoError(t, err) + defer restored.Close() //nolint:errcheck + restoredPage := newWebAuthnPage(t, restored) + gotID, err := restoredPage.Evaluate(webAuthnDiscoverableCredentialScript, map[string]any{"rpId": webAuthnRPID}) + require.NoError(t, err) + require.Equal(t, createdIDString, gotID) +} + +func TestStorageStateRoundTripWebAuthnCredentials(t *testing.T) { + BeforeEach(t) + require.NoError(t, context.AddCookies([]playwright.OptionalCookie{{ + Name: "session", + Value: "cookie-value", + URL: playwright.String(server.PREFIX), + }})) + _, err := page.Goto(server.EMPTY_PAGE) + require.NoError(t, err) + _, err = page.Evaluate(`() => localStorage.setItem("roll-key", "roll-value")`) + require.NoError(t, err) + + // Seed a virtual credential. + require.NoError(t, context.Credentials().Install()) + cred, err := context.Credentials().Create("example.com") + require.NoError(t, err) + require.NotEmpty(t, cred.Id) + withoutCredentials, err := context.StorageState() + require.NoError(t, err) + require.Empty(t, withoutCredentials.Credentials, "credentials must be opt-in") + require.NotEmpty(t, withoutCredentials.Cookies) + require.NotEmpty(t, withoutCredentials.Origins) + + state, err := context.StorageState(playwright.BrowserContextStorageStateOptions{ + Credentials: playwright.Bool(true), + }) + require.NoError(t, err) + require.NotEmpty(t, state.Credentials) + require.Equal(t, cred.Id, state.Credentials[0].Id) + + // In-memory round-trip via OptionalStorageState. + opt := state.ToOptionalStorageState() + require.NotEmpty(t, opt.Credentials) + + ctx2, err := browser.NewContext(playwright.BrowserNewContextOptions{ + StorageState: opt, + }) + require.NoError(t, err) + defer ctx2.Close() //nolint:errcheck + restored, err := ctx2.Credentials().Get() + require.NoError(t, err) + require.NotEmpty(t, restored) + require.Equal(t, cred.Id, restored[0].Id) + + // Path round-trip. + path := filepath.Join(t.TempDir(), "state.json") + _, err = context.StorageState(playwright.BrowserContextStorageStateOptions{ + Credentials: playwright.Bool(true), + Path: playwright.String(path), + }) + require.NoError(t, err) + ctx3, err := browser.NewContext(playwright.BrowserNewContextOptions{ + StorageStatePath: playwright.String(path), + }) + require.NoError(t, err) + defer ctx3.Close() //nolint:errcheck + restored2, err := ctx3.Credentials().Get() + require.NoError(t, err) + require.NotEmpty(t, restored2) + + // SetStorageState must restore and subsequently clear credentials. + ctx4, err := browser.NewContext() + require.NoError(t, err) + defer ctx4.Close() //nolint:errcheck + require.NoError(t, ctx4.SetStorageState(path)) + restored3, err := ctx4.Credentials().Get() + require.NoError(t, err) + require.NotEmpty(t, restored3) + withoutCredentialsPath := filepath.Join(t.TempDir(), "state-without-credentials.json") + _, err = context.StorageState(playwright.BrowserContextStorageStateOptions{ + Path: playwright.String(withoutCredentialsPath), + }) + require.NoError(t, err) + require.NoError(t, ctx4.SetStorageState(withoutCredentialsPath)) + restored3, err = ctx4.Credentials().Get() + require.NoError(t, err) + require.Empty(t, restored3) + + // APIRequestContext strips credentials while preserving cookies/origins. + req, err := pw.Request.NewContext(playwright.APIRequestNewContextOptions{ + StorageState: state, + }) + require.NoError(t, err) + reqState, err := req.StorageState() + require.NoError(t, err) + require.Empty(t, reqState.Credentials) + require.Equal(t, state.Cookies, reqState.Cookies) + require.Equal(t, state.Origins, reqState.Origins) + require.NoError(t, req.Dispose()) + + reqFromFile, err := pw.Request.NewContext(playwright.APIRequestNewContextOptions{ + StorageStatePath: playwright.String(path), + }) + require.NoError(t, err) + reqFileState, err := reqFromFile.StorageState() + require.NoError(t, err) + require.Empty(t, reqFileState.Credentials) + require.Equal(t, state.Cookies, reqFileState.Cookies) + require.Equal(t, state.Origins, reqFileState.Origins) + require.NoError(t, reqFromFile.Dispose()) +} diff --git a/tests/browser_type_test.go b/tests/browser_type_test.go index 20f76664..e2145242 100644 --- a/tests/browser_type_test.go +++ b/tests/browser_type_test.go @@ -1,9 +1,11 @@ package playwright_test import ( + "encoding/json" "errors" "fmt" "math" + "net/http" "os" "path/filepath" "slices" @@ -11,6 +13,7 @@ import ( "testing" "time" + "github.com/coder/websocket" "github.com/mxschmitt/playwright-go" "github.com/stretchr/testify/require" ) @@ -190,6 +193,214 @@ func TestBrowserTypeConnectShouldEmitDisconnectedEvent(t *testing.T) { require.Len(t, disconnected2.Get(), 1) } +func TestBrowserTypeConnectTimeoutIncludesInitializationAndClosesTransport(t *testing.T) { + BeforeEach(t) + + connected := server.WaitForWebSocketConnection() + closed := make(chan struct{}, 1) + server.OnceWebSocketClose(func(_ *websocket.CloseError) { + closed <- struct{}{} + }) + + result := make(chan error, 1) + started := time.Now() + go func() { + _, err := browserType.Connect( + strings.Replace(server.PREFIX, "http://", "ws://", 1)+"/ws", + playwright.BrowserTypeConnectOptions{Timeout: playwright.Float(500)}, + ) + result <- err + }() + + select { + case <-connected: + case <-time.After(5 * time.Second): + server.CloseClientConnections() + t.Fatal("WebSocket endpoint was not reached") + } + + select { + case err := <-result: + require.ErrorIs(t, err, playwright.ErrTimeout) + require.ErrorContains(t, err, "Timeout 500ms exceeded.") + require.Less(t, time.Since(started), 2*time.Second) + case <-time.After(5 * time.Second): + server.CloseClientConnections() + t.Fatal("BrowserType.Connect did not time out while Root.initialize was pending") + } + + select { + case <-closed: + case <-time.After(5 * time.Second): + t.Fatal("BrowserType.Connect did not close the WebSocket after timing out") + } +} + +func TestBrowserTypeConnectInitializationErrorClosesTransport(t *testing.T) { + BeforeEach(t) + + protocolErrors := make(chan error, 1) + server.OnceWebSocketMessage(func(connection *websocket.Conn, request *http.Request, _ websocket.MessageType, payload []byte) { + var initialize struct { + ID int `json:"id"` + } + if err := json.Unmarshal(payload, &initialize); err != nil { + protocolErrors <- err + _ = connection.CloseNow() + return + } + response, err := json.Marshal(map[string]any{ + "id": initialize.ID, + "error": map[string]any{ + "error": map[string]any{ + "name": "Error", + "message": "initialize failed", + "stack": "", + }, + }, + }) + if err == nil { + err = connection.Write(request.Context(), websocket.MessageText, response) + } + if err != nil { + protocolErrors <- err + _ = connection.CloseNow() + } + }) + + closed := make(chan struct{}, 1) + server.OnceWebSocketClose(func(_ *websocket.CloseError) { + closed <- struct{}{} + }) + result := make(chan error, 1) + go func() { + _, err := browserType.Connect(strings.Replace(server.PREFIX, "http://", "ws://", 1) + "/ws") + result <- err + }() + + select { + case err := <-result: + require.ErrorContains(t, err, "initialize failed") + case err := <-protocolErrors: + t.Fatalf("could not serve the initialization error: %v", err) + case <-time.After(5 * time.Second): + server.CloseClientConnections() + t.Fatal("BrowserType.Connect did not return the initialization error") + } + + select { + case err := <-protocolErrors: + t.Fatalf("could not serve the initialization error: %v", err) + case <-closed: + case <-time.After(5 * time.Second): + t.Fatal("BrowserType.Connect did not close the endpoint after initialization failed") + } +} + +func TestBrowserTypeConnectMalformedEndpointClosesTransport(t *testing.T) { + BeforeEach(t) + + protocolErrors := make(chan error, 1) + server.OnceWebSocketMessage(func(connection *websocket.Conn, request *http.Request, _ websocket.MessageType, payload []byte) { + var initialize struct { + ID int `json:"id"` + } + if err := json.Unmarshal(payload, &initialize); err != nil { + protocolErrors <- err + _ = connection.CloseNow() + return + } + + messages := []map[string]any{ + { + "guid": "", + "method": "__create__", + "params": map[string]any{ + "type": "BrowserType", + "guid": "chromium", + "initializer": map[string]any{"name": "chromium", "executablePath": ""}, + }, + }, + { + "guid": "", + "method": "__create__", + "params": map[string]any{ + "type": "BrowserType", + "guid": "firefox", + "initializer": map[string]any{"name": "firefox", "executablePath": ""}, + }, + }, + { + "guid": "", + "method": "__create__", + "params": map[string]any{ + "type": "BrowserType", + "guid": "webkit", + "initializer": map[string]any{"name": "webkit", "executablePath": ""}, + }, + }, + { + "guid": "", + "method": "__create__", + "params": map[string]any{ + "type": "Playwright", + "guid": "playwright", + "initializer": map[string]any{ + "chromium": map[string]any{"guid": "chromium"}, + "firefox": map[string]any{"guid": "firefox"}, + "webkit": map[string]any{"guid": "webkit"}, + }, + }, + }, + { + "id": initialize.ID, + "result": map[string]any{ + "playwright": map[string]any{"guid": "playwright"}, + }, + }, + } + for _, message := range messages { + data, err := json.Marshal(message) + if err == nil { + err = connection.Write(request.Context(), websocket.MessageText, data) + } + if err != nil { + protocolErrors <- err + _ = connection.CloseNow() + return + } + } + }) + + closed := make(chan struct{}, 1) + server.OnceWebSocketClose(func(_ *websocket.CloseError) { + closed <- struct{}{} + }) + result := make(chan error, 1) + go func() { + _, err := browserType.Connect(strings.Replace(server.PREFIX, "http://", "ws://", 1) + "/ws") + result <- err + }() + + select { + case err := <-result: + require.ErrorContains(t, err, "malformed endpoint") + case err := <-protocolErrors: + t.Fatalf("could not serve the minimal Playwright protocol: %v", err) + case <-time.After(5 * time.Second): + server.CloseClientConnections() + t.Fatal("BrowserType.Connect did not reject the malformed endpoint") + } + + select { + case err := <-protocolErrors: + t.Fatalf("could not serve the minimal Playwright protocol: %v", err) + case <-closed: + case <-time.After(5 * time.Second): + t.Fatal("BrowserType.Connect did not close the malformed endpoint") + } +} + func TestBrowserTypeConnectSlowMo(t *testing.T) { BeforeEach(t) @@ -198,7 +409,8 @@ func TestBrowserTypeConnectSlowMo(t *testing.T) { defer remoteServer.Close() browser1, err := browserType.Connect(remoteServer.url, playwright.BrowserTypeConnectOptions{ - SlowMo: playwright.Float(100), + SlowMo: playwright.Float(100), + Timeout: playwright.Float(0), }) require.NoError(t, err) require.NotNil(t, browser1) @@ -463,3 +675,14 @@ func TestBrowserBind(t *testing.T) { require.NoError(t, browser2.Close()) require.NoError(t, browser.Unbind()) } + +func TestBrowserTypeLaunchExplicitZeroTimeout(t *testing.T) { + BeforeEach(t) + + launched, err := browserType.Launch(playwright.BrowserTypeLaunchOptions{ + Headless: playwright.Bool(true), + Timeout: playwright.Float(0), + }) + require.NoError(t, err) + require.NoError(t, launched.Close()) +} diff --git a/tests/fetch_test.go b/tests/fetch_test.go index 4c865f39..26d1c6bd 100644 --- a/tests/fetch_test.go +++ b/tests/fetch_test.go @@ -1,12 +1,16 @@ package playwright_test import ( + "crypto/tls" + "crypto/x509" "encoding/base64" "encoding/json" "io" "net/http" + "net/http/httptest" "os" "path/filepath" + "strconv" "strings" "sync" "sync/atomic" @@ -69,19 +73,70 @@ func TestAPIResponseServerAddrAndSecurityDetails(t *testing.T) { request, err := pw.Request.NewContext() require.NoError(t, err) - response, err := request.Get(server.PREFIX + "/simple.json") - require.NoError(t, err) + defer request.Dispose() //nolint:errcheck + // The second request reuses the keep-alive socket. Both responses must + // retain the address instead of only the first response exposing it. + for range 2 { + // Use localhost as upstream's TestServer does. Node's Happy Eyeballs + // agent may expose the connected address later for an explicit IPv4 URL. + response, err := request.Get(server.CROSS_PROCESS_PREFIX + "/empty.html") + require.NoError(t, err) + addr, err := response.ServerAddr() + require.NoError(t, err) + require.NotNil(t, addr) + require.Contains(t, []string{"127.0.0.1", "::1"}, addr.IpAddress) + require.Equal(t, server.PORT, strconv.Itoa(addr.Port)) - // Over plain HTTP the server address is reported and security details are absent. - addr, err := response.ServerAddr() + details, err := response.SecurityDetails() + require.NoError(t, err) + require.Nil(t, details) + } + + tlsServer, certificate := newAPIResponseTLSServer(t) + defer tlsServer.Close() + secureRequest, err := pw.Request.NewContext(playwright.APIRequestNewContextOptions{ + IgnoreHttpsErrors: playwright.Bool(true), + }) require.NoError(t, err) - if addr != nil { - require.Greater(t, addr.Port, 0) + defer secureRequest.Dispose() //nolint:errcheck + // APIRequestContext is implemented by the driver, so its HTTPS metadata + // must be present regardless of the browser currently under test. + url := strings.Replace(tlsServer.URL, "127.0.0.1", "localhost", 1) + for range 2 { + response, err := secureRequest.Get(url) + require.NoError(t, err) + details, err := response.SecurityDetails() + require.NoError(t, err) + require.NotNil(t, details) + require.NotNil(t, details.Issuer) + require.NotNil(t, details.SubjectName) + require.NotNil(t, details.Protocol) + require.NotNil(t, details.ValidFrom) + require.NotNil(t, details.ValidTo) + require.Equal(t, certificate.Issuer.CommonName, *details.Issuer) + require.Equal(t, certificate.Subject.CommonName, *details.SubjectName) + require.Equal(t, "TLSv1.3", *details.Protocol) + require.Equal(t, float64(certificate.NotBefore.Unix()), *details.ValidFrom) + require.Equal(t, float64(certificate.NotAfter.Unix()), *details.ValidTo) } +} - details, err := response.SecurityDetails() +func newAPIResponseTLSServer(t *testing.T) (*httptest.Server, *x509.Certificate) { + t.Helper() + cert, err := tls.LoadX509KeyPair( + Asset("client-certificates/server/server_cert.pem"), + Asset("client-certificates/server/server_key.pem"), + ) require.NoError(t, err) - require.Nil(t, details) + certificate, err := x509.ParseCertificate(cert.Certificate[0]) + require.NoError(t, err) + + server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + })) + server.TLS = &tls.Config{Certificates: []tls.Certificate{cert}} + server.StartTLS() + return server, certificate } func TestShouldDisposeGlobalRequest(t *testing.T) { @@ -695,3 +750,34 @@ func TestShouldFollowMaxRedirects(t *testing.T) { require.Equal(t, int32(2), redirectCount.Load()) require.NoError(t, request.Dispose()) } + +func TestAPIResponseShouldReturnTiming(t *testing.T) { + BeforeEach(t) + + response, err := context.Request().Get(server.EMPTY_PAGE) + require.NoError(t, err) + timing := response.Timing() + require.NotNil(t, timing) + // Live HTTP responses should populate timing fields (not all -1). + require.Greater(t, timing.StartTime, float64(-1)) + require.GreaterOrEqual(t, timing.ResponseEnd, float64(-1)) +} + +func TestAPIRequestExplicitZeroOverridesContextTimeout(t *testing.T) { + BeforeEach(t) + + server.SetRoute("/roll-v162-slow", func(w http.ResponseWriter, r *http.Request) { + time.Sleep(200 * time.Millisecond) + }) + request, err := pw.Request.NewContext(playwright.APIRequestNewContextOptions{ + Timeout: playwright.Float(50), + }) + require.NoError(t, err) + defer request.Dispose() //nolint:errcheck + response, err := request.Get( + server.PREFIX+"/roll-v162-slow", + playwright.APIRequestContextGetOptions{Timeout: playwright.Float(0)}, + ) + require.NoError(t, err, "an explicit zero must override the request context default") + require.Equal(t, 200, response.Status()) +} diff --git a/tests/har_test.go b/tests/har_test.go index c60c9073..36f773b6 100644 --- a/tests/har_test.go +++ b/tests/har_test.go @@ -494,6 +494,41 @@ func TestShouldRoundTripHarZip(t *testing.T) { require.Contains(t, content, "hello, world!") } +func TestShouldNotInterceptAPIRequestContextRequestsFromHARByDefault(t *testing.T) { + BeforeEach(t) + + server.SetRoute("/api/data", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("content-type", "application/json") + _, _ = w.Write([]byte(`{"hello":"live"}`)) + }) + harPath := filepath.Join(t.TempDir(), "api.har") + require.NoError(t, context.RouteFromHAR(harPath, playwright.BrowserContextRouteFromHAROptions{ + Update: playwright.Bool(true), + })) + _, err := page.Goto(server.EMPTY_PAGE) + require.NoError(t, err) + recorded, err := page.Request().Get(server.PREFIX + "/api/data") + require.NoError(t, err) + require.True(t, recorded.Ok()) + require.NoError(t, recorded.Dispose()) + require.NoError(t, context.Close()) + + server.SetRoute("/api/data", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("content-type", "application/json") + _, _ = w.Write([]byte(`{"hello":"fresh"}`)) + }) + context2, page2 := newBrowserContextAndPage(t, playwright.BrowserNewContextOptions{}) + require.NoError(t, context2.RouteFromHAR(harPath, playwright.BrowserContextRouteFromHAROptions{ + NotFound: playwright.HarNotFoundFallback, + })) + replayed, err := page2.Request().Get(server.PREFIX + "/api/data") + require.NoError(t, err) + defer replayed.Dispose() //nolint:errcheck + var body map[string]string + require.NoError(t, replayed.JSON(&body)) + require.Equal(t, map[string]string{"hello": "fresh"}, body) +} + func TestShouldRoundTripHarWithPostData(t *testing.T) { harPath := filepath.Join(t.TempDir(), "har.zip") BeforeEach(t, playwright.BrowserNewContextOptions{ diff --git a/tests/input_test.go b/tests/input_test.go index a661f661..99151e43 100644 --- a/tests/input_test.go +++ b/tests/input_test.go @@ -384,3 +384,85 @@ func TestSetInputFilesShouldRespectDefaultTimeout(t *testing.T) { err := page.Locator("input#does-not-exist").SetInputFiles(file) require.ErrorContains(t, err, "Timeout 500ms exceeded") } + +func TestScrollModeNoneDoesNotScroll(t *testing.T) { + BeforeEach(t) + require.NoError(t, page.SetContent(` + +
+ + `)) + // Element is below the fold. + err := page.Locator("#btn").Click(playwright.LocatorClickOptions{ + Scroll: playwright.ScrollModeNone, + Timeout: playwright.Float(1000), + }) + require.Error(t, err) + scrollY, err := page.Evaluate(`() => window.scrollY`) + require.NoError(t, err) + require.EqualValues(t, 0, scrollY) +} + +func TestScrollModeNoneClicksInViewport(t *testing.T) { + BeforeEach(t) + require.NoError(t, page.SetContent(``)) + require.NoError(t, page.Locator("#btn").Click(playwright.LocatorClickOptions{ + Scroll: playwright.ScrollModeNone, + })) +} + +func TestScrollModeAcrossPageFrameAndElementHandle(t *testing.T) { + BeforeEach(t) + setOffscreenButton := func() { + t.Helper() + require.NoError(t, page.SetContent(` + +
+ `)) + } + assertNotScrolled := func() { + t.Helper() + scrollY, err := page.Evaluate(`() => window.scrollY`) + require.NoError(t, err) + require.EqualValues(t, 0, scrollY) + } + + setOffscreenButton() + //nolint:staticcheck // ScrollMode was added to this legacy Page action API. + err := page.Click("#btn", playwright.PageClickOptions{ + Scroll: playwright.ScrollModeNone, + Timeout: playwright.Float(300), + }) + require.Error(t, err) + assertNotScrolled() + + setOffscreenButton() + //nolint:staticcheck // ScrollMode was added to this legacy Frame action API. + err = page.MainFrame().Click("#btn", playwright.FrameClickOptions{ + Scroll: playwright.ScrollModeNone, + Timeout: playwright.Float(300), + }) + require.Error(t, err) + assertNotScrolled() + + setOffscreenButton() + //nolint:staticcheck // ScrollMode was added to this legacy ElementHandle API. + handle, err := page.QuerySelector("#btn") + require.NoError(t, err) + require.NotNil(t, handle) + //nolint:staticcheck // ScrollMode was added to this legacy ElementHandle action API. + err = handle.Click(playwright.ElementHandleClickOptions{ + Scroll: playwright.ScrollModeNone, + Timeout: playwright.Float(300), + }) + require.Error(t, err) + require.NoError(t, handle.Dispose()) + assertNotScrolled() + + setOffscreenButton() + //nolint:staticcheck // The legacy Page API must retain the default auto-scroll behavior. + require.NoError(t, page.Click("#btn", playwright.PageClickOptions{Scroll: playwright.ScrollModeAuto})) + scrollY, err := page.Evaluate(`() => window.scrollY`) + require.NoError(t, err) + require.NotEqualValues(t, 0, scrollY) +} diff --git a/tests/locator_test.go b/tests/locator_test.go index c2bde8fe..6d8ed2db 100644 --- a/tests/locator_test.go +++ b/tests/locator_test.go @@ -4,6 +4,7 @@ import ( "fmt" "os" "testing" + "time" "github.com/mxschmitt/playwright-go" "github.com/stretchr/testify/require" @@ -894,3 +895,87 @@ func TestLocatorDescribeMultipleCalls(t *testing.T) { require.NoError(t, err) require.Empty(t, desc, "chained locator without describe should have empty description") } + +func TestLocatorWaitForFunction(t *testing.T) { + BeforeEach(t) + require.NoError(t, page.SetContent(`
`)) + locator := page.Locator("#el") + + // Already truthy: should return immediately. + require.NoError(t, locator.WaitForFunction(`element => !!element`, nil)) + + // Wait for an attribute to appear. + done := make(chan error, 1) + go func() { + done <- locator.WaitForFunction(`element => element.hasAttribute("data-ready")`, nil, + playwright.LocatorWaitForFunctionOptions{Timeout: playwright.Float(5000)}) + }() + time.Sleep(50 * time.Millisecond) + _, err := page.Evaluate(`() => document.getElementById("el").setAttribute("data-ready", "1")`) + require.NoError(t, err) + require.NoError(t, <-done) + + // Scalar argument. + require.NoError(t, locator.WaitForFunction( + `(element, expected) => element.getAttribute("data-ready") === expected`, + "1", + )) +} + +func TestLocatorWaitForFunctionTimeout(t *testing.T) { + BeforeEach(t) + require.NoError(t, page.SetContent(`
`)) + err := page.Locator("#el").WaitForFunction( + `element => element.hasAttribute("missing")`, + nil, + playwright.LocatorWaitForFunctionOptions{Timeout: playwright.Float(200)}, + ) + require.Error(t, err) + require.ErrorIs(t, err, playwright.ErrTimeout) +} + +func TestLocatorWaitForFunctionStrictViolation(t *testing.T) { + BeforeEach(t) + require.NoError(t, page.SetContent(`
`)) + err := page.Locator(".x").WaitForFunction(`element => true`, nil, + playwright.LocatorWaitForFunctionOptions{Timeout: playwright.Float(1000)}) + require.Error(t, err) +} + +func TestLocatorWaitForFunctionElementHandleThrowRerenderAndDefaultTimeout(t *testing.T) { + BeforeEach(t) + require.NoError(t, page.SetContent(`
first
`)) + locator := page.Locator("#el") + //nolint:staticcheck // The roll must preserve ElementHandle argument compatibility. + handle, err := page.QuerySelector("#el") + require.NoError(t, err) + require.NotNil(t, handle) + defer handle.Dispose() //nolint:errcheck + + require.NoError(t, locator.WaitForFunction( + `(element, expected) => element === expected`, + handle, + )) + + err = locator.WaitForFunction(`() => { throw new Error("wait-function-boom") }`, nil) + require.Error(t, err) + require.ErrorContains(t, err, "wait-function-boom") + + _, err = page.Evaluate(`() => { + setTimeout(() => { + document.querySelector('#el').outerHTML = '
second
'; + }, 50); + }`) + require.NoError(t, err) + require.NoError(t, locator.WaitForFunction( + `element => element.dataset.ready === "yes"`, + nil, + playwright.LocatorWaitForFunctionOptions{Timeout: playwright.Float(5000)}, + )) + + page.SetDefaultTimeout(150) + started := time.Now() + err = locator.WaitForFunction(`element => element.dataset.never === "true"`, nil) + require.ErrorIs(t, err, playwright.ErrTimeout) + require.Less(t, time.Since(started), 2*time.Second) +} diff --git a/tests/page_aria_snapshot_test.go b/tests/page_aria_snapshot_test.go index c9e60081..4a35a1a5 100644 --- a/tests/page_aria_snapshot_test.go +++ b/tests/page_aria_snapshot_test.go @@ -161,6 +161,42 @@ func TestShouldSnapshotWithUnexpectedChildrenDeepEqual(t *testing.T) { `, playwright.LocatorAssertionsToMatchAriaSnapshotOptions{Timeout: playwright.Float(1000)})) } +func TestPageAssertionsToMatchAriaSnapshotInvalidAttribute(t *testing.T) { + BeforeEach(t) + + require.NoError(t, page.SetContent(` + + + `)) + require.NoError(t, expect.Page(page).ToMatchAriaSnapshot(Unshift(` + - textbox "Email" [invalid]: not-an-email + - textbox "Name": Alice + `))) + require.NoError(t, expect.Page(page).ToMatchAriaSnapshot(Unshift(` + - textbox "Email" [invalid=true]: not-an-email + - textbox "Name" [invalid=false]: Alice + `))) + + // `grammar` and `spelling` retain their semantic value instead of being + // collapsed into the generic invalid state. + require.NoError(t, page.SetContent(` + + + `)) + require.NoError(t, expect.Page(page).ToMatchAriaSnapshot(Unshift(` + - textbox "Bio" [invalid=grammar] + - textbox "Note" [invalid=spelling] + `))) + err := expect.Page(page).ToMatchAriaSnapshot(Unshift(` + - textbox "Bio" [invalid] + `), playwright.PageAssertionsToMatchAriaSnapshotOptions{Timeout: playwright.Float(1000)}) + require.ErrorContains(t, err, "[invalid=grammar]") + + // Any non-false value other than grammar/spelling is the generic true state. + require.NoError(t, page.SetContent(``)) + require.NoError(t, expect.Page(page).ToMatchAriaSnapshot(`- textbox "Zip" [invalid]`)) +} + // Covers PageAssertions.ToMatchAriaSnapshot, which must use the // "to.match.aria" expect expression (mirrors the Locator variant). func TestPageAssertionsToMatchAriaSnapshot(t *testing.T) { diff --git a/tests/page_assertions_test.go b/tests/page_assertions_test.go index 3a028692..ad9b45f5 100644 --- a/tests/page_assertions_test.go +++ b/tests/page_assertions_test.go @@ -3,6 +3,7 @@ package playwright_test import ( "regexp" "testing" + "time" "github.com/mxschmitt/playwright-go" "github.com/stretchr/testify/require" @@ -91,3 +92,14 @@ func TestPageAssertionsToHaveAccessibleErrorMessage(t *testing.T) { })) require.NoError(t, expect.Locator(locator).Not().ToHaveAccessibleErrorMessage("This should not be considered.")) } + +func TestPageAssertionsUsesConfiguredTimeout(t *testing.T) { + BeforeEach(t) + + require.NoError(t, page.SetContent(`actual`)) + shortExpect := playwright.NewPlaywrightAssertions(150) + started := time.Now() + err := shortExpect.Page(page).ToHaveTitle("never") + require.Error(t, err) + require.Less(t, time.Since(started), 2*time.Second) +} diff --git a/tests/page_clock_test.go b/tests/page_clock_test.go index 9f3d819a..e08935ce 100644 --- a/tests/page_clock_test.go +++ b/tests/page_clock_test.go @@ -578,6 +578,20 @@ func TestPageClockFixedTime(t *testing.T) { } func TestPageClockWhileRunning(t *testing.T) { + t.Run("should reject an invalid target time with an active animation frame loop", func(t *testing.T) { + BeforeEach(t) + + require.NoError(t, page.Clock().Install()) + require.NoError(t, page.SetContent(``)) + now, err := page.Evaluate(`Date.now()`) + require.NoError(t, err) + nowMillis, ok := now.(int) + require.True(t, ok, "integral JavaScript numbers should deserialize as int") + invalidTime := int64(nowMillis) * 1_000_000 + err = page.Clock().PauseAt(invalidTime) + require.ErrorContains(t, err, fmt.Sprintf("Invalid date: %v", invalidTime)) + }) + t.Run("should progress time", func(t *testing.T) { BeforeEach(t) diff --git a/tests/route_test.go b/tests/route_test.go index 20f71f4b..3fd256d8 100644 --- a/tests/route_test.go +++ b/tests/route_test.go @@ -5,6 +5,7 @@ import ( "fmt" "io" "net/http" + "runtime" "testing" "github.com/mxschmitt/playwright-go" @@ -88,6 +89,24 @@ func TestRouteContinueOverwriteBodyBytes(t *testing.T) { require.Equal(t, "foobar", string(respData)) } +func TestRouteContinueOverwriteBodyWithEmptyString(t *testing.T) { + BeforeEach(t) + + _, err := page.Goto(server.EMPTY_PAGE) + require.NoError(t, err) + require.NoError(t, page.Route("**/*", func(route playwright.Route) { + require.NoError(t, route.Continue(playwright.RouteContinueOptions{PostData: ""})) + })) + request, err := page.ExpectRequest("**/sleep.zzz", func() error { + _, err := page.Evaluate(`url => fetch(url, { method: "POST", body: "original" })`, server.PREFIX+"/sleep.zzz") + return err + }) + require.NoError(t, err) + postData, err := request.PostData() + require.NoError(t, err) + require.Equal(t, "", postData) +} + func TestRouteFulfill(t *testing.T) { BeforeEach(t) @@ -364,9 +383,6 @@ func TestFulfillWithURLOverride(t *testing.T) { func TestResponseSecurityDetails(t *testing.T) { BeforeEach(t) - if isWebKit { - t.Skip("https://github.com/microsoft/playwright/issues/6759") - } tlsServer := newTestServer(true) defer tlsServer.testServer.Close() page2, err := browser.NewPage(playwright.BrowserNewPageOptions{ @@ -378,8 +394,35 @@ func TestResponseSecurityDetails(t *testing.T) { require.NoError(t, response.Finished()) securityDetails, err := response.SecurityDetails() require.NoError(t, err) - - require.Equal(t, "TLS 1.3", *securityDetails.Protocol) + if isWebKit && (securityDetails == nil || securityDetails.SubjectName == nil) { + // Frozen WebKit builds do not expose the complete response security + // details. This mirrors Playwright's own platform-specific exclusion + // for that build. + t.Skip("frozen WebKit does not expose complete response security details") + } + require.NotNil(t, securityDetails) + if isWebKit { + // httptest's self-signed certificate does not expose validity dates + // through WebKit. The upstream suite uses a fixed certificate when it + // asserts these optional fields. + require.Nil(t, securityDetails.Issuer) + require.NotNil(t, securityDetails.SubjectName) + // WebKit on Windows and WSL does not reliably expose the TLS protocol. + if runtime.GOOS == "windows" { + // Windows WebKit intentionally reports the literal value "true"; + // keep this assertion in sync with Playwright's cross-language suite. + require.Equal(t, "true", *securityDetails.SubjectName) + } else if securityDetails.Protocol != nil { + require.NotNil(t, securityDetails.Protocol) + require.Equal(t, "TLS 1.3", *securityDetails.Protocol) + } + } else { + require.NotNil(t, securityDetails.Issuer) + require.NotNil(t, securityDetails.Protocol) + require.Equal(t, "TLS 1.3", *securityDetails.Protocol) + require.NotNil(t, securityDetails.ValidFrom) + require.NotNil(t, securityDetails.ValidTo) + } require.NoError(t, page2.Close()) } diff --git a/tests/screencast_test.go b/tests/screencast_test.go index f506fc58..d11933b7 100644 --- a/tests/screencast_test.go +++ b/tests/screencast_test.go @@ -1,8 +1,13 @@ package playwright_test import ( + "errors" + "fmt" + "strings" "sync" + "sync/atomic" "testing" + "time" "github.com/mxschmitt/playwright-go" "github.com/stretchr/testify/require" @@ -36,20 +41,35 @@ func TestScreencastOnFrameReceivesViewportSizeAndTimestamp(t *testing.T) { _, err = p.Goto(server.EMPTY_PAGE) require.NoError(t, err) - _, err = p.Evaluate("() => document.body.style.backgroundColor = 'red'") - require.NoError(t, err) - for i := 0; i < 100; i++ { + + // Drive distinct visual mutations until at least two frames arrive. + // Without screencastFrameAck the stream stalls after the first frame. + colors := []string{"red", "green", "blue", "yellow", "purple"} + deadline := time.Now().Add(10 * time.Second) + for i := 0; time.Now().Before(deadline); i++ { + color := colors[i%len(colors)] + _, err = p.Evaluate(fmt.Sprintf("() => { document.body.style.backgroundColor = '%s'; }", color)) + require.NoError(t, err) _, err = p.Evaluate("() => new Promise(f => requestAnimationFrame(() => requestAnimationFrame(f)))") require.NoError(t, err) + mu.Lock() + n := len(received) + mu.Unlock() + if n >= 2 { + break + } + time.Sleep(20 * time.Millisecond) } - _, err = p.Screenshot() - require.NoError(t, err) require.NoError(t, sc.Stop()) mu.Lock() defer mu.Unlock() - require.GreaterOrEqual(t, len(received), 1) + require.GreaterOrEqual(t, len(received), 2, "expected multi-frame screencast; ACK may be missing") for _, frame := range received { + require.GreaterOrEqual(t, len(frame.Data), 2) + require.Equal(t, byte(0xff), frame.Data[0]) + require.Equal(t, byte(0xd8), frame.Data[1]) + require.Equal(t, 1000, frame.ViewportWidth) require.Equal(t, 400, frame.ViewportHeight) // Timestamp is milliseconds since the Unix epoch; just assert it was set. require.Greater(t, frame.Timestamp, float64(0)) @@ -75,3 +95,159 @@ func TestScreencastShowActionsAcceptsCursorParam(t *testing.T) { Cursor: playwright.ScreencastCursorNone, })) } + +func TestScreencastOnFrameCanReenterClient(t *testing.T) { + BeforeEach(t) + + sc, err := page.Screencast() + require.NoError(t, err) + result := make(chan error, 1) + var once sync.Once + var callbackCount atomic.Int32 + require.NoError(t, sc.Start(playwright.ScreencastStartOptions{ + OnFrame: func(playwright.OnFrame) { + _, callbackErr := page.Evaluate(`() => document.title`) + callbackCount.Add(1) + once.Do(func() { result <- callbackErr }) + }, + })) + defer sc.Stop() //nolint:errcheck + + require.NoError(t, page.SetContent(`reentrantfirst`)) + _, err = page.Evaluate(`() => { document.body.textContent = "second" }`) + require.NoError(t, err) + select { + case callbackErr := <-result: + require.NoError(t, callbackErr) + case <-time.After(10 * time.Second): + t.Fatal("timed out waiting for a reentrant screencast callback") + } + + _, err = page.Evaluate(`() => { document.body.style.backgroundColor = "blue" }`) + require.NoError(t, err) + require.Eventually(t, func() bool { + return callbackCount.Load() > 1 + }, 10*time.Second, 20*time.Millisecond, "expected another frame after the reentrant callback completed") +} + +func TestScreencastAppliesBackpressureWhileOnFrameCallbackPending(t *testing.T) { + BeforeEach(t) + + releaseCallback := make(chan struct{}) + firstFrame := make(chan struct{}) + var firstFrameOnce sync.Once + var callbackCount atomic.Int32 + var lastFrameAt atomic.Int64 + + sc, err := page.Screencast() + require.NoError(t, err) + require.NoError(t, sc.Start(playwright.ScreencastStartOptions{ + OnFrame: func(playwright.OnFrame) { + callbackCount.Add(1) + lastFrameAt.Store(time.Now().UnixNano()) + firstFrameOnce.Do(func() { close(firstFrame) }) + <-releaseCallback + }, + })) + + released := false + stopped := false + defer func() { + if !released { + close(releaseCallback) + } + if !stopped { + _ = sc.Stop() + } + }() + + require.NoError(t, page.SetContent(``)) + select { + case <-firstFrame: + case <-time.After(10 * time.Second): + t.Fatal("timed out waiting for the first screencast frame") + } + // A small number of frames can already be in flight when the first callback + // starts. Wait until that initial queue has drained before taking a baseline. + require.Eventually(t, func() bool { + last := lastFrameAt.Load() + return last != 0 && time.Since(time.Unix(0, last)) > time.Second + }, 10*time.Second, 20*time.Millisecond, "screencast frames did not quiesce while the callback was blocked") + + framesWhileBlocked := callbackCount.Load() + blockedUntil := time.Now().Add(1200 * time.Millisecond) + for time.Now().Before(blockedUntil) { + _, err = page.Evaluate(`() => new Promise(f => requestAnimationFrame(() => requestAnimationFrame(f)))`) + require.NoError(t, err) + require.Equal(t, framesWhileBlocked, callbackCount.Load(), "a new frame arrived before the callback returned") + } + require.Equal(t, framesWhileBlocked, callbackCount.Load(), "a new frame arrived before the callback returned") + + close(releaseCallback) + released = true + require.Eventually(t, func() bool { + _, evaluateErr := page.Evaluate(`() => { + document.body.style.backgroundColor = document.body.style.backgroundColor === "red" ? "blue" : "red"; + }`) + return evaluateErr == nil && callbackCount.Load() > framesWhileBlocked + }, 10*time.Second, 20*time.Millisecond, "expected frame delivery to resume after the callback returned") + + require.NoError(t, sc.Stop()) + stopped = true +} + +func TestScreencastReportsCallbackPanicsAndContinuesDeliveringFrames(t *testing.T) { + BeforeEach(t) + + require.NoError(t, page.SetContent(``)) + + sc, err := page.Screencast() + require.NoError(t, err) + var callbackCount atomic.Int32 + require.NoError(t, sc.Start(playwright.ScreencastStartOptions{ + OnFrame: func(playwright.OnFrame) { + if callbackCount.Add(1) == 1 { + panic(errors.New("screencast callback failed")) + } + }, + })) + stopped := false + defer func() { + if !stopped { + _ = sc.Stop() + } + }() + + var callbackErr error + deadline := time.Now().Add(10 * time.Second) + for time.Now().Before(deadline) { + _, evaluateErr := page.Evaluate(`() => new Promise(f => requestAnimationFrame(() => requestAnimationFrame(f)))`) + if evaluateErr != nil { + if strings.Contains(evaluateErr.Error(), "screencast callback failed") { + callbackErr = evaluateErr + } else { + require.NoError(t, evaluateErr) + } + } + if callbackErr != nil && callbackCount.Load() > 1 { + break + } + } + require.ErrorContains(t, callbackErr, "screencast callback failed") + require.Greater(t, callbackCount.Load(), int32(1)) + + require.NoError(t, sc.Stop()) + stopped = true +} diff --git a/tests/screenshot_test.go b/tests/screenshot_test.go index c939ca7a..fa67289f 100644 --- a/tests/screenshot_test.go +++ b/tests/screenshot_test.go @@ -1,13 +1,49 @@ package playwright_test import ( + "net/http" + "os" "path/filepath" + "strings" "testing" "github.com/mxschmitt/playwright-go" "github.com/stretchr/testify/require" ) +func TestScreenshotShouldWorkWhileNavigating(t *testing.T) { + BeforeEach(t) + + server.SetRoute("/redirectloop1.html", func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(``)) + }) + server.SetRoute("/redirectloop2.html", func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(``)) + }) + + require.NoError(t, page.SetViewportSize(500, 500)) + _, err := page.Goto(server.PREFIX + "/redirectloop1.html") + require.NoError(t, err) + successfulScreenshots := 0 + for range 10 { + screenshot, err := page.Screenshot(playwright.PageScreenshotOptions{FullPage: playwright.Bool(true)}) + if err != nil && strings.Contains(err.Error(), "Cannot take a screenshot while page is navigating") { + continue + } + require.NoError(t, err) + require.NotNil(t, screenshot) + successfulScreenshots++ + } + require.Positive(t, successfulScreenshots, "all screenshot attempts failed while the page was navigating") +} + func TestLocatorScreenshotShouldWork(t *testing.T) { BeforeEach(t) @@ -101,3 +137,108 @@ func TestShouldScreenshotWithMask(t *testing.T) { require.NoError(t, err) AssertToBeGolden(t, screenshot, "mask-should-work-with-elementhandle.png") } + +func TestScreenshotWebPTypeAndPath(t *testing.T) { + BeforeEach(t) + _, err := page.Goto(server.EMPTY_PAGE) + require.NoError(t, err) + require.NoError(t, page.SetContent(`
`)) + + // Explicit type. + data, err := page.Screenshot(playwright.PageScreenshotOptions{ + Type: playwright.ScreenshotTypeWebp, + }) + require.NoError(t, err) + require.True(t, isWebP(data), "expected RIFF/WEBP signature") + + // Path inference. + dir := t.TempDir() + path := filepath.Join(dir, "shot.webp") + _, err = page.Screenshot(playwright.PageScreenshotOptions{Path: playwright.String(path)}) + require.NoError(t, err) + raw, err := os.ReadFile(path) + require.NoError(t, err) + require.True(t, isWebP(raw)) + + // Quality variants. + high, err := page.Screenshot(playwright.PageScreenshotOptions{ + Type: playwright.ScreenshotTypeWebp, + Quality: playwright.Int(100), + }) + require.NoError(t, err) + low, err := page.Screenshot(playwright.PageScreenshotOptions{ + Type: playwright.ScreenshotTypeWebp, + Quality: playwright.Int(1), + }) + require.NoError(t, err) + require.True(t, isWebP(high)) + require.True(t, isWebP(low)) + // Both quality settings must produce valid, non-empty WebP payloads. + // Relative size is encoder-dependent for solid-color images, so we do not + // assert low < high here (Firefox/libwebp may invert that for tiny frames). + require.NotEmpty(t, high) + require.NotEmpty(t, low) +} + +func TestWebPScreenshotLocatorAndElementHandleHonorQuality(t *testing.T) { + BeforeEach(t) + require.NoError(t, page.SetContent(``)) + _, err := page.Evaluate(`() => { + const canvas = document.querySelector('#target'); + const context = canvas.getContext('2d'); + const image = context.createImageData(canvas.width, canvas.height); + for (let i = 0; i < image.data.length; i += 4) { + const pixel = i / 4; + image.data[i] = (pixel * 17) % 256; + image.data[i + 1] = (pixel * 31) % 256; + image.data[i + 2] = (pixel * 47) % 256; + image.data[i + 3] = 255; + } + context.putImageData(image, 0, 0); + }`) + require.NoError(t, err) + + locator := page.Locator("#target") + locatorLossless, err := locator.Screenshot(playwright.LocatorScreenshotOptions{ + Type: playwright.ScreenshotTypeWebp, + Quality: playwright.Int(100), + }) + require.NoError(t, err) + locatorLossy, err := locator.Screenshot(playwright.LocatorScreenshotOptions{ + Type: playwright.ScreenshotTypeWebp, + Quality: playwright.Int(1), + }) + require.NoError(t, err) + require.True(t, isWebP(locatorLossless)) + require.True(t, isWebP(locatorLossy)) + require.NotEqual(t, locatorLossless, locatorLossy, "WebP quality must reach the driver") + + //nolint:staticcheck // The new WebP format is explicitly exposed on the legacy ElementHandle API. + handle, err := page.QuerySelector("#target") + require.NoError(t, err) + require.NotNil(t, handle) + defer handle.Dispose() //nolint:errcheck + //nolint:staticcheck // Required regression coverage for the ElementHandle API surface. + handleLossless, err := handle.Screenshot(playwright.ElementHandleScreenshotOptions{ + Type: playwright.ScreenshotTypeWebp, + Quality: playwright.Int(100), + }) + require.NoError(t, err) + //nolint:staticcheck // Required regression coverage for the ElementHandle API surface. + handleLossy, err := handle.Screenshot(playwright.ElementHandleScreenshotOptions{ + Type: playwright.ScreenshotTypeWebp, + Quality: playwright.Int(1), + }) + require.NoError(t, err) + require.True(t, isWebP(handleLossless)) + require.True(t, isWebP(handleLossy)) + require.NotEqual(t, handleLossless, handleLossy, "WebP quality must reach the driver") +} + +func isWebP(data []byte) bool { + if len(data) < 12 { + return false + } + // RIFF....WEBP + return string(data[0:4]) == "RIFF" && string(data[8:12]) == "WEBP" +} diff --git a/tests/video_test.go b/tests/video_test.go index 7fa5c23f..24e0ab64 100644 --- a/tests/video_test.go +++ b/tests/video_test.go @@ -300,3 +300,40 @@ func TestScreencastStartStop(t *testing.T) { require.Greater(t, len(frames), 0, "should have received at least one frame") require.Greater(t, len(frames[0]), 0, "frame data should not be empty") } + +func TestVideoShowActionsCursorAccepted(t *testing.T) { + BeforeEach(t) + dir := t.TempDir() + ctx, err := browser.NewContext(playwright.BrowserNewContextOptions{ + RecordVideo: &playwright.RecordVideo{ + Dir: playwright.String(dir), + ShowActions: &playwright.ShowAction{ + Cursor: playwright.ScreencastCursorPointer, + }, + }, + }) + require.NoError(t, err) + defer ctx.Close() //nolint:errcheck + p, err := ctx.NewPage() + require.NoError(t, err) + _, err = p.Goto(server.EMPTY_PAGE) + require.NoError(t, err) + // Options are accepted if context creation and a simple navigation succeed. + require.NoError(t, ctx.Close()) + + ctx2, err := browser.NewContext(playwright.BrowserNewContextOptions{ + RecordVideo: &playwright.RecordVideo{ + Dir: playwright.String(dir), + ShowActions: &playwright.ShowAction{ + Cursor: playwright.ScreencastCursorNone, + }, + }, + }) + require.NoError(t, err) + defer ctx2.Close() //nolint:errcheck + p2, err := ctx2.NewPage() + require.NoError(t, err) + _, err = p2.Goto(server.EMPTY_PAGE) + require.NoError(t, err) + require.NoError(t, ctx2.Close()) +} diff --git a/tests/worker_test.go b/tests/worker_test.go index 6de5a4b4..a2370e5f 100644 --- a/tests/worker_test.go +++ b/tests/worker_test.go @@ -38,6 +38,23 @@ func TestWorkerShouldWork(t *testing.T) { require.Equal(t, 0, len(page.Workers())) } +func TestWorkerShouldUseContextLocale(t *testing.T) { + BeforeEach(t, playwright.BrowserNewContextOptions{Locale: playwright.String("ru-RU")}) + + _, err := page.Goto(server.EMPTY_PAGE) + require.NoError(t, err) + worker, err := page.ExpectWorker(func() error { + _, err := page.Evaluate(`() => new Worker(URL.createObjectURL(new Blob(["console.log(1)"], { type: "application/javascript" })))`) + return err + }) + require.NoError(t, err) + formatted, err := worker.Evaluate(`() => (10000.20).toLocaleString()`) + require.NoError(t, err) + // Firefox 153 fixed the worker locale regression. All engines must now + // render the same Russian thousands separator and decimal mark. + require.Equal(t, "10\u00a0000,2", formatted) +} + func TestWorkerShouldEmitCreatedAndDestroyedEvents(t *testing.T) { BeforeEach(t) diff --git a/timeout_semantics_test.go b/timeout_semantics_test.go new file mode 100644 index 00000000..71e986ca --- /dev/null +++ b/timeout_semantics_test.go @@ -0,0 +1,254 @@ +package playwright + +import ( + "encoding/json" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +// timeoutSemanticsTransport records protocol envelopes and returns the small +// canned responses needed by the timeout semantics tests below. +type timeoutSemanticsTransport struct { + mu sync.Mutex + messages []map[string]any + replies chan *message + closed chan struct{} + delays map[string]time.Duration + elementGUID string +} + +func newTimeoutSemanticsTransport() *timeoutSemanticsTransport { + return &timeoutSemanticsTransport{ + replies: make(chan *message, 16), + closed: make(chan struct{}), + delays: make(map[string]time.Duration), + } +} + +func (t *timeoutSemanticsTransport) Send(outgoing map[string]any) error { + encoded, _ := json.Marshal(outgoing) + var captured map[string]any + _ = json.Unmarshal(encoded, &captured) + + t.mu.Lock() + t.messages = append(t.messages, captured) + delay := t.delays[captured["method"].(string)] + t.mu.Unlock() + if delay > 0 { + time.Sleep(delay) + } + + result := map[string]any{} + switch captured["method"] { + case "isHidden": + result["value"] = true + case "isVisible": + result["value"] = false + case "inputValue": + result["value"] = "input value" + case "waitForSelector": + result["element"] = map[string]any{"guid": t.elementGUID} + } + t.replies <- &message{ID: int(captured["id"].(float64)), Result: result} + return nil +} + +func (t *timeoutSemanticsTransport) Poll() (*message, error) { + select { + case reply := <-t.replies: + return reply, nil + case <-t.closed: + return nil, ErrTargetClosed + } +} + +func (t *timeoutSemanticsTransport) Close() error { + select { + case <-t.closed: + default: + close(t.closed) + } + return nil +} + +func (t *timeoutSemanticsTransport) messageFor(method string) map[string]any { + t.mu.Lock() + defer t.mu.Unlock() + for i := len(t.messages) - 1; i >= 0; i-- { + if t.messages[i]["method"] == method { + return t.messages[i] + } + } + return nil +} + +func newTimeoutSemanticsFixture(t *testing.T, defaultTimeout float64) (*timeoutSemanticsTransport, *frameImpl, *elementHandleImpl) { + t.Helper() + transport := newTimeoutSemanticsTransport() + connection := newConnection(transport) + root := &channelOwner{ + guid: "timeout-test-root", + connection: connection, + objects: make(map[string]*channelOwner), + } + root.channel = newChannel(root, root) + connection.objects.Store(root.guid, root) + + frame := newFrame(root, "Frame", "timeout-test-frame", map[string]any{ + "name": "", + "url": "about:blank", + "loadStates": []any{}, + }) + settings := newTimeoutSettings(nil) + settings.SetDefaultTimeout(Float(defaultTimeout)) + settings.SetDefaultNavigationTimeout(Float(defaultTimeout)) + frame.page = &pageImpl{ + timeoutSettings: settings, + browserContext: &browserContextImpl{}, + } + + handle := newElementHandle(&frame.channelOwner, "ElementHandle", "timeout-test-element", map[string]any{ + "preview": "JSHandle@node", + }) + transport.elementGUID = handle.guid + + go func() { + for connection.pollOnce() { + } + }() + t.Cleanup(func() { _ = transport.Close() }) + return transport, frame, handle +} + +func requireProtocolNoTimeout(t *testing.T, envelope map[string]any) { + t.Helper() + require.NotNil(t, envelope) + metadata := envelope["metadata"].(map[string]any) + require.Equal(t, float64(0), metadata["timeout"]) + params := envelope["params"].(map[string]any) + _, hasParamsTimeout := params["timeout"] + require.False(t, hasParamsTimeout) +} + +func TestImmediateQueriesIgnoreDeprecatedTimeout(t *testing.T) { + transport, frame, handle := newTimeoutSemanticsFixture(t, 5000) + + hidden, err := frame.IsHidden("#hidden", FrameIsHiddenOptions{ + Strict: Bool(true), + Timeout: Float(1234), + }) + require.NoError(t, err) + require.True(t, hidden) + hiddenMessage := transport.messageFor("isHidden") + requireProtocolNoTimeout(t, hiddenMessage) + require.Equal(t, "#hidden", hiddenMessage["params"].(map[string]any)["selector"]) + require.Equal(t, true, hiddenMessage["params"].(map[string]any)["strict"]) + + visible, err := frame.IsVisible("#visible", FrameIsVisibleOptions{ + Strict: Bool(true), + Timeout: Float(2345), + }) + require.NoError(t, err) + require.False(t, visible) + visibleMessage := transport.messageFor("isVisible") + requireProtocolNoTimeout(t, visibleMessage) + require.Equal(t, "#visible", visibleMessage["params"].(map[string]any)["selector"]) + require.Equal(t, true, visibleMessage["params"].(map[string]any)["strict"]) + + value, err := handle.InputValue(ElementHandleInputValueOptions{Timeout: Float(3456)}) + require.NoError(t, err) + require.Equal(t, "input value", value) + requireProtocolNoTimeout(t, transport.messageFor("inputValue")) +} + +func TestWaitForTimeoutUsesProtocolWaitTimeout(t *testing.T) { + transport, frame, _ := newTimeoutSemanticsFixture(t, 5000) + + frame.WaitForTimeout(42.5) + + envelope := transport.messageFor("waitForTimeout") + requireProtocolNoTimeout(t, envelope) + require.Equal(t, float64(42.5), envelope["params"].(map[string]any)["waitTimeout"]) +} + +func TestLocatorWithElementPreservesExplicitZeroTimeout(t *testing.T) { + transport, frame, _ := newTimeoutSemanticsFixture(t, 5000) + locator := newLocator(frame, "button") + + _, err := locator.withElement(func(handle ElementHandle, timeout *float64) (any, error) { + require.NotNil(t, timeout) + require.Zero(t, *timeout) + return nil, handle.ScrollIntoViewIfNeeded(ElementHandleScrollIntoViewIfNeededOptions{Timeout: timeout}) + }, FrameWaitForSelectorOptions{Timeout: Float(0)}) + require.NoError(t, err) + + waitMetadata := transport.messageFor("waitForSelector")["metadata"].(map[string]any) + require.Equal(t, float64(0), waitMetadata["timeout"]) + actionMessage := transport.messageFor("scrollIntoViewIfNeeded") + actionMetadata := actionMessage["metadata"].(map[string]any) + require.Equal(t, float64(0), actionMetadata["timeout"]) + _, hasParamsTimeout := actionMessage["params"].(map[string]any)["timeout"] + require.False(t, hasParamsTimeout) +} + +func TestLocatorWithElementSharesResolvedDefaultTimeout(t *testing.T) { + transport, frame, _ := newTimeoutSemanticsFixture(t, 1000) + transport.delays["waitForSelector"] = 30 * time.Millisecond + locator := newLocator(frame, "button") + + _, err := locator.withElement(func(handle ElementHandle, timeout *float64) (any, error) { + require.NotNil(t, timeout) + return nil, handle.ScrollIntoViewIfNeeded(ElementHandleScrollIntoViewIfNeededOptions{Timeout: timeout}) + }) + require.NoError(t, err) + + waitTimeout := transport.messageFor("waitForSelector")["metadata"].(map[string]any)["timeout"].(float64) + actionTimeout := transport.messageFor("scrollIntoViewIfNeeded")["metadata"].(map[string]any)["timeout"].(float64) + require.Equal(t, float64(1000), waitTimeout) + require.Positive(t, actionTimeout) + require.Less(t, actionTimeout, waitTimeout) +} + +func TestExpectNavigationZeroTimeoutStillWaitsForLoadState(t *testing.T) { + _, frame, _ := newTimeoutSemanticsFixture(t, 1000) + + started := time.Now() + _, err := frame.ExpectNavigation(func() error { + frame.Emit("navigated", map[string]any{ + "url": "https://example.test/", + "newDocument": nil, + }) + go func() { + time.Sleep(25 * time.Millisecond) + frame.Emit("loadstate", "load") + }() + return nil + }, FrameExpectNavigationOptions{ + Timeout: Float(0), + WaitUntil: WaitUntilStateLoad, + }) + require.NoError(t, err) + require.GreaterOrEqual(t, time.Since(started), 20*time.Millisecond) +} + +func TestExpectNavigationUsesOnePositiveTimeoutBudget(t *testing.T) { + _, frame, _ := newTimeoutSemanticsFixture(t, 1000) + + started := time.Now() + _, err := frame.ExpectNavigation(func() error { + time.Sleep(70 * time.Millisecond) + frame.Emit("navigated", map[string]any{ + "url": "https://example.test/", + "newDocument": nil, + }) + return nil + }, FrameExpectNavigationOptions{ + Timeout: Float(120), + WaitUntil: WaitUntilStateLoad, + }) + require.ErrorIs(t, err, ErrTimeout) + require.Less(t, time.Since(started), 170*time.Millisecond) +} diff --git a/type_helpers.go b/type_helpers.go index 744bc115..81bd7c6b 100644 --- a/type_helpers.go +++ b/type_helpers.go @@ -53,8 +53,9 @@ func (s StorageState) ToOptionalStorageState() *OptionalStorageState { cookies[i] = c.ToOptionalCookie() } return &OptionalStorageState{ - Origins: s.Origins, - Cookies: cookies, + Origins: s.Origins, + Cookies: cookies, + Credentials: s.Credentials, } } diff --git a/type_helpers_test.go b/type_helpers_test.go index d6177fa1..486569fd 100644 --- a/type_helpers_test.go +++ b/type_helpers_test.go @@ -31,3 +31,17 @@ func Test_assignFloatIfPresent(t *testing.T) { require.Equal(t, -1.0, target) }) } + +func TestToOptionalStorageStatePreservesCredentials(t *testing.T) { + state := StorageState{ + Cookies: []Cookie{{Name: "a", Value: "b", Domain: "ex.com", Path: "/"}}, + Origins: []Origin{{Origin: "https://ex.com", LocalStorage: []NameValue{{Name: "k", Value: "v"}}}}, + Credentials: []VirtualCredential{{ + Id: "id1", RpId: "ex.com", UserHandle: "u", PrivateKey: "pk", PublicKey: "pub", + }}, + } + opt := state.ToOptionalStorageState() + require.Len(t, opt.Credentials, 1) + require.Equal(t, "id1", opt.Credentials[0].Id) + require.Equal(t, "a", opt.Cookies[0].Name) +}