Compare commits

...

14 Commits

Author SHA1 Message Date
3b6d864330 Preserve cookie security attributes in updateCookies round-trip
All checks were successful
CI / build (push) Successful in 1m18s
CI / vet (push) Successful in 1m17s
CI / test (push) Successful in 1m19s
Chromium's Cookies() API can lose or normalize Secure, SameSite, and
HttpOnly attributes during the AddCookies → navigate → Cookies()
round-trip. This caused cookies like cf_clearance (set with
Secure=true, SameSite=None) to be overwritten with Chromium's defaults
(Secure=false, SameSite=Lax).

Now updateCookies() looks up existing cookies in the jar first. For
cookies that already exist, only Value and Expires are updated —
security attributes are preserved from the original. New cookies from
the server are still written with all their attributes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-17 02:37:47 +00:00
adefaaef36 feat: add SetDefaultTimeout to InteractiveBrowser interface
All checks were successful
CI / test (push) Successful in 45s
CI / vet (push) Successful in 49s
CI / build (push) Successful in 50s
Adds SetDefaultTimeout(time.Duration) to the InteractiveBrowser interface,
delegating to Playwright's Page and BrowserContext SetDefaultTimeout and
SetDefaultNavigationTimeout methods. This allows callers to set a timeout
so Playwright operations return an error instead of blocking forever when
the browser process crashes or the remote server becomes unresponsive.

