Skip to content
Merged
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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **WDA `launchApp` restarts a running app unless `stopApp: false`, as Maestro does.** It only activated the running app, so a relaunch left the app on the screen it was already on, and a flow checking what survives a restart restarted nothing.
- **WDA `notVisible` passes only when a lookup finds the element absent.** `assertNotVisible` and `extendedWaitUntil: notVisible` treated any failed lookup, such as an unreadable page source or a dropped connection, as the element being gone, so they could pass without the screen being looked at. Other errors are now retried until the timeout, and then fail the step.
- **`checked` selectors work on iOS.** The iOS drivers dropped `checked` with a warning, so `checked: true` matched a switch in either state. WDA now derives checked from a CheckBox, Switch or Toggle whose value is 1, as Maestro does, and filters on it on every path (tap, assert, relative).
- **`runScript` runs a script file as written, as Maestro does.** The runner expanded `${...}` across the whole file before running it, so a template literal that used the script's own variables, such as `${encodeURIComponent(email)}`, was replaced ahead of the script, against variables that did not exist yet, and came out as `undefined`. A script file now runs as plain JavaScript. Inline script text keeps its `${...}` expansion.
- **A `runFlow`, `retry` or sub-flow `env` no longer leaves its keys behind, as in Maestro.** The runner put each key back to its old value, but a key that had none was set to an empty string instead of being removed, so after `runFlow` with `env: {KEY: ...}` the name stayed defined: `typeof KEY` was `"string"`, `$KEY` expanded to nothing, and `runShell` saw `KEY=""`. A key the block added is now removed when it ends.
- **A script's `http` call waits up to 5 minutes, as in Maestro.** Maestro's script client allows a call 5 minutes. The runner gave up after 30 seconds unless the call set `timeout`, so a script that calls a slow endpoint, such as one that seeds test data, failed with `HTTP request failed` where Maestro waits. A call without a `timeout` option now gets 5 minutes, and the option still wins.

## [1.1.28] - 2026-09-30

Expand Down
6 changes: 0 additions & 6 deletions pkg/driver/uiautomator2/driver.go
Original file line number Diff line number Diff line change
Expand Up @@ -1280,12 +1280,6 @@ func looksLikeRegex(text string) bool {
return false
}

// escapeUIAutomatorString escapes only the double quotes for UiAutomator string.
// Used when the text is already a regex pattern.
func escapeUIAutomatorString(s string) string {
return strings.ReplaceAll(s, `"`, `\"`)
}

