Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions pkg/core/driver.go
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,13 @@ type TypingFrequencyConfigurer interface {
SetTypingFrequency(freq int) error
}

// ViewportConfigurer is an optional interface drivers can implement to
// resize the rendering viewport. Currently only the web (CDP) driver
// supports this — it mirrors Playwright's page.setViewportSize semantics.
type ViewportConfigurer interface {
SetViewport(width, height int) error
}

// SessionEnsurer is an optional interface drivers can implement to create
// a session before flow execution starts. This is needed when a flow has
// no launchApp step (e.g. stopApp → openLink pattern) so that WDA commands
Expand Down
10 changes: 10 additions & 0 deletions pkg/driver/browser/cdp/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -818,6 +818,16 @@ func (d *Driver) setClipboard(step *flow.SetClipboardStep) *core.CommandResult {
return successResult(fmt.Sprintf("Set clipboard: %s", step.Text), nil)
}

// viewport resizes the browser viewport mid-flow. Mirrors Playwright's
// page.setViewportSize semantics — affects the current page and is reused
// by initPage for any tabs opened later.
func (d *Driver) viewport(step *flow.ViewportStep) *core.CommandResult {
if err := d.SetViewport(step.Width, step.Height); err != nil {
return errorResult(err, fmt.Sprintf("Failed to set viewport %dx%d", step.Width, step.Height))
}
return successResult(fmt.Sprintf("Set viewport: %dx%d", step.Width, step.Height), nil)
}

// setOrientation changes viewport dimensions to simulate orientation.
func (d *Driver) setOrientation(step *flow.SetOrientationStep) *core.CommandResult {
switch strings.ToUpper(step.Orientation) {
Expand Down
20 changes: 20 additions & 0 deletions pkg/driver/browser/cdp/driver.go
Original file line number Diff line number Diff line change
Expand Up @@ -500,6 +500,8 @@ func (d *Driver) Execute(step flow.Step) *core.CommandResult {
// Device control
case *flow.SetOrientationStep:
result = d.setOrientation(s)
case *flow.ViewportStep:
result = d.viewport(s)
case *flow.OpenLinkStep:
result = d.openLink(s)
case *flow.OpenBrowserStep:
Expand Down Expand Up @@ -660,6 +662,24 @@ func (d *Driver) SetWaitForIdleTimeout(ms int) error {
return nil
}

// SetViewport resizes the rendering viewport. Implements core.ViewportConfigurer.
// Mirrors Playwright's page.setViewportSize semantics — the new size affects
// the current page immediately and is reused by initPage for new tabs.
func (d *Driver) SetViewport(width, height int) error {
if width <= 0 || height <= 0 {
return fmt.Errorf("viewport dimensions must be positive (got %dx%d)", width, height)
}
if err := d.page.SetViewport(&proto.EmulationSetDeviceMetricsOverride{
Width: width,
Height: height,
}); err != nil {
return fmt.Errorf("failed to set viewport: %w", err)
}
d.viewportW = width
d.viewportH = height
return nil
}

// SetContext is a no-op for browser — Rod waits use their own timeouts.
func (d *Driver) SetContext(ctx context.Context) {}

Expand Down
75 changes: 75 additions & 0 deletions pkg/driver/browser/cdp/driver_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -692,6 +692,81 @@ func TestSetOrientation(t *testing.T) {
}
}

func TestViewportStep(t *testing.T) {
ts := newTestServer()
defer ts.Close()

d := newTestDriver(t, ts.URL)
defer d.Close()

// Initial size from newTestDriver should be 1024x768
if info := d.GetPlatformInfo(); info.ScreenWidth != 1024 || info.ScreenHeight != 768 {
t.Fatalf("initial viewport: expected 1024x768, got %dx%d", info.ScreenWidth, info.ScreenHeight)
}

// Mid-flow resize via step dispatch
result := d.Execute(&flow.ViewportStep{Width: 1280, Height: 720})
if !result.Success {
t.Fatalf("viewport step should succeed: %s", result.Message)
}
if info := d.GetPlatformInfo(); info.ScreenWidth != 1280 || info.ScreenHeight != 720 {
t.Errorf("after 1280x720 step: got %dx%d", info.ScreenWidth, info.ScreenHeight)
}

// Verify the browser actually rendered at the new size via window.innerWidth
jsResult, err := d.page.Eval(`() => ({w: window.innerWidth, h: window.innerHeight})`)
if err != nil {
t.Fatalf("eval innerWidth/Height: %v", err)
}
if w := jsResult.Value.Get("w").Int(); w != 1280 {
t.Errorf("window.innerWidth: got %d, want 1280", w)
}
if h := jsResult.Value.Get("h").Int(); h != 720 {
t.Errorf("window.innerHeight: got %d, want 720", h)
}

// Second resize must also stick (mid-flow change like Playwright)
result = d.Execute(&flow.ViewportStep{Width: 1920, Height: 1080})
if !result.Success {
t.Fatalf("second viewport resize should succeed: %s", result.Message)
}
if info := d.GetPlatformInfo(); info.ScreenWidth != 1920 || info.ScreenHeight != 1080 {
t.Errorf("after 1920x1080 step: got %dx%d", info.ScreenWidth, info.ScreenHeight)
}
}

func TestSetViewport_InvalidDimensions(t *testing.T) {
ts := newTestServer()
defer ts.Close()

d := newTestDriver(t, ts.URL)
defer d.Close()

cases := []struct {
name string
width int
height int
}{
{"zero width", 0, 720},
{"zero height", 1280, 0},
{"negative width", -1, 720},
{"both zero", 0, 0},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if err := d.SetViewport(tc.width, tc.height); err == nil {
t.Errorf("expected error for %dx%d, got nil", tc.width, tc.height)
}
})
}
}

