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:
pj authored and GitHub committed 2026-04-22 23:59:30 +07:00
1 parent eed99e58aa
commit af7b7e27b0
31 files changed
+2970 -74

No files matched your search

+61 -30
View File
@@ -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 {
+10 -6
View File
@@ -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
+10 -5
View File
@@ -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
+2 -2
View File
@@ -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
View File
@@ -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 {