feat(driver): add WebDriver capability + chrome implementation

WebDriver exposes InstallBundle/EvaluateExtractors/NextActionFromV8/Document
for the V8-native web tick path. Mobile drivers stay binary-compatible.
This commit is contained in:
pj committed 2026-04-27 18:41:04 +07:00
1 parent b6a6aeff5f
commit 87b30d0f22
2 files changed
+110 -1

No files matched your search

+83 -1
View File
@@ -10,6 +10,7 @@ import (
"github.com/chromedp/cdproto/input"
"github.com/chromedp/cdproto/network"
"github.com/chromedp/cdproto/page"
"github.com/chromedp/cdproto/runtime"
"github.com/chromedp/chromedp"
@@ -25,6 +26,9 @@ type Driver struct {
logsMu sync.Mutex
logs []driver.LogEntry
bundleMu sync.Mutex
bundleSource string
}
// New creates a new ChromeDriver. Call Terminate when done.
@@ -320,4 +324,82 @@ func pngDimensions(png []byte) (int, int) {
return w, h
}
var _ driver.DeviceDriver = (*Driver)(nil)
var (
_ driver.DeviceDriver = (*Driver)(nil)
_ driver.WebDriver = (*Driver)(nil)
)
// InstallBundle registers the source so it runs at every freshly-navigated
// document context, then immediately evaluates it against the current page so
// the very first tick has access to the registered globals.
func (d *Driver) InstallBundle(_ context.Context, source []byte) error {
d.bundleMu.Lock()
d.bundleSource = string(source)
d.bundleMu.Unlock()
return chromedp.Run(d.tabCtx,
chromedp.ActionFunc(func(ctx context.Context) error {
if _, err := page.AddScriptToEvaluateOnNewDocument(string(source)).Do(ctx); err != nil {
return fmt.Errorf("addScriptToEvaluateOnNewDocument: %w", err)
}
_, exception, err := runtime.Evaluate(string(source)).Do(ctx)
if err != nil {
return fmt.Errorf("evaluate bundle: %w", err)
}
if exception != nil {
return fmt.Errorf("bundle threw: %s", exception.Text)
}
return nil
}),
)
}
// EvaluateExtractors invokes the bundle-installed extractor table and returns
// each extractor's JSON-encoded current value keyed by its registration index.
func (d *Driver) EvaluateExtractors(_ context.Context) (map[int]json.RawMessage, error) {
const script = `JSON.stringify(window.__sanderlingExtractors__ ? window.__sanderlingExtractors__() : {})`
var encoded string
if err := chromedp.Run(d.tabCtx, chromedp.Evaluate(script, &encoded)); err != nil {
return nil, fmt.Errorf("evaluate extractors: %w", err)
}
if encoded == "" || encoded == "{}" {
return map[int]json.RawMessage{}, nil
}
stringMap := map[string]json.RawMessage{}
if err := json.Unmarshal([]byte(encoded), &stringMap); err != nil {
return nil, fmt.Errorf("decode extractor map: %w", err)
}
result := make(map[int]json.RawMessage, len(stringMap))
for key, value := range stringMap {
index := 0
if _, err := fmt.Sscanf(key, "%d", &index); err != nil {
return nil, fmt.Errorf("non-integer extractor key %q", key)
}
result[index] = value
}
return result, nil
}
// NextActionFromV8 invokes the bundle-installed action generator and returns
// the resulting Action JSON. Returns an empty json.RawMessage when the
// generator declines to act this tick.
func (d *Driver) NextActionFromV8(_ context.Context) (json.RawMessage, error) {
const script = `JSON.stringify(window.__sanderlingNextAction__ ? window.__sanderlingNextAction__() : null)`
var encoded string
if err := chromedp.Run(d.tabCtx, chromedp.Evaluate(script, &encoded)); err != nil {
return nil, fmt.Errorf("evaluate next action: %w", err)
}
if encoded == "" || encoded == "null" {
return nil, nil
}
return json.RawMessage(encoded), nil
}
// Document returns the current page's outer HTML for trace capture.
func (d *Driver) Document(_ context.Context) (string, error) {
var html string
if err := chromedp.Run(d.tabCtx, chromedp.OuterHTML("html", &html, chromedp.ByQuery)); err != nil {
return "", fmt.Errorf("document: %w", err)
}
return html, nil
}
+27
View File
@@ -2,6 +2,7 @@ package driver
import (
"context"
"encoding/json"
"time"
)
@@ -58,3 +59,29 @@ type Metrics struct {
HeapBytes int64
TotalMemoryBytes int64
}
// WebDriver is the optional capability surface exposed by the chrome driver
// for the V8-native tick path. The runner type-asserts on this interface;
// mobile drivers stay binary-compatible by simply not implementing it.
//
// Element references never cross V8/host. V8 serializes targets as {x, y}
// (or bounds) into the returned WebAction JSON; the host dispatches via the
// normal DeviceDriver methods (Tap, InputText, etc.).
type WebDriver interface {
// InstallBundle injects the given JS source so it runs once per
// freshly-navigated document, plus immediately in the current page.
// The bundle is expected to register globals
// `__sanderlingExtractors__` and `__sanderlingNextAction__` on
// `window`.
InstallBundle(ctx context.Context, source []byte) error
// EvaluateExtractors invokes the extractor table installed by the
// bundle and returns each extractor's JSON-encoded current value
// keyed by its registration index.
EvaluateExtractors(ctx context.Context) (map[int]json.RawMessage, error)
// NextActionFromV8 invokes the action generator installed by the
// bundle and returns the resulting Action JSON for the host to
// dispatch. The shape mirrors verifier.Action's JSON form.
NextActionFromV8(ctx context.Context) (json.RawMessage, error)
// Document returns the current page outerHTML for trace capture.
Document(ctx context.Context) (string, error)
}