Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) <!-- GEN:chromium-version-badge -->[![Chromium version](https://img.shields.io/badge/chromium-149.0.7827.55-blue.svg?logo=google-chrome)](https://www.chromium.org/Home)<!-- GEN:stop --> <!-- GEN:firefox-version-badge -->[![Firefox version](https://img.shields.io/badge/firefox-151.0-blue.svg?logo=mozilla-firefox)](https://www.mozilla.org/en-US/firefox/new/)<!-- GEN:stop --> <!-- GEN:webkit-version-badge -->[![WebKit version](https://img.shields.io/badge/webkit-26.5-blue.svg?logo=safari)](https://webkit.org/)<!-- GEN:stop -->
[![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) <!-- GEN:chromium-version-badge -->[![Chromium version](https://img.shields.io/badge/chromium-151.0.7922.34-blue.svg?logo=google-chrome)](https://www.chromium.org/Home)<!-- GEN:stop --> <!-- GEN:firefox-version-badge -->[![Firefox version](https://img.shields.io/badge/firefox-153.0-blue.svg?logo=mozilla-firefox)](https://www.mozilla.org/en-US/firefox/new/)<!-- GEN:stop --> <!-- GEN:webkit-version-badge -->[![WebKit version](https://img.shields.io/badge/webkit-26.5-blue.svg?logo=safari)](https://webkit.org/)<!-- GEN:stop -->


[API reference](https://playwright.dev/docs/api/class-playwright) | [Example recipes](https://github.com/mxschmitt/playwright-go/tree/main/examples)
Expand All @@ -12,9 +12,9 @@ Playwright is a Go library to automate [Chromium](https://www.chromium.org/Home)

| | Linux | macOS | Windows |
| :--- | :---: | :---: | :---: |
| Chromium <!-- GEN:chromium-version -->149.0.7827.55<!-- GEN:stop --> | :white_check_mark: | :white_check_mark: | :white_check_mark: |
| Chromium <!-- GEN:chromium-version -->151.0.7922.34<!-- GEN:stop --> | :white_check_mark: | :white_check_mark: | :white_check_mark: |
| WebKit <!-- GEN:webkit-version -->26.5<!-- GEN:stop --> | :white_check_mark: | :white_check_mark: | :white_check_mark: |
| Firefox <!-- GEN:firefox-version -->151.0<!-- GEN:stop --> | :white_check_mark: | :white_check_mark: | :white_check_mark: |
| Firefox <!-- GEN:firefox-version -->153.0<!-- GEN:stop --> | :white_check_mark: | :white_check_mark: | :white_check_mark: |

Headless execution is supported for all the browsers on all platforms.

Expand Down
1 change: 1 addition & 0 deletions browser_context.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
118 changes: 100 additions & 18 deletions browser_type.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"errors"
"fmt"
"path/filepath"
"time"
)

// defaultLaunchTimeout matches DEFAULT_PLAYWRIGHT_LAUNCH_TIMEOUT upstream (3 minutes).
Expand All @@ -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
}
Expand All @@ -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
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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 {
Expand All @@ -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)
Expand Down Expand Up @@ -198,24 +209,95 @@ 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")
}
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 {
overrides["headers"] = serializeMapToNameAndValue(options[0].Headers)
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
}
Expand Down
59 changes: 51 additions & 8 deletions channel.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
}
Expand All @@ -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
}
Expand All @@ -65,31 +95,44 @@ 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)
pc.SetError(err)
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
Expand Down
Loading