func TestViewportConfigurer_InterfaceSatisfied(t *testing.T) {
// Compile-time check: *Driver must satisfy core.ViewportConfigurer so
// flow_runner.go can pick it up via the optional-interface lookup.
var _ core.ViewportConfigurer = (*Driver)(nil)
}

func TestWaitUntilVisible(t *testing.T) {
ts := newTestServer()
defer ts.Close()
Expand Down
13 changes: 13 additions & 0 deletions pkg/executor/flow_runner.go
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,19 @@ func (fr *FlowRunner) Run() FlowResult {
}
}

// Apply flow-level viewport before any step runs. Web (CDP) driver
// implements core.ViewportConfigurer; mobile drivers do not and the
// header is silently ignored on those platforms (Playwright parity:
// the option is web-only).
if fr.flow.Config.Viewport != nil {
if configurer, ok := core.Unwrap(fr.driver).(core.ViewportConfigurer); ok {
vp := fr.flow.Config.Viewport
if err := configurer.SetViewport(vp.Width, vp.Height); err != nil {
logger.Warn("failed to apply flow-level viewport %dx%d: %v", vp.Width, vp.Height, err)
}
}
}

// Ensure a WDA session exists before execution starts.
// If launchApp runs later, it reuses this session and updates settings.
// Use Unwrap to reach through wrapper layers (e.g. FlutterDriver).
Expand Down
8 changes: 8 additions & 0 deletions pkg/flow/flow.go
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,14 @@ type Config struct {
CommandTimeout int `yaml:"commandTimeout"` // Default timeout for all commands in ms (overrides driver default)
WaitForIdleTimeout *int `yaml:"waitForIdleTimeout"` // Wait for device idle in ms (nil = use global, 0 = disabled)
TypingFrequency *int `yaml:"typingFrequency"` // WDA typing speed in keys/sec (nil = use global, 0 = disabled)
Viewport *ViewportConfig `yaml:"viewport,omitempty"` // Web-only: initial browser viewport size (nil = driver default)
OnFlowStart []Step `yaml:"-"` // Lifecycle hook: runs before commands
OnFlowComplete []Step `yaml:"-"` // Lifecycle hook: runs after commands
}

// ViewportConfig is the flow-header viewport setting (web-only).
// Mirrors Playwright's browser.newContext({ viewport }) shape.
type ViewportConfig struct {
Width int `yaml:"width"`
Height int `yaml:"height"`
}
24 changes: 23 additions & 1 deletion pkg/flow/parser.go
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,16 @@ func parseConfig(content string, flow *Flow) error {
config.OnFlowComplete = append(config.OnFlowComplete, step)
}

if config.Viewport != nil {
if config.Viewport.Width <= 0 || config.Viewport.Height <= 0 {
return &ParseError{
Path: flow.SourcePath,
Message: fmt.Sprintf("viewport requires positive width and height, got %dx%d",
config.Viewport.Width, config.Viewport.Height),
}
}
}

