mirror of
https://github.com/priyanshujain/sanderling.git
synced 2026-10-02 11:07:10 +00:00
* fix(replay-ui): size overlay viewBox from hierarchy root bounds
Tap points are recorded in the hierarchy's coordinate space (iOS points,
Android pixels, web CSS px) while screenshots are device pixels, so the
overlay rendered at 1/3 position on iOS 3x screens. Derive the viewBox
from the root element bounds; natural image size stays the fallback.
* fix(runner): derive trace tap point from resolveCoordinates
stampSelectorTarget preferred possibly-stale action X/Y while dispatch
preferred the fresh tree-resolved center, so the trace could record a
different point than the one tapped. Both now share resolveCoordinates.
* fix(runner): settle after InputText focus tap before key events
The focus tap raises the keyboard; with no settle the keyboard
animation races the erase/type key events on iOS, landing them in the
wrong field or dropping them. Wait for idle after a successful focus
tap, bounded by the run's idle timeout.
* fix(driver): skip pre-erase for replace-on-input drivers
The web driver's InputText already replaces content via select-all, so
the runner's unconditional EraseText was a redundant round-trip on
every InputText. A new optional TextReplacer capability lets a driver
assert replace semantics; the runner skips the erase when asserted.
* fix(hierarchy): rank spatial-fallback matches by specificity
The bounds-containment fallback returned the first pre-order match, so
a screen-sized container could win over the intended small element.
Matches are now ordered smallest-area first; equal-area matches keep
pre-order, preserving the iOS-flat equal-bounds sibling pattern.
* fix(runner): treat an unchanging transitional tree as settled
A UI persistently showing two route-level Screen ids (overlay, both
route ids alive at rest) burned the full retry budget every step and
skipped the verifier forever. A tree byte-identical to the previous
attempt now breaks the retry loop as settled; genuine cross-fades
differ between attempts and keep the retry/skip behavior.
* fix(replay-ui): skip synthetic zero-bounds root in deviceSpaceOf
The iOS hierarchy prepends a zero-bounds node before the real root
window, so elements[0] returned undefined and the overlay fell back to
the screenshot's pixel size. Take the first element with positive
extent instead; pre-order puts the root window before any content.
Verified against a real iOS trace in the replay UI.
* fix(sidecar): never replay non-idempotent actions after reconnect
A dropped connection mid-action (e.g. a read timeout while the device
is still typing) re-ran the whole block after reconnecting, typing the
text twice and double-firing taps. Non-idempotent actions now reconnect
for the next RPC's benefit but surface UNAVAILABLE, which the runner
already treats as transient; idempotent reads keep the replay.
* fix(sidecar): land the second double-tap sequentially on gesture collision
The overlapped second tap can hit the XCTest runner while the first
gesture is still executing ('only one gesture can be performed at a
time'), failing the step. The second tap now waits the first out and
retries once, keeping the tight gap on the happy path.
* fix(sidecar): map non-Exception throwables to INTERNAL status
The vendored iOS client throws failures that do not extend Exception;
runRpc missed them, killing the RPC as a channel-level Unknown the
runner cannot classify. Catch Throwable instead.
* feat(sidecar): close the driver and app under test on shutdown
* test(sidecar): cover service shutdown paths
* fix(testrun): stop the sidecar with SIGTERM before killing
* fix(sidecar): reap orphaned XCTest runner sessions at iOS init
* fix(sidecar): probe channel liveness before restarting the XCTest runner
* test(sidecar): cover WdaRecovery restart and retry policy
* fix(sidecar): absorb first-leg double-tap collision sequentially
* fix(runner): scope WDA-drop detection and cap consecutive transient failures
* chore(sidecar): silence vendored loggers on expected failure paths
* fix(runner): absorb one-off apply errors; only an unbroken streak aborts
* fix(folio): install the current build before the Android fuzz run
* chore(sidecar): silence absorbed view-hierarchy poll noise in Android runs
The driver logs an ERROR for every on-device view-hierarchy fetch that the
device-side server cancels or times out while the UI animates. The stability
poll fetches the hierarchy on a sub-second cadence and swallows those throws
to keep polling, so each line is advisory with no effect on the run. Real
failures still reach the runner as gRPC status errors, so nothing is lost.
515 lines
17 KiB
Go
515 lines
17 KiB
Go
// Package chrome implements the device driver for web targets by driving Chrome over the DevTools protocol.
|
|
package chrome
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/chromedp/cdproto/input"
|
|
"github.com/chromedp/cdproto/network"
|
|
"github.com/chromedp/cdproto/page"
|
|
"github.com/chromedp/cdproto/runtime"
|
|
"github.com/chromedp/chromedp"
|
|
"github.com/chromedp/chromedp/kb"
|
|
|
|
"github.com/priyanshujain/sanderling/internal/driver"
|
|
)
|
|
|
|
// Driver implements DeviceDriver via chromedp for web platform testing.
|
|
type Driver struct {
|
|
allocCtx context.Context
|
|
allocCancel context.CancelFunc
|
|
tabCtx context.Context
|
|
tabCancel context.CancelFunc
|
|
|
|
logsMu sync.Mutex
|
|
logs []driver.LogEntry
|
|
}
|
|
|
|
// New creates a new ChromeDriver. Call Terminate when done.
|
|
func New() *Driver {
|
|
allocCtx, allocCancel := chromedp.NewExecAllocator(context.Background(),
|
|
append(chromedp.DefaultExecAllocatorOptions[:],
|
|
chromedp.Flag("headless", true),
|
|
chromedp.Flag("disable-gpu", true),
|
|
chromedp.NoSandbox,
|
|
// CI runners give Chrome a tiny /dev/shm; without this the browser
|
|
// process hangs on startup and never reports its DevTools socket.
|
|
chromedp.Flag("disable-dev-shm-usage", true),
|
|
// Cold-starting Chrome on a loaded CI runner can take longer than the
|
|
// 20s default to print its DevTools websocket URL; give it more room
|
|
// so launch does not flake with "websocket url timeout reached".
|
|
chromedp.WSURLReadTimeout(60*time.Second),
|
|
)...,
|
|
)
|
|
tabCtx, tabCancel := chromedp.NewContext(allocCtx)
|
|
|
|
d := &Driver{
|
|
allocCtx: allocCtx,
|
|
allocCancel: allocCancel,
|
|
tabCtx: tabCtx,
|
|
tabCancel: tabCancel,
|
|
}
|
|
|
|
chromedp.ListenTarget(tabCtx, func(ev any) {
|
|
e, ok := ev.(*runtime.EventConsoleAPICalled)
|
|
if !ok {
|
|
return
|
|
}
|
|
var parts []string
|
|
for _, arg := range e.Args {
|
|
if arg.Value != nil {
|
|
var s string
|
|
if err := json.Unmarshal(arg.Value, &s); err == nil {
|
|
parts = append(parts, s)
|
|
} else {
|
|
parts = append(parts, string(arg.Value))
|
|
}
|
|
}
|
|
}
|
|
level := strings.ToUpper(string(e.Type))
|
|
if level == "LOG" {
|
|
level = "I"
|
|
}
|
|
d.logsMu.Lock()
|
|
d.logs = append(d.logs, driver.LogEntry{
|
|
UnixMillis: int64(e.Timestamp.Time().UnixMilli()),
|
|
Level: level,
|
|
Tag: "console",
|
|
Message: strings.Join(parts, " "),
|
|
})
|
|
d.logsMu.Unlock()
|
|
})
|
|
|
|
return d
|
|
}
|
|
|
|
func (d *Driver) Launch(ctx context.Context, bundleID string, clearState bool, _ map[string]string) error {
|
|
if clearState {
|
|
if err := chromedp.Run(d.tabCtx, network.ClearBrowserCookies()); err != nil {
|
|
return fmt.Errorf("clear cookies: %w", err)
|
|
}
|
|
if err := chromedp.Run(d.tabCtx, chromedp.ActionFunc(func(ctx context.Context) error {
|
|
_, exp, err := runtime.Evaluate(`localStorage.clear(); sessionStorage.clear();`).Do(ctx)
|
|
if exp != nil {
|
|
return fmt.Errorf("clear storage: %s", exp.Text)
|
|
}
|
|
return err
|
|
})); err != nil {
|
|
return fmt.Errorf("clear storage: %w", err)
|
|
}
|
|
}
|
|
if err := chromedp.Run(d.tabCtx, chromedp.Navigate(bundleID)); err != nil {
|
|
return err
|
|
}
|
|
// After navigation, read CSS custom properties --frame-w / --frame-h (common
|
|
// mobile-frame convention) so screenshots fit the app without grey borders.
|
|
// Falls back to the body scroll dimensions if the properties are absent.
|
|
var dims [2]int64
|
|
if err := chromedp.Run(d.tabCtx, chromedp.Evaluate(`
|
|
(function() {
|
|
const s = getComputedStyle(document.documentElement);
|
|
const pw = parseInt(s.getPropertyValue('--frame-w'), 10);
|
|
const ph = parseInt(s.getPropertyValue('--frame-h'), 10);
|
|
const w = isNaN(pw) ? document.body.scrollWidth : pw;
|
|
const h = isNaN(ph) ? document.body.scrollHeight : ph;
|
|
return [w, h];
|
|
})()`, &dims)); err == nil && dims[0] > 0 && dims[1] > 0 {
|
|
_ = chromedp.Run(d.tabCtx, chromedp.EmulateViewport(dims[0], dims[1]))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (d *Driver) Terminate(_ context.Context) error {
|
|
d.tabCancel()
|
|
d.allocCancel()
|
|
return nil
|
|
}
|
|
|
|
func (d *Driver) Tap(_ context.Context, x, y int) error {
|
|
return chromedp.Run(d.tabCtx,
|
|
chromedp.MouseClickXY(float64(x), float64(y)),
|
|
)
|
|
}
|
|
|
|
func (d *Driver) TapSelector(_ context.Context, selector string) error {
|
|
target, isXPath, err := TranslateStringSelector(selector)
|
|
if err != nil {
|
|
// Fall back to passing the string straight through; chromedp will
|
|
// reject it loudly if it isn't a valid CSS selector.
|
|
target = selector
|
|
}
|
|
if isXPath {
|
|
return chromedp.Run(d.tabCtx, chromedp.Click(target, chromedp.NodeVisible, chromedp.BySearch))
|
|
}
|
|
return chromedp.Run(d.tabCtx, chromedp.Click(target, chromedp.NodeVisible))
|
|
}
|
|
|
|
// doubleTapGap is the inter-tap delay for DoubleTap: short enough to land both
|
|
// events inside a sub-100 ms race window. The browser has no single double-tap
|
|
// primitive, so the gesture is two taps with this gap.
|
|
const doubleTapGap = 50 * time.Millisecond
|
|
|
|
func (d *Driver) DoubleTap(ctx context.Context, x, y int) error {
|
|
return webDoubleTap(ctx, func() error { return d.Tap(ctx, x, y) })
|
|
}
|
|
|
|
func (d *Driver) DoubleTapSelector(ctx context.Context, selector string) error {
|
|
return webDoubleTap(ctx, func() error { return d.TapSelector(ctx, selector) })
|
|
}
|
|
|
|
func webDoubleTap(ctx context.Context, tap func() error) error {
|
|
if err := tap(); err != nil {
|
|
return err
|
|
}
|
|
timer := time.NewTimer(doubleTapGap)
|
|
defer timer.Stop()
|
|
select {
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
case <-timer.C:
|
|
}
|
|
return tap()
|
|
}
|
|
|
|
func (d *Driver) InputText(_ context.Context, text string) error {
|
|
return chromedp.Run(d.tabCtx,
|
|
chromedp.ActionFunc(func(ctx context.Context) error {
|
|
// Select any existing content so InsertText replaces rather than appends.
|
|
if err := chromedp.Evaluate(`
|
|
(function() {
|
|
const el = document.activeElement;
|
|
if (el && typeof el.select === 'function') el.select();
|
|
})()`, nil).Do(ctx); err != nil {
|
|
return err
|
|
}
|
|
return input.InsertText(text).Do(ctx)
|
|
}),
|
|
)
|
|
}
|
|
|
|
// ReplacesTextOnInput reports that InputText replaces existing content via
|
|
// select-all, so the runner skips its pre-erase.
|
|
func (d *Driver) ReplacesTextOnInput() bool {
|
|
return true
|
|
}
|
|
|
|
// EraseText clears the focused field. InputText above already replaces via
|
|
// select-all, so the character count is not needed to bound the deletion.
|
|
func (d *Driver) EraseText(_ context.Context, _ int) error {
|
|
return chromedp.Run(d.tabCtx,
|
|
chromedp.ActionFunc(func(ctx context.Context) error {
|
|
if err := chromedp.Evaluate(`
|
|
(function() {
|
|
const el = document.activeElement;
|
|
if (el && typeof el.select === 'function') el.select();
|
|
})()`, nil).Do(ctx); err != nil {
|
|
return err
|
|
}
|
|
return input.InsertText("").Do(ctx)
|
|
}),
|
|
)
|
|
}
|
|
|
|
func (d *Driver) Swipe(_ context.Context, fromX, fromY, toX, toY int, duration time.Duration) error {
|
|
millis := max(duration.Milliseconds(), 50)
|
|
script := fmt.Sprintf(`
|
|
(function() {
|
|
const el = document.elementFromPoint(%d, %d);
|
|
if (!el) return;
|
|
const steps = Math.max(1, Math.floor(%d / 16));
|
|
const dx = (%d - %d) / steps;
|
|
const dy = (%d - %d) / steps;
|
|
el.dispatchEvent(new PointerEvent('pointerdown', {clientX: %d, clientY: %d, bubbles: true}));
|
|
for (let i = 1; i <= steps; i++) {
|
|
el.dispatchEvent(new PointerEvent('pointermove', {clientX: %d + dx*i, clientY: %d + dy*i, bubbles: true}));
|
|
}
|
|
el.dispatchEvent(new PointerEvent('pointerup', {clientX: %d, clientY: %d, bubbles: true}));
|
|
})();`,
|
|
fromX, fromY,
|
|
millis,
|
|
toX, fromX, toY, fromY,
|
|
fromX, fromY,
|
|
fromX, fromY,
|
|
toX, toY,
|
|
)
|
|
return chromedp.Run(d.tabCtx, chromedp.Evaluate(script, nil))
|
|
}
|
|
|
|
func (d *Driver) PressKey(_ context.Context, key string) error {
|
|
k, ok := keyMap[key]
|
|
if !ok {
|
|
return fmt.Errorf("unsupported key: %q", key)
|
|
}
|
|
return chromedp.Run(d.tabCtx, chromedp.KeyEvent(k))
|
|
}
|
|
|
|
func (d *Driver) LongPress(_ context.Context, x, y int) error {
|
|
script := fmt.Sprintf(`
|
|
(function() {
|
|
const el = document.elementFromPoint(%d, %d);
|
|
if (!el) return;
|
|
el.dispatchEvent(new PointerEvent('pointerdown', {clientX: %d, clientY: %d, bubbles: true}));
|
|
setTimeout(function() {
|
|
el.dispatchEvent(new PointerEvent('pointerup', {clientX: %d, clientY: %d, bubbles: true}));
|
|
}, 600);
|
|
})();`,
|
|
x, y,
|
|
x, y,
|
|
x, y,
|
|
)
|
|
return chromedp.Run(d.tabCtx, chromedp.Evaluate(script, nil))
|
|
}
|
|
|
|
// keyMap covers the keys web specs may emit (enter/tab/escape/arrows).
|
|
// "back"/"home" are intentionally absent: backspace/NUL have no navigation
|
|
// semantics in a browser, and the V8 action mix already excludes them.
|
|
var keyMap = map[string]string{
|
|
"enter": kb.Enter,
|
|
"tab": kb.Tab,
|
|
"escape": kb.Escape,
|
|
"up": kb.ArrowUp,
|
|
"down": kb.ArrowDown,
|
|
"left": kb.ArrowLeft,
|
|
"right": kb.ArrowRight,
|
|
}
|
|
|
|
func (d *Driver) Hierarchy(_ context.Context) (string, error) {
|
|
script := `
|
|
(function() {
|
|
const route = window.location.hash.replace(/^#/, '').split('?')[0] || '/';
|
|
function buildTree(el, isRoot) {
|
|
const rect = el.getBoundingClientRect();
|
|
const attrs = {};
|
|
const bounds = '[' + Math.round(rect.left) + ',' + Math.round(rect.top) + ',' +
|
|
Math.round(rect.right) + ',' + Math.round(rect.bottom) + ']';
|
|
if (rect.width > 0 || rect.height > 0) attrs.bounds = bounds;
|
|
const text = (el.textContent || '').trim().slice(0, 200);
|
|
if (text) attrs.text = text;
|
|
if (el.id) attrs['resource-id'] = el.id;
|
|
const label = el.getAttribute('aria-label') || el.getAttribute('alt') || el.getAttribute('title') || '';
|
|
if (label) attrs['content-desc'] = label;
|
|
const tag = (el.tagName || '').toLowerCase();
|
|
if (tag) attrs['tag'] = tag;
|
|
if (el.className && typeof el.className === 'string' && el.className.trim()) {
|
|
attrs['class'] = el.className.trim();
|
|
}
|
|
if (isRoot) attrs['sanderling-screen'] = route;
|
|
const isClickable = !!(el.onclick || el.tagName === 'A' || el.tagName === 'BUTTON' ||
|
|
el.tagName === 'INPUT' || el.tagName === 'SELECT' ||
|
|
el.getAttribute('role') === 'button' || el.getAttribute('onclick'));
|
|
const isEditable = el.isContentEditable || tag === 'textarea' ||
|
|
(tag === 'input' && !['button','submit','checkbox','radio','range','color','file','image','reset']
|
|
.includes((el.type || '').toLowerCase()));
|
|
const children = [];
|
|
for (const child of el.children) {
|
|
children.push(buildTree(child, false));
|
|
}
|
|
return {
|
|
attributes: attrs,
|
|
children: children,
|
|
clickable: isClickable || null,
|
|
enabled: (!el.disabled) || null,
|
|
focused: document.activeElement === el || null,
|
|
checked: el.checked || null,
|
|
selected: el.selected || null,
|
|
editable: isEditable || null,
|
|
};
|
|
}
|
|
return buildTree(document.body, true);
|
|
})()`
|
|
|
|
var result any
|
|
if err := chromedp.Run(d.tabCtx, chromedp.Evaluate(script, &result)); err != nil {
|
|
return "", fmt.Errorf("hierarchy: %w", err)
|
|
}
|
|
bytes, err := json.Marshal(result)
|
|
if err != nil {
|
|
return "", fmt.Errorf("hierarchy marshal: %w", err)
|
|
}
|
|
return string(bytes), nil
|
|
}
|
|
|
|
func (d *Driver) Screenshot(_ context.Context) (driver.Image, error) {
|
|
var buf []byte
|
|
if err := chromedp.Run(d.tabCtx, chromedp.CaptureScreenshot(&buf)); err != nil {
|
|
return driver.Image{}, fmt.Errorf("screenshot: %w", err)
|
|
}
|
|
w, h := pngDimensions(buf)
|
|
return driver.Image{PNG: buf, Width: w, Height: h}, nil
|
|
}
|
|
|
|
// Snapshot pairs hierarchy and screenshot back-to-back. The chromedp tab
|
|
// is single-threaded so the two CDP round-trips are already serialized:
|
|
// pairing them here matches the DeviceDriver contract without extra locking.
|
|
func (d *Driver) Snapshot(ctx context.Context) (string, driver.Image, error) {
|
|
hierarchy, err := d.Hierarchy(ctx)
|
|
if err != nil {
|
|
return "", driver.Image{}, err
|
|
}
|
|
image, err := d.Screenshot(ctx)
|
|
if err != nil {
|
|
return hierarchy, driver.Image{}, err
|
|
}
|
|
return hierarchy, image, nil
|
|
}
|
|
|
|
func (d *Driver) RecentLogs(_ context.Context, since time.Time, minLevel string) ([]driver.LogEntry, error) {
|
|
sinceMillis := since.UnixMilli()
|
|
d.logsMu.Lock()
|
|
defer d.logsMu.Unlock()
|
|
var result []driver.LogEntry
|
|
for _, entry := range d.logs {
|
|
if entry.UnixMillis < sinceMillis {
|
|
continue
|
|
}
|
|
if minLevel != "" && !meetsLevel(entry.Level, minLevel) {
|
|
continue
|
|
}
|
|
result = append(result, entry)
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func (d *Driver) WaitForIdle(_ context.Context, _ time.Duration) error {
|
|
return chromedp.Run(d.tabCtx, chromedp.WaitReady("body", chromedp.ByQuery))
|
|
}
|
|
|
|
func (d *Driver) Health(_ context.Context) (driver.Health, error) {
|
|
select {
|
|
case <-d.tabCtx.Done():
|
|
return driver.Health{Ready: false, Version: "chrome", Platform: "web"}, nil
|
|
default:
|
|
return driver.Health{Ready: true, Version: "chrome", Platform: "web"}, nil
|
|
}
|
|
}
|
|
|
|
func (d *Driver) Metrics(_ context.Context, _ string) (driver.Metrics, error) {
|
|
var result map[string]any
|
|
script := `
|
|
(function() {
|
|
const mem = performance.memory || {};
|
|
return {heap: mem.usedJSHeapSize || 0, totalMem: mem.totalJSHeapSize || 0};
|
|
})()`
|
|
if err := chromedp.Run(d.tabCtx, chromedp.Evaluate(script, &result)); err != nil {
|
|
return driver.Metrics{}, nil
|
|
}
|
|
heap, _ := result["heap"].(float64)
|
|
total, _ := result["totalMem"].(float64)
|
|
return driver.Metrics{
|
|
HeapBytes: int64(heap),
|
|
TotalMemoryBytes: int64(total),
|
|
}, nil
|
|
}
|
|
|
|
func meetsLevel(level, minLevel string) bool {
|
|
order := map[string]int{"V": 0, "D": 1, "I": 2, "W": 3, "E": 4, "F": 5}
|
|
return order[level] >= order[minLevel]
|
|
}
|
|
|
|
func pngDimensions(png []byte) (int, int) {
|
|
if len(png) < 24 {
|
|
return 0, 0
|
|
}
|
|
w := int(png[16])<<24 | int(png[17])<<16 | int(png[18])<<8 | int(png[19])
|
|
h := int(png[20])<<24 | int(png[21])<<16 | int(png[22])<<8 | int(png[23])
|
|
return w, h
|
|
}
|
|
|
|
var (
|
|
_ driver.DeviceDriver = (*Driver)(nil)
|
|
_ driver.WebDriver = (*Driver)(nil)
|
|
)
|
|
|
|
// runCtx returns a chromedp-bound context that is also cancelled when the
|
|
// caller's ctx is cancelled. This is how step deadlines and Ctrl-C propagate
|
|
// into a CDP round-trip - chromedp.Run only honors the ctx it is given, and
|
|
// d.tabCtx alone has no link to the caller.
|
|
func (d *Driver) runCtx(ctx context.Context) (context.Context, context.CancelFunc) {
|
|
derived, cancel := context.WithCancel(d.tabCtx)
|
|
if ctx == nil || ctx.Done() == nil {
|
|
return derived, cancel
|
|
}
|
|
go func() {
|
|
select {
|
|
case <-ctx.Done():
|
|
cancel()
|
|
case <-derived.Done():
|
|
}
|
|
}()
|
|
return derived, cancel
|
|
}
|
|
|
|
// 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(ctx context.Context, source []byte) error {
|
|
runCtx, cancel := d.runCtx(ctx)
|
|
defer cancel()
|
|
return chromedp.Run(runCtx,
|
|
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(ctx context.Context) (map[int]json.RawMessage, error) {
|
|
const script = `JSON.stringify(window.__sanderlingExtractors__ ? window.__sanderlingExtractors__() : {})`
|
|
var encoded string
|
|
runCtx, cancel := d.runCtx(ctx)
|
|
defer cancel()
|
|
if err := chromedp.Run(runCtx, 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, err := strconv.Atoi(key)
|
|
if 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(ctx context.Context) (json.RawMessage, error) {
|
|
const script = `JSON.stringify(window.__sanderlingNextAction__ ? window.__sanderlingNextAction__() : null)`
|
|
var encoded string
|
|
runCtx, cancel := d.runCtx(ctx)
|
|
defer cancel()
|
|
if err := chromedp.Run(runCtx, 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
|
|
}
|