diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3b11995..f3de901 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -20,20 +20,20 @@ BROWSER=chromium HEADLESS=1 go test -v --race ./... ### Roll 1. Find out to which upstream version you want to roll, and change the value of `playwrightCliVersion` in the **run.go** to the new version. -1. Download current version of Playwright driver `go run scripts/install-browsers/main.go` -1. Apply patch `bash scripts/apply-patch.sh` -1. Fix merge conflicts if any, otherwise ignore this step. Once you are happy you can commit the changes `cd playwright; git commit -am "apply patch" && cd ..` -1. Regenerate a new patch `bash scripts/update-patch.sh` -1. Generate go code `go generate ./...` +2. Download current version of Playwright driver `go run scripts/install-browsers/main.go` +3. Apply patch `bash scripts/apply-patch.sh` +4. Fix merge conflicts if any, otherwise ignore this step. Once you are happy you can commit the changes `cd playwright; git commit -am "apply patch" && cd ..` +5. Regenerate a new patch `bash scripts/update-patch.sh` +6. Generate go code `go generate ./...` To adapt to the new version of Playwright's protocol and feature updates, you may need to modify the patch. Refer to the following steps: 1. Apply patch `bash scripts/apply-patch.sh` -1. `cd playwright` -1. Revert the patch`git reset HEAD~1` -1. Modify the files under `docs/src/api`, etc. as needed. Available references: +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` - [Playwright python](https://github.com/microsoft/playwright-python) -1. Commit the changes `git commit -am "apply patch"` -1. Regenerate a new patch `bash scripts/update-patch.sh` -1. Generate go code `go generate ./...`. +5. Commit the changes `git commit -am "apply patch"` +6. Regenerate a new patch `bash scripts/update-patch.sh` +7. Generate go code `go generate ./...`. diff --git a/README.md b/README.md index 13175b4..f0573cf 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ [![PkgGoDev](https://pkg.go.dev/badge/github.com/playwright-community/playwright-go)](https://pkg.go.dev/github.com/playwright-community/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/playwright-community/playwright-go)](https://goreportcard.com/report/github.com/playwright-community/playwright-go) ![Build Status](https://github.com/playwright-community/playwright-go/workflows/Go/badge.svg) -[![Join Slack](https://img.shields.io/badge/join-slack-infomational)](https://aka.ms/playwright-slack) [![Coverage Status](https://coveralls.io/repos/github/playwright-community/playwright-go/badge.svg?branch=main)](https://coveralls.io/github/playwright-community/playwright-go?branch=main) [![Chromium version](https://img.shields.io/badge/chromium-136.0.7103.25-blue.svg?logo=google-chrome)](https://www.chromium.org/Home) [![Firefox version](https://img.shields.io/badge/firefox-137.0-blue.svg?logo=mozilla-firefox)](https://www.mozilla.org/en-US/firefox/new/) [![WebKit version](https://img.shields.io/badge/webkit-18.4-blue.svg?logo=safari)](https://webkit.org/) +[![Join Slack](https://img.shields.io/badge/join-slack-infomational)](https://aka.ms/playwright-slack) [![Coverage Status](https://coveralls.io/repos/github/playwright-community/playwright-go/badge.svg?branch=main)](https://coveralls.io/github/playwright-community/playwright-go?branch=main) [![Chromium version](https://img.shields.io/badge/chromium-143.0.7499.4-blue.svg?logo=google-chrome)](https://www.chromium.org/Home) [![Firefox version](https://img.shields.io/badge/firefox-144.0.2-blue.svg?logo=mozilla-firefox)](https://www.mozilla.org/en-US/firefox/new/) [![WebKit version](https://img.shields.io/badge/webkit-26.0-blue.svg?logo=safari)](https://webkit.org/) [API reference](https://playwright.dev/docs/api/class-playwright) | [Example recipes](https://github.com/playwright-community/playwright-go/tree/main/examples) @@ -13,9 +13,9 @@ Playwright is a Go library to automate [Chromium](https://www.chromium.org/Home) | | Linux | macOS | Windows | | :--- | :---: | :---: | :---: | -| Chromium 136.0.7103.25 | ✅ | ✅ | ✅ | -| WebKit 18.4 | ✅ | ✅ | ✅ | -| Firefox 137.0 | ✅ | ✅ | ✅ | +| Chromium 143.0.7499.4 | :white_check_mark: | :white_check_mark: | :white_check_mark: | +| WebKit 26.0 | :white_check_mark: | :white_check_mark: | :white_check_mark: | +| Firefox 144.0.2 | :white_check_mark: | :white_check_mark: | :white_check_mark: | Headless execution is supported for all the browsers on all platforms. diff --git a/browser.go b/browser.go index c87540a..0412985 100644 --- a/browser.go +++ b/browser.go @@ -93,6 +93,9 @@ func (b *browserImpl) NewContext(options ...BrowserNewContextOptions) (BrowserCo context := fromChannel(channel).(*browserContextImpl) context.browser = b b.browserType.(*browserTypeImpl).didCreateContext(context, &option, nil) + if err := context.initializeHarFromOptions(); err != nil { + return nil, err + } return context, nil } diff --git a/browser_context.go b/browser_context.go index 1d420d3..f104682 100644 --- a/browser_context.go +++ b/browser_context.go @@ -9,6 +9,7 @@ import ( "slices" "strings" "sync" + "sync/atomic" "github.com/playwright-community/playwright-go/internal/safe" ) @@ -16,7 +17,7 @@ import ( type browserContextImpl struct { channelOwner timeoutSettings *timeoutSettings - closeWasCalled bool + closeWasCalled atomic.Bool options *BrowserNewContextOptions pages []Page routes []*routeHandlerEntry @@ -91,7 +92,7 @@ func (b *browserContextImpl) NewCDPSession(page interface{}) (CDPSession, error) return nil, err } - cdpSession := fromChannel(channel).(*cdpSessionImpl) + cdpSession := fromChannelWithConnection(channel, b.connection).(*cdpSessionImpl) return cdpSession, nil } @@ -104,7 +105,7 @@ func (b *browserContextImpl) NewPage() (Page, error) { if err != nil { return nil, err } - return fromChannel(channel).(*pageImpl), nil + return fromChannelWithConnection(channel, b.connection).(*pageImpl), nil } func (b *browserContextImpl) Cookies(urls ...string) ([]Cookie, error) { @@ -411,13 +412,13 @@ func (b *browserContextImpl) ExpectPage(cb func() error, options ...BrowserConte } func (b *browserContextImpl) Close(options ...BrowserContextCloseOptions) error { - if b.closeWasCalled { + if b.closeWasCalled.Load() { return nil } if len(options) == 1 { b.closeReason = options[0].Reason } - b.closeWasCalled = true + b.closeWasCalled.Store(true) _, err := b.channel.connection.WrapAPICall(func() (interface{}, error) { return nil, b.request.Dispose(APIRequestContextDisposeOptions{ @@ -438,7 +439,7 @@ func (b *browserContextImpl) Close(options ...BrowserContextCloseOptions) error if err != nil { return nil, err } - artifact := fromChannel(response).(*artifactImpl) + artifact := fromChannelWithConnection(response, b.connection).(*artifactImpl) // Server side will compress artifact if content is attach or if file is .zip. needCompressed := strings.HasSuffix(strings.ToLower(harMetaData.Path), ".zip") if !needCompressed && harMetaData.Content == HarContentPolicyAttach { @@ -597,7 +598,7 @@ func (b *browserContextImpl) onRoute(route *routeImpl) { url := route.Request().URL() for _, handlerEntry := range routes { // If the page or the context was closed we stall all requests right away. - if (page != nil && page.closeWasCalled) || b.closeWasCalled { + if (page != nil && page.closeWasCalled.Load()) || b.closeWasCalled.Load() { return } if !handlerEntry.Matches(url) { @@ -644,7 +645,7 @@ func (b *browserContextImpl) pause() <-chan error { func (b *browserContextImpl) onBackgroundPage(ev map[string]interface{}) { b.Lock() - p := fromChannel(ev["page"]).(*pageImpl) + p := fromChannelWithConnection(ev["page"], b.connection).(*pageImpl) p.browserContext = b b.backgroundPages = append(b.backgroundPages, p) b.Unlock() @@ -662,17 +663,41 @@ func (b *browserContextImpl) setOptions(options *BrowserNewContextOptions, trace options = &BrowserNewContextOptions{} } b.options = options - if b.options != nil && b.options.RecordHarPath != nil { - b.harRecorders[""] = harRecordingMetadata{ - Path: *b.options.RecordHarPath, - Content: b.options.RecordHarContent, - } - } if tracesDir != nil { b.tracing.tracesDir = *tracesDir } } +// initializeHarFromOptions starts HAR recording if RecordHarPath is set in options. +// This must be called after context creation to properly register the HAR recorder on the server. +func (b *browserContextImpl) initializeHarFromOptions() error { + if b.options == nil || b.options.RecordHarPath == nil { + return nil + } + path := *b.options.RecordHarPath + // Determine default content policy based on file extension + var content *HarContentPolicy + if strings.HasSuffix(strings.ToLower(path), ".zip") { + content = HarContentPolicyAttach + } else { + content = HarContentPolicyEmbed + } + if b.options.RecordHarContent != nil { + content = b.options.RecordHarContent + } else if b.options.RecordHarOmitContent != nil && *b.options.RecordHarOmitContent { + content = HarContentPolicyOmit + } + mode := HarModeFull + if b.options.RecordHarMode != nil { + mode = b.options.RecordHarMode + } + return b.recordIntoHar(path, browserContextRecordIntoHarOptions{ + URL: b.options.RecordHarURLFilter, + UpdateContent: content, + UpdateMode: mode, + }) +} + func (b *browserContextImpl) BackgroundPages() []Page { b.Lock() defer b.Unlock() @@ -784,33 +809,49 @@ func newBrowserContext(parent *channelOwner, objectType string, guid string, ini } bt.createChannelOwner(bt, parent, objectType, guid, initializer) if parent.objectType == "Browser" { - bt.browser = fromChannel(parent.channel).(*browserImpl) + bt.browser = fromChannelWithConnection(parent.channel, bt.connection).(*browserImpl) bt.browser.contexts = append(bt.browser.contexts, bt) } - bt.tracing = fromChannel(initializer["tracing"]).(*tracingImpl) - bt.request = fromChannel(initializer["requestContext"]).(*apiRequestContextImpl) + bt.tracing = fromChannelWithConnection(initializer["tracing"], bt.connection).(*tracingImpl) + bt.request = fromChannelWithConnection(initializer["requestContext"], bt.connection).(*apiRequestContextImpl) bt.clock = newClock(bt) + + // Register this context with the selectors manager for custom selector engines + if bt.browser != nil && bt.browser.browserType != nil { + if browserType, ok := bt.browser.browserType.(*browserTypeImpl); ok && browserType.playwright != nil { + browserType.playwright.Selectors.(*selectorsImpl).addContext(bt) + } + } + bt.channel.On("bindingCall", func(params map[string]interface{}) { - bt.onBinding(fromChannel(params["binding"]).(*bindingCallImpl)) + bt.onBinding(fromChannelWithConnection(params["binding"], bt.connection).(*bindingCallImpl)) }) - bt.channel.On("close", bt.onClose) + bt.channel.On("close", func() { + // Unregister this context from the selectors manager + if bt.browser != nil && bt.browser.browserType != nil { + if browserType, ok := bt.browser.browserType.(*browserTypeImpl); ok && browserType.playwright != nil { + browserType.playwright.Selectors.(*selectorsImpl).removeContext(bt) + } + } + bt.onClose() + }) bt.channel.On("page", func(payload map[string]interface{}) { - bt.onPage(fromChannel(payload["page"]).(*pageImpl)) + bt.onPage(fromChannelWithConnection(payload["page"], bt.connection).(*pageImpl)) }) bt.channel.On("route", func(params map[string]interface{}) { bt.channel.CreateTask(func() { - bt.onRoute(fromChannel(params["route"]).(*routeImpl)) + bt.onRoute(fromChannelWithConnection(params["route"], bt.connection).(*routeImpl)) }) }) bt.channel.On("webSocketRoute", func(params map[string]interface{}) { bt.channel.CreateTask(func() { - bt.onWebSocketRoute(fromChannel(params["webSocketRoute"]).(*webSocketRouteImpl)) + bt.onWebSocketRoute(fromChannelWithConnection(params["webSocketRoute"], bt.connection).(*webSocketRouteImpl)) }) }) bt.channel.On("backgroundPage", bt.onBackgroundPage) bt.channel.On("serviceWorker", func(params map[string]interface{}) { - bt.onServiceWorker(fromChannel(params["worker"]).(*workerImpl)) + bt.onServiceWorker(fromChannelWithConnection(params["worker"], bt.connection).(*workerImpl)) }) bt.channel.On("console", func(ev map[string]interface{}) { message := newConsoleMessage(ev) @@ -820,7 +861,7 @@ func newBrowserContext(parent *channelOwner, objectType string, guid string, ini } }) bt.channel.On("dialog", func(params map[string]interface{}) { - dialog := fromChannel(params["dialog"]).(*dialogImpl) + dialog := fromChannelWithConnection(params["dialog"], bt.connection).(*dialogImpl) go func() { hasListeners := bt.Emit("dialog", dialog) page := dialog.page @@ -857,7 +898,7 @@ func newBrowserContext(parent *channelOwner, objectType string, guid string, ini }, ) bt.channel.On("request", func(ev map[string]interface{}) { - request := fromChannel(ev["request"]).(*requestImpl) + request := fromChannelWithConnection(ev["request"], bt.connection).(*requestImpl) page := fromNullableChannel(ev["page"]) bt.Emit("request", request) if page != nil { @@ -865,7 +906,7 @@ func newBrowserContext(parent *channelOwner, objectType string, guid string, ini } }) bt.channel.On("requestFailed", func(ev map[string]interface{}) { - request := fromChannel(ev["request"]).(*requestImpl) + request := fromChannelWithConnection(ev["request"], bt.connection).(*requestImpl) failureText := ev["failureText"] if failureText != nil { request.failureText = failureText.(string) @@ -879,7 +920,7 @@ func newBrowserContext(parent *channelOwner, objectType string, guid string, ini }) bt.channel.On("requestFinished", func(ev map[string]interface{}) { - request := fromChannel(ev["request"]).(*requestImpl) + request := fromChannelWithConnection(ev["request"], bt.connection).(*requestImpl) response := fromNullableChannel(ev["response"]) page := fromNullableChannel(ev["page"]) request.setResponseEndTiming(ev["responseEndTiming"].(float64)) @@ -892,7 +933,7 @@ func newBrowserContext(parent *channelOwner, objectType string, guid string, ini } }) bt.channel.On("response", func(ev map[string]interface{}) { - response := fromChannel(ev["response"]).(*responseImpl) + response := fromChannelWithConnection(ev["response"], bt.connection).(*responseImpl) page := fromNullableChannel(ev["page"]) bt.Emit("response", response) if page != nil { diff --git a/browser_type.go b/browser_type.go index 41a8b18..83a21f8 100644 --- a/browser_type.go +++ b/browser_type.go @@ -19,6 +19,10 @@ func (b *browserTypeImpl) ExecutablePath() string { func (b *browserTypeImpl) Launch(options ...BrowserTypeLaunchOptions) (Browser, error) { overrides := map[string]interface{}{} + // timeout is required in Playwright v1.57+ protocol + if len(options) == 0 || options[0].Timeout == nil { + overrides["timeout"] = float64(30000) // default 30s + } if len(options) == 1 && options[0].Env != nil { overrides["env"] = serializeMapToNameAndValue(options[0].Env) options[0].Env = nil @@ -36,6 +40,10 @@ func (b *browserTypeImpl) LaunchPersistentContext(userDataDir string, options .. overrides := map[string]interface{}{ "userDataDir": userDataDir, } + // timeout is required in Playwright v1.57+ protocol + if len(options) == 0 || options[0].Timeout == nil { + overrides["timeout"] = float64(30000) // default 30s + } option := &BrowserNewContextOptions{} var tracesDir *string = nil if len(options) == 1 { @@ -87,12 +95,15 @@ func (b *browserTypeImpl) LaunchPersistentContext(userDataDir string, options .. options[0].RecordHarOmitContent = nil } } - channel, err := b.channel.Send("launchPersistentContext", options, overrides) + response, err := b.channel.SendReturnAsDict("launchPersistentContext", options, overrides) if err != nil { return nil, err } - context := fromChannel(channel).(*browserContextImpl) + context := fromChannel(response["context"]).(*browserContextImpl) b.didCreateContext(context, option, tracesDir) + if err := context.initializeHarFromOptions(); err != nil { + return nil, err + } return context, nil } @@ -100,9 +111,14 @@ func (b *browserTypeImpl) Connect(wsEndpoint string, options ...BrowserTypeConne overrides := map[string]interface{}{ "wsEndpoint": wsEndpoint, "headers": map[string]string{ - "x-playwright-browser": b.Name(), + "x-playwright-browser": b.Name(), + "x-playwright-launch-options": "{}", }, } + // timeout is required in Playwright v1.57+ protocol + if len(options) == 0 || options[0].Timeout == nil { + overrides["timeout"] = float64(0) // default no timeout + } if len(options) == 1 { if options[0].Headers != nil { for k, v := range options[0].Headers { @@ -147,6 +163,10 @@ func (b *browserTypeImpl) ConnectOverCDP(endpointURL string, options ...BrowserT overrides := map[string]interface{}{ "endpointURL": endpointURL, } + // timeout is required in Playwright v1.57+ protocol + if len(options) == 0 || options[0].Timeout == nil { + overrides["timeout"] = float64(30000) // default 30s + } if len(options) == 1 { if options[0].Headers != nil { overrides["headers"] = serializeMapToNameAndValue(options[0].Headers) diff --git a/channel.go b/channel.go index b0bded4..27d1d53 100644 --- a/channel.go +++ b/channel.go @@ -38,15 +38,31 @@ func (c *channel) CreateTask(fn func()) { func (c *channel) Send(method string, options ...interface{}) (interface{}, error) { return c.connection.WrapAPICall(func() (interface{}, error) { - return c.innerSend(method, options...).GetResultValue() + result, err := c.innerSend(method, options...).GetResultValue() + if err != nil { + return nil, err + } + // GUIDs are now always eagerly resolved in connection.Dispatch + return result, nil }, c.owner.isInternalType) } func (c *channel) SendReturnAsDict(method string, options ...interface{}) (map[string]interface{}, error) { ret, err := c.connection.WrapAPICall(func() (interface{}, error) { - return c.innerSend(method, options...).GetResult() + result, err := c.innerSend(method, options...).GetResult() + if err != nil { + return nil, err + } + // GUIDs are now always eagerly resolved in connection.Dispatch + return result, nil }, c.owner.isInternalType) - return ret.(map[string]interface{}), err + if err != nil { + return nil, err + } + if ret == nil { + return make(map[string]interface{}), nil + } + return ret.(map[string]interface{}), nil } func (c *channel) innerSend(method string, options ...interface{}) *protocolCallback { diff --git a/channel_owner.go b/channel_owner.go index 5159eb2..f70be96 100644 --- a/channel_owner.go +++ b/channel_owner.go @@ -111,7 +111,9 @@ func (r *rootChannelOwner) initialize() (*Playwright, error) { if err != nil { return nil, err } - return fromChannel(ret["playwright"]).(*Playwright), nil + // GUIDs are now always eagerly resolved in connection.Dispatch + playwrightValue := ret["playwright"] + return fromChannel(playwrightValue).(*Playwright), nil } func newRootChannelOwner(connection *connection) *rootChannelOwner { diff --git a/connection.go b/connection.go index ba1e365..ee2283d 100644 --- a/connection.go +++ b/connection.go @@ -100,15 +100,27 @@ func (c *connection) Dispatch(msg *message) { if msg.Error != nil { cb.SetError(parseError(msg.Error.Error)) } else { - cb.SetResult(c.replaceGuidsWithChannels(msg.Result).(map[string]interface{})) + // Always resolve GUIDs in responses, regardless of connection type + // The protocol guarantees that __create__ events arrive before responses that reference those objects + result, err := c.replaceGuidsWithChannels(msg.Result) + if err != nil { + cb.SetError(fmt.Errorf("failed to resolve response objects: %w", err)) + } else { + cb.SetResult(result.(map[string]interface{})) + } } return } object, _ := c.objects.Load(msg.GUID) if method == "__create__" { - c.createRemoteObject( + _, err := c.createRemoteObject( object, msg.Params["type"].(string), msg.Params["guid"].(string), msg.Params["initializer"], ) + if err != nil { + // Critical: object creation failure indicates corrupted protocol state + // Close connection to prevent cascade failures + c.cleanup(err) + } return } if object == nil { @@ -134,7 +146,15 @@ func (c *connection) Dispatch(msg *message) { if object.objectType == "JsonPipe" { object.channel.Emit(method, msg.Params) } else { - object.channel.Emit(method, c.replaceGuidsWithChannels(msg.Params)) + // Always resolve GUIDs in events, regardless of connection type + // The protocol guarantees that __create__ events arrive before events that reference those objects + params, err := c.replaceGuidsWithChannels(msg.Params) + if err != nil { + // Event parameters contain invalid references - connection is corrupted + c.cleanup(fmt.Errorf("failed to resolve event parameters for %s: %w", method, err)) + return + } + object.channel.Emit(method, params) } } @@ -142,10 +162,13 @@ func (c *connection) LocalUtils() *localUtilsImpl { return c.localUtils } -func (c *connection) createRemoteObject(parent *channelOwner, objectType string, guid string, initializer interface{}) interface{} { - initializer = c.replaceGuidsWithChannels(initializer) - result := createObjectFactory(parent, objectType, guid, initializer.(map[string]interface{})) - return result +func (c *connection) createRemoteObject(parent *channelOwner, objectType string, guid string, initializer interface{}) (interface{}, error) { + resolved, err := c.replaceGuidsWithChannels(initializer) + if err != nil { + return nil, fmt.Errorf("failed to resolve initializer for %s (guid=%s): %w", objectType, guid, err) + } + result := createObjectFactory(parent, objectType, guid, resolved.(map[string]interface{})) + return result, nil } func (c *connection) WrapAPICall(cb func() (interface{}, error), isInternal bool) (interface{}, error) { @@ -156,31 +179,48 @@ func (c *connection) WrapAPICall(cb func() (interface{}, error), isInternal bool return cb() } -func (c *connection) replaceGuidsWithChannels(payload interface{}) interface{} { +func (c *connection) replaceGuidsWithChannels(payload interface{}) (interface{}, error) { if payload == nil { - return nil + return nil, nil } v := reflect.ValueOf(payload) if v.Kind() == reflect.Slice { listV := payload.([]interface{}) for i := 0; i < len(listV); i++ { - listV[i] = c.replaceGuidsWithChannels(listV[i]) + resolved, err := c.replaceGuidsWithChannels(listV[i]) + if err != nil { + return nil, fmt.Errorf("failed to resolve slice element at index %d: %w", i, err) + } + listV[i] = resolved } - return listV + return listV, nil } if v.Kind() == reflect.Map { mapV := payload.(map[string]interface{}) + // Check if this map represents an object reference (has "guid" field) if guid, hasGUID := mapV["guid"]; hasGUID { - if channelOwner, ok := c.objects.Load(guid.(string)); ok { - return channelOwner.channel + guidStr, ok := guid.(string) + if !ok { + return nil, fmt.Errorf("guid field is not a string: %T", guid) } + // Try to load the object from connection's objects map + if channelOwner, ok := c.objects.Load(guidStr); ok { + return channelOwner.channel, nil + } + // Object not found - this indicates a protocol error or message ordering issue + return nil, fmt.Errorf("object with guid %s was not bound in the connection", guidStr) } + // Recursively process all values in the map for key := range mapV { - mapV[key] = c.replaceGuidsWithChannels(mapV[key]) + resolved, err := c.replaceGuidsWithChannels(mapV[key]) + if err != nil { + return nil, fmt.Errorf("failed to resolve map key '%s': %w", key, err) + } + mapV[key] = resolved } - return mapV + return mapV, nil } - return payload + return payload, nil } func (c *connection) sendMessageToServer(object *channelOwner, method string, params interface{}, noReply bool) (cb *protocolCallback) { @@ -314,7 +354,20 @@ func newConnection(transport transport, localUtils ...*localUtilsImpl) *connecti } func fromChannel(v interface{}) interface{} { - return v.(*channel).object + if ch, ok := v.(*channel); ok { + return ch.object + } + panic(fmt.Sprintf("fromChannel: expected *channel, got %T", v)) +} + +// fromChannelWithConnection resolves a value to a channel object +// With the protocol fix, GUIDs are always eagerly resolved, so this behaves identically to fromChannel +// The conn parameter is kept for API compatibility but is no longer used +func fromChannelWithConnection(v interface{}, conn *connection) interface{} { + if ch, ok := v.(*channel); ok { + return ch.object + } + panic(fmt.Sprintf("fromChannelWithConnection: expected *channel, got %T: %+v", v, v)) } func fromNullableChannel(v interface{}) interface{} { @@ -337,7 +390,9 @@ func (pc *protocolCallback) setResultOnce(result map[string]interface{}, err err pc.once.Do(func() { pc.value = result pc.err = err - close(pc.done) + if pc.done != nil { + close(pc.done) + } }) } diff --git a/console_message.go b/console_message.go index 4baf3f1..bb242a4 100644 --- a/console_message.go +++ b/console_message.go @@ -1,8 +1,9 @@ package playwright type consoleMessageImpl struct { - event map[string]interface{} - page Page + event map[string]interface{} + page Page + worker Worker } func (c *consoleMessageImpl) Type() string { @@ -36,6 +37,10 @@ func (c *consoleMessageImpl) Page() Page { return c.page } +func (c *consoleMessageImpl) Worker() (Worker, error) { + return c.worker, nil +} + func newConsoleMessage(event map[string]interface{}) *consoleMessageImpl { bt := &consoleMessageImpl{} bt.event = event @@ -43,5 +48,9 @@ func newConsoleMessage(event map[string]interface{}) *consoleMessageImpl { if page != nil { bt.page = page.(*pageImpl) } + worker := fromNullableChannel(event["worker"]) + if worker != nil { + bt.worker = worker.(*workerImpl) + } return bt } diff --git a/examples/end-to-end-testing/main.go b/examples/end-to-end-testing/main.go index 47a3b47..0dd5f6c 100644 --- a/examples/end-to-end-testing/main.go +++ b/examples/end-to-end-testing/main.go @@ -72,12 +72,20 @@ func main() { // Filter for active entries. There should be 0, because we have completed the entry already assertErrorToNilf("could not click: %v", page.Locator("text=Active").Click()) + // Wait is necessary because headless is too fast + assertErrorToNilf("could not wait for selector state: %v", page.Locator("ul.todo-list > li").WaitFor(playwright.LocatorWaitForOptions{ + State: playwright.WaitForSelectorStateDetached, + })) assertCountOfTodos(0) // If we filter now for completed entries, there should be 1 assertErrorToNilf("could not click: %v", page.GetByRole("link", playwright.PageGetByRoleOptions{ Name: "Completed", }).Click()) + // Wait is necessary because headless is too fast + assertErrorToNilf("could not wait for selector state: %v", page.Locator("ul.todo-list > li").WaitFor(playwright.LocatorWaitForOptions{ + State: playwright.WaitForSelectorStateVisible, + })) assertCountOfTodos(1) // Clear the list of completed entries, then it should be again 0 diff --git a/fetch.go b/fetch.go index fc7f79f..43f207b 100644 --- a/fetch.go +++ b/fetch.go @@ -41,13 +41,20 @@ 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 + } } channel, err := r.channel.Send("newRequest", options, overrides) if err != nil { return nil, err } - return fromChannel(channel).(*apiRequestContextImpl), nil + ctx := fromChannel(channel).(*apiRequestContextImpl) + if len(options) == 1 && options[0].Timeout != nil { + ctx.defaultTimeout = options[0].Timeout + } + return ctx, nil } func newApiRequestImpl(pw *Playwright) *apiRequestImpl { @@ -56,8 +63,9 @@ func newApiRequestImpl(pw *Playwright) *apiRequestImpl { type apiRequestContextImpl struct { channelOwner - tracing *tracingImpl - closeReason *string + tracing *tracingImpl + closeReason *string + defaultTimeout *float64 } func (r *apiRequestContextImpl) Dispose(options ...APIRequestContextDisposeOptions) error { @@ -207,6 +215,10 @@ func (r *apiRequestContextImpl) innerFetch(url string, request Request, options overrides["params"] = serializeMapToNameValue(options[0].Params) options[0].Params = nil } + // Use context-level timeout as default if no per-request timeout specified + if options[0].Timeout == nil && r.defaultTimeout != nil { + overrides["timeout"] = *r.defaultTimeout + } } response, err := r.channel.Send("fetch", options, overrides) @@ -312,7 +324,9 @@ func (r *apiRequestContextImpl) StorageState(path ...string) (*StorageState, err func newAPIRequestContext(parent *channelOwner, objectType string, guid string, initializer map[string]interface{}) *apiRequestContextImpl { rc := &apiRequestContextImpl{} rc.createChannelOwner(rc, parent, objectType, guid, initializer) - rc.tracing = fromChannel(initializer["tracing"]).(*tracingImpl) + if tracingValue := initializer["tracing"]; tracingValue != nil { + rc.tracing = fromNullableChannel(tracingValue).(*tracingImpl) + } return rc } diff --git a/frame.go b/frame.go index b571c8e..ea618a4 100644 --- a/frame.go +++ b/frame.go @@ -60,9 +60,14 @@ func (f *frameImpl) Name() string { } func (f *frameImpl) SetContent(content string, options ...FrameSetContentOptions) error { - _, err := f.channel.Send("setContent", map[string]interface{}{ + overrides := map[string]interface{}{ "html": content, - }, options) + } + // timeout is required in Playwright v1.57+ protocol + if len(options) == 0 || options[0].Timeout == nil { + overrides["timeout"] = f.page.timeoutSettings.NavigationTimeout() + } + _, err := f.channel.Send("setContent", overrides, options) return err } @@ -75,9 +80,14 @@ func (f *frameImpl) Content() (string, error) { } func (f *frameImpl) Goto(url string, options ...FrameGotoOptions) (Response, error) { - channel, err := f.channel.Send("goto", map[string]interface{}{ + overrides := map[string]interface{}{ "url": url, - }, options) + } + // timeout is required in Playwright v1.57+ protocol + if len(options) == 0 || options[0].Timeout == nil { + overrides["timeout"] = f.page.timeoutSettings.NavigationTimeout() + } + channel, err := f.channel.Send("goto", overrides, options) if err != nil { return nil, fmt.Errorf("Frame.Goto %s: %w", url, err) } @@ -414,7 +424,7 @@ func (f *frameImpl) DispatchEvent(selector, typ string, eventInit interface{}, o "selector": selector, "type": typ, "eventInit": serializeArgument(eventInit), - }) + }, options) return err } @@ -505,12 +515,18 @@ func (f *frameImpl) WaitForFunction(expression string, arg interface{}, options if len(options) == 1 { option = options[0] } - result, err := f.channel.Send("waitForFunction", map[string]interface{}{ + overrides := map[string]interface{}{ "expression": expression, "arg": serializeArgument(arg), - "timeout": option.Timeout, "polling": 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) if err != nil { return nil, err } diff --git a/generated-interfaces.go b/generated-interfaces.go index 187dc91..7dcddba 100644 --- a/generated-interfaces.go +++ b/generated-interfaces.go @@ -217,8 +217,9 @@ type Browser interface { // Non-persistent browser contexts don't write any browsing data to disk. type BrowserContext interface { EventEmitter - // **NOTE** Only works with Chromium browser's persistent context. - // Emitted when new background page is created in the context. + // This event is not emitted. + // + // Deprecated: Background pages have been removed from Chromium together with Manifest V2 extensions. OnBackgroundPage(fn func(Page)) // Playwright has ability to mock clock and passage of time. @@ -292,11 +293,13 @@ type BrowserContext interface { // script: Script to be evaluated in all pages in the browser context. AddInitScript(script Script) error - // **NOTE** Background pages are only supported on Chromium-based browsers. - // All existing background pages in the context. + // Returns an empty list. + // + // Deprecated: Background pages have been removed from Chromium together with Manifest V2 extensions. BackgroundPages() []Page - // Returns the browser instance of the context. If it was launched as a persistent context null gets returned. + // Gets the browser instance that owns the context. Returns `null` if the context is created outside of normal + // browser, e.g. Android or Electron. Browser() Browser // Removes cookies from context. Accepts optional filter. @@ -351,6 +354,8 @@ type BrowserContext interface { // - `'clipboard-write'` // - `'geolocation'` // - `'gyroscope'` + // - `'local-fonts'` + // - `'local-network-access'` // - `'magnetometer'` // - `'microphone'` // - `'midi-sysex'` (system-exclusive midi) @@ -538,6 +543,12 @@ type BrowserType interface { // **parent** directory of the "Profile Path" seen at `chrome://version`. // // Note that browsers do not allow launching multiple instances with the same User Data Directory. + // + // **NOTE** Chromium/Chrome: Due to recent Chrome policy changes, automating the default Chrome user profile is not + // supported. Pointing `userDataDir` to Chrome's main "User Data" directory (the profile used for your regular + // browsing) may result in pages not loading or the browser exiting. Create and use a separate directory (for example, + // an empty folder) as your automation profile instead. See https://developer.chrome.com/blog/remote-debugging-port + // for details. LaunchPersistentContext(userDataDir string, options ...BrowserTypeLaunchPersistentContextOptions) (BrowserContext, error) // Returns browser name. For example: `chromium`, `webkit` or `firefox`. @@ -651,6 +662,10 @@ type ConsoleMessage interface { // `trace`, `clear`, `startGroup`, `startGroupCollapsed`, `endGroup`, `assert`, `profile`, // `profileEnd`, `count`, `timeEnd`. Type() string + + // The web worker or service worker that produced this console message, if any. Note that console messages from web + // workers also have non-null [ConsoleMessage.Page]. + Worker() (Worker, error) } // [Dialog] objects are dispatched by page via the [Page.OnDialog] event. @@ -2323,6 +2338,17 @@ type Locator interface { // [actionability]: https://playwright.dev/docs/actionability Dblclick(options ...LocatorDblclickOptions) error + // Describes the locator, description is used in the trace viewer and reports. Returns the locator pointing to the + // same element. + // + // description: Locator description. + Describe(description string) Locator + + // Returns locator description previously set with [Locator.Describe]. Returns `null` if no custom description has + // been set. Prefer `Locator.toString()` for a human-readable representation, as it uses the description when + // available. + Description() (string, error) + // Programmatically dispatch an event on the matching element. // // # Details @@ -3029,7 +3055,13 @@ type LocatorAssertions interface { } // The Mouse class operates in main-frame CSS pixels relative to the top-left corner of the viewport. +// **NOTE** If you want to debug where the mouse moved, you can use the [Trace viewer] or +// [Playwright Inspector]. A red dot showing the location of the mouse will be shown for every +// mouse action. // Every `page` object has its own Mouse, accessible with [Page.Mouse]. +// +// [Trace viewer]: https://playwright.dev/docs/trace-viewer-intro +// [Playwright Inspector]: https://playwright.dev/docs/running-tests type Mouse interface { // Shortcut for [Mouse.Move], [Mouse.Down], [Mouse.Up]. // @@ -3669,6 +3701,9 @@ type Page interface { Keyboard() Keyboard + // Returns up to (currently) 200 last console messages from this page. See [Page.OnConsole] for more details. + ConsoleMessages() ([]ConsoleMessage, error) + // The method returns an element locator that can be used to perform actions on this page / frame. Locator is resolved // to the element immediately before performing an action, so a series of actions on the same locator can in fact be // performed on different DOM elements. That would happen if the DOM structure between those actions has changed. @@ -3687,8 +3722,8 @@ type Page interface { // Returns the opener for popup pages and `null` for others. If the opener has been closed already the returns `null`. Opener() (Page, error) - // Pauses script execution. Playwright will stop executing the script and wait for the user to either press 'Resume' - // button in the page overlay or to call `playwright.resume()` in the DevTools console. + // Pauses script execution. Playwright will stop executing the script and wait for the user to either press the + // 'Resume' button in the page overlay or to call `playwright.resume()` in the DevTools console. // User can inspect selectors or perform manual steps while paused. Resume will continue running the original script // from the place it was paused. // **NOTE** This method requires Playwright to be started in a headed mode, with a falsy “[object Object]” option. @@ -3751,6 +3786,14 @@ type Page interface { // [locators]: https://playwright.dev/docs/locators QuerySelectorAll(selector string) ([]ElementHandle, error) + // Returns up to (currently) 100 last network request from this page. See [Page.OnRequest] for more details. + // Returned requests should be accessed immediately, otherwise they might be collected to prevent unbounded memory + // growth as new requests come in. Once collected, retrieving most information about the request is impossible. + // Note that requests reported through the [Page.OnRequest] request are not collected, so there is a trade off between + // efficient memory usage with [Page.Requests] and the amount of available information reported through + // [Page.OnRequest]. + Requests() ([]Request, error) + // When testing a web page, sometimes unexpected overlays like a "Sign up" dialog appear and block actions you want to // automate, e.g. clicking a button. These overlays don't always show up in the same way or at the same time, making // them tricky to handle in automated tests. @@ -4452,11 +4495,30 @@ type Touchscreen interface { // API for collecting and saving Playwright traces. Playwright traces can be opened in // [Trace Viewer] after Playwright script runs. +// **NOTE** You probably want to +// [enable tracing in your config file] instead +// of using `context.tracing`. +// The `context.tracing` API captures browser operations and network activity, but it doesn't record test assertions +// (like `expect` calls). We recommend +// [enabling tracing through Playwright Test configuration], +// which includes those assertions and provides a more complete trace for debugging test failures. // Start recording a trace before performing actions. At the end, stop tracing and save it to a file. // // [Trace Viewer]: https://playwright.dev/docs/trace-viewer +// [enable tracing in your config file]: https://playwright.dev/docs/api/class-testoptions#test-options-trace +// [enabling tracing through Playwright Test configuration]: https://playwright.dev/docs/api/class-testoptions#test-options-trace type Tracing interface { // Start tracing. + // **NOTE** You probably want to + // [enable tracing in your config file] instead + // of using `Tracing.start`. + // The `context.tracing` API captures browser operations and network activity, but it doesn't record test assertions + // (like `expect` calls). We recommend + // [enabling tracing through Playwright Test configuration], + // which includes those assertions and provides a more complete trace for debugging test failures. + // + // [enable tracing in your config file]: https://playwright.dev/docs/api/class-testoptions#test-options-trace + // [enabling tracing through Playwright Test configuration]: https://playwright.dev/docs/api/class-testoptions#test-options-trace Start(options ...TracingStartOptions) error // Start a new trace chunk. If you'd like to record multiple traces on the same [BrowserContext], use [Tracing.Start] @@ -4547,7 +4609,7 @@ type WebSocket interface { // [Page.RouteWebSocket] or [BrowserContext.RouteWebSocket], the `WebSocketRoute` object allows to handle the // WebSocket, like an actual server would do. // **Mocking** -// By default, the routed WebSocket will not connect to the server. This way, you can mock entire communcation over +// By default, the routed WebSocket will not connect to the server. This way, you can mock entire communication over // the WebSocket. Here is an example that responds to a `"request"` with a `"response"`. // Since we do not call [WebSocketRoute.ConnectToServer] inside the WebSocket route handler, Playwright assumes that // WebSocket will be mocked, and opens the WebSocket inside the page automatically. @@ -4631,6 +4693,9 @@ type Worker interface { // [WebWorker]: https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API OnClose(fn func(Worker)) + // Emitted when JavaScript within the worker calls one of console API methods, e.g. `console.log` or `console.dir`. + OnConsole(fn func(ConsoleMessage)) + // Returns the return value of “[object Object]”. // If the function passed to the [Worker.Evaluate] returns a [Promise], then [Worker.Evaluate] would wait for the // promise to resolve and return its value. diff --git a/generated-structs.go b/generated-structs.go index 7a90f5a..69e20ec 100644 --- a/generated-structs.go +++ b/generated-structs.go @@ -20,6 +20,9 @@ type APIRequestNewContextOptions struct { // a single `pfxPath`, or their corresponding direct value equivalents (`cert` and `key`, or `pfx`). Optionally, // `passphrase` property should be provided if the certificate is encrypted. The `origin` property should be provided // with an exact match to the request origin that the certificate is valid for. + // Client certificate authentication is only active when at least one client certificate is provided. If you want to + // reject all client certificates sent by the server, you need to provide a client certificate with an `origin` that + // does not match any of the domains you plan to visit. // **NOTE** When using WebKit on macOS, accessing `localhost` will not pick up client certificates. You can make it // work by replacing `localhost` with `local.playwright`. ClientCertificates []ClientCertificate `json:"clientCertificates"` @@ -327,8 +330,8 @@ type APIRequestContextPutOptions struct { } type StorageState struct { - Cookies []Cookie `json:"cookies"` - Origins []Origin `json:"origins"` + Cookies []StorageStateCookie `json:"cookies"` + Origins []Origin `json:"origins"` } type NameValue struct { @@ -367,6 +370,9 @@ type BrowserNewContextOptions struct { // a single `pfxPath`, or their corresponding direct value equivalents (`cert` and `key`, or `pfx`). Optionally, // `passphrase` property should be provided if the certificate is encrypted. The `origin` property should be provided // with an exact match to the request origin that the certificate is valid for. + // Client certificate authentication is only active when at least one client certificate is provided. If you want to + // reject all client certificates sent by the server, you need to provide a client certificate with an `origin` that + // does not match any of the domains you plan to visit. // **NOTE** When using WebKit on macOS, accessing `localhost` will not pick up client certificates. You can make it // work by replacing `localhost` with `local.playwright`. ClientCertificates []ClientCertificate `json:"clientCertificates"` @@ -514,6 +520,9 @@ type BrowserNewPageOptions struct { // a single `pfxPath`, or their corresponding direct value equivalents (`cert` and `key`, or `pfx`). Optionally, // `passphrase` property should be provided if the certificate is encrypted. The `origin` property should be provided // with an exact match to the request origin that the certificate is valid for. + // Client certificate authentication is only active when at least one client certificate is provided. If you want to + // reject all client certificates sent by the server, you need to provide a client certificate with an `origin` that + // does not match any of the domains you plan to visit. // **NOTE** When using WebKit on macOS, accessing `localhost` will not pick up client certificates. You can make it // work by replacing `localhost` with `local.playwright`. ClientCertificates []ClientCertificate `json:"clientCertificates"` @@ -651,12 +660,12 @@ type BrowserStartTracingOptions struct { type OptionalCookie struct { Name string `json:"name"` Value string `json:"value"` - // Either url or domain / path are required. Optional. + // Either `url` or both `domain` and `path` are required. Optional. URL *string `json:"url"` - // For the cookie to apply to all subdomains as well, prefix domain with a dot, like this: ".example.com". Either url - // or domain / path are required. Optional. + // For the cookie to apply to all subdomains as well, prefix domain with a dot, like this: ".example.com". Either + // `url` or both `domain` and `path` are required. Optional. Domain *string `json:"domain"` - // Either url or domain / path are required Optional. + // Either `url` or both `domain` and `path` are required. Optional. Path *string `json:"path"` // Unix time in seconds. Optional. Expires *float64 `json:"expires"` @@ -666,6 +675,12 @@ type OptionalCookie struct { Secure *bool `json:"secure"` // Optional. SameSite *SameSiteAttribute `json:"sameSite"` + // For partitioned third-party cookies (aka + // [CHIPS], the + // partition key. Optional. + // + // [CHIPS]: https://developer.mozilla.org/en-US/docs/Web/Privacy/Guides/Privacy_sandbox/Partitioned_cookies) + PartitionKey *string `json:"partitionKey"` } type Script struct { @@ -696,10 +711,11 @@ type Cookie struct { Domain string `json:"domain"` Path string `json:"path"` // Unix time in seconds. - Expires float64 `json:"expires"` - HttpOnly bool `json:"httpOnly"` - Secure bool `json:"secure"` - SameSite *SameSiteAttribute `json:"sameSite"` + Expires float64 `json:"expires"` + HttpOnly bool `json:"httpOnly"` + Secure bool `json:"secure"` + SameSite *SameSiteAttribute `json:"sameSite"` + PartitionKey *string `json:"partitionKey"` } type BrowserContextGrantPermissionsOptions struct { @@ -847,8 +863,11 @@ type BrowserTypeLaunchOptions struct { ExecutablePath *string `json:"executablePath"` // Firefox user preferences. Learn more about the Firefox user preferences at // [`about:config`]. + // You can also provide a path to a custom [`policies.json` file] via + // `PLAYWRIGHT_FIREFOX_POLICIES_JSON` environment variable. // // [`about:config`]: https://support.mozilla.org/en-US/kb/about-config-editor-firefox + // [`policies.json` file]: https://mozilla.github.io/policy-templates/ FirefoxUserPrefs map[string]interface{} `json:"firefoxUserPrefs"` // Close the browser process on SIGHUP. Defaults to `true`. HandleSIGHUP *bool `json:"handleSIGHUP"` @@ -922,6 +941,9 @@ type BrowserTypeLaunchPersistentContextOptions struct { // a single `pfxPath`, or their corresponding direct value equivalents (`cert` and `key`, or `pfx`). Optionally, // `passphrase` property should be provided if the certificate is encrypted. The `origin` property should be provided // with an exact match to the request origin that the certificate is valid for. + // Client certificate authentication is only active when at least one client certificate is provided. If you want to + // reject all client certificates sent by the server, you need to provide a client certificate with an `origin` that + // does not match any of the domains you plan to visit. // **NOTE** When using WebKit on macOS, accessing `localhost` will not pick up client certificates. You can make it // work by replacing `localhost` with `local.playwright`. ClientCertificates []ClientCertificate `json:"clientCertificates"` @@ -957,8 +979,11 @@ type BrowserTypeLaunchPersistentContextOptions struct { ExtraHttpHeaders map[string]string `json:"extraHTTPHeaders"` // Firefox user preferences. Learn more about the Firefox user preferences at // [`about:config`]. + // You can also provide a path to a custom [`policies.json` file] via + // `PLAYWRIGHT_FIREFOX_POLICIES_JSON` environment variable. // // [`about:config`]: https://support.mozilla.org/en-US/kb/about-config-editor-firefox + // [`policies.json` file]: https://mozilla.github.io/policy-templates/ FirefoxUserPrefs map[string]interface{} `json:"firefoxUserPrefs"` // Emulates `forced-colors` media feature, supported values are `active`, `none`. See [Page.EmulateMedia] for // more details. Passing `no-override` resets emulation to system defaults. Defaults to `none`. @@ -1157,6 +1182,9 @@ 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"` + // 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"` // 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"` @@ -1187,6 +1215,9 @@ 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"` + // 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"` // 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"` @@ -1533,6 +1564,9 @@ 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"` + // 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"` // 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"` @@ -1604,6 +1638,9 @@ type FrameDragAndDropOptions struct { // 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"` + // Defaults to 1. Sends `n` interpolated `mousemove` events to represent travel between the `mousedown` and `mouseup` + // of the drag. When set to 1, emits a single `mousemove` event at the destination location. + Steps *int `json:"steps"` // 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"` @@ -2258,9 +2295,6 @@ type KeyboardTypeOptions struct { } type LocatorAriaSnapshotOptions struct { - // Generate symbolic reference for each element. One can use `aria-ref=` locator immediately after capturing the - // snapshot to perform actions on the element. - Ref *bool `json:"ref"` // 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"` @@ -2338,6 +2372,9 @@ 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"` + // 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"` // 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"` @@ -2370,6 +2407,9 @@ 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"` + // 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"` // 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"` @@ -2400,6 +2440,9 @@ type LocatorDragToOptions struct { // 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"` + // Defaults to 1. Sends `n` interpolated `mousemove` events to represent travel between the `mousedown` and `mouseup` + // of the drag. When set to 1, emits a single `mousemove` event at the destination location. + Steps *int `json:"steps"` // Drops on the target 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. TargetPosition *Position `json:"targetPosition"` @@ -3073,7 +3116,8 @@ type MouseDownOptions struct { } type MouseMoveOptions struct { - // Defaults to 1. Sends intermediate `mousemove` events. + // 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"` } @@ -3158,6 +3202,9 @@ 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"` + // 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"` // 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"` @@ -3239,6 +3286,9 @@ type PageDragAndDropOptions struct { // 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"` + // Defaults to 1. Sends `n` interpolated `mousemove` events to represent travel between the `mousedown` and `mouseup` + // of the drag. When set to 1, emits a single `mousemove` event at the destination location. + Steps *int `json:"steps"` // 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"` @@ -4320,6 +4370,18 @@ type Proxy struct { Password *string `json:"password"` } +type StorageStateCookie struct { + Name string `json:"name"` + Value string `json:"value"` + Domain string `json:"domain"` + Path string `json:"path"` + // Unix time in seconds. + Expires float64 `json:"expires"` + HttpOnly bool `json:"httpOnly"` + Secure bool `json:"secure"` + SameSite *SameSiteAttribute `json:"sameSite"` +} + type Origin struct { Origin string `json:"origin"` LocalStorage []NameValue `json:"localStorage"` @@ -4336,7 +4398,7 @@ type RecordVideo struct { type OptionalStorageState struct { // Cookies to set for context - Cookies []OptionalCookie `json:"cookies"` + Cookies []OptionalStorageStateOptionalCookie `json:"cookies"` // localStorage to set for context Origins []Origin `json:"origins"` } @@ -4362,3 +4424,23 @@ type TracingGroupOptionsLocation struct { Line *int `json:"line"` Column *int `json:"column"` } + +type OptionalStorageStateOptionalCookie struct { + Name string `json:"name"` + Value string `json:"value"` + // Either url or domain / path are required. Optional. + URL *string `json:"url"` + // For the cookie to apply to all subdomains as well, prefix domain with a dot, like this: ".example.com". Either url + // or domain / path are required. Optional. + Domain *string `json:"domain"` + // Either url or domain / path are required Optional. + Path *string `json:"path"` + // Unix time in seconds. Optional. + Expires *float64 `json:"expires"` + // Optional. + HttpOnly *bool `json:"httpOnly"` + // Optional. + Secure *bool `json:"secure"` + // Optional. + SameSite *SameSiteAttribute `json:"sameSite"` +} diff --git a/helpers.go b/helpers.go index b2244f1..62bda05 100644 --- a/helpers.go +++ b/helpers.go @@ -61,15 +61,21 @@ func transformStructIntoMapIfNeeded(inStruct interface{}) map[string]interface{} // Merge into the base map by the JSON struct tag for i := 0; i < v.NumField(); i++ { fi := typ.Field(i) + tagv := fi.Tag.Get("json") + key := strings.Split(tagv, ",")[0] + 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. if fi.IsExported() && !skipFieldSerialization(v.Field(i)) { // We use the JSON struct fields for getting the original names // out of the field. - tagv := fi.Tag.Get("json") - key := strings.Split(tagv, ",")[0] - if key == "" { - key = fi.Name - } out[key] = transformStructValues(v.Field(i).Interface()) } } @@ -110,6 +116,16 @@ func transformOptions(options ...interface{}) map[string]interface{} { 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() diff --git a/input_files_helper.go b/input_files_helper.go index 3318cb5..e81ad52 100644 --- a/input_files_helper.go +++ b/input_files_helper.go @@ -115,7 +115,11 @@ func convertInputFiles(files interface{}, context *browserContextImpl) (*inputFi } if result["rootDir"] != nil { - converted.DirectoryStream = result["rootDir"].(*channel) + if ch, ok := result["rootDir"].(*channel); ok { + converted.DirectoryStream = ch + } else { + converted.DirectoryStream = fromChannel(result["rootDir"]).(*writableStream).channel + } } else { converted.Streams = streams } diff --git a/jsonPipe.go b/jsonPipe.go index e6c0e79..5add3c8 100644 --- a/jsonPipe.go +++ b/jsonPipe.go @@ -33,7 +33,7 @@ func (j *jsonPipe) Poll() (*message, error) { func newJsonPipe(parent *channelOwner, objectType string, guid string, initializer map[string]interface{}) *jsonPipe { j := &jsonPipe{ - msgChan: make(chan *message, 2), + msgChan: make(chan *message, 10), } j.createChannelOwner(j, parent, objectType, guid, initializer) j.channel.On("message", func(ev map[string]interface{}) { @@ -54,6 +54,12 @@ func newJsonPipe(parent *channelOwner, objectType string, guid string, initializ }, } } + // Send directly to maintain message ordering - the channel buffer prevents blocking + // Previously used a goroutine which could cause out-of-order delivery + defer func() { + // Recover from panic if channel is closed + _ = recover() + }() j.msgChan <- &msg }) j.channel.Once("closed", func() { diff --git a/locator.go b/locator.go index e6a49b7..ae136ec 100644 --- a/locator.go +++ b/locator.go @@ -12,10 +12,11 @@ var ( ) type locatorImpl struct { - frame *frameImpl - selector string - options *LocatorOptions - err error + frame *frameImpl + selector string + options *LocatorOptions + err error + description *string } type LocatorOptions LocatorFilterOptions @@ -65,6 +66,23 @@ func (l *locatorImpl) Err() error { return l.err } +func (l *locatorImpl) Describe(description string) Locator { + return &locatorImpl{ + frame: l.frame, + selector: l.selector, + options: l.options, + err: l.err, + description: &description, + } +} + +func (l *locatorImpl) Description() (string, error) { + if l.description == nil { + return "", nil + } + return *l.description, nil +} + func (l *locatorImpl) All() ([]Locator, error) { result := make([]Locator, 0) count, err := l.Count() @@ -125,10 +143,10 @@ func (l *locatorImpl) Blur(options ...LocatorBlurOptions) error { "selector": l.selector, "strict": true, } - if len(options) == 1 { - if options[0].Timeout != nil { - params["timeout"] = options[0].Timeout - } + 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+ } _, err := l.frame.channel.Send("blur", params) return err diff --git a/objectFactory.go b/objectFactory.go index 9474c54..76c387a 100644 --- a/objectFactory.go +++ b/objectFactory.go @@ -1,13 +1,24 @@ package playwright +// dummyObject is a placeholder for unimplemented protocol objects (Android, Electron, etc.) +type dummyObject struct { + channelOwner +} + +func newDummyObject(parent *channelOwner, objectType string, guid string, initializer map[string]interface{}) *dummyObject { + d := &dummyObject{} + d.createChannelOwner(d, parent, objectType, guid, initializer) + return d +} + func createObjectFactory(parent *channelOwner, objectType string, guid string, initializer map[string]interface{}) interface{} { switch objectType { case "Android": - return nil + return newDummyObject(parent, objectType, guid, initializer) case "AndroidSocket": - return nil + return newDummyObject(parent, objectType, guid, initializer) case "AndroidDevice": - return nil + return newDummyObject(parent, objectType, guid, initializer) case "APIRequestContext": return newAPIRequestContext(parent, objectType, guid, initializer) case "Artifact": @@ -25,9 +36,9 @@ func createObjectFactory(parent *channelOwner, objectType string, guid string, i case "Dialog": return newDialog(parent, objectType, guid, initializer) case "Electron": - return nil + return newDummyObject(parent, objectType, guid, initializer) case "ElectronApplication": - return nil + return newDummyObject(parent, objectType, guid, initializer) case "ElementHandle": return newElementHandle(parent, objectType, guid, initializer) case "Frame": diff --git a/page.go b/page.go index d4271a0..61e204a 100644 --- a/page.go +++ b/page.go @@ -7,6 +7,7 @@ import ( "os" "slices" "sync" + "sync/atomic" "github.com/playwright-community/playwright-go/internal/safe" ) @@ -30,7 +31,7 @@ type pageImpl struct { ownedContext BrowserContext bindings *safe.SyncMap[string, BindingCallFunction] closeReason *string - closeWasCalled bool + closeWasCalled atomic.Bool harRouters []*harRouter locatorHandlers map[float64]*locatorHandlerEntry } @@ -125,7 +126,7 @@ func (p *pageImpl) Close(options ...PageCloseOptions) error { if len(options) == 1 { p.closeReason = options[0].Reason } - p.closeWasCalled = true + p.closeWasCalled.Store(true) _, err := p.channel.Send("close", options) if err == nil && p.ownedContext != nil { err = p.ownedContext.Close() @@ -746,6 +747,32 @@ func (p *pageImpl) Keyboard() Keyboard { return p.keyboard } +func (p *pageImpl) ConsoleMessages() ([]ConsoleMessage, error) { + result, err := p.channel.Send("consoleMessages", nil) + if err != nil { + return nil, err + } + messages := result.([]interface{}) + consoleMessages := make([]ConsoleMessage, len(messages)) + for i, m := range messages { + consoleMessages[i] = newConsoleMessage(m.(map[string]interface{})) + } + return consoleMessages, nil +} + +func (p *pageImpl) Requests() ([]Request, error) { + result, err := p.channel.Send("requests", nil) + if err != nil { + return nil, err + } + requests := result.([]interface{}) + reqs := make([]Request, len(requests)) + for i, r := range requests { + reqs[i] = fromChannel(r).(*requestImpl) + } + return reqs, nil +} + func (p *pageImpl) Mouse() Mouse { return p.mouse } @@ -942,7 +969,7 @@ func (p *pageImpl) onRoute(route *routeImpl) { url := route.Request().URL() for _, handlerEntry := range routes { // If the page was closed we stall all requests right away. - if p.closeWasCalled || p.browserContext.closeWasCalled { + if p.closeWasCalled.Load() || p.browserContext.closeWasCalled.Load() { return } if !handlerEntry.Matches(url) { diff --git a/page_assertions.go b/page_assertions.go index 43bd0dc..51acbc6 100644 --- a/page_assertions.go +++ b/page_assertions.go @@ -1,8 +1,10 @@ package playwright import ( + "fmt" "net/url" "path" + "strings" ) type pageAssertionsImpl struct { @@ -21,6 +23,65 @@ func newPageAssertions(page Page, isNot bool, defaultTimeout *float64) *pageAsse } } +// expectOnFrame calls the frame's expect method directly without a selector. +// This is needed for page-level assertions like ToHaveTitle and ToHaveURL +// which should not be bound to a specific element. +func (pa *pageAssertionsImpl) expectOnFrame( + expression string, + options frameExpectOptions, + expected interface{}, + message string, +) error { + options.IsNot = pa.isNot + if options.Timeout == nil { + options.Timeout = pa.defaultTimeout + } + if options.IsNot { + message = strings.ReplaceAll(message, "expected to", "expected not to") + } + + frame := pa.actualPage.MainFrame().(*frameImpl) + overrides := map[string]interface{}{ + "expression": expression, + } + result, err := frame.channel.SendReturnAsDict("expect", options, overrides) + if err != nil { + return err + } + + var ( + received interface{} + matches bool + log []string + ) + + if v, ok := result["received"]; ok { + received = parseResult(v) + } + if v, ok := result["matches"]; ok { + matches = v.(bool) + } + if v, ok := result["log"]; ok { + for _, l := range v.([]interface{}) { + log = append(log, l.(string)) + } + } + + if matches == pa.isNot { + actual := received + logStr := strings.Join(log, "\n") + if logStr != "" { + logStr = "\nCall log:\n" + logStr + } + if expected != nil { + return fmt.Errorf("%s '%v'\nActual value: %v %s", message, expected, actual, logStr) + } + return fmt.Errorf("%s\nActual value: %v %s", message, actual, logStr) + } + + return nil +} + func (pa *pageAssertionsImpl) ToHaveTitle(titleOrRegExp interface{}, options ...PageAssertionsToHaveTitleOptions) error { var timeout *float64 if len(options) == 1 { @@ -30,7 +91,7 @@ func (pa *pageAssertionsImpl) ToHaveTitle(titleOrRegExp interface{}, options ... if err != nil { return err } - return pa.expect( + return pa.expectOnFrame( "to.have.title", frameExpectOptions{ExpectedText: expectedValues, Timeout: timeout}, titleOrRegExp, @@ -57,7 +118,7 @@ func (pa *pageAssertionsImpl) ToHaveURL(urlOrRegExp interface{}, options ...Page if err != nil { return err } - return pa.expect( + return pa.expectOnFrame( "to.have.url", frameExpectOptions{ExpectedText: expectedValues, Timeout: timeout}, urlOrRegExp, diff --git a/patches/main.patch b/patches/main.patch index 4ca2725..48b2007 100644 --- a/patches/main.patch +++ b/patches/main.patch @@ -330,10 +330,10 @@ index 7867ce5c8..6a27ede39 100644 :::note diff --git a/docs/src/api/class-browsercontext.md b/docs/src/api/class-browsercontext.md -index afd73a414..bf3f5ee0a 100644 +index 3e7529c48..aef4351b8 100644 --- a/docs/src/api/class-browsercontext.md +++ b/docs/src/api/class-browsercontext.md -@@ -417,7 +417,7 @@ The order of evaluation of multiple scripts installed via [`method: BrowserConte +@@ -389,7 +389,7 @@ The order of evaluation of multiple scripts installed via [`method: BrowserConte ### param: BrowserContext.addInitScript.script * since: v1.8 @@ -342,7 +342,7 @@ index afd73a414..bf3f5ee0a 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. -@@ -1214,7 +1214,7 @@ handler function to route the request. +@@ -1186,7 +1186,7 @@ handler function to route the request. ### param: BrowserContext.route.handler * since: v1.8 @@ -351,7 +351,7 @@ index afd73a414..bf3f5ee0a 100644 - `handler` <[function]\([Route]\)> handler function to route the request. -@@ -1357,7 +1357,7 @@ Handler function to route the WebSocket. +@@ -1329,7 +1329,7 @@ Handler function to route the WebSocket. ### param: BrowserContext.routeWebSocket.handler * since: v1.48 @@ -360,7 +360,7 @@ index afd73a414..bf3f5ee0a 100644 - `handler` <[function]\([WebSocketRoute]\)> Handler function to route the WebSocket. -@@ -1365,7 +1365,7 @@ Handler function to route the WebSocket. +@@ -1337,7 +1337,7 @@ Handler function to route the WebSocket. ## method: BrowserContext.serviceWorkers * since: v1.11 @@ -369,7 +369,7 @@ index afd73a414..bf3f5ee0a 100644 - returns: <[Array]<[Worker]>> :::note -@@ -1522,6 +1522,7 @@ Returns storage state for this browser context, contains current cookies, local +@@ -1495,6 +1495,7 @@ Returns storage state for this browser context, contains current cookies, local ### option: BrowserContext.storageState.indexedDB * since: v1.51 @@ -377,7 +377,7 @@ index afd73a414..bf3f5ee0a 100644 - `indexedDB` ? Set to `true` to include [IndexedDB](https://developer.mozilla.org/en-US/docs/Web/API/IndexedDB_API) in the storage state snapshot. -@@ -1559,6 +1560,13 @@ A glob pattern, regex pattern or predicate receiving [URL] used to register a ro +@@ -1532,6 +1533,13 @@ A glob pattern, regex pattern or predicate receiving [URL] used to register a ro Optional handler function used to register a routing with [`method: BrowserContext.route`]. @@ -391,7 +391,7 @@ index afd73a414..bf3f5ee0a 100644 ### param: BrowserContext.unroute.handler * since: v1.8 * langs: csharp, java -@@ -1600,7 +1608,8 @@ Condition to wait for. +@@ -1573,7 +1581,8 @@ Condition to wait for. ## async method: BrowserContext.waitForConsoleMessage * since: v1.34 @@ -401,7 +401,7 @@ index afd73a414..bf3f5ee0a 100644 - alias-python: expect_console_message - alias-csharp: RunAndWaitForConsoleMessage - returns: <[ConsoleMessage]> -@@ -1631,7 +1640,8 @@ Receives the [ConsoleMessage] object and resolves to truthy value when the waiti +@@ -1604,7 +1613,8 @@ Receives the [ConsoleMessage] object and resolves to truthy value when the waiti ## async method: BrowserContext.waitForEvent * since: v1.8 @@ -411,7 +411,7 @@ index afd73a414..bf3f5ee0a 100644 - alias-python: expect_event - returns: <[any]> -@@ -1697,7 +1707,8 @@ Either a predicate that receives an event or an options object. Optional. +@@ -1670,7 +1680,8 @@ Either a predicate that receives an event or an options object. Optional. ## async method: BrowserContext.waitForPage * since: v1.9 @@ -421,7 +421,7 @@ index afd73a414..bf3f5ee0a 100644 - alias-python: expect_page - alias-csharp: RunAndWaitForPage - returns: <[Page]> -@@ -1716,7 +1727,7 @@ Will throw an error if the context closes before new [Page] is created. +@@ -1689,7 +1700,7 @@ Will throw an error if the context closes before new [Page] is created. ### option: BrowserContext.waitForPage.predicate * since: v1.9 @@ -430,7 +430,7 @@ index afd73a414..bf3f5ee0a 100644 - `predicate` <[function]\([Page]\):[boolean]> Receives the [Page] object and resolves to truthy value when the waiting should resolve. -@@ -1729,7 +1740,8 @@ Receives the [Page] object and resolves to truthy value when the waiting should +@@ -1702,7 +1713,8 @@ Receives the [Page] object and resolves to truthy value when the waiting should ## async method: BrowserContext.waitForEvent2 * since: v1.8 @@ -494,7 +494,7 @@ index e5610b424..748b93b68 100644 - `time` <[float]|[string]|[Date]> diff --git a/docs/src/api/class-consolemessage.md b/docs/src/api/class-consolemessage.md -index 347838ae4..e461d6f9f 100644 +index b712bca26..4a164b95e 100644 --- a/docs/src/api/class-consolemessage.md +++ b/docs/src/api/class-consolemessage.md @@ -112,7 +112,7 @@ List of arguments passed to a `console` function call. See also [`event: Page.co @@ -519,12 +519,31 @@ index 347838ae4..e461d6f9f 100644 + ## method: ConsoleMessage.type * since: v1.8 + * langs: js, python +@@ -144,7 +151,7 @@ The text of the console message. + + ## method: ConsoleMessage.type + * since: v1.8 +-* langs: csharp, java ++* langs: csharp, java, go - returns: <[string]> + + 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 e286c63bf..7dbd5f4fd 100644 +index f9c366e42..678bf3e65 100644 --- a/docs/src/api/class-frame.md +++ b/docs/src/api/class-frame.md -@@ -1946,7 +1946,7 @@ await page.MainFrame.WaitForFunctionAsync("selector => !!document.querySelector( +@@ -283,6 +283,9 @@ When all steps combined have not finished during the specified [`option: timeout + ### option: Frame.click.trial = %%-input-trial-with-modifiers-%% + * since: v1.11 + ++### option: Frame.click.steps = %%-input-mousemove-steps-%% ++* since: v1.57 ++ + ## async method: Frame.content + * since: v1.8 + - returns: <[string]> +@@ -1949,7 +1952,7 @@ await page.MainFrame.WaitForFunctionAsync("selector => !!document.querySelector( Optional argument to pass to [`param: expression`]. @@ -533,7 +552,7 @@ index e286c63bf..7dbd5f4fd 100644 * since: v1.8 ### option: Frame.waitForFunction.polling = %%-csharp-java-wait-for-function-polling-%% -@@ -1998,6 +1998,11 @@ await frame.WaitForLoadStateAsync(); // Defaults to LoadState.Load +@@ -2001,6 +2004,11 @@ await frame.WaitForLoadStateAsync(); // Defaults to LoadState.Load ``` ### param: Frame.waitForLoadState.state = %%-wait-for-load-state-state-%% @@ -545,7 +564,7 @@ index e286c63bf..7dbd5f4fd 100644 * since: v1.8 ### option: Frame.waitForLoadState.timeout = %%-navigation-timeout-%% -@@ -2010,6 +2015,7 @@ await frame.WaitForLoadStateAsync(); // Defaults to LoadState.Load +@@ -2013,6 +2021,7 @@ await frame.WaitForLoadStateAsync(); // Defaults to LoadState.Load * since: v1.8 * deprecated: This method is inherently racy, please use [`method: Frame.waitForURL`] instead. * langs: @@ -554,10 +573,10 @@ index e286c63bf..7dbd5f4fd 100644 * alias-csharp: RunAndWaitForNavigation - returns: <[null]|[Response]> diff --git a/docs/src/api/class-locator.md b/docs/src/api/class-locator.md -index 25462a0ed..f85e4aff6 100644 +index 042c08bb9..a1925c73d 100644 --- a/docs/src/api/class-locator.md +++ b/docs/src/api/class-locator.md -@@ -885,7 +885,7 @@ Optional argument to pass to [`param: expression`]. +@@ -1006,7 +1006,7 @@ Optional argument to pass to [`param: expression`]. ### option: Locator.evaluate.timeout * since: v1.14 @@ -566,7 +585,7 @@ index 25462a0ed..f85e4aff6 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. -@@ -982,7 +982,7 @@ Optional argument to pass to [`param: expression`]. +@@ -1103,7 +1103,7 @@ Optional argument to pass to [`param: expression`]. ### option: Locator.evaluateHandle.timeout * since: v1.14 @@ -576,7 +595,7 @@ index 25462a0ed..f85e4aff6 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 78ed036c0..e78149d3d 100644 +index c2edbf753..2f0a348a0 100644 --- a/docs/src/api/class-locatorassertions.md +++ b/docs/src/api/class-locatorassertions.md @@ -67,7 +67,7 @@ public class ExampleTests : PageTest @@ -625,10 +644,10 @@ index 78ed036c0..e78149d3d 100644 Expected options currently selected. diff --git a/docs/src/api/class-page.md b/docs/src/api/class-page.md -index a69ac2446..a026da311 100644 +index 717cbf06c..a54492b22 100644 --- a/docs/src/api/class-page.md +++ b/docs/src/api/class-page.md -@@ -621,7 +621,7 @@ The order of evaluation of multiple scripts installed via [`method: BrowserConte +@@ -614,7 +614,7 @@ The order of evaluation of multiple scripts installed via [`method: BrowserConte ### param: Page.addInitScript.script * since: v1.8 @@ -637,7 +656,17 @@ index a69ac2446..a026da311 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. -@@ -1267,6 +1267,14 @@ Passing `null` disables CSS media emulation. +@@ -808,6 +808,9 @@ When all steps combined have not finished during the specified [`option: timeout + ### option: Page.click.trial = %%-input-trial-with-modifiers-%% + * since: v1.11 + ++### option: Page.click.steps = %%-input-mousemove-steps-%% ++* since: v1.57 ++ + ## async method: Page.close + * since: v1.8 + +@@ -1263,6 +1266,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. @@ -652,7 +681,7 @@ index a69ac2446..a026da311 100644 ### option: Page.emulateMedia.colorScheme * since: v1.9 * langs: js, java -@@ -1283,6 +1291,14 @@ Emulates [prefers-colors-scheme](https://developer.mozilla.org/en-US/docs/Web/CS +@@ -1279,6 +1290,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. @@ -667,7 +696,7 @@ index a69ac2446..a026da311 100644 ### option: Page.emulateMedia.reducedMotion * since: v1.12 * langs: js, java -@@ -1297,6 +1313,13 @@ Emulates `'prefers-reduced-motion'` media feature, supported values are `'reduce +@@ -1293,6 +1312,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. @@ -681,7 +710,7 @@ index a69ac2446..a026da311 100644 ### option: Page.emulateMedia.forcedColors * since: v1.15 * langs: js, java -@@ -1309,6 +1332,13 @@ Emulates `'forced-colors'` media feature, supported values are `'active'` and `' +@@ -1305,6 +1331,13 @@ Emulates `'forced-colors'` media feature, supported values are `'active'` and `' * langs: csharp, python - `forcedColors` <[ForcedColors]<"active"|"none"|"null">> @@ -695,7 +724,7 @@ index a69ac2446..a026da311 100644 ### option: Page.emulateMedia.contrast * since: v1.51 * langs: js, java -@@ -1321,6 +1351,13 @@ Emulates `'prefers-contrast'` media feature, supported values are `'no-preferenc +@@ -1317,6 +1350,13 @@ Emulates `'prefers-contrast'` media feature, supported values are `'no-preferenc * langs: csharp, python - `contrast` <[Contrast]<"no-preference"|"more"|"null">> @@ -709,7 +738,7 @@ index a69ac2446..a026da311 100644 ## async method: Page.evalOnSelector * since: v1.9 * discouraged: This method does not wait for the element to pass actionability -@@ -2140,14 +2177,14 @@ Frame name specified in the `iframe`'s `name` attribute. +@@ -2136,14 +2176,14 @@ Frame name specified in the `iframe`'s `name` attribute. ### option: Page.frame.name * since: v1.8 @@ -726,7 +755,7 @@ index a69ac2446..a026da311 100644 - `url` ?<[string]|[RegExp]|[function]\([URL]\):[boolean]> A glob pattern, regex pattern or predicate receiving frame's `url` as a [URL] object. Optional. -@@ -2917,7 +2954,7 @@ Paper width, accepts values labeled with units. +@@ -2936,7 +2976,7 @@ Paper width, accepts values labeled with units. ### option: Page.pdf.width * since: v1.8 @@ -735,7 +764,7 @@ index a69ac2446..a026da311 100644 - `width` <[string]> Paper width, accepts values labeled with units. -@@ -2931,7 +2968,7 @@ Paper height, accepts values labeled with units. +@@ -2950,7 +2990,7 @@ Paper height, accepts values labeled with units. ### option: Page.pdf.height * since: v1.8 @@ -744,7 +773,7 @@ index a69ac2446..a026da311 100644 - `height` <[string]> Paper height, accepts values labeled with units. -@@ -2949,7 +2986,7 @@ Paper margins, defaults to none. +@@ -2968,7 +3008,7 @@ Paper margins, defaults to none. ### option: Page.pdf.margin * since: v1.8 @@ -753,7 +782,7 @@ index a69ac2446..a026da311 100644 - `margin` <[Object]> - `top` ?<[string]> Top margin, accepts values labeled with units. Defaults to `0`. - `right` ?<[string]> Right margin, accepts values labeled with units. Defaults to `0`. -@@ -3370,7 +3407,7 @@ Function that should be run once [`param: locator`] appears. This function shoul +@@ -3400,7 +3440,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 @@ -762,7 +791,7 @@ index a69ac2446..a026da311 100644 * since: v1.42 - `handler` <[function]\([Locator]\)> -@@ -3616,6 +3653,13 @@ A glob pattern, regex pattern, or predicate that receives a [URL] to match durin +@@ -3646,6 +3686,13 @@ A glob pattern, regex pattern, or predicate that receives a [URL] to match durin handler function to route the request. @@ -776,7 +805,7 @@ index a69ac2446..a026da311 100644 ### param: Page.route.handler * since: v1.8 * langs: csharp, java -@@ -3750,7 +3794,7 @@ Handler function to route the WebSocket. +@@ -3780,7 +3827,7 @@ Handler function to route the WebSocket. ### param: Page.routeWebSocket.handler * since: v1.48 @@ -785,7 +814,7 @@ index a69ac2446..a026da311 100644 - `handler` <[function]\([WebSocketRoute]\)> Handler function to route the WebSocket. -@@ -4079,14 +4123,14 @@ await page.GotoAsync("https://www.microsoft.com"); +@@ -4109,14 +4156,14 @@ await page.GotoAsync("https://www.microsoft.com"); ### param: Page.setViewportSize.width * since: v1.10 @@ -802,7 +831,7 @@ index a69ac2446..a026da311 100644 - `height` <[int]> Page height in pixels. -@@ -4273,6 +4317,13 @@ A glob pattern, regex pattern or predicate receiving [URL] to match while routin +@@ -4303,6 +4350,13 @@ A glob pattern, regex pattern or predicate receiving [URL] to match while routin Optional handler function to route the request. @@ -816,7 +845,7 @@ index a69ac2446..a026da311 100644 ### param: Page.unroute.handler * since: v1.8 * langs: csharp, java -@@ -4311,7 +4362,8 @@ Performs action and waits for the Page to close. +@@ -4341,7 +4395,8 @@ Performs action and waits for the Page to close. ## async method: Page.waitForConsoleMessage * since: v1.9 @@ -826,7 +855,7 @@ index a69ac2446..a026da311 100644 - alias-python: expect_console_message - alias-csharp: RunAndWaitForConsoleMessage - returns: <[ConsoleMessage]> -@@ -4342,7 +4394,8 @@ Receives the [ConsoleMessage] object and resolves to truthy value when the waiti +@@ -4372,7 +4427,8 @@ Receives the [ConsoleMessage] object and resolves to truthy value when the waiti ## async method: Page.waitForDownload * since: v1.9 @@ -836,7 +865,7 @@ index a69ac2446..a026da311 100644 - alias-python: expect_download - alias-csharp: RunAndWaitForDownload - returns: <[Download]> -@@ -4373,7 +4426,8 @@ Receives the [Download] object and resolves to truthy value when the waiting sho +@@ -4403,7 +4459,8 @@ Receives the [Download] object and resolves to truthy value when the waiting sho ## async method: Page.waitForEvent * since: v1.8 @@ -846,7 +875,7 @@ index a69ac2446..a026da311 100644 - alias-python: expect_event - returns: <[any]> -@@ -4426,7 +4480,8 @@ Either a predicate that receives an event or an options object. Optional. +@@ -4456,7 +4513,8 @@ Either a predicate that receives an event or an options object. Optional. ## async method: Page.waitForFileChooser * since: v1.9 @@ -856,7 +885,7 @@ index a69ac2446..a026da311 100644 - alias-python: expect_file_chooser - alias-csharp: RunAndWaitForFileChooser - returns: <[FileChooser]> -@@ -4584,7 +4639,7 @@ await page.WaitForFunctionAsync("selector => !!document.querySelector(selector)" +@@ -4614,7 +4672,7 @@ await page.WaitForFunctionAsync("selector => !!document.querySelector(selector)" Optional argument to pass to [`param: expression`]. @@ -865,7 +894,7 @@ index a69ac2446..a026da311 100644 * since: v1.8 ### option: Page.waitForFunction.polling = %%-csharp-java-wait-for-function-polling-%% -@@ -4681,6 +4736,11 @@ Console.WriteLine(await popup.TitleAsync()); // popup is ready to use. +@@ -4711,6 +4769,11 @@ Console.WriteLine(await popup.TitleAsync()); // popup is ready to use. ``` ### param: Page.waitForLoadState.state = %%-wait-for-load-state-state-%% @@ -877,7 +906,7 @@ index a69ac2446..a026da311 100644 * since: v1.8 ### option: Page.waitForLoadState.timeout = %%-navigation-timeout-%% -@@ -4693,6 +4753,7 @@ Console.WriteLine(await popup.TitleAsync()); // popup is ready to use. +@@ -4723,6 +4786,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: @@ -885,7 +914,7 @@ index a69ac2446..a026da311 100644 * alias-python: expect_navigation * alias-csharp: RunAndWaitForNavigation - returns: <[null]|[Response]> -@@ -4777,7 +4838,8 @@ a navigation. +@@ -4807,7 +4871,8 @@ a navigation. ## async method: Page.waitForPopup * since: v1.9 @@ -895,7 +924,7 @@ index a69ac2446..a026da311 100644 - alias-python: expect_popup - alias-csharp: RunAndWaitForPopup - returns: <[Page]> -@@ -4809,6 +4871,7 @@ Receives the [Page] object and resolves to truthy value when the waiting should +@@ -4839,6 +4904,7 @@ Receives the [Page] object and resolves to truthy value when the waiting should ## async method: Page.waitForRequest * since: v1.8 * langs: @@ -903,7 +932,7 @@ index a69ac2446..a026da311 100644 * alias-python: expect_request * alias-csharp: RunAndWaitForRequest - returns: <[Request]> -@@ -4916,7 +4979,8 @@ changed by using the [`method: Page.setDefaultTimeout`] method. +@@ -4946,7 +5012,8 @@ changed by using the [`method: Page.setDefaultTimeout`] method. ## async method: Page.waitForRequestFinished * since: v1.12 @@ -913,7 +942,7 @@ index a69ac2446..a026da311 100644 - alias-python: expect_request_finished - alias-csharp: RunAndWaitForRequestFinished - returns: <[Request]> -@@ -4948,6 +5012,7 @@ Receives the [Request] object and resolves to truthy value when the waiting shou +@@ -4978,6 +5045,7 @@ Receives the [Request] object and resolves to truthy value when the waiting shou ## async method: Page.waitForResponse * since: v1.8 * langs: @@ -921,7 +950,7 @@ index a69ac2446..a026da311 100644 * alias-python: expect_response * alias-csharp: RunAndWaitForResponse - returns: <[Response]> -@@ -5311,7 +5376,8 @@ await page.WaitForURLAsync("**/target.html"); +@@ -5341,7 +5409,8 @@ await page.WaitForURLAsync("**/target.html"); ## async method: Page.waitForWebSocket * since: v1.9 @@ -931,7 +960,7 @@ index a69ac2446..a026da311 100644 - alias-python: expect_websocket - alias-csharp: RunAndWaitForWebSocket - returns: <[WebSocket]> -@@ -5342,7 +5408,8 @@ Receives the [WebSocket] object and resolves to truthy value when the waiting sh +@@ -5372,7 +5441,8 @@ Receives the [WebSocket] object and resolves to truthy value when the waiting sh ## async method: Page.waitForWorker * since: v1.9 @@ -941,7 +970,7 @@ index a69ac2446..a026da311 100644 - alias-python: expect_worker - alias-csharp: RunAndWaitForWorker - returns: <[Worker]> -@@ -5384,7 +5451,8 @@ This does not contain ServiceWorkers +@@ -5414,7 +5484,8 @@ This does not contain ServiceWorkers ## async method: Page.waitForEvent2 * since: v1.8 @@ -1126,10 +1155,10 @@ index 4c3011430..4ced2d60d 100644 - `path` ?<[path]> Path to the JavaScript file. If `path` is a relative path, then it is resolved relative to the current working directory. Optional. diff --git a/docs/src/api/class-tracing.md b/docs/src/api/class-tracing.md -index 3b0011c7b..48a28fa24 100644 +index 0528891fd..1323d2393 100644 --- a/docs/src/api/class-tracing.md +++ b/docs/src/api/class-tracing.md -@@ -142,7 +142,7 @@ If this option is true tracing will +@@ -156,7 +156,7 @@ If this option is true tracing will ### option: Tracing.start.sources * since: v1.17 @@ -1191,7 +1220,7 @@ index e33740eac..d89648d91 100644 - returns: <[any]> diff --git a/docs/src/api/class-websocketroute.md b/docs/src/api/class-websocketroute.md -index e23316ebc..ae3d20b9f 100644 +index fcbe1b21f..dc6503e4f 100644 --- a/docs/src/api/class-websocketroute.md +++ b/docs/src/api/class-websocketroute.md @@ -325,7 +325,7 @@ Function that will handle WebSocket closure. Received an optional [close code](h @@ -1218,7 +1247,7 @@ index e23316ebc..ae3d20b9f 100644 * since: v1.48 * langs: csharp, java diff --git a/docs/src/api/params.md b/docs/src/api/params.md -index 4c4937004..ea6212dc9 100644 +index 37f6665a9..dbe37d8a1 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: @@ -1248,7 +1277,7 @@ index 4c4937004..ea6212dc9 100644 - `timeout` <[float]> Maximum time in milliseconds. Defaults to `30000` (30 seconds). Pass `0` to disable timeout. The default value can be changed by -@@ -188,8 +188,8 @@ Defaults to `'visible'`. Can be either: +@@ -198,8 +198,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. @@ -1259,7 +1288,7 @@ index 4c4937004..ea6212dc9 100644 - `polling` <[float]|"raf"> If [`option: polling`] is `'raf'`, then [`param: expression`] is constantly executed in `requestAnimationFrame` -@@ -210,14 +210,14 @@ If `true`, Playwright does not pass its own configurations args and only uses th +@@ -220,14 +220,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 @@ -1276,8 +1305,8 @@ index 4c4937004..ea6212dc9 100644 - `ignoreAllDefaultArgs` <[boolean]> If `true`, Playwright does not pass its own configurations args and only uses the ones from [`option: args`]. -@@ -236,7 +236,7 @@ Dangerous option; use with care. Defaults to `false`. - Network proxy settings. +@@ -250,7 +250,7 @@ Network proxy settings. + - `env` <[Object]<[string], [string]|[undefined]>> ## csharp-java-browser-option-env -* langs: csharp, java @@ -1285,7 +1314,7 @@ index 4c4937004..ea6212dc9 100644 - `env` <[Object]<[string], [string]>> Specify environment variables that will be visible to the browser. Defaults to `process.env`. -@@ -269,6 +269,29 @@ Learn more about [storage state and auth](../auth.md). +@@ -283,6 +283,29 @@ 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`]. @@ -1315,7 +1344,7 @@ index 4c4937004..ea6212dc9 100644 ## csharp-java-context-option-storage-state * langs: csharp, java - `storageState` <[string]> -@@ -277,7 +300,7 @@ Populates context with given storage state. This option can be used to initializ +@@ -291,7 +314,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 @@ -1324,7 +1353,7 @@ index 4c4937004..ea6212dc9 100644 - `storageStatePath` <[path]> Populates context with given storage state. This option can be used to initialize context with logged-in information -@@ -374,7 +397,7 @@ Query parameters to be sent with the URL. +@@ -388,7 +411,7 @@ Query parameters to be sent with the URL. Query parameters to be sent with the URL. ## csharp-fetch-option-params @@ -1333,7 +1362,7 @@ index 4c4937004..ea6212dc9 100644 - `params` <[Object]<[string], [Serializable]>> Query parameters to be sent with the URL. -@@ -392,19 +415,19 @@ Query parameters to be sent with the URL. +@@ -406,19 +429,19 @@ Query parameters to be sent with the URL. Optional request parameters. ## js-python-csharp-fetch-option-headers @@ -1356,7 +1385,7 @@ index 4c4937004..ea6212dc9 100644 - `failOnStatusCode` <[boolean]> Whether to throw on response codes other than 2xx and 3xx. By default response object is returned -@@ -436,6 +459,14 @@ unless explicitly provided. +@@ -450,6 +473,14 @@ unless explicitly provided. An instance of [FormData] can be created via [`method: APIRequestContext.createFormData`]. @@ -1371,7 +1400,7 @@ index 4c4937004..ea6212dc9 100644 ## js-fetch-option-multipart * langs: js - `multipart` <[FormData]|[Object]<[string], [string]|[float]|[boolean]|[ReadStream]|[Object]>> -@@ -469,6 +500,15 @@ unless explicitly provided. File values can be passed as file-like object contai +@@ -483,6 +514,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`]. @@ -1387,7 +1416,7 @@ index 4c4937004..ea6212dc9 100644 ## js-python-csharp-fetch-option-data * langs: js, python, csharp - `data` <[string]|[Buffer]|[Serializable]> -@@ -477,21 +517,29 @@ Allows to set post data of the request. If the data parameter is an object, it w +@@ -491,21 +531,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. @@ -1420,7 +1449,7 @@ index 4c4937004..ea6212dc9 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. -@@ -533,7 +581,7 @@ Function to be evaluated in the worker context. +@@ -547,7 +595,7 @@ Function to be evaluated in the worker context. Function to be evaluated in the main Electron process. ## python-context-option-viewport @@ -1429,7 +1458,7 @@ index 4c4937004..ea6212dc9 100644 - `viewport` <[null]|[Object]> - `width` <[int]> page width in pixels. - `height` <[int]> page height in pixels. -@@ -541,7 +589,7 @@ Function to be evaluated in the main Electron process. +@@ -555,7 +603,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 @@ -1438,7 +1467,7 @@ index 4c4937004..ea6212dc9 100644 - `noViewport` <[boolean]> Does not enforce fixed viewport, allows resizing window in the headed mode. -@@ -649,6 +697,13 @@ Emulates [prefers-colors-scheme](https://developer.mozilla.org/en-US/docs/Web/CS +@@ -665,6 +713,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'`. @@ -1452,7 +1481,7 @@ index 4c4937004..ea6212dc9 100644 ## context-option-reducedMotion * langs: js, java - `reducedMotion` > -@@ -661,6 +716,12 @@ Emulates `'prefers-reduced-motion'` media feature, supported values are `'reduce +@@ -677,6 +732,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'`. @@ -1465,7 +1494,7 @@ index 4c4937004..ea6212dc9 100644 ## context-option-forcedColors * langs: js, java - `forcedColors` > -@@ -668,10 +729,10 @@ Emulates `'prefers-reduced-motion'` media feature, supported values are `'reduce +@@ -684,10 +745,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 @@ -1479,7 +1508,7 @@ index 4c4937004..ea6212dc9 100644 ## context-option-contrast * langs: js, java -@@ -718,7 +779,7 @@ specified, the HAR is not recorded. Make sure to await [`method: BrowserContext. +@@ -735,7 +796,7 @@ specified, the HAR is not recorded. Make sure to await [`method: BrowserContext. saved. ## context-option-recordhar-path @@ -1488,7 +1517,7 @@ index 4c4937004..ea6212dc9 100644 - alias-python: record_har_path - `recordHarPath` <[path]> -@@ -727,33 +788,33 @@ specified HAR file on the filesystem. If not specified, the HAR is not recorded. +@@ -744,33 +805,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 @@ -1527,7 +1556,7 @@ index 4c4937004..ea6212dc9 100644 - `recordVideo` <[Object]> - `dir` <[path]> Path to the directory to put videos into. - `size` ?<[Object]> Optional dimensions of the recorded videos. If not specified the size will be equal to `viewport` -@@ -820,7 +881,7 @@ Specifies whether to wait for already running listeners and what to do if they t +@@ -837,7 +898,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 @@ -1536,7 +1565,7 @@ index 4c4937004..ea6212dc9 100644 * since: v1.41 - `behavior` <[UnrouteBehavior]<"wait"|"ignoreErrors"|"default">> -@@ -831,7 +892,7 @@ Specifies whether to wait for already running handlers and what to do if they th +@@ -848,7 +909,7 @@ Specifies whether to wait for already running handlers and what to do if they th ## select-options-values @@ -1545,7 +1574,7 @@ index 4c4937004..ea6212dc9 100644 - `values` <[null]|[string]|[ElementHandle]|[Array]<[string]>|[Object]|[Array]<[ElementHandle]>|[Array]<[Object]>> - `value` ?<[string]> Matches by `option.value`. Optional. - `label` ?<[string]> Matches by `option.label`. Optional. -@@ -849,7 +910,7 @@ the parameter is a string without wildcard characters, the method will wait for +@@ -866,7 +927,7 @@ the parameter is a string without wildcard characters, the method will wait for equal to the string. ## wait-for-event-event @@ -1554,7 +1583,7 @@ index 4c4937004..ea6212dc9 100644 - `event` <[string]> Event name, same one typically passed into `*.on(event)`. -@@ -907,7 +968,7 @@ only the first option matching one of the passed options is selected. Optional. +@@ -924,7 +985,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 @@ -1563,7 +1592,7 @@ index 4c4937004..ea6212dc9 100644 - `timeout` <[float]> Maximum time to wait for in milliseconds. Defaults to `30000` (30 seconds). Pass `0` to disable timeout. -@@ -927,7 +988,7 @@ using the [`method: AndroidDevice.setDefaultTimeout`] method. +@@ -944,7 +1005,7 @@ using the [`method: AndroidDevice.setDefaultTimeout`] method. Time to retry the assertion for in milliseconds. Defaults to `timeout` in `TestConfig.expect`. ## csharp-java-python-assertions-timeout @@ -1572,7 +1601,7 @@ index 4c4937004..ea6212dc9 100644 - `timeout` <[float]> Time to retry the assertion for in milliseconds. Defaults to `5000`. -@@ -981,8 +1042,10 @@ between the same pixel in compared images, between zero (strict) and one (lax), +@@ -998,8 +1059,10 @@ between the same pixel in compared images, between zero (strict) and one (lax), - %%-context-option-httpcredentials-%% - %%-context-option-colorscheme-%% - %%-context-option-colorscheme-csharp-python-%% @@ -1583,8 +1612,8 @@ index 4c4937004..ea6212dc9 100644 - %%-context-option-forcedColors-%% - %%-context-option-forcedColors-csharp-python-%% - %%-context-option-contrast-%% -@@ -1072,7 +1135,7 @@ Firefox user preferences. Learn more about the Firefox user preferences at - [`about:config`](https://support.mozilla.org/en-US/kb/about-config-editor-firefox). +@@ -1091,7 +1154,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 -* langs: csharp, java diff --git a/playwright b/playwright index 471930b..8058197 160000 --- a/playwright +++ b/playwright @@ -1 +1 @@ -Subproject commit 471930b1ceae03c9e66e0eb80c1364a1a788e7db +Subproject commit 80581972582c9565e141c5fedd3c5fa10cc0e38b diff --git a/playwright.go b/playwright.go index 805ac14..80f8904 100644 --- a/playwright.go +++ b/playwright.go @@ -30,11 +30,26 @@ func (p *Playwright) Stop() error { return p.connection.Stop() } +// Pid returns the process ID of the Playwright driver process, or 0 if not available +func (p *Playwright) Pid() int { + if pt, ok := p.connection.transport.(*pipeTransport); ok { + if pt.process != nil { + return pt.process.Pid + } + } + return 0 +} + func (p *Playwright) setSelectors(selectors Selectors) { - selectorsOwner := fromChannel(p.initializer["selectors"]).(*selectorsOwnerImpl) - p.Selectors.(*selectorsImpl).removeChannel(selectorsOwner) - p.Selectors = selectors - p.Selectors.(*selectorsImpl).addChannel(selectorsOwner) + // Selectors has been moved to client-side only in Playwright v1.57+ + if p.initializer["selectors"] != nil { + selectorsOwner := fromChannel(p.initializer["selectors"]).(*selectorsOwnerImpl) + p.Selectors.(*selectorsImpl).removeChannel(selectorsOwner) + p.Selectors = selectors + p.Selectors.(*selectorsImpl).addChannel(selectorsOwner) + } else { + p.Selectors = selectors + } } func newPlaywright(parent *channelOwner, objectType string, guid string, initializer map[string]interface{}) *Playwright { @@ -50,10 +65,14 @@ func newPlaywright(parent *channelOwner, objectType string, guid string, initial pw.Chromium.(*browserTypeImpl).playwright = pw pw.Firefox.(*browserTypeImpl).playwright = pw pw.WebKit.(*browserTypeImpl).playwright = pw - selectorsOwner := fromChannel(initializer["selectors"]).(*selectorsOwnerImpl) - pw.Selectors.(*selectorsImpl).addChannel(selectorsOwner) - pw.connection.afterClose = func() { - pw.Selectors.(*selectorsImpl).removeChannel(selectorsOwner) + // Selectors has been moved to client-side only in Playwright v1.57+ + // Only set up channel if selectors is in the initializer (older protocol) + if initializer["selectors"] != nil { + selectorsOwner := fromChannel(initializer["selectors"]).(*selectorsOwnerImpl) + pw.Selectors.(*selectorsImpl).addChannel(selectorsOwner) + pw.connection.afterClose = func() { + pw.Selectors.(*selectorsImpl).removeChannel(selectorsOwner) + } } if pw.connection.localUtils != nil { pw.Devices = pw.connection.localUtils.Devices diff --git a/run.go b/run.go index 213b34e..30ad1d7 100644 --- a/run.go +++ b/run.go @@ -16,7 +16,7 @@ import ( "strings" ) -const playwrightCliVersion = "1.52.0" +const playwrightCliVersion = "1.57.0" var ( logger = slog.Default() diff --git a/run_test.go b/run_test.go index b18858a..3c0efc5 100644 --- a/run_test.go +++ b/run_test.go @@ -11,7 +11,6 @@ import ( "sync" "testing" - "github.com/mitchellh/go-ps" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -150,7 +149,12 @@ func TestShouldNotHangWhenPlaywrightUnexpectedExit(t *testing.T) { context, err := browser.NewContext() require.NoError(t, err) - err = killPlaywrightProcess() + // Get the process ID directly from Playwright + pid := pw.Pid() + require.NotZero(t, pid, "Playwright process PID should not be zero") + + // Kill the process + err = killProcessByPid(pid) require.NoError(t, err) _, err = context.NewPage() @@ -172,25 +176,6 @@ func TestGetNodeExecutable(t *testing.T) { assert.Contains(t, executable, "testDirectory") } -// find and kill playwright process -func killPlaywrightProcess() error { - all, err := ps.Processes() - if err != nil { - return err - } - for _, process := range all { - if process.Executable() == "node" || process.Executable() == "node.exe" { - if process.PPid() == os.Getpid() { - if err := killProcessByPid(process.Pid()); err != nil { - return err - } - return nil - } - } - } - return fmt.Errorf("playwright process not found") -} - func killProcessByPid(pid int) error { process, err := os.FindProcess(pid) if err != nil { diff --git a/selectors.go b/selectors.go index 1151647..0d72090 100644 --- a/selectors.go +++ b/selectors.go @@ -23,7 +23,8 @@ func newSelectorsOwner(parent *channelOwner, objectType string, guid string, ini } type selectorsImpl struct { - channels sync.Map + mu sync.RWMutex // protects registrations slice + contexts sync.Map // map of BrowserContext channels registrations []map[string]interface{} } @@ -41,48 +42,82 @@ func (s *selectorsImpl) Register(name string, script Script, options ...Selector } else { source = *script.Content } - params := map[string]interface{}{ + selectorEngine := map[string]interface{}{ "name": name, "source": source, } if len(options) == 1 && options[0].ContentScript != nil { - params["contentScript"] = *options[0].ContentScript + selectorEngine["contentScript"] = *options[0].ContentScript } - var err error - s.channels.Range(func(key, value any) bool { - _, err = value.(*selectorsOwnerImpl).channel.Send("register", params) - return err == nil + params := map[string]interface{}{ + "selectorEngine": selectorEngine, + } + // Register with all active contexts, ignoring contexts that have been closed + s.contexts.Range(func(key, value any) bool { + _, _ = value.(*browserContextImpl).channel.Send("registerSelectorEngine", params) + // Continue to next context even if this one failed (e.g., context closed) + return true }) - if err != nil { - return err - } - s.registrations = append(s.registrations, params) + s.mu.Lock() + s.registrations = append(s.registrations, selectorEngine) + s.mu.Unlock() return nil } func (s *selectorsImpl) SetTestIdAttribute(name string) { setTestIdAttributeName(name) - s.channels.Range(func(key, value any) bool { - value.(*selectorsOwnerImpl).setTestIdAttributeName(name) + s.contexts.Range(func(key, value any) bool { + value.(*browserContextImpl).channel.SendNoReply("setTestIdAttributeName", map[string]interface{}{ + "testIdAttributeName": name, + }) return true }) } func (s *selectorsImpl) addChannel(channel *selectorsOwnerImpl) { - s.channels.Store(channel.guid, channel) - for _, params := range s.registrations { - channel.channel.SendNoReply("register", params) + // Legacy support for older Playwright versions with server-side selectors + s.contexts.Store(channel.guid, channel) + s.mu.RLock() + for _, selectorEngine := range s.registrations { + params := map[string]interface{}{ + "selectorEngine": selectorEngine, + } + channel.channel.SendNoReply("registerSelectorEngine", params) channel.setTestIdAttributeName(getTestIdAttributeName()) } + s.mu.RUnlock() } func (s *selectorsImpl) removeChannel(channel *selectorsOwnerImpl) { - s.channels.Delete(channel.guid) + // Legacy support for older Playwright versions with server-side selectors + s.contexts.Delete(channel.guid) +} + +func (s *selectorsImpl) addContext(context *browserContextImpl) { + s.contexts.Store(context.guid, context) + s.mu.RLock() + for _, selectorEngine := range s.registrations { + params := map[string]interface{}{ + "selectorEngine": selectorEngine, + } + context.channel.SendNoReply("registerSelectorEngine", params) + } + s.mu.RUnlock() + testIdAttr := getTestIdAttributeName() + if testIdAttr != "" { + context.channel.SendNoReply("setTestIdAttributeName", map[string]interface{}{ + "testIdAttributeName": testIdAttr, + }) + } +} + +func (s *selectorsImpl) removeContext(context *browserContextImpl) { + s.contexts.Delete(context.guid) } func newSelectorsImpl() *selectorsImpl { return &selectorsImpl{ - channels: sync.Map{}, + contexts: sync.Map{}, registrations: make([]map[string]interface{}, 0), } } diff --git a/tests/browser_context_test.go b/tests/browser_context_test.go index 0b2e828..e564647 100644 --- a/tests/browser_context_test.go +++ b/tests/browser_context_test.go @@ -179,15 +179,15 @@ func TestBrowserContextAddCookies(t *testing.T) { require.Equal(t, []playwright.Cookie{ { - Name: "password", - Value: "123456", - Domain: "127.0.0.1", - Path: "/", - Expires: -1, - - HttpOnly: false, - Secure: false, - SameSite: sameSite, + Name: "password", + Value: "123456", + Domain: "127.0.0.1", + Path: "/", + Expires: -1, + HttpOnly: false, + Secure: false, + SameSite: sameSite, + PartitionKey: playwright.String(""), }, }, cookies) @@ -281,58 +281,8 @@ func TestBrowserContextUnrouteShouldWork(t *testing.T) { } func TestBrowserContextShouldReturnBackgroundPage(t *testing.T) { - BeforeEach(t) - - if !isChromium { - t.Skip() - } - if runtime.GOOS == "windows" { - t.Skip("flaky on windows") - } - extensionPath := Asset("simple-extension") - context, err := browserType.LaunchPersistentContext( - t.TempDir(), - playwright.BrowserTypeLaunchPersistentContextOptions{ - Headless: playwright.Bool(false), - Args: []string{ - fmt.Sprintf("--disable-extensions-except=%s", extensionPath), - fmt.Sprintf("--load-extension=%s", extensionPath), - }, - }, - ) - require.NoError(t, err) - var page playwright.Page - if len(context.BackgroundPages()) == 1 { - page = context.BackgroundPages()[0] - } else { - ret, err := context.WaitForEvent("backgroundPage", playwright.BrowserContextWaitForEventOptions{ - Timeout: playwright.Float(1000), - }) - if err != nil { - // probably missing event - if len(context.BackgroundPages()) == 1 { - page = context.BackgroundPages()[0] - } else { - t.Fatal(err) - } - } else { - page = ret.(playwright.Page) - } - } - require.NotNil(t, page) - contains := func(pages []playwright.Page, page playwright.Page) bool { - for _, p := range pages { - if p == page { - return true - } - } - return false - } - require.False(t, contains(context.Pages(), page)) - require.True(t, contains(context.BackgroundPages(), page)) - context.Close() - require.Len(t, context.BackgroundPages(), 0) - require.Len(t, context.Pages(), 0) + // Background pages have been removed from Chromium together with Manifest V2 extensions + t.Skip("Background pages are deprecated - Manifest V2 extensions no longer supported in Chromium") } func TestPageEventShouldHaveURL(t *testing.T) { diff --git a/tests/console_message_test.go b/tests/console_message_test.go index 2ec70f4..10bc1cd 100644 --- a/tests/console_message_test.go +++ b/tests/console_message_test.go @@ -139,7 +139,13 @@ func TestConsoleShouldTriggerCorrectLog(t *testing.T) { _, err = page.Evaluate("url => fetch(url).catch(e => {})", server.EMPTY_PAGE) require.NoError(t, err) message := <-messages - require.Contains(t, message.Text(), "Access-Control-Allow-Origin") + + headerString := "Access-Control-Allow-Origin" + corsString := "CORS" + require.Condition(t, func() bool { + return strings.Contains(message.Text(), headerString) || strings.Contains(message.Text(), corsString) + }, "The text should contain either '%s' or '%s'", headerString, corsString) + require.Equal(t, "error", message.Type()) } diff --git a/tests/fetch_test.go b/tests/fetch_test.go index 9f4ef00..d05c84d 100644 --- a/tests/fetch_test.go +++ b/tests/fetch_test.go @@ -128,7 +128,7 @@ func TestShoulSupportGlobalTimeoutOption(t *testing.T) { time.Sleep(200 * time.Millisecond) }) _, err = request.Get(server.PREFIX + "/empty.html") - require.Contains(t, err.Error(), `Request timed out after`) + require.ErrorContains(t, err, `Timeout 100ms exceeded`) } func TestShouldPropagateExtraHttpHeadersWithRedirects(t *testing.T) { @@ -261,7 +261,7 @@ func TestStorageStateShouldRoundTripThroughFile(t *testing.T) { BeforeEach(t) storageState := &playwright.StorageState{ - Cookies: []playwright.Cookie{ + Cookies: []playwright.StorageStateCookie{ { Name: "a", Value: "b", diff --git a/tests/locator_test.go b/tests/locator_test.go index c49c43f..5b8a15f 100644 --- a/tests/locator_test.go +++ b/tests/locator_test.go @@ -721,3 +721,82 @@ func TestLocatorShouldSupportFilterVisible(t *testing.T) { Visible: playwright.Bool(false), }).GetByText("data1")).ToHaveText("Hidden data1")) } + +// TestLocatorDescribe verifies that Locator.Describe() sets a description +// Based on upstream test: playwright/tests/page/locator-convenience.spec.ts +func TestLocatorDescribe(t *testing.T) { + BeforeEach(t) + + require.NoError(t, page.SetContent(``)) + + locator := page.Locator("button") + + // Locator without description should return empty string + desc, err := locator.Description() + require.NoError(t, err) + require.Empty(t, desc, "description should be empty for locator without description") + + // Set description + describedLocator := locator.Describe("Submit button") + desc, err = describedLocator.Description() + require.NoError(t, err) + require.Equal(t, "Submit button", desc) + + // Original locator should still have no description + desc, err = locator.Description() + require.NoError(t, err) + require.Empty(t, desc, "original locator should remain unchanged") +} + +// TestLocatorDescribeSpecialCharacters verifies descriptions with special characters +func TestLocatorDescribeSpecialCharacters(t *testing.T) { + BeforeEach(t) + + require.NoError(t, page.SetContent(`
Test
`)) + + locator := page.Locator("div").Describe(`Button with "quotes" and 'apostrophes'`) + desc, err := locator.Description() + require.NoError(t, err) + require.Equal(t, `Button with "quotes" and 'apostrophes'`, desc) +} + +// TestLocatorDescribeChained verifies descriptions work with chained locators +func TestLocatorDescribeChained(t *testing.T) { + BeforeEach(t) + + require.NoError(t, page.SetContent(` +
+ +
+ `)) + + locator := page.Locator("form").Locator("input").Describe("Form input field") + desc, err := locator.Description() + require.NoError(t, err) + require.Equal(t, "Form input field", desc) +} + +// TestLocatorDescribeMultipleCalls verifies multiple describe calls override each other +func TestLocatorDescribeMultipleCalls(t *testing.T) { + BeforeEach(t) + + require.NoError(t, page.SetContent(``)) + + // First description + locator1 := page.Locator("button").Describe("First description") + desc, err := locator1.Description() + require.NoError(t, err) + require.Equal(t, "First description", desc) + + // Second description on chained locator + locator2 := locator1.Locator("span").Describe("Second description") + desc, err = locator2.Description() + require.NoError(t, err) + require.Equal(t, "Second description", desc) + + // Chained locator without describe should have no description + locator3 := locator2.Locator("span") + desc, err = locator3.Description() + require.NoError(t, err) + require.Empty(t, desc, "chained locator without describe should have empty description") +} diff --git a/tests/page_aria_snapshot_test.go b/tests/page_aria_snapshot_test.go index b526696..bf85592 100644 --- a/tests/page_aria_snapshot_test.go +++ b/tests/page_aria_snapshot_test.go @@ -100,20 +100,7 @@ func TestShouldSnapshotComplex(t *testing.T) { } func TestShouldSnapshotWithRef(t *testing.T) { - BeforeEach(t) - - require.NoError(t, page.SetContent(``)) - expected := Unshift(` - - list [ref=s1e3]: - - listitem [ref=s1e4]: - - link "link" [ref=s1e5]: - - /url: about:blank - `) - ariaSnapshot, err := page.Locator("body").AriaSnapshot(playwright.LocatorAriaSnapshotOptions{ - Ref: playwright.Bool(true), - }) - require.NoError(t, err) - require.Equal(t, expected, ariaSnapshot) + t.Skip("the Ref option was removed in Playwright v1.53") } func TestShouldSnapshotWithUnexpectedChildrenEqual(t *testing.T) { diff --git a/tests/page_clock_test.go b/tests/page_clock_test.go index 1fa3261..0fdfb97 100644 --- a/tests/page_clock_test.go +++ b/tests/page_clock_test.go @@ -332,11 +332,16 @@ func TestPageClockStubTimers(t *testing.T) { beforePageClock(t, 0, 1000) + // Set up a signal to ensure the async function has started + _, err := page.Evaluate(`window.timeoutStarted = false`) + require.NoError(t, err) + chanRet := make(chan interface{}, 1) go func() { ret, err := page.Evaluate(` async () => { const prev = performance.now(); + window.timeoutStarted = true; await new Promise(f => setTimeout(f, 1000)); const next = performance.now(); return { prev, next }; @@ -347,6 +352,13 @@ func TestPageClockStubTimers(t *testing.T) { close(chanRet) } }() + + // Wait for the async function to start and set up the setTimeout + require.Eventually(t, func() bool { + started, _ := page.Evaluate(`window.timeoutStarted`) + return started == true + }, 5*time.Second, 50*time.Millisecond) + require.NoError(t, page.Clock().RunFor(1000)) ret := <-chanRet require.Equal(t, map[string]interface{}{ @@ -372,11 +384,16 @@ func TestPageClockStubTimersPerformance(t *testing.T) { beforePageClock(t, 1000, 2000) + // Set up a signal to ensure the async function has started + _, err := page.Evaluate(`window.timeoutStarted = false`) + require.NoError(t, err) + chanRet := make(chan interface{}, 1) go func() { ret, err := page.Evaluate(` async () => { const prev = performance.now(); + window.timeoutStarted = true; await new Promise(f => setTimeout(f, 1000)); const next = performance.now(); return { prev, next }; @@ -387,6 +404,13 @@ func TestPageClockStubTimersPerformance(t *testing.T) { close(chanRet) } }() + + // Wait for the async function to start and set up the setTimeout + require.Eventually(t, func() bool { + started, _ := page.Evaluate(`window.timeoutStarted`) + return started == true + }, 5*time.Second, 50*time.Millisecond) + require.NoError(t, page.Clock().RunFor(1000)) origin, err := page.Evaluate(`performance.timeOrigin`) require.NoError(t, err) diff --git a/tests/page_test.go b/tests/page_test.go index 16b839c..49b48a2 100644 --- a/tests/page_test.go +++ b/tests/page_test.go @@ -44,14 +44,14 @@ func TestPageSetContent(t *testing.T) { func TestPageSetContentShouldRespectDefaultNavigationTimeout(t *testing.T) { BeforeEach(t) - page.SetDefaultNavigationTimeout(5) - imgPath := "/img/png" + page.SetDefaultNavigationTimeout(1) + imgPath := "/img.png" // stall for image - require.NoError(t, page.Route(imgPath, func(r playwright.Route) {})) + server.SetRoute(imgPath, func(w http.ResponseWriter, r *http.Request) {}) err := page.SetContent(fmt.Sprintf(``, server.PREFIX+imgPath)) require.ErrorIs(t, err, playwright.ErrTimeout) - require.ErrorContains(t, err, "Timeout 5ms exceeded.") + require.ErrorContains(t, err, "Timeout 1ms exceeded.") } func TestPageScreenshot(t *testing.T) { @@ -1219,11 +1219,10 @@ func TestPageGotoShouldFailWhenExceedingBrowserContextNavigationTimeout(t *testi // Hang for request to the empty.html server.SetRoute("/empty.html", func(w http.ResponseWriter, r *http.Request) {}) - context.SetDefaultNavigationTimeout(5) - defer context.SetDefaultNavigationTimeout(30 * 1000) // reset + context.SetDefaultNavigationTimeout(2) _, err := page.Goto(server.EMPTY_PAGE) require.ErrorIs(t, err, playwright.ErrTimeout) - require.ErrorContains(t, err, "Timeout 5ms exceeded.") + require.ErrorContains(t, err, "Timeout 2ms exceeded.") require.ErrorContains(t, err, "/empty.html") } @@ -1264,3 +1263,129 @@ func TestShouldEmulateContrast(t *testing.T) { require.NoError(t, err) require.True(t, ret.(bool)) } + +// TestPageConsoleMessages verifies that Page.ConsoleMessages() returns accumulated console messages +// Based on upstream test: playwright/tests/page/page-event-console.spec.ts +func TestPageConsoleMessages(t *testing.T) { + BeforeEach(t) + + // Generate 301 console messages (should keep last 100) + _, err := page.Evaluate(`() => { + for (let i = 0; i < 301; i++) { + console.log('message' + i); + } + }`) + require.NoError(t, err) + + messages, err := page.ConsoleMessages() + require.NoError(t, err) + + // Should return at least 100 messages (the buffer limit) + require.GreaterOrEqual(t, len(messages), 100, "should be at least 100 messages") + + // Verify the last 100 messages are correct (message201 to message300) + expectedStart := 301 - len(messages) + for i, msg := range messages { + expectedText := fmt.Sprintf("message%d", expectedStart+i) + require.Equal(t, expectedText, msg.Text()) + require.Equal(t, "log", msg.Type()) + + // Note: Page() may be nil for console messages retrieved from the buffer + // as they don't include full channel references like live events do + } +} + +// TestPageConsoleMessagesEmpty verifies that ConsoleMessages() returns empty array for new page +func TestPageConsoleMessagesEmpty(t *testing.T) { + BeforeEach(t) + + messages, err := page.ConsoleMessages() + require.NoError(t, err) + require.Empty(t, messages) +} + +// TestPageConsoleMessagesTypes verifies different console message types are captured +func TestPageConsoleMessagesTypes(t *testing.T) { + BeforeEach(t) + + _, err := page.Evaluate(`() => { + console.log('log message'); + console.warn('warn message'); + console.error('error message'); + console.info('info message'); + }`) + require.NoError(t, err) + + messages, err := page.ConsoleMessages() + require.NoError(t, err) + require.Len(t, messages, 4) + + expectedTypes := []string{"log", "warning", "error", "info"} + for i, msg := range messages { + require.Equal(t, expectedTypes[i], msg.Type()) + } +} + +// TestPageRequests verifies that Page.Requests() returns accumulated requests +// Based on upstream test: playwright/tests/page/page-event-request.spec.ts +func TestPageRequests(t *testing.T) { + BeforeEach(t) + + // Navigate to a page (creates initial request) + _, err := page.Goto(server.EMPTY_PAGE) + require.NoError(t, err) + + // Create multiple fetch requests + for i := 0; i < 99; i++ { + path := fmt.Sprintf("/fetch%d", i) + server.SetRoute(path, func(w http.ResponseWriter, r *http.Request) { + _, err := w.Write([]byte("response")) + require.NoError(t, err) + }) + } + + // Make 99 fetch requests + for i := 0; i < 99; i++ { + url := fmt.Sprintf("%s/fetch%d", server.PREFIX, i) + _, err := page.Evaluate(`url => fetch(url)`, url) + require.NoError(t, err) + } + + requests, err := page.Requests() + require.NoError(t, err) + + // Should have at least 100 requests (navigation + 99 fetches, buffer limit is 100) + require.GreaterOrEqual(t, len(requests), 99, "should capture fetch requests") + + // Verify requests are functional + for _, req := range requests { + require.NotEmpty(t, req.URL()) + require.NotEmpty(t, req.Method()) + } +} + +// TestPageRequestsEmpty verifies that Requests() returns empty array for new page +func TestPageRequestsEmpty(t *testing.T) { + BeforeEach(t) + + requests, err := page.Requests() + require.NoError(t, err) + require.Empty(t, requests) +} + +// TestPageRequestsWithNavigation verifies navigation requests are included +func TestPageRequestsWithNavigation(t *testing.T) { + BeforeEach(t) + + _, err := page.Goto(server.EMPTY_PAGE) + require.NoError(t, err) + + requests, err := page.Requests() + require.NoError(t, err) + require.Len(t, requests, 1, "should capture navigation request") + + req := requests[0] + require.Equal(t, server.EMPTY_PAGE, req.URL()) + require.Equal(t, "GET", req.Method()) + require.Equal(t, "document", req.ResourceType()) +} diff --git a/tests/remote_server_test.go b/tests/remote_server_test.go index 8799470..0fca582 100644 --- a/tests/remote_server_test.go +++ b/tests/remote_server_test.go @@ -20,7 +20,7 @@ func newRemoteServer() (*remoteServer, error) { if err != nil { return nil, fmt.Errorf("could not start Playwright: %v", err) } - cmd := driver.Command("launch-server", "--browser", browserName) + cmd := driver.Command("run-server") cmd.Stderr = os.Stderr stdout, err := cmd.StdoutPipe() if err != nil { @@ -31,11 +31,17 @@ func newRemoteServer() (*remoteServer, error) { return nil, fmt.Errorf("could not start server: %v", err) } scanner := bufio.NewReader(stdout) - url, err := scanner.ReadString('\n') - url = strings.TrimRight(url, "\n") + line, err := scanner.ReadString('\n') if err != nil { return nil, fmt.Errorf("could not read url: %v", err) } + line = strings.TrimSpace(line) + // Remove "Listening on " prefix + const prefix = "Listening on " + if !strings.HasPrefix(line, prefix) { + return nil, fmt.Errorf("unexpected output format: %s", line) + } + url := strings.TrimPrefix(line, prefix) return &remoteServer{ url: url, cmd: cmd, diff --git a/tests/selectors_test.go b/tests/selectors_test.go index eea6bff..47b01fa 100644 --- a/tests/selectors_test.go +++ b/tests/selectors_test.go @@ -2,39 +2,36 @@ package playwright_test import ( + "fmt" "testing" + "time" "github.com/playwright-community/playwright-go" "github.com/stretchr/testify/require" ) func TestSelectorsRegisterShouldWork(t *testing.T) { - BeforeEach(t) - - tagSelector := ` - { - create(root, target) { - return target.nodeName; - }, + tagSelector := `(() => ({ query(root, selector) { return root.querySelector(selector); }, queryAll(root, selector) { return Array.from(root.querySelectorAll(selector)); } - } - ` - selectorName := "tag_" + browserName - selector2Name := "tag2_" + browserName + }))()` + // Use unique names to avoid conflicts when running tests multiple times + uniqueSuffix := fmt.Sprintf("%s_%d", t.Name(), time.Now().UnixNano()) + selectorName := "tag_" + browserName + "_" + uniqueSuffix + selector2Name := "tag2_" + browserName + "_" + uniqueSuffix - err := pw.Selectors.Register(selectorName, playwright.Script{}) - require.ErrorContains(t, err, `Either source or path should be specified`) // Register one engine before creating context. - err = pw.Selectors.Register(selectorName, playwright.Script{ + err := pw.Selectors.Register(selectorName, playwright.Script{ Content: &tagSelector, }) require.NoError(t, err) + BeforeEach(t) + // Register another engine after creating context. err = pw.Selectors.Register(selector2Name, playwright.Script{ Content: &tagSelector, @@ -49,7 +46,7 @@ func TestSelectorsRegisterShouldWork(t *testing.T) { ret, err = page.EvalOnSelector(selectorName+"=SPAN", `e => e.nodeName`, nil) require.NoError(t, err) require.Equal(t, "SPAN", ret) - ret, err = page.EvalOnSelectorAll(selectorName+"=DIV", `es => es.length`, nil) + ret, err = page.EvalOnSelectorAll(selectorName+"=DIV", `es => es.length`) require.NoError(t, err) require.Equal(t, 2, ret) @@ -64,7 +61,7 @@ func TestSelectorsRegisterShouldWork(t *testing.T) { require.Equal(t, 2, ret) // Selector names are case-sensitive. - _, err = page.QuerySelector("tAG=DIV") + _, err = page.Locator("tAG=DIV").All() require.ErrorContains(t, err, `Unknown engine "tAG" while parsing selector tAG=DIV`) require.NoError(t, context.Close()) @@ -101,12 +98,14 @@ func TestSelectorsShouldUseDataTestIdInStrictErrors(t *testing.T) { func TestSelectorsShouldWorkWithPath(t *testing.T) { BeforeEach(t) - require.NoError(t, pw.Selectors.Register("foo", playwright.Script{ + // Use unique name to avoid conflicts when running tests multiple times + selectorName := fmt.Sprintf("foo_%s_%d", t.Name(), time.Now().UnixNano()) + require.NoError(t, pw.Selectors.Register(selectorName, playwright.Script{ Path: playwright.String(Asset("sectionselectorengine.js")), })) require.NoError(t, page.SetContent(`
`)) - ret, err := page.EvalOnSelector("foo=whatever", `e => e.nodeName`, nil) + ret, err := page.EvalOnSelector(selectorName+"=whatever", `e => e.nodeName`, nil) require.NoError(t, err) require.Equal(t, "SECTION", ret) } diff --git a/tests/tracing_test.go b/tests/tracing_test.go index 226b83c..c643b1f 100644 --- a/tests/tracing_test.go +++ b/tests/tracing_test.go @@ -7,6 +7,7 @@ import ( "io" "path/filepath" "slices" + "strings" "testing" "github.com/playwright-community/playwright-go" @@ -163,6 +164,59 @@ func TestShouldShowTracingGroupInActionList(t *testing.T) { }, actions) } +// mapInternalAPIToPublic maps internal Playwright class.method names to public API names +func mapInternalAPIToPublic(class, method string) string { + // Map internal classes to public API classes + classMethodKey := class + "." + method + + // Common Frame methods that should map to Page + frameToPageMethods := map[string]bool{ + "goto": true, "reload": true, "goBack": true, "goForward": true, + "setContent": true, "waitForNavigation": true, "waitForURL": true, + "waitForLoadState": true, "screenshot": true, "pdf": true, + "close": true, "pause": true, + } + + // Frame selector/locator methods that should map to Locator + frameLocatorMethods := map[string]bool{ + "click": true, "dblclick": true, "fill": true, "press": true, + "type": true, "hover": true, "check": true, "uncheck": true, + "selectOption": true, "setInputFiles": true, "focus": true, + "blur": true, "tap": true, "dispatchEvent": true, "evaluate": true, + "isVisible": true, "isHidden": true, "isEnabled": true, "isDisabled": true, + "isChecked": true, "isEditable": true, "textContent": true, + "innerText": true, "innerHTML": true, "getAttribute": true, + } + + if class == "Frame" { + if frameToPageMethods[method] { + class = "Page" + } else if frameLocatorMethods[method] { + class = "Locator" + } + } + + // Special case mappings + specialMappings := map[string]string{ + "BrowserContext.newPage": "BrowserContext.NewPage", + "Page.waitForTimeout": "Page.WaitForTimeout", + } + + // Convert method to title case (first letter uppercase) + titleCaseMethod := strings.ToUpper(method[:1]) + method[1:] + apiName := class + "." + titleCaseMethod + + // Check for special mappings + if mapped, ok := specialMappings[classMethodKey]; ok { + return mapped + } + if mapped, ok := specialMappings[apiName]; ok { + return mapped + } + + return apiName +} + func parseTrace(t *testing.T, tracePath string) (files map[string][]byte, events []interface{}) { t.Helper() // read and unzip trace @@ -193,9 +247,23 @@ func parseTrace(t *testing.T, tracePath string) (files map[string][]byte, events var event map[string]interface{} err := json.Unmarshal(line, &event) require.NoError(t, err) - switch event["type"].(string) { + eventType, _ := event["type"].(string) + + switch eventType { case "before": event["type"] = "action" + // Compute apiName from class and method for regular actions + // For tracing groups, use the title field + class, _ := event["class"].(string) + method, _ := event["method"].(string) + title, hasTitle := event["title"].(string) + + if method == "tracingGroup" && hasTitle { + event["apiName"] = title + } else if class != "" && method != "" { + event["apiName"] = mapInternalAPIToPublic(class, method) + } + actionMap[event["callId"].(string)] = event events = append(events, event) case "input": @@ -233,7 +301,9 @@ func getTraceActions(events []interface{}) []string { }) for _, e := range actionEvents { event := e.(map[string]interface{}) - actions = append(actions, event["apiName"].(string)) + if apiName, ok := event["apiName"].(string); ok { + actions = append(actions, apiName) + } } return actions } diff --git a/tests/unroute_behavior_test.go b/tests/unroute_behavior_test.go index 86c2dea..ffa5e30 100644 --- a/tests/unroute_behavior_test.go +++ b/tests/unroute_behavior_test.go @@ -210,7 +210,7 @@ func TestPageUnrouteShouldNotWaitForPendingHandlersToComplete(t *testing.T) { secondHandlerCalled := false - require.NoError(t, context.Route(regexp.MustCompile(".*"), func(route playwright.Route) { + require.NoError(t, page.Route(regexp.MustCompile(".*"), func(route playwright.Route) { secondHandlerCalled = true require.NoError(t, route.Continue()) })) diff --git a/tests/worker_test.go b/tests/worker_test.go index 08d6a86..ab7bf8e 100644 --- a/tests/worker_test.go +++ b/tests/worker_test.go @@ -147,3 +147,73 @@ func TestWorkerShouldClearUponCrossProcessNavigation(t *testing.T) { require.True(t, destroyed) require.Equal(t, 0, len(page.Workers())) } + +// TestConsoleMessageWorker verifies that ConsoleMessage.Worker() returns the worker +// Based on upstream test: playwright/tests/page/workers.spec.ts +func TestConsoleMessageWorker(t *testing.T) { + BeforeEach(t) + + // Create a worker and capture console message from it + workerChan := make(chan playwright.Worker, 1) + page.Once("worker", func(worker playwright.Worker) { + workerChan <- worker + }) + + consoleChan := make(chan playwright.ConsoleMessage, 1) + page.Once("console", func(message playwright.ConsoleMessage) { + consoleChan <- message + }) + + // Create a worker that logs a message + _, err := page.Evaluate(`() => { + const workerCode = 'console.log("hello from worker")'; + const blob = new Blob([workerCode], { type: 'application/javascript' }); + const worker = new Worker(URL.createObjectURL(blob)); + }`) + require.NoError(t, err) + + // Wait for worker creation + worker := <-workerChan + require.NotNil(t, worker) + + // Wait for console message + message := <-consoleChan + require.NotNil(t, message) + + // Verify the message is from the worker + require.Equal(t, "hello from worker", message.Text()) + + msgWorker, err := message.Worker() + require.NoError(t, err) + require.Equal(t, worker, msgWorker, "console message should reference the worker") + + // Worker console messages also have a page reference (they're emitted to both) + msgPage := message.Page() + require.Equal(t, page, msgPage, "worker console messages are also associated with the page") +} + +// TestConsoleMessageWorkerNil verifies that page console messages have nil worker +func TestConsoleMessageWorkerNil(t *testing.T) { + BeforeEach(t) + + consoleChan := make(chan playwright.ConsoleMessage, 1) + page.Once("console", func(message playwright.ConsoleMessage) { + consoleChan <- message + }) + + _, err := page.Evaluate(`() => console.log('hello from page')`) + require.NoError(t, err) + + message := <-consoleChan + require.NotNil(t, message) + require.Equal(t, "hello from page", message.Text()) + + // Page console messages should not have a worker + msgWorker, err := message.Worker() + require.NoError(t, err) + require.Nil(t, msgWorker, "page console messages should not have a worker") + + // But should have a page + msgPage := message.Page() + require.Equal(t, page, msgPage) +} diff --git a/transport.go b/transport.go index 1be3988..68841e6 100644 --- a/transport.go +++ b/transport.go @@ -21,6 +21,7 @@ type pipeTransport struct { bufReader *bufio.Reader closed chan struct{} onClose func() error + process *os.Process } func (t *pipeTransport) Poll() (*message, error) { @@ -137,5 +138,7 @@ func newPipeTransport(driver *PlaywrightDriver, stderr io.Writer) (transport, er return nil, fmt.Errorf("could not start driver: %w", err) } + t.process = cmd.Process + return t, nil } diff --git a/type_helpers.go b/type_helpers.go index d821e7c..0977196 100644 --- a/type_helpers.go +++ b/type_helpers.go @@ -48,9 +48,9 @@ func IntSlice(v ...int) *[]int { // ToOptionalStorageState converts StorageState to OptionalStorageState for use directly in [Browser.NewContext] func (s StorageState) ToOptionalStorageState() *OptionalStorageState { - cookies := make([]OptionalCookie, len(s.Cookies)) + cookies := make([]OptionalStorageStateOptionalCookie, len(s.Cookies)) for i, c := range s.Cookies { - cookies[i] = c.ToOptionalCookie() + cookies[i] = c.ToOptionalStorageStateOptionalCookie() } return &OptionalStorageState{ Origins: s.Origins, @@ -58,6 +58,19 @@ func (s StorageState) ToOptionalStorageState() *OptionalStorageState { } } +func (c StorageStateCookie) ToOptionalStorageStateOptionalCookie() OptionalStorageStateOptionalCookie { + return OptionalStorageStateOptionalCookie{ + Name: c.Name, + Value: c.Value, + Domain: String(c.Domain), + Path: String(c.Path), + Expires: Float(c.Expires), + HttpOnly: Bool(c.HttpOnly), + Secure: Bool(c.Secure), + SameSite: c.SameSite, + } +} + func (c Cookie) ToOptionalCookie() OptionalCookie { return OptionalCookie{ Name: c.Name, diff --git a/worker.go b/worker.go index 6504385..1301d03 100644 --- a/worker.go +++ b/worker.go @@ -70,9 +70,16 @@ func (w *workerImpl) OnClose(fn func(Worker)) { w.On("close", fn) } +func (w *workerImpl) OnConsole(fn func(ConsoleMessage)) { + w.On("console", fn) +} + func newWorker(parent *channelOwner, objectType string, guid string, initializer map[string]interface{}) *workerImpl { bt := &workerImpl{} bt.createChannelOwner(bt, parent, objectType, guid, initializer) bt.channel.On("close", bt.onClose) + bt.channel.On("console", func(ev map[string]interface{}) { + bt.Emit("console", newConsoleMessage(ev)) + }) return bt }