flow.Config = config
return nil
}
Expand Down Expand Up @@ -230,7 +240,7 @@ func isStepType(key string) bool {
StepAssertVisible, StepAssertNotVisible, StepAssertTrue, StepAssertCondition,
StepAssertNoDefectsWithAI, StepAssertWithAI, StepExtractTextWithAI, StepWaitUntil,
StepLaunchApp, StepStopApp, StepKillApp, StepClearState, StepClearKeychain, StepSetPermissions,
StepSetLocation, StepSetOrientation, StepSetAirplaneMode, StepToggleAirplaneMode,
StepSetLocation, StepSetOrientation, StepViewport, StepSetAirplaneMode, StepToggleAirplaneMode,
StepTravel, StepOpenLink, StepOpenBrowser, StepRepeat, StepRetry, StepRunFlow,
StepRunScript, StepEvalScript, StepEvalBrowserScript,
StepRunBrowserScript, StepEvalWebViewScript, StepRunWebViewScript,
Expand Down Expand Up @@ -551,6 +561,18 @@ func decodeStep(stepType StepType, valueNode *yaml.Node, sourcePath string) (Ste
s.StepType = stepType
return &s, nil

case StepViewport:
var s ViewportStep
if err := valueNode.Decode(&s); err != nil {
return nil, wrapParseError(sourcePath, valueNode.Line, err)
}
if s.Width <= 0 || s.Height <= 0 {
return nil, wrapParseError(sourcePath, valueNode.Line,
fmt.Errorf("viewport requires positive width and height, got %dx%d", s.Width, s.Height))
}
s.StepType = stepType
return &s, nil

case StepSetAirplaneMode:
var s SetAirplaneModeStep
if valueNode.Kind == yaml.ScalarNode {
Expand Down
93 changes: 92 additions & 1 deletion pkg/flow/parser_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,8 @@ func TestParse_AllStepTypes(t *testing.T) {
{"setLocation", `- setLocation: {latitude: "37.7", longitude: "-122.4"}`, StepSetLocation},
{"setOrientation scalar", `- setOrientation: LANDSCAPE`, StepSetOrientation},
{"setOrientation mapping", `- setOrientation: {orientation: PORTRAIT}`, StepSetOrientation},
{"viewport mapping", `- viewport: {width: 1280, height: 720}`, StepViewport},
{"viewport HD", `- viewport: {width: 1920, height: 1080}`, StepViewport},
{"setAirplaneMode enabled scalar", `- setAirplaneMode: enabled`, StepSetAirplaneMode},
{"setAirplaneMode disabled scalar", `- setAirplaneMode: disabled`, StepSetAirplaneMode},
{"setAirplaneMode mapping", `- setAirplaneMode: {enabled: true}`, StepSetAirplaneMode},
Expand Down Expand Up @@ -1007,7 +1009,7 @@ func TestIsStepType(t *testing.T) {
"assertNotVisible", "assertTrue", "assertCondition", "assertNoDefectsWithAI",
"assertWithAI", "extractTextWithAI", "extendedWaitUntil", "launchApp",
"stopApp", "killApp", "clearState", "clearKeychain", "setPermissions",
"setLocation", "setOrientation", "setAirplaneMode", "toggleAirplaneMode",
"setLocation", "setOrientation", "viewport", "setAirplaneMode", "toggleAirplaneMode",
"travel", "openLink", "openBrowser", "repeat", "retry", "runFlow",
"runScript", "evalScript", "takeScreenshot", "startRecording", "stopRecording",
"addMedia", "pressKey", "waitForAnimationToEnd", "defineVariables",
Expand Down Expand Up @@ -1183,6 +1185,10 @@ func TestParse_DecodeErrors(t *testing.T) {
{"clearState invalid", `- clearState: {appId: [invalid]}`},
{"setLocation invalid", `- setLocation: {latitude: [invalid]}`},
{"setOrientation invalid", `- setOrientation: {orientation: [invalid]}`},
{"viewport missing width", `- viewport: {height: 720}`},
{"viewport zero width", `- viewport: {width: 0, height: 720}`},
{"viewport negative", `- viewport: {width: -1, height: 720}`},
{"viewport invalid type", `- viewport: {width: "wide", height: 720}`},
{"setAirplaneMode invalid mapping", `- setAirplaneMode: {enabled: "not a bool"}`},
{"setAirplaneMode invalid scalar", `- setAirplaneMode: foobar`},
{"travel invalid", `- travel: {points: "not an array"}`},
Expand Down Expand Up @@ -1434,6 +1440,91 @@ name: Web Test
}
}

func TestParse_ConfigWithViewport(t *testing.T) {
yaml := `
url: https://example.com
viewport:
width: 1280
height: 720
---
- tapOn: "Login"
`
flow, err := Parse([]byte(yaml), "test.yaml")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if flow.Config.Viewport == nil {
t.Fatal("expected viewport to be set")
}
if flow.Config.Viewport.Width != 1280 || flow.Config.Viewport.Height != 720 {
t.Errorf("expected 1280x720, got %dx%d", flow.Config.Viewport.Width, flow.Config.Viewport.Height)
}

// Mid-flow change should also parse
yaml2 := `
url: https://example.com
---
- viewport: {width: 1280, height: 720}
- viewport:
width: 1920
height: 1080
`
flow2, err := Parse([]byte(yaml2), "test.yaml")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(flow2.Steps) != 2 {
t.Fatalf("expected 2 viewport steps, got %d", len(flow2.Steps))
}
step1, ok := flow2.Steps[0].(*ViewportStep)
if !ok {
t.Fatalf("expected *ViewportStep, got %T", flow2.Steps[0])
}
if step1.Width != 1280 || step1.Height != 720 {
t.Errorf("step1: expected 1280x720, got %dx%d", step1.Width, step1.Height)
}
step2, ok := flow2.Steps[1].(*ViewportStep)
if !ok {
t.Fatalf("expected *ViewportStep, got %T", flow2.Steps[1])
}
if step2.Width != 1920 || step2.Height != 1080 {
t.Errorf("step2: expected 1920x1080, got %dx%d", step2.Width, step2.Height)
}
}

func TestParse_ConfigWithInvalidViewport(t *testing.T) {
cases := []struct {
name string
yaml string
}{
{"zero width", `
url: https://example.com
viewport: {width: 0, height: 720}
---
- tapOn: "Login"
`},
{"negative height", `
url: https://example.com
viewport: {width: 1280, height: -1}
---
- tapOn: "Login"
`},
{"missing height", `
url: https://example.com
viewport: {width: 1280}
---
- tapOn: "Login"
`},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if _, err := Parse([]byte(tc.yaml), "test.yaml"); err == nil {
t.Error("expected parse error for invalid viewport, got nil")
}
})
}
}

func TestParse_OnFlowStart(t *testing.T) {
yaml := `
appId: com.example.app
Expand Down
20 changes: 19 additions & 1 deletion pkg/flow/step.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
// Package flow handles parsing and representation of Maestro YAML flow files.
package flow

import "gopkg.in/yaml.v3"
import (
"fmt"

"gopkg.in/yaml.v3"
)

// StepType represents the type of step.
type StepType string
Expand Down Expand Up @@ -54,6 +58,7 @@ const (
// Device Control
StepSetLocation StepType = "setLocation"
StepSetOrientation StepType = "setOrientation"
StepViewport StepType = "viewport"
StepSetAirplaneMode StepType = "setAirplaneMode"
StepToggleAirplaneMode StepType = "toggleAirplaneMode"
StepTravel StepType = "travel"
Expand Down Expand Up @@ -432,6 +437,14 @@ type SetOrientationStep struct {
Orientation string `yaml:"orientation"` // PORTRAIT, LANDSCAPE
}

// ViewportStep resizes the browser viewport mid-flow (web-only).
// Mirrors Playwright's page.setViewportSize semantics.
type ViewportStep struct {
BaseStep `yaml:",inline"`
Width int `yaml:"width"`
Height int `yaml:"height"`
}

// SetAirplaneModeStep sets airplane mode.
type SetAirplaneModeStep struct {
BaseStep `yaml:",inline"`
Expand Down Expand Up @@ -852,6 +865,11 @@ func (s *SwipeStep) Describe() string {
return "swipe"
}

// Describe returns a human-readable description of the viewport step.
func (s *ViewportStep) Describe() string {
return fmt.Sprintf("viewport: %dx%d", s.Width, s.Height)
}

// Describe returns a human-readable description of the scroll step.
func (s *ScrollStep) Describe() string {
if s.Direction != "" {
Expand Down
Loading
Loading