Compare commits
14 Commits
01aea52533
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 3b6d864330 | |||
| adefaaef36 | |||
| d89031b20d | |||
| 84e811572b | |||
| 61b68adfd0 | |||
| 0447f1bdbe | |||
| ace6c1e0bf | |||
| 1b95d12890 | |||
| 035151d9fa | |||
| 00ff7ea830 | |||
| d35d144fa2 | |||
| e0da88b9b0 | |||
| 39371dc261 | |||
| debf0ee2ed |
@@ -152,12 +152,11 @@ func initBrowser(opt BrowserOptions) (*browserInitResult, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("error getting cookies from cookie jar: %w", err)
|
return nil, fmt.Errorf("error getting cookies from cookie jar: %w", err)
|
||||||
}
|
}
|
||||||
pwCookies := make([]playwright.OptionalCookie, len(cookies))
|
for _, c := range cookies {
|
||||||
for i, c := range cookies {
|
oc := cookieToPlaywrightOptionalCookie(c)
|
||||||
pwCookies[i] = cookieToPlaywrightOptionalCookie(c)
|
if err := bctx.AddCookies([]playwright.OptionalCookie{oc}); err != nil {
|
||||||
}
|
slog.Warn("skipping invalid cookie", "name", c.Name, "host", c.Host, "error", err)
|
||||||
if err := bctx.AddCookies(pwCookies); err != nil {
|
}
|
||||||
return nil, fmt.Errorf("error adding cookies to browser: %w", err)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
10
document.go
10
document.go
@@ -22,9 +22,10 @@ type Document interface {
|
|||||||
|
|
||||||
type document struct {
|
type document struct {
|
||||||
node
|
node
|
||||||
pw *playwright.Playwright
|
pw *playwright.Playwright
|
||||||
browser playwright.Browser
|
browser playwright.Browser
|
||||||
page playwright.Page
|
page playwright.Page
|
||||||
|
detached bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func newDocument(pw *playwright.Playwright, browser playwright.Browser, page playwright.Page) (Document, error) {
|
func newDocument(pw *playwright.Playwright, browser playwright.Browser, page playwright.Page) (Document, error) {
|
||||||
@@ -44,6 +45,9 @@ func newDocument(pw *playwright.Playwright, browser playwright.Browser, page pla
|
|||||||
return res, nil
|
return res, nil
|
||||||
}
|
}
|
||||||
func (d *document) Close() error {
|
func (d *document) Close() error {
|
||||||
|
if d.detached {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
return d.page.Close()
|
return d.page.Close()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
123
interactive.go
123
interactive.go
@@ -23,8 +23,13 @@ type InteractiveBrowser interface {
|
|||||||
|
|
||||||
// MouseClick clicks at the given coordinates with the specified button ("left", "middle", "right").
|
// MouseClick clicks at the given coordinates with the specified button ("left", "middle", "right").
|
||||||
MouseClick(x, y float64, button string) error
|
MouseClick(x, y float64, button string) error
|
||||||
// MouseMove moves the mouse to the given coordinates.
|
// MouseDown presses the mouse button at the given coordinates without releasing.
|
||||||
MouseMove(x, y float64) error
|
MouseDown(x, y float64, button string) error
|
||||||
|
// MouseUp releases the mouse button at the given coordinates.
|
||||||
|
MouseUp(x, y float64, button string) error
|
||||||
|
// MouseMove moves the mouse to the given coordinates. An optional steps parameter
|
||||||
|
// controls how many intermediate mousemove events are generated (default 1).
|
||||||
|
MouseMove(x, y float64, steps ...int) error
|
||||||
// MouseWheel scrolls by the given delta.
|
// MouseWheel scrolls by the given delta.
|
||||||
MouseWheel(deltaX, deltaY float64) error
|
MouseWheel(deltaX, deltaY float64) error
|
||||||
|
|
||||||
@@ -43,15 +48,27 @@ type InteractiveBrowser interface {
|
|||||||
// Cookies returns all cookies from the browser context.
|
// Cookies returns all cookies from the browser context.
|
||||||
Cookies() ([]Cookie, error)
|
Cookies() ([]Cookie, error)
|
||||||
|
|
||||||
|
// SetDefaultTimeout sets the default timeout for all Playwright operations
|
||||||
|
// (navigation, clicks, screenshots, cookie extraction, etc.). A value of 0
|
||||||
|
// disables timeouts. By default, Playwright uses a 30-second timeout.
|
||||||
|
//
|
||||||
|
// This is the primary mechanism for preventing hung sessions: callers can
|
||||||
|
// set a timeout so that any Playwright call returns an error instead of
|
||||||
|
// blocking forever if the browser process crashes or the remote server
|
||||||
|
// becomes unresponsive.
|
||||||
|
SetDefaultTimeout(timeout time.Duration)
|
||||||
|
|
||||||
// Close tears down the browser.
|
// Close tears down the browser.
|
||||||
Close() error
|
Close() error
|
||||||
}
|
}
|
||||||
|
|
||||||
type interactiveBrowser struct {
|
type interactiveBrowser struct {
|
||||||
pw *playwright.Playwright
|
pw *playwright.Playwright
|
||||||
browser playwright.Browser
|
browser playwright.Browser
|
||||||
ctx playwright.BrowserContext
|
ctx playwright.BrowserContext
|
||||||
page playwright.Page
|
page playwright.Page
|
||||||
|
ownsInfrastructure bool
|
||||||
|
detached bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewInteractiveBrowser creates a headless browser with a page ready for interactive control.
|
// NewInteractiveBrowser creates a headless browser with a page ready for interactive control.
|
||||||
@@ -88,22 +105,32 @@ func NewInteractiveBrowser(ctx context.Context, opts ...BrowserOptions) (Interac
|
|||||||
|
|
||||||
page, err := res.bctx.NewPage()
|
page, err := res.bctx.NewPage()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
_ = res.bctx.Close()
|
||||||
|
_ = res.browser.Close()
|
||||||
|
_ = res.pw.Stop()
|
||||||
ch <- result{nil, fmt.Errorf("failed to create page: %w", err)}
|
ch <- result{nil, fmt.Errorf("failed to create page: %w", err)}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
ch <- result{
|
ch <- result{
|
||||||
ib: &interactiveBrowser{
|
ib: &interactiveBrowser{
|
||||||
pw: res.pw,
|
pw: res.pw,
|
||||||
browser: res.browser,
|
browser: res.browser,
|
||||||
ctx: res.bctx,
|
ctx: res.bctx,
|
||||||
page: page,
|
page: page,
|
||||||
|
ownsInfrastructure: true,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
|
go func() {
|
||||||
|
r := <-ch
|
||||||
|
if r.err == nil && r.ib != nil {
|
||||||
|
_ = r.ib.Close()
|
||||||
|
}
|
||||||
|
}()
|
||||||
return nil, ctx.Err()
|
return nil, ctx.Err()
|
||||||
case r := <-ch:
|
case r := <-ch:
|
||||||
return r.ib, r.err
|
return r.ib, r.err
|
||||||
@@ -153,8 +180,44 @@ func (ib *interactiveBrowser) MouseClick(x, y float64, button string) error {
|
|||||||
return ib.page.Mouse().Click(x, y, playwright.MouseClickOptions{Button: btn})
|
return ib.page.Mouse().Click(x, y, playwright.MouseClickOptions{Button: btn})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (ib *interactiveBrowser) MouseMove(x, y float64) error {
|
func (ib *interactiveBrowser) MouseDown(x, y float64, button string) error {
|
||||||
return ib.page.Mouse().Move(x, y)
|
if err := ib.page.Mouse().Move(x, y); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var btn *playwright.MouseButton
|
||||||
|
switch button {
|
||||||
|
case "right":
|
||||||
|
btn = playwright.MouseButtonRight
|
||||||
|
case "middle":
|
||||||
|
btn = playwright.MouseButtonMiddle
|
||||||
|
default:
|
||||||
|
btn = playwright.MouseButtonLeft
|
||||||
|
}
|
||||||
|
return ib.page.Mouse().Down(playwright.MouseDownOptions{Button: btn})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ib *interactiveBrowser) MouseUp(x, y float64, button string) error {
|
||||||
|
if err := ib.page.Mouse().Move(x, y); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var btn *playwright.MouseButton
|
||||||
|
switch button {
|
||||||
|
case "right":
|
||||||
|
btn = playwright.MouseButtonRight
|
||||||
|
case "middle":
|
||||||
|
btn = playwright.MouseButtonMiddle
|
||||||
|
default:
|
||||||
|
btn = playwright.MouseButtonLeft
|
||||||
|
}
|
||||||
|
return ib.page.Mouse().Up(playwright.MouseUpOptions{Button: btn})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ib *interactiveBrowser) MouseMove(x, y float64, steps ...int) error {
|
||||||
|
var opts playwright.MouseMoveOptions
|
||||||
|
if len(steps) > 0 && steps[0] > 1 {
|
||||||
|
opts.Steps = playwright.Int(steps[0])
|
||||||
|
}
|
||||||
|
return ib.page.Mouse().Move(x, y, opts)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (ib *interactiveBrowser) MouseWheel(deltaX, deltaY float64) error {
|
func (ib *interactiveBrowser) MouseWheel(deltaX, deltaY float64) error {
|
||||||
@@ -193,26 +256,40 @@ func (ib *interactiveBrowser) Cookies() ([]Cookie, error) {
|
|||||||
return cookies, nil
|
return cookies, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (ib *interactiveBrowser) SetDefaultTimeout(timeout time.Duration) {
|
||||||
|
ms := float64(timeout.Milliseconds())
|
||||||
|
ib.page.SetDefaultTimeout(ms)
|
||||||
|
ib.page.SetDefaultNavigationTimeout(ms)
|
||||||
|
ib.ctx.SetDefaultTimeout(ms)
|
||||||
|
ib.ctx.SetDefaultNavigationTimeout(ms)
|
||||||
|
}
|
||||||
|
|
||||||
func (ib *interactiveBrowser) Close() error {
|
func (ib *interactiveBrowser) Close() error {
|
||||||
|
if ib.detached {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
var errs []error
|
var errs []error
|
||||||
if ib.page != nil {
|
if ib.page != nil {
|
||||||
if err := ib.page.Close(); err != nil {
|
if err := ib.page.Close(); err != nil {
|
||||||
errs = append(errs, err)
|
errs = append(errs, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if ib.ctx != nil {
|
if ib.ownsInfrastructure {
|
||||||
if err := ib.ctx.Close(); err != nil {
|
if ib.ctx != nil {
|
||||||
errs = append(errs, err)
|
if err := ib.ctx.Close(); err != nil {
|
||||||
|
errs = append(errs, err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
if ib.browser != nil {
|
||||||
if ib.browser != nil {
|
if err := ib.browser.Close(); err != nil {
|
||||||
if err := ib.browser.Close(); err != nil {
|
errs = append(errs, err)
|
||||||
errs = append(errs, err)
|
}
|
||||||
}
|
}
|
||||||
}
|
if ib.pw != nil {
|
||||||
if ib.pw != nil {
|
if err := ib.pw.Stop(); err != nil {
|
||||||
if err := ib.pw.Stop(); err != nil {
|
errs = append(errs, err)
|
||||||
errs = append(errs, err)
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if len(errs) > 0 {
|
if len(errs) > 0 {
|
||||||
|
|||||||
2
node.go
2
node.go
@@ -29,7 +29,7 @@ type node struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (n node) Type(input string) error {
|
func (n node) Type(input string) error {
|
||||||
return n.locator.Type(input)
|
return n.locator.PressSequentially(input)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (n node) Click() error {
|
func (n node) Click() error {
|
||||||
|
|||||||
@@ -122,12 +122,18 @@ func playwrightSameSiteToSameSite(s *playwright.SameSiteAttribute) SameSite {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func cookieToPlaywrightOptionalCookie(cookie Cookie) playwright.OptionalCookie {
|
func cookieToPlaywrightOptionalCookie(cookie Cookie) playwright.OptionalCookie {
|
||||||
|
expires := float64(cookie.Expires.Unix())
|
||||||
|
if cookie.Expires.IsZero() || expires <= 0 {
|
||||||
|
expires = -1
|
||||||
|
}
|
||||||
|
|
||||||
oc := playwright.OptionalCookie{
|
oc := playwright.OptionalCookie{
|
||||||
Name: cookie.Name,
|
Name: cookie.Name,
|
||||||
Value: cookie.Value,
|
Value: cookie.Value,
|
||||||
Domain: playwright.String(cookie.Host),
|
Domain: playwright.String(cookie.Host),
|
||||||
Path: playwright.String(cookie.Path),
|
Path: playwright.String(cookie.Path),
|
||||||
Expires: playwright.Float(float64(cookie.Expires.Unix())),
|
Expires: playwright.Float(expires),
|
||||||
|
Secure: playwright.Bool(cookie.Secure),
|
||||||
HttpOnly: playwright.Bool(cookie.HttpOnly),
|
HttpOnly: playwright.Bool(cookie.HttpOnly),
|
||||||
}
|
}
|
||||||
if cookie.SameSite != "" {
|
if cookie.SameSite != "" {
|
||||||
@@ -143,6 +149,7 @@ func playwrightCookieToCookie(cookie playwright.Cookie) Cookie {
|
|||||||
Host: cookie.Domain,
|
Host: cookie.Domain,
|
||||||
Path: cookie.Path,
|
Path: cookie.Path,
|
||||||
Expires: time.Unix(int64(cookie.Expires), 0),
|
Expires: time.Unix(int64(cookie.Expires), 0),
|
||||||
|
Secure: cookie.Secure,
|
||||||
HttpOnly: cookie.HttpOnly,
|
HttpOnly: cookie.HttpOnly,
|
||||||
SameSite: playwrightSameSiteToSameSite(cookie.SameSite),
|
SameSite: playwrightSameSiteToSameSite(cookie.SameSite),
|
||||||
}
|
}
|
||||||
@@ -193,6 +200,12 @@ func NewBrowser(ctx context.Context, opts ...BrowserOptions) (Browser, error) {
|
|||||||
|
|
||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
|
go func() {
|
||||||
|
r := <-resultCh
|
||||||
|
if r.err == nil && r.browser != nil {
|
||||||
|
_ = r.browser.Close()
|
||||||
|
}
|
||||||
|
}()
|
||||||
return nil, ctx.Err()
|
return nil, ctx.Err()
|
||||||
case result := <-resultCh:
|
case result := <-resultCh:
|
||||||
return result.browser, result.err
|
return result.browser, result.err
|
||||||
@@ -206,11 +219,35 @@ func (b playWrightBrowser) updateCookies(_ context.Context, page playwright.Page
|
|||||||
return fmt.Errorf("error getting cookies from browser: %w", err)
|
return fmt.Errorf("error getting cookies from browser: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Build a lookup of existing cookies so we can preserve their security
|
||||||
|
// attributes. Chromium's Cookies() API can lose or normalize Secure,
|
||||||
|
// SameSite, and HttpOnly during the AddCookies → navigate → Cookies()
|
||||||
|
// round-trip, so we only update Value and Expires for cookies that
|
||||||
|
// already exist in the jar.
|
||||||
|
existing, err := b.cookieJar.Get(page.URL())
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("error getting existing cookies from jar: %w", err)
|
||||||
|
}
|
||||||
|
type cookieKey struct{ Name, Path string }
|
||||||
|
existingMap := make(map[cookieKey]Cookie, len(existing))
|
||||||
|
for _, c := range existing {
|
||||||
|
existingMap[cookieKey{c.Name, c.Path}] = c
|
||||||
|
}
|
||||||
|
|
||||||
for _, cookie := range cookies {
|
for _, cookie := range cookies {
|
||||||
// TODO: add support for deleting cookies from the jar which are deleted in the browser
|
// TODO: add support for deleting cookies from the jar which are deleted in the browser
|
||||||
err = b.cookieJar.Set(playwrightCookieToCookie(cookie))
|
c := playwrightCookieToCookie(cookie)
|
||||||
|
|
||||||
if err != nil {
|
if prev, ok := existingMap[cookieKey{c.Name, c.Path}]; ok {
|
||||||
|
// Preserve the original security attributes; only update
|
||||||
|
// Value and Expires which are the fields that legitimately
|
||||||
|
// change during navigation.
|
||||||
|
c.Secure = prev.Secure
|
||||||
|
c.HttpOnly = prev.HttpOnly
|
||||||
|
c.SameSite = prev.SameSite
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = b.cookieJar.Set(c); err != nil {
|
||||||
return fmt.Errorf("error setting cookie in cookie jar: %w", err)
|
return fmt.Errorf("error setting cookie in cookie jar: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -240,6 +277,7 @@ func (b playWrightBrowser) openPage(_ context.Context, target string, opts OpenP
|
|||||||
|
|
||||||
resp, err := page.Goto(target, pwOpts)
|
resp, err := page.Goto(target, pwOpts)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
_ = page.Close()
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -275,8 +313,8 @@ func (b playWrightBrowser) Open(ctx context.Context, url string, opts OpenPageOp
|
|||||||
|
|
||||||
func (b playWrightBrowser) Close() error {
|
func (b playWrightBrowser) Close() error {
|
||||||
return errors.Join(
|
return errors.Join(
|
||||||
b.browser.Close(),
|
|
||||||
b.ctx.Close(),
|
b.ctx.Close(),
|
||||||
|
b.browser.Close(),
|
||||||
b.pw.Stop(),
|
b.pw.Stop(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
138
playwright_test.go
Normal file
138
playwright_test.go
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
package extractor
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/playwright-community/playwright-go"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPlaywrightCookieToCookie_AllFields(t *testing.T) {
|
||||||
|
pwCookie := playwright.Cookie{
|
||||||
|
Name: "session",
|
||||||
|
Value: "abc123",
|
||||||
|
Domain: ".example.com",
|
||||||
|
Path: "/app",
|
||||||
|
Expires: 1700000000,
|
||||||
|
Secure: true,
|
||||||
|
HttpOnly: true,
|
||||||
|
SameSite: playwright.SameSiteAttributeStrict,
|
||||||
|
}
|
||||||
|
|
||||||
|
c := playwrightCookieToCookie(pwCookie)
|
||||||
|
|
||||||
|
if c.Name != "session" {
|
||||||
|
t.Errorf("Name = %q, want %q", c.Name, "session")
|
||||||
|
}
|
||||||
|
if c.Value != "abc123" {
|
||||||
|
t.Errorf("Value = %q, want %q", c.Value, "abc123")
|
||||||
|
}
|
||||||
|
if c.Host != ".example.com" {
|
||||||
|
t.Errorf("Host = %q, want %q", c.Host, ".example.com")
|
||||||
|
}
|
||||||
|
if c.Path != "/app" {
|
||||||
|
t.Errorf("Path = %q, want %q", c.Path, "/app")
|
||||||
|
}
|
||||||
|
if c.Expires != time.Unix(1700000000, 0) {
|
||||||
|
t.Errorf("Expires = %v, want %v", c.Expires, time.Unix(1700000000, 0))
|
||||||
|
}
|
||||||
|
if !c.Secure {
|
||||||
|
t.Error("Secure = false, want true")
|
||||||
|
}
|
||||||
|
if !c.HttpOnly {
|
||||||
|
t.Error("HttpOnly = false, want true")
|
||||||
|
}
|
||||||
|
if c.SameSite != SameSiteStrict {
|
||||||
|
t.Errorf("SameSite = %q, want %q", c.SameSite, SameSiteStrict)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPlaywrightCookieToCookie_SecureFalse(t *testing.T) {
|
||||||
|
pwCookie := playwright.Cookie{
|
||||||
|
Name: "tracking",
|
||||||
|
Value: "xyz",
|
||||||
|
Domain: "example.com",
|
||||||
|
Path: "/",
|
||||||
|
Secure: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
c := playwrightCookieToCookie(pwCookie)
|
||||||
|
|
||||||
|
if c.Secure {
|
||||||
|
t.Error("Secure = true, want false")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCookieToPlaywrightOptionalCookie_AllFields(t *testing.T) {
|
||||||
|
c := Cookie{
|
||||||
|
Name: "__Secure-ID",
|
||||||
|
Value: "token123",
|
||||||
|
Host: ".example.com",
|
||||||
|
Path: "/secure",
|
||||||
|
Expires: time.Unix(1700000000, 0),
|
||||||
|
Secure: true,
|
||||||
|
HttpOnly: true,
|
||||||
|
SameSite: SameSiteLax,
|
||||||
|
}
|
||||||
|
|
||||||
|
oc := cookieToPlaywrightOptionalCookie(c)
|
||||||
|
|
||||||
|
if oc.Name != "__Secure-ID" {
|
||||||
|
t.Errorf("Name = %q, want %q", oc.Name, "__Secure-ID")
|
||||||
|
}
|
||||||
|
if oc.Value != "token123" {
|
||||||
|
t.Errorf("Value = %q, want %q", oc.Value, "token123")
|
||||||
|
}
|
||||||
|
if oc.Domain == nil || *oc.Domain != ".example.com" {
|
||||||
|
t.Errorf("Domain = %v, want %q", oc.Domain, ".example.com")
|
||||||
|
}
|
||||||
|
if oc.Path == nil || *oc.Path != "/secure" {
|
||||||
|
t.Errorf("Path = %v, want %q", oc.Path, "/secure")
|
||||||
|
}
|
||||||
|
if oc.Expires == nil || *oc.Expires != 1700000000 {
|
||||||
|
t.Errorf("Expires = %v, want %v", oc.Expires, 1700000000)
|
||||||
|
}
|
||||||
|
if oc.Secure == nil || !*oc.Secure {
|
||||||
|
t.Error("Secure = nil or false, want *true")
|
||||||
|
}
|
||||||
|
if oc.HttpOnly == nil || !*oc.HttpOnly {
|
||||||
|
t.Error("HttpOnly = nil or false, want *true")
|
||||||
|
}
|
||||||
|
if oc.SameSite == nil || *oc.SameSite != *playwright.SameSiteAttributeLax {
|
||||||
|
t.Errorf("SameSite = %v, want Lax", oc.SameSite)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCookieToPlaywrightOptionalCookie_SecureFalse(t *testing.T) {
|
||||||
|
c := Cookie{
|
||||||
|
Name: "tracker",
|
||||||
|
Value: "v",
|
||||||
|
Host: "example.com",
|
||||||
|
Path: "/",
|
||||||
|
Secure: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
oc := cookieToPlaywrightOptionalCookie(c)
|
||||||
|
|
||||||
|
if oc.Secure == nil {
|
||||||
|
t.Fatal("Secure = nil, want *false")
|
||||||
|
}
|
||||||
|
if *oc.Secure {
|
||||||
|
t.Error("Secure = *true, want *false")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCookieToPlaywrightOptionalCookie_NoSameSite(t *testing.T) {
|
||||||
|
c := Cookie{
|
||||||
|
Name: "basic",
|
||||||
|
Value: "val",
|
||||||
|
Host: "example.com",
|
||||||
|
Path: "/",
|
||||||
|
}
|
||||||
|
|
||||||
|
oc := cookieToPlaywrightOptionalCookie(c)
|
||||||
|
|
||||||
|
if oc.SameSite != nil {
|
||||||
|
t.Errorf("SameSite = %v, want nil", oc.SameSite)
|
||||||
|
}
|
||||||
|
}
|
||||||
65
promote.go
Normal file
65
promote.go
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
package extractor
|
||||||
|
|
||||||
|
import "errors"
|
||||||
|
|
||||||
|
// ErrNotPromotable is returned when a Document cannot be promoted to an InteractiveBrowser.
|
||||||
|
// This happens when the Document is not backed by a Playwright page (e.g. a mock or custom implementation).
|
||||||
|
var ErrNotPromotable = errors.New("document is not promotable to InteractiveBrowser")
|
||||||
|
|
||||||
|
// ErrNotDemotable is returned when an InteractiveBrowser cannot be demoted to a Document.
|
||||||
|
// This happens when the InteractiveBrowser is not backed by a Playwright page.
|
||||||
|
var ErrNotDemotable = errors.New("interactive browser is not demotable to Document")
|
||||||
|
|
||||||
|
// ErrAlreadyDetached is returned when attempting to promote or demote an object that has
|
||||||
|
// already been transferred. Each Document or InteractiveBrowser can only be promoted/demoted once.
|
||||||
|
var ErrAlreadyDetached = errors.New("already detached")
|
||||||
|
|
||||||
|
// PromoteToInteractive transfers ownership of the underlying Playwright page from a Document
|
||||||
|
// to a new InteractiveBrowser. After promotion, the Document's Close method becomes a no-op
|
||||||
|
// (the page is now owned by the returned InteractiveBrowser).
|
||||||
|
//
|
||||||
|
// The caller must keep the original Browser alive while the promoted InteractiveBrowser is in use,
|
||||||
|
// since the Browser still owns the Playwright process and browser instance.
|
||||||
|
//
|
||||||
|
// Returns ErrNotPromotable if the Document is not backed by a Playwright page,
|
||||||
|
// or ErrAlreadyDetached if the Document was already promoted.
|
||||||
|
func PromoteToInteractive(doc Document) (InteractiveBrowser, error) {
|
||||||
|
d, ok := doc.(*document)
|
||||||
|
if !ok {
|
||||||
|
return nil, ErrNotPromotable
|
||||||
|
}
|
||||||
|
|
||||||
|
if d.detached {
|
||||||
|
return nil, ErrAlreadyDetached
|
||||||
|
}
|
||||||
|
|
||||||
|
d.detached = true
|
||||||
|
|
||||||
|
return &interactiveBrowser{
|
||||||
|
pw: d.pw,
|
||||||
|
browser: d.browser,
|
||||||
|
ctx: d.page.Context(),
|
||||||
|
page: d.page,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DemoteToDocument transfers ownership of the underlying Playwright page from an
|
||||||
|
// InteractiveBrowser back to a new Document. After demotion, the InteractiveBrowser's
|
||||||
|
// Close method becomes a no-op (the page is now owned by the returned Document).
|
||||||
|
//
|
||||||
|
// Returns ErrNotDemotable if the InteractiveBrowser is not backed by a Playwright page,
|
||||||
|
// or ErrAlreadyDetached if the InteractiveBrowser was already demoted.
|
||||||
|
func DemoteToDocument(ib InteractiveBrowser) (Document, error) {
|
||||||
|
b, ok := ib.(*interactiveBrowser)
|
||||||
|
if !ok {
|
||||||
|
return nil, ErrNotDemotable
|
||||||
|
}
|
||||||
|
|
||||||
|
if b.detached {
|
||||||
|
return nil, ErrAlreadyDetached
|
||||||
|
}
|
||||||
|
|
||||||
|
b.detached = true
|
||||||
|
|
||||||
|
return newDocument(b.pw, b.browser, b.page)
|
||||||
|
}
|
||||||
59
promote_test.go
Normal file
59
promote_test.go
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
package extractor
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// mockInteractiveBrowser implements InteractiveBrowser for testing without Playwright.
|
||||||
|
type mockInteractiveBrowser struct{}
|
||||||
|
|
||||||
|
func (m mockInteractiveBrowser) Navigate(string) (string, error) { return "", nil }
|
||||||
|
func (m mockInteractiveBrowser) GoBack() (string, error) { return "", nil }
|
||||||
|
func (m mockInteractiveBrowser) GoForward() (string, error) { return "", nil }
|
||||||
|
func (m mockInteractiveBrowser) URL() string { return "" }
|
||||||
|
func (m mockInteractiveBrowser) MouseClick(float64, float64, string) error { return nil }
|
||||||
|
func (m mockInteractiveBrowser) MouseDown(float64, float64, string) error { return nil }
|
||||||
|
func (m mockInteractiveBrowser) MouseUp(float64, float64, string) error { return nil }
|
||||||
|
func (m mockInteractiveBrowser) MouseMove(float64, float64, ...int) error { return nil }
|
||||||
|
func (m mockInteractiveBrowser) MouseWheel(float64, float64) error { return nil }
|
||||||
|
func (m mockInteractiveBrowser) KeyboardType(string) error { return nil }
|
||||||
|
func (m mockInteractiveBrowser) KeyboardPress(string) error { return nil }
|
||||||
|
func (m mockInteractiveBrowser) KeyboardInsertText(string) error { return nil }
|
||||||
|
func (m mockInteractiveBrowser) Screenshot(int) ([]byte, error) { return nil, nil }
|
||||||
|
func (m mockInteractiveBrowser) Cookies() ([]Cookie, error) { return nil, nil }
|
||||||
|
func (m mockInteractiveBrowser) SetDefaultTimeout(time.Duration) {}
|
||||||
|
func (m mockInteractiveBrowser) Close() error { return nil }
|
||||||
|
|
||||||
|
func TestPromoteToInteractive_NonPromotable(t *testing.T) {
|
||||||
|
doc := &mockDocument{}
|
||||||
|
_, err := PromoteToInteractive(doc)
|
||||||
|
if !errors.Is(err, ErrNotPromotable) {
|
||||||
|
t.Fatalf("expected ErrNotPromotable, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPromoteToInteractive_AlreadyDetached(t *testing.T) {
|
||||||
|
d := &document{detached: true}
|
||||||
|
_, err := PromoteToInteractive(d)
|
||||||
|
if !errors.Is(err, ErrAlreadyDetached) {
|
||||||
|
t.Fatalf("expected ErrAlreadyDetached, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDemoteToDocument_NonDemotable(t *testing.T) {
|
||||||
|
ib := &mockInteractiveBrowser{}
|
||||||
|
_, err := DemoteToDocument(ib)
|
||||||
|
if !errors.Is(err, ErrNotDemotable) {
|
||||||
|
t.Fatalf("expected ErrNotDemotable, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDemoteToDocument_AlreadyDetached(t *testing.T) {
|
||||||
|
ib := &interactiveBrowser{detached: true}
|
||||||
|
_, err := DemoteToDocument(ib)
|
||||||
|
if !errors.Is(err, ErrAlreadyDetached) {
|
||||||
|
t.Fatalf("expected ErrAlreadyDetached, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user