mirror of
https://github.com/priyanshujain/sanderling.git
synced 2026-10-02 11:07:10 +00:00
feat(folio-web): web sample app + CDP spec tests (#36)
* fix(runner): allow nil connection for web platform * fix(testrun): skip SDK handshake for web platform * feat(folio-web): add React/Vite web sample app * feat(folio-web): add sanderling spec * fix(chrome): use InsertText for multi-char text input * feat(hierarchy): add Screen field populated from sanderling-screen attr * fix(chrome): auto-detect viewport from CSS vars, fix InputText accumulation, expose route as screen * fix(runner): fall back to hierarchy root screen when snapshot screen is empty * fix(folio-web): broaden loggedIn extractor to all authenticated pages
This commit is contained in:
31 files changed
+2970
-74
No files matched your search
@@ -92,7 +92,25 @@ func (d *Driver) Launch(ctx context.Context, bundleID string, clearState bool) e
|
||||
return fmt.Errorf("clear storage: %w", err)
|
||||
}
|
||||
}
|
||||
return chromedp.Run(d.tabCtx, chromedp.Navigate(bundleID))
|
||||
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 {
|
||||
@@ -116,7 +134,15 @@ func (d *Driver) TapSelector(_ context.Context, selector string) error {
|
||||
func (d *Driver) InputText(_ context.Context, text string) error {
|
||||
return chromedp.Run(d.tabCtx,
|
||||
chromedp.ActionFunc(func(ctx context.Context) error {
|
||||
return input.DispatchKeyEvent(input.KeyChar).WithText(text).Do(ctx)
|
||||
// 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)
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -167,35 +193,40 @@ var keyMap = map[string]string{
|
||||
|
||||
func (d *Driver) Hierarchy(_ context.Context) (string, error) {
|
||||
script := `
|
||||
(function buildTree(el) {
|
||||
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;
|
||||
if (el.tagName) attrs['class'] = el.tagName.toLowerCase();
|
||||
const isClickable = !!(el.onclick || el.tagName === 'A' || el.tagName === 'BUTTON' ||
|
||||
el.tagName === 'INPUT' || el.tagName === 'SELECT' ||
|
||||
el.getAttribute('role') === 'button' || el.getAttribute('onclick'));
|
||||
const children = [];
|
||||
for (const child of el.children) {
|
||||
children.push(buildTree(child));
|
||||
(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;
|
||||
if (el.tagName) attrs['class'] = el.tagName.toLowerCase();
|
||||
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 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,
|
||||
};
|
||||
}
|
||||
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,
|
||||
};
|
||||
})(document.body);`
|
||||
return buildTree(document.body, true);
|
||||
})()`
|
||||
|
||||
var result any
|
||||
if err := chromedp.Run(d.tabCtx, chromedp.Evaluate(script, &result)); err != nil {
|
||||
|
||||
@@ -43,12 +43,15 @@ type Element struct {
|
||||
Description string `json:"description,omitempty"`
|
||||
Class string `json:"class,omitempty"`
|
||||
Package string `json:"package,omitempty"`
|
||||
Clickable bool `json:"clickable,omitempty"`
|
||||
Enabled bool `json:"enabled,omitempty"`
|
||||
Checked bool `json:"checked,omitempty"`
|
||||
Focused bool `json:"focused,omitempty"`
|
||||
Selected bool `json:"selected,omitempty"`
|
||||
Bounds Bounds `json:"bounds"`
|
||||
// Screen holds the current route/screen name when set by the driver on the
|
||||
// root element (web platform only; empty for native platforms).
|
||||
Screen string `json:"screen,omitempty"`
|
||||
Clickable bool `json:"clickable,omitempty"`
|
||||
Enabled bool `json:"enabled,omitempty"`
|
||||
Checked bool `json:"checked,omitempty"`
|
||||
Focused bool `json:"focused,omitempty"`
|
||||
Selected bool `json:"selected,omitempty"`
|
||||
Bounds Bounds `json:"bounds"`
|
||||
}
|
||||
|
||||
// Tree is a flat collection of every node in a hierarchy dump, in pre-order.
|
||||
@@ -105,6 +108,7 @@ func elementFromNode(node *treeNodeJSON) *Element {
|
||||
}
|
||||
element.Class = attrs["class"]
|
||||
element.Package = attrs["package"]
|
||||
element.Screen = attrs["sanderling-screen"]
|
||||
|
||||
if node.Clickable != nil {
|
||||
element.Clickable = *node.Clickable
|
||||
|
||||
@@ -135,6 +135,9 @@ func Run(ctx context.Context, options Options) (Summary, error) {
|
||||
if screenErr != nil {
|
||||
logger.Warn("screen snapshot decode failed", "step", stepIndex, "err", screenErr)
|
||||
}
|
||||
if screen == "" && tree != nil && len(tree.Elements) > 0 {
|
||||
screen = tree.Elements[0].Screen
|
||||
}
|
||||
logger.Info("step", "index", stepIndex, "screen", screen, "nodes", treeSize)
|
||||
verdicts := options.Verifier.EvaluateProperties()
|
||||
violations := violationNames(verdicts)
|
||||
@@ -181,8 +184,10 @@ func Run(ctx context.Context, options Options) (Summary, error) {
|
||||
})
|
||||
}
|
||||
|
||||
if err := options.Connection.Release(ctx); err != nil {
|
||||
return summary, fmt.Errorf("step %d release: %w", stepIndex, err)
|
||||
if options.Connection != nil {
|
||||
if err := options.Connection.Release(ctx); err != nil {
|
||||
return summary, fmt.Errorf("step %d release: %w", stepIndex, err)
|
||||
}
|
||||
}
|
||||
|
||||
if nextErr == nil {
|
||||
@@ -216,9 +221,6 @@ func Run(ctx context.Context, options Options) (Summary, error) {
|
||||
}
|
||||
|
||||
func validate(options Options) error {
|
||||
if options.Connection == nil {
|
||||
return errors.New("runner: Connection is required")
|
||||
}
|
||||
if options.Driver == nil {
|
||||
return errors.New("runner: Driver is required")
|
||||
}
|
||||
@@ -241,6 +243,9 @@ func validate(options Options) error {
|
||||
}
|
||||
|
||||
func snapshotStep(ctx context.Context, options Options) (agent.Message, error) {
|
||||
if options.Connection == nil {
|
||||
return agent.Message{}, nil
|
||||
}
|
||||
snapshotTimeout := options.SnapshotTimeout
|
||||
if snapshotTimeout <= 0 {
|
||||
snapshotTimeout = 5 * time.Second
|
||||
|
||||
@@ -233,8 +233,8 @@ globalThis.actions = __sanderling__.actions(() => [__sanderling__.tap({ on: "id:
|
||||
|
||||
func TestRunner_RejectsMissingFields(t *testing.T) {
|
||||
_, err := Run(context.Background(), Options{Duration: time.Second})
|
||||
if err == nil || !strings.Contains(err.Error(), "Connection") {
|
||||
t.Errorf("expected Connection-required error, got %v", err)
|
||||
if err == nil || !strings.Contains(err.Error(), "Driver") {
|
||||
t.Errorf("expected Driver-required error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+37
-30
@@ -67,14 +67,16 @@ func Execute(ctx context.Context, options Options, stdout io.Writer) error {
|
||||
}
|
||||
defer cleanup()
|
||||
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
return fmt.Errorf("agent listener: %w", err)
|
||||
}
|
||||
defer listener.Close()
|
||||
agentPort := listener.Addr().(*net.TCPAddr).Port
|
||||
var connection *agent.Conn
|
||||
|
||||
if options.Platform != "web" {
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
return fmt.Errorf("agent listener: %w", err)
|
||||
}
|
||||
defer listener.Close()
|
||||
agentPort := listener.Addr().(*net.TCPAddr).Port
|
||||
|
||||
if err := android.AdbReverse(socketName, agentPort); err != nil {
|
||||
return fmt.Errorf("adb reverse: %w", err)
|
||||
}
|
||||
@@ -84,35 +86,40 @@ func Execute(ctx context.Context, options Options, stdout io.Writer) error {
|
||||
}
|
||||
}()
|
||||
fmt.Fprintf(stdout, "forwarded localabstract:%s -> tcp:%d\n", socketName, agentPort)
|
||||
}
|
||||
|
||||
agentServer := agent.NewServer(listener)
|
||||
agentServer := agent.NewServer(listener)
|
||||
|
||||
type acceptResult struct {
|
||||
connection *agent.Conn
|
||||
err error
|
||||
}
|
||||
acceptChannel := make(chan acceptResult, 1)
|
||||
go func() {
|
||||
acceptCtx, cancel := context.WithTimeout(ctx, sdkAcceptTimeout)
|
||||
defer cancel()
|
||||
connection, acceptErr := agentServer.Accept(acceptCtx)
|
||||
acceptChannel <- acceptResult{connection: connection, err: acceptErr}
|
||||
}()
|
||||
type acceptResult struct {
|
||||
conn *agent.Conn
|
||||
err error
|
||||
}
|
||||
acceptChannel := make(chan acceptResult, 1)
|
||||
go func() {
|
||||
acceptCtx, cancel := context.WithTimeout(ctx, sdkAcceptTimeout)
|
||||
defer cancel()
|
||||
conn, acceptErr := agentServer.Accept(acceptCtx)
|
||||
acceptChannel <- acceptResult{conn: conn, err: acceptErr}
|
||||
}()
|
||||
|
||||
if err := activeDriver.Launch(ctx, options.BundleID, false); err != nil {
|
||||
return fmt.Errorf("launch app: %w", err)
|
||||
}
|
||||
fmt.Fprintf(stdout, "launched %s; waiting for SDK to connect (%.0fs timeout)\n", options.BundleID, sdkAcceptTimeout.Seconds())
|
||||
if err := activeDriver.Launch(ctx, options.BundleID, false); err != nil {
|
||||
return fmt.Errorf("launch app: %w", err)
|
||||
}
|
||||
fmt.Fprintf(stdout, "launched %s; waiting for SDK to connect (%.0fs timeout)\n", options.BundleID, sdkAcceptTimeout.Seconds())
|
||||
|
||||
result := <-acceptChannel
|
||||
if result.err != nil {
|
||||
return fmt.Errorf("accept SDK: %w", result.err)
|
||||
result := <-acceptChannel
|
||||
if result.err != nil {
|
||||
return fmt.Errorf("accept SDK: %w", result.err)
|
||||
}
|
||||
connection = result.conn
|
||||
defer connection.Close()
|
||||
hello := connection.Hello()
|
||||
fmt.Fprintf(stdout, "SDK connected: platform=%s app=%s sdk=%s\n", hello.Platform, hello.AppPackage, hello.Version)
|
||||
} else {
|
||||
fmt.Fprintln(stdout, "web mode: skipping SDK")
|
||||
if err := activeDriver.Launch(ctx, options.BundleID, false); err != nil {
|
||||
return fmt.Errorf("launch app: %w", err)
|
||||
}
|
||||
}
|
||||
connection := result.connection
|
||||
defer connection.Close()
|
||||
hello := connection.Hello()
|
||||
fmt.Fprintf(stdout, "SDK connected: platform=%s app=%s sdk=%s\n", hello.Platform, hello.AppPackage, hello.Version)
|
||||
|
||||
seed := options.Seed
|
||||
if seed == 0 {
|
||||
|
||||
Reference in new issue
Block a user