feat: promote headless page to InteractiveBrowser mid-session #78

Merged
steve merged 1 commits from feature/76-promote-to-interactive into main 2026-02-24 02:29:07 +00:00
4 changed files with 155 additions and 22 deletions
Showing only changes of commit e0da88b9b0 - Show all commits

View File

@@ -25,6 +25,7 @@ type document struct {
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()
} }

View File

@@ -52,6 +52,8 @@ type interactiveBrowser struct {
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.
@@ -98,6 +100,7 @@ func NewInteractiveBrowser(ctx context.Context, opts ...BrowserOptions) (Interac
browser: res.browser, browser: res.browser,
ctx: res.bctx, ctx: res.bctx,
page: page, page: page,
ownsInfrastructure: true,
}, },
} }
}() }()
@@ -194,12 +197,17 @@ 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.ownsInfrastructure {
if ib.ctx != nil { if ib.ctx != nil {
if err := ib.ctx.Close(); err != nil { if err := ib.ctx.Close(); err != nil {
errs = append(errs, err) errs = append(errs, err)
@@ -215,6 +223,7 @@ func (ib *interactiveBrowser) Close() error {
errs = append(errs, err) errs = append(errs, err)
} }
} }
}
if len(errs) > 0 { if len(errs) > 0 {
return fmt.Errorf("errors during close: %v", errs) return fmt.Errorf("errors during close: %v", errs)
} }

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)
}

55
promote_test.go Normal file
View 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)
}
}