feat: promote headless page to InteractiveBrowser mid-session #78
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()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -48,10 +48,12 @@ type InteractiveBrowser interface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
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.
|
||||||
@@ -94,10 +96,11 @@ func NewInteractiveBrowser(ctx context.Context, opts ...BrowserOptions) (Interac
|
|||||||
|
|
||||||
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,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
@@ -194,25 +197,31 @@ func (ib *interactiveBrowser) Cookies() ([]Cookie, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
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 {
|
||||||
|
|||||||
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)
|
||||||
|
}
|
||||||
55
promote_test.go
Normal file
55
promote_test.go
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
package extractor
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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) MouseMove(float64, float64) 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) 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