From 85ffa30ae7194ec0be43eed8a3c1812152cefb83 Mon Sep 17 00:00:00 2001 From: richjun Date: Tue, 12 May 2026 16:07:28 +0900 Subject: [PATCH 1/2] Web: viewport DSL keyword + flow-header config (Playwright parity) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a web-only `viewport` surface that mirrors Playwright's two-tier viewport model: an initial `viewport: {width, height}` in the flow header (analogous to browser.newContext({ viewport })) and an in-flow `viewport` step that resizes mid-run (analogous to page.setViewportSize). - flow schema: new ViewportStep + Config.Viewport (positive int W/H, validated at parse time, no runtime workaround for missing/zero/neg). - core: optional ViewportConfigurer interface so the executor can apply the flow-header viewport without coupling to the web driver. - web (CDP) driver: SetViewport(w,h) calls Emulation.setDeviceMetrics via go-rod and updates the cached viewportW/H used by tap coords, orientation, and initPage for future tabs. - executor: flow_runner applies Config.Viewport via the optional interface before any step runs; mobile drivers silently skip. Mobile drivers fall through their existing default branch — the step returns a clear "not supported" message rather than being silently ignored. --- pkg/core/driver.go | 7 +++++++ pkg/driver/browser/cdp/commands.go | 10 ++++++++++ pkg/driver/browser/cdp/driver.go | 20 ++++++++++++++++++++ pkg/executor/flow_runner.go | 13 +++++++++++++ pkg/flow/flow.go | 8 ++++++++ pkg/flow/parser.go | 24 +++++++++++++++++++++++- pkg/flow/step.go | 20 +++++++++++++++++++- 7 files changed, 100 insertions(+), 2 deletions(-) diff --git a/pkg/core/driver.go b/pkg/core/driver.go index 79696e4..7bb7f49 100644 --- a/pkg/core/driver.go +++ b/pkg/core/driver.go @@ -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 diff --git a/pkg/driver/browser/cdp/commands.go b/pkg/driver/browser/cdp/commands.go index 7bbad27..ca7555e 100644 --- a/pkg/driver/browser/cdp/commands.go +++ b/pkg/driver/browser/cdp/commands.go @@ -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) { diff --git a/pkg/driver/browser/cdp/driver.go b/pkg/driver/browser/cdp/driver.go index 232bb58..5461872 100644 --- a/pkg/driver/browser/cdp/driver.go +++ b/pkg/driver/browser/cdp/driver.go @@ -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: @@ -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) {} diff --git a/pkg/executor/flow_runner.go b/pkg/executor/flow_runner.go index b357489..641d1b3 100644 --- a/pkg/executor/flow_runner.go +++ b/pkg/executor/flow_runner.go @@ -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). diff --git a/pkg/flow/flow.go b/pkg/flow/flow.go index 9345d08..4ee18ec 100644 --- a/pkg/flow/flow.go +++ b/pkg/flow/flow.go @@ -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"` +} diff --git a/pkg/flow/parser.go b/pkg/flow/parser.go index cefdbd2..5f690a7 100644 --- a/pkg/flow/parser.go +++ b/pkg/flow/parser.go @@ -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 } @@ -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, @@ -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 { diff --git a/pkg/flow/step.go b/pkg/flow/step.go index 42e8b70..1c85dd9 100644 --- a/pkg/flow/step.go +++ b/pkg/flow/step.go @@ -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 @@ -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" @@ -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"` @@ -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 != "" { From d2206c73b0640102b118a50fec24a0e465e32f01 Mon Sep 17 00:00:00 2001 From: richjun Date: Tue, 12 May 2026 16:07:34 +0900 Subject: [PATCH 2/2] Web: tests for viewport step parsing and CDP driver resize - parser: viewport mapping accepted in header and in-flow; missing/ zero/negative width or height rejected at parse time; isStepType recognizes the new keyword. - step: ViewportStep registered in the step-type sanity matrix and Describe renders "viewport: WxH". - CDP driver: TestViewportStep dispatches mid-flow resize and verifies window.innerWidth/innerHeight reflect the new size; SetViewport rejects non-positive dimensions; compile-time assertion that *Driver satisfies core.ViewportConfigurer so flow_runner can pick it up. --- pkg/driver/browser/cdp/driver_test.go | 75 +++++++++++++++++++++ pkg/flow/parser_test.go | 93 ++++++++++++++++++++++++++- pkg/flow/step_test.go | 14 ++++ 3 files changed, 181 insertions(+), 1 deletion(-) diff --git a/pkg/driver/browser/cdp/driver_test.go b/pkg/driver/browser/cdp/driver_test.go index c733bba..c4835bb 100644 --- a/pkg/driver/browser/cdp/driver_test.go +++ b/pkg/driver/browser/cdp/driver_test.go @@ -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() diff --git a/pkg/flow/parser_test.go b/pkg/flow/parser_test.go index ac03eab..2d288eb 100644 --- a/pkg/flow/parser_test.go +++ b/pkg/flow/parser_test.go @@ -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}, @@ -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", @@ -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"}`}, @@ -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 diff --git a/pkg/flow/step_test.go b/pkg/flow/step_test.go index 9c36be9..7676b64 100644 --- a/pkg/flow/step_test.go +++ b/pkg/flow/step_test.go @@ -118,6 +118,7 @@ func TestStepInterface(t *testing.T) { &SetPermissionsStep{BaseStep: BaseStep{StepType: StepSetPermissions}}, &SetLocationStep{BaseStep: BaseStep{StepType: StepSetLocation}}, &SetOrientationStep{BaseStep: BaseStep{StepType: StepSetOrientation}}, + &ViewportStep{BaseStep: BaseStep{StepType: StepViewport}}, &SetAirplaneModeStep{BaseStep: BaseStep{StepType: StepSetAirplaneMode}}, &ToggleAirplaneModeStep{BaseStep: BaseStep{StepType: StepToggleAirplaneMode}}, &TravelStep{BaseStep: BaseStep{StepType: StepTravel}}, @@ -553,6 +554,18 @@ func TestLongPressOnStep_Describe(t *testing.T) { } } +func TestViewportStep_Describe(t *testing.T) { + s := ViewportStep{ + BaseStep: BaseStep{StepType: StepViewport}, + Width: 1280, + Height: 720, + } + expected := "viewport: 1280x720" + if got := s.Describe(); got != expected { + t.Errorf("Describe() = %q, want %q", got, expected) + } +} + func TestAssertVisibleStep_Describe(t *testing.T) { s := AssertVisibleStep{ BaseStep: BaseStep{StepType: StepAssertVisible}, @@ -833,6 +846,7 @@ func TestStepTypeConstants(t *testing.T) { StepSetPermissions: "setPermissions", StepSetLocation: "setLocation", StepSetOrientation: "setOrientation", + StepViewport: "viewport", StepSetAirplaneMode: "setAirplaneMode", StepToggleAirplaneMode: "toggleAirplaneMode", StepTravel: "travel",