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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ 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.

## [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
Loading