// buildStateFilters returns UiSelector chain for state filters.
// e.g., ".enabled(true).checked(false)"
func buildStateFilters(sel flow.Selector) string {
Expand Down
53 changes: 53 additions & 0 deletions pkg/executor/scoped_env_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
package executor

import (
"testing"

"github.com/devicelab-dev/maestro-runner/pkg/flow"
"github.com/devicelab-dev/maestro-runner/pkg/report"
)

// A key a runFlow, retry or sub-flow env added is gone when it returns, as in
// Maestro, whose leaveEnvScope restores the env as it was (GraalJsEngine.kt:
// 223-238). It used to stay behind set to "".
func TestWithEnvVars_RestoreRemovesAddedKeys(t *testing.T) {
se := NewScriptEngine()
defer se.Close()
se.SetVariable("KEPT", "before")

restore := se.withEnvVars(map[string]string{"KEPT": "inside", "ADDED": "inside"})
if se.GetVariable("ADDED") != "inside" || se.GetVariable("KEPT") != "inside" {
t.Fatalf("env not applied: ADDED=%q KEPT=%q", se.GetVariable("ADDED"), se.GetVariable("KEPT"))
}
restore()

if got := se.GetVariable("KEPT"); got != "before" {
t.Errorf("KEPT = %q after restore, want its old value", got)
}
if _, ok := se.Variables()["ADDED"]; ok {
t.Error("ADDED is still a variable after restore, want it removed")
}
if got, err := se.js.Eval("typeof ADDED"); err != nil || got != "undefined" {
t.Errorf("typeof ADDED = %v (%v) after restore, want undefined", got, err)
}
}

func TestRunFlowEnv_IsGoneAfterTheRunFlow(t *testing.T) {
result := runOneFlow(t, &mockDriver{}, flow.Flow{
SourcePath: "test.yaml",
Config: flow.Config{Name: "scoped env"},
Steps: []flow.Step{
&flow.RunFlowStep{
BaseStep: flow.BaseStep{StepType: flow.StepRunFlow},
Env: map[string]string{"SCOPED": "1"},
Steps: []flow.Step{
&flow.AssertTrueStep{BaseStep: flow.BaseStep{StepType: flow.StepAssertTrue}, Script: "${SCOPED === '1'}"},
},
},
&flow.AssertTrueStep{BaseStep: flow.BaseStep{StepType: flow.StepAssertTrue}, Script: "${typeof SCOPED === 'undefined'}"},
},
})
if result.Status != report.StatusPassed {
t.Errorf("status = %v, want passed: SCOPED should be set inside the runFlow and undefined after it", result.Status)
}
}
33 changes: 19 additions & 14 deletions pkg/executor/scripting.go
Original file line number Diff line number Diff line change
Expand Up @@ -251,8 +251,18 @@ func expandDollarVar(text, name, value string) string {
// outlive a single runScript call still goes through the global `output`
// bag, exactly as documented.
func (se *ScriptEngine) RunScript(script string, env map[string]string) error {
// Expand variables in script
script = se.ExpandVariables(script)
return se.runScript(script, env, true)
}

// runScript runs a script with its env. expandBody expands ${...} and $VAR in the script text
// first, which suits inline script text. A script file is plain JavaScript and runs as written,
// as in Maestro: expanding it first replaced the file's own template literals (`${localVar}`)
// ahead of the script, against variables that did not exist yet.
func (se *ScriptEngine) runScript(script string, env map[string]string, expandBody bool) error {
if expandBody {
// Expand variables in script
script = se.ExpandVariables(script)
}

// Apply env variables for the duration of THIS script only, expanded so
// values like "mockoon-cli start --port ${output.port}" resolve before the
Expand Down Expand Up @@ -422,7 +432,8 @@ func (se *ScriptEngine) ExecuteRunScript(step *flow.RunScriptStep) *core.Command
script := step.ScriptPath()

// Check if it's a file path (ends with .js)
if strings.HasSuffix(script, ".js") {
isFile := strings.HasSuffix(script, ".js")
if isFile {
filePath := se.ResolvePath(script)
content, err := os.ReadFile(filePath)
if err != nil {
Expand All @@ -435,7 +446,7 @@ func (se *ScriptEngine) ExecuteRunScript(step *flow.RunScriptStep) *core.Command
script = string(content)
}

if err := se.RunScript(script, step.Env); err != nil {
if err := se.runScript(script, step.Env, !isFile); err != nil {
return &core.CommandResult{
Success: false,
Error: err,
Expand Down Expand Up @@ -656,17 +667,11 @@ func conditionTimeout(cond flow.Condition, sel *flow.Selector, fallback int) int

// withEnvVars applies environment variables and returns a restore function.
// Values are expanded through ExpandVariables to support ${VAR || "default"} syntax.
// The restore puts back what each key held and removes a key that was not set
// before, as Maestro's leaveEnvScope does (GraalJsEngine.kt:223-238), rather
// than leaving it set to "".
func (se *ScriptEngine) withEnvVars(env map[string]string) func() {
oldVars := make(map[string]string)
for k, v := range env {
oldVars[k] = se.GetVariable(k)
se.SetVariable(k, se.ExpandVariables(v))
}
return func() {
for k, v := range oldVars {
se.SetVariable(k, v)
}
}
return se.applyScopedEnv(env)
}

// parseBoolExpr converts the resolved value of an `enabled:` argument into a
Expand Down
23 changes: 23 additions & 0 deletions pkg/executor/scripting_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -792,6 +792,29 @@ func TestScriptEngine_ExecuteRunScript_File(t *testing.T) {
}
}

// A script file's own template literals are plain JavaScript: they must see the script's local
// variables, not be expanded ahead of the script against the flow's variables.
func TestScriptEngine_ExecuteRunScript_FileTemplateLiteral(t *testing.T) {
se := NewScriptEngine()
defer se.Close()

tmpDir := t.TempDir()
src := "const who = EMAIL;\nconst state = 'onboarded';\n" +
"output.url = `/v1/x?email=${encodeURIComponent(who)}&state=${state}`;\n"
if err := os.WriteFile(filepath.Join(tmpDir, "tl.js"), []byte(src), 0o644); err != nil {
t.Fatalf("Failed to create test script: %v", err)
}
se.SetFlowDir(tmpDir)

step := &flow.RunScriptStep{Script: "tl.js", Env: map[string]string{"EMAIL": "a+b@x.io"}}
if result := se.ExecuteRunScript(step); !result.Success {
t.Fatalf("ExecuteRunScript() success = false, error = %v", result.Error)
}
if got, want := se.GetVariable("url"), "/v1/x?email=a%2Bb%40x.io&state=onboarded"; got != want {
t.Errorf("url = %q, want %q", got, want)
}
}

func TestScriptEngine_ExecuteRunScript_FileNotFound(t *testing.T) {
se := NewScriptEngine()
defer se.Close()
Expand Down
7 changes: 6 additions & 1 deletion pkg/jsengine/http.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@ import (
"github.com/dop251/goja"
)

// defaultHTTPTimeout bounds an http.* call that sets no timeout of its own:
// the 5 minutes Maestro's script client allows a call (GraalJsEngine.kt:28-34).
// A variable so tests can shorten it.
var defaultHTTPTimeout = 5 * time.Minute

// httpModule returns the http object with get, post, put, delete methods
func (e *Engine) httpModule() *goja.Object {
obj := e.runtime.NewObject()
Expand Down Expand Up @@ -84,7 +89,7 @@ func (e *Engine) doHTTPRequest(method string, call goja.FunctionCall) goja.Value
// Parse options if provided
var body io.Reader
headers := make(map[string]string)
timeout := 30 * time.Second
timeout := defaultHTTPTimeout
insecure := e.insecureHTTP

if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) {
Expand Down
48 changes: 48 additions & 0 deletions pkg/jsengine/http_timeout_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package jsengine

import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)

// Maestro's script client allows a call 5 minutes (GraalJsEngine.kt:28-34).
// The runner's gave up after 30 s, so a slow test-data endpoint that works
// under Maestro failed the script.
func TestDefaultHTTPTimeoutIsMaestros(t *testing.T) {
if defaultHTTPTimeout != 5*time.Minute {
t.Errorf("default http timeout = %v, want 5m", defaultHTTPTimeout)
}
}

// A call with no timeout option waits for the default, and the option still
// wins over it.
func TestHTTPRequestWithoutATimeoutUsesTheDefault(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
time.Sleep(300 * time.Millisecond)
_, _ = w.Write([]byte(`ok`))
}))
defer srv.Close()

saved := defaultHTTPTimeout
defaultHTTPTimeout = 50 * time.Millisecond
defer func() { defaultHTTPTimeout = saved }()

e := New()
defer e.Close()

_, err := e.Eval(`http.get(` + jsString(srv.URL) + `).status`)
if err == nil || !strings.Contains(err.Error(), "HTTP request failed") {
t.Errorf("a call slower than the default timeout returned err = %v, want it to time out", err)
}

v, err := e.Eval(`http.get(` + jsString(srv.URL) + `, { timeout: 5000 }).status`)
if err != nil {
t.Fatalf("a call with its own longer timeout failed: %v", err)
}
if n, _ := v.(int64); n != 200 {
t.Errorf("status = %v, want 200", v)
}
}
Loading