Closes #86

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-17 01:59:16 +00:00
d89031b20d Merge pull request 'fix: sanitize cookie expires for Playwright compatibility' (#85) from fix/84-cookie-expires-validation into main
All checks were successful
CI / vet (push) Successful in 2m48s
CI / build (push) Successful in 2m50s
CI / test (push) Successful in 2m51s
2026-03-13 01:29:07 +00:00
84e811572b fix: sanitize cookie expires for Playwright compatibility
All checks were successful
CI / build (pull_request) Successful in 1m51s
CI / vet (pull_request) Successful in 2m38s
CI / test (pull_request) Successful in 2m59s
Playwright requires cookie expires to be either -1 (session cookie) or
a positive unix timestamp. When a cookie has no expiry (zero time.Time),
.Unix() returns -62135596800 which Playwright rejects. Cookies with
non-positive timestamps (e.g. Cloudflare's __cf_bm) also fail.

Now treats zero time or non-positive unix timestamps as session cookies
by setting expires to -1.

Fixes #84

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 01:28:45 +00:00
61b68adfd0 Merge pull request 'fix: address Playwright API audit findings' (#83) from fix/playwright-api-audit into main
All checks were successful
CI / test (push) Successful in 34s
CI / vet (push) Successful in 47s
CI / build (push) Successful in 48s
Reviewed-on: #83
2026-03-02 04:59:43 +00:00
0447f1bdbe fix: address Playwright API audit findings
All checks were successful
CI / test (pull_request) Successful in 34s
CI / vet (pull_request) Successful in 48s
CI / build (pull_request) Successful in 49s
- Replace deprecated Locator.Type() with PressSequentially() (node.go)
- Close page on Goto failure to prevent resource leak (playwright.go)
- Fix teardown order: close context before browser (playwright.go)
- Clean up resources on NewPage failure (interactive.go)
- Spawn cleanup goroutine on context cancellation in both constructors

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 04:54:00 +00:00
ace6c1e0bf Merge pull request 'feat: add steps parameter to MouseMove' (#82) from feature/81-mousemove-steps into main
All checks were successful
CI / vet (push) Successful in 44s
CI / build (push) Successful in 1m4s
CI / test (push) Successful in 1m44s
2026-02-28 16:23:49 +00:00
1b95d12890 feat: add steps parameter to MouseMove for smooth drag gestures
All checks were successful
CI / test (pull_request) Successful in 47s
CI / vet (pull_request) Successful in 1m34s
CI / build (pull_request) Successful in 1m37s
Makes MouseMove accept an optional steps variadic parameter that maps
to Playwright's MouseMoveOptions.Steps, generating intermediate
mousemove events for human-like drag behavior.

Closes #81

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 16:23:32 +00:00
035151d9fa Merge pull request 'feat: add MouseDown/MouseUp to InteractiveBrowser' (#80) from feature/79-mouse-down-up into main
All checks were successful
CI / vet (push) Successful in 1m7s
CI / build (push) Successful in 1m30s
CI / test (push) Successful in 1m33s
2026-02-28 15:35:55 +00:00
00ff7ea830 feat: add MouseDown/MouseUp methods to InteractiveBrowser
All checks were successful
CI / test (pull_request) Successful in 1m11s
CI / vet (pull_request) Successful in 1m28s
CI / build (pull_request) Successful in 1m30s
Enables drag operations (mousedown → mousemove → mouseup) needed for
slider captchas and other drag-based interactions.

Closes #79

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 15:35:36 +00:00
d35d144fa2 Merge pull request 'feat: promote headless page to InteractiveBrowser mid-session' (#78) from feature/76-promote-to-interactive into main
All checks were successful
CI / vet (push) Successful in 1m8s
CI / build (push) Successful in 1m10s
CI / test (push) Successful in 1m10s
Reviewed-on: #78
2026-02-24 02:29:07 +00:00
e0da88b9b0 feat: add PromoteToInteractive and DemoteToDocument for mid-session page transfer
Some checks failed
CI / build (pull_request) Successful in 29s
CI / test (pull_request) Successful in 36s
CI / vet (pull_request) Failing after 6m18s
Allow transferring ownership of a Playwright page between Document and
InteractiveBrowser modes without tearing down the browser. This enables
handing a live page to a human (e.g. for captcha solving) and resuming
scraping on the same page afterward.

Closes #76

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 02:27:42 +00:00
39371dc261 Merge pull request 'fix: secure field lost in cookie round-trip & batch AddCookies failure' (#77) from fix/75-cookie-secure-field-roundtrip into main
All checks were successful
CI / vet (push) Successful in 1m9s
CI / test (push) Successful in 1m10s
CI / build (push) Successful in 1m11s
Reviewed-on: #77
2026-02-24 02:14:43 +00:00
debf0ee2ed fix: map Secure field in cookie conversions and add per-cookie error handling
All checks were successful
CI / vet (pull_request) Successful in 1m7s
CI / build (pull_request) Successful in 1m7s
CI / test (pull_request) Successful in 1m10s
The Secure field was dropped in both Playwright<->internal cookie
conversion functions, causing cookies with __Secure-/__Host- prefixes
to be rejected by Chromium. Additionally, batch AddCookies meant one
invalid cookie would fail browser creation entirely.

Changes:
- Map Secure field in cookieToPlaywrightOptionalCookie and
  playwrightCookieToCookie
- Add cookies one-by-one with slog.Warn on failure instead of
  failing the entire batch
- Add unit tests for both conversion functions

Closes #75

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 02:13:19 +00:00
8 changed files with 417 additions and 37 deletions

View File

@@ -152,12 +152,11 @@ func initBrowser(opt BrowserOptions) (*browserInitResult, error) {
if err != nil {
return nil, fmt.Errorf("error getting cookies from cookie jar: %w", err)
}
pwCookies := make([]playwright.OptionalCookie, len(cookies))
for i, c := range cookies {
pwCookies[i] = cookieToPlaywrightOptionalCookie(c)
}
if err := bctx.AddCookies(pwCookies); err != nil {
return nil, fmt.Errorf("error adding cookies to browser: %w", err)
for _, c := range cookies {
oc := cookieToPlaywrightOptionalCookie(c)
if err := bctx.AddCookies([]playwright.OptionalCookie{oc}); err != nil {
slog.Warn("skipping invalid cookie", "name", c.Name, "host", c.Host, "error", err)
}
}
}

View File

@@ -22,9 +22,10 @@ type Document interface {
type document struct {
node
pw *playwright.Playwright
browser playwright.Browser
page playwright.Page
pw *playwright.Playwright
browser playwright.Browser
page playwright.Page
detached bool
}
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
}
func (d *document) Close() error {
if d.detached {
return nil
}
return d.page.Close()
}

View File

@@ -23,8 +23,13 @@ type InteractiveBrowser interface {
// MouseClick clicks at the given coordinates with the specified button ("left", "middle", "right").
MouseClick(x, y float64, button string) error
// MouseMove moves the mouse to the given coordinates.
MouseMove(x, y float64) error
// MouseDown presses the mouse button at the given coordinates without releasing.
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(deltaX, deltaY float64) error
@@ -43,15 +48,27 @@ type InteractiveBrowser interface {
// Cookies returns all cookies from the browser context.
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() error
}
type interactiveBrowser struct {
pw *playwright.Playwright
browser playwright.Browser
ctx playwright.BrowserContext
page playwright.Page
pw *playwright.Playwright
browser playwright.Browser
ctx playwright.BrowserContext
page playwright.Page
ownsInfrastructure bool
detached bool
}
// 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()
if err != nil {
_ = res.bctx.Close()
_ = res.browser.Close()
_ = res.pw.Stop()
ch <- result{nil, fmt.Errorf("failed to create page: %w", err)}
return
}
ch <- result{
ib: &interactiveBrowser{
pw: res.pw,
browser: res.browser,
ctx: res.bctx,
page: page,
pw: res.pw,
browser: res.browser,
ctx: res.bctx,
page: page,
ownsInfrastructure: true,
},
}
}()
select {
case <-ctx.Done():
go func() {
r := <-ch
if r.err == nil && r.ib != nil {
_ = r.ib.Close()
}
}()
return nil, ctx.Err()
case r := <-ch:
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})
}
func (ib *interactiveBrowser) MouseMove(x, y float64) error {
return ib.page.Mouse().Move(x, y)
func (ib *interactiveBrowser) MouseDown(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().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 {
@@ -193,26 +256,40 @@ func (ib *interactiveBrowser) Cookies() ([]Cookie, error) {
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 {
if ib.detached {
return nil
}
var errs []error
if ib.page != nil {
if err := ib.page.Close(); err != nil {
errs = append(errs, err)
}
}
if ib.ctx != nil {
if err := ib.ctx.Close(); err != nil {
errs = append(errs, err)
if ib.ownsInfrastructure {
if ib.ctx != nil {
if err := ib.ctx.Close(); err != nil {
errs = append(errs, err)
}
}
}
if ib.browser != nil {
if err := ib.browser.Close(); err != nil {
errs = append(errs, err)
if ib.browser != nil {
if err := ib.browser.Close(); err != nil {
errs = append(errs, err)
}
}
}
if ib.pw != nil {
if err := ib.pw.Stop(); err != nil {
errs = append(errs, err)
if ib.pw != nil {
if err := ib.pw.Stop(); err != nil {
errs = append(errs, err)
}
}
}
if len(errs) > 0 {

View File

@@ -29,7 +29,7 @@ type node struct {
}
func (n node) Type(input string) error {
return n.locator.Type(input)
return n.locator.PressSequentially(input)
}
func (n node) Click() error {

View File

@@ -122,12 +122,18 @@ func playwrightSameSiteToSameSite(s *playwright.SameSiteAttribute) SameSite {
}
func cookieToPlaywrightOptionalCookie(cookie Cookie) playwright.OptionalCookie {
expires := float64(cookie.Expires.Unix())
if cookie.Expires.IsZero() || expires <= 0 {
expires = -1
}
oc := playwright.OptionalCookie{
Name: cookie.Name,
Value: cookie.Value,
Domain: playwright.String(cookie.Host),
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),
}
if cookie.SameSite != "" {
@@ -143,6 +149,7 @@ func playwrightCookieToCookie(cookie playwright.Cookie) Cookie {
Host: cookie.Domain,
Path: cookie.Path,
Expires: time.Unix(int64(cookie.Expires), 0),
Secure: cookie.Secure,
HttpOnly: cookie.HttpOnly,
SameSite: playwrightSameSiteToSameSite(cookie.SameSite),
}
@@ -193,6 +200,12 @@ func NewBrowser(ctx context.Context, opts ...BrowserOptions) (Browser, error) {
select {
case <-ctx.Done():
go func() {
r := <-resultCh
if r.err == nil && r.browser != nil {
_ = r.browser.Close()
}
}()
return nil, ctx.Err()
case result := <-resultCh:
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)
}
// 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 {
// 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)
}
}
@@ -240,6 +277,7 @@ func (b playWrightBrowser) openPage(_ context.Context, target string, opts OpenP
resp, err := page.Goto(target, pwOpts)
if err != nil {
_ = page.Close()
return nil, err
}
@@ -275,8 +313,8 @@ func (b playWrightBrowser) Open(ctx context.Context, url string, opts OpenPageOp
func (b playWrightBrowser) Close() error {
return errors.Join(
b.browser.Close(),
b.ctx.Close(),
b.browser.Close(),
b.pw.Stop(),
)
}

138
playwright_test.go Normal file
View 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
View 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
View 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)
}
}