From 352d9f5bc06b9b9d97b5e2316055bb88745afd2b Mon Sep 17 00:00:00 2001 From: Bulat Galeev Date: Fri, 2 Oct 2026 08:03:49 +0400 Subject: [PATCH 1/3] chore(uiautomator2): remove escapeUIAutomatorString, which nothing calls any more 30257df (Android device queries match the whole text and id) took out the last caller of this helper in the uiautomator2 driver. The unused linter has flagged it since, so Lint has failed on every push to main, and Build, which needs Lint, has been skipped each time. The same helper in the appium and devicelab drivers is untouched. --- pkg/driver/uiautomator2/driver.go | 6 ------ 1 file changed, 6 deletions(-) diff --git a/pkg/driver/uiautomator2/driver.go b/pkg/driver/uiautomator2/driver.go index baad23df..d34f3f76 100644 --- a/pkg/driver/uiautomator2/driver.go +++ b/pkg/driver/uiautomator2/driver.go @@ -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 { From a289cc7cd8e429a1fc3f8ffa151cc136668a3466 Mon Sep 17 00:00:00 2001 From: Bulat Galeev Date: Mon, 28 Sep 2026 09:37:11 +0400 Subject: [PATCH 2/3] fix(runscript): run a script file as written Maestro runs a runScript file as plain JavaScript. It reads the file and evaluates ${...} only in the step's env, when: condition and label; the script text goes to the engine as it is (YamlFluentCommand.kt:409-424, Commands.kt:1029-1035, Orchestra.kt:723-737). The runner expanded ${...} and $VAR across the whole file before running it. A template literal that used the script's own variables was replaced ahead of the script, against variables that did not exist yet: `/v1/x?email=${encodeURIComponent(who)}&state=${state}` came out as `/v1/x?email=undefined&state=`. A script file now runs as written. Inline script text, which Maestro has no equivalent of, keeps the expansion. --- CHANGELOG.md | 1 + pkg/executor/scripting.go | 19 +++++++++++++++---- pkg/executor/scripting_test.go | 23 +++++++++++++++++++++++ 3 files changed, 39 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ddfda590..05497ce1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ 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. ## [1.1.28] - 2026-09-30 diff --git a/pkg/executor/scripting.go b/pkg/executor/scripting.go index d453cb89..0804fa7d 100644 --- a/pkg/executor/scripting.go +++ b/pkg/executor/scripting.go @@ -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 @@ -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 { @@ -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, diff --git a/pkg/executor/scripting_test.go b/pkg/executor/scripting_test.go index 5e4eaf71..82d5a96f 100644 --- a/pkg/executor/scripting_test.go +++ b/pkg/executor/scripting_test.go @@ -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() From df20b1b5aa0263b93e309e19be710629803bff12 Mon Sep 17 00:00:00 2001 From: Bulat Galeev Date: Mon, 28 Sep 2026 19:35:05 +0400 Subject: [PATCH 3/3] fix(executor): remove the env keys a runFlow or retry added when it ends Maestro gives a sub-flow its own env scope: enterEnvScope saves the env and leaveEnvScope puts that copy back, so a key the sub-flow added is gone when it returns (GraalJsEngine.kt:223-238, around runSubFlow at Orchestra.kt:1159-1197, which repeat, retry and runFlow all go through). The runner's withEnvVars restored each key to the value it had before, and a key that had none was set to "" rather than removed. After a runFlow, retry or sub-flow with `env: {KEY: ...}`, KEY stayed defined: typeof KEY was "string", `$KEY` expanded to nothing, and runShell saw KEY="" in its environment. withEnvVars now uses applyScopedEnv, which runScript's env already used: a key is restored when it existed and removed when it did not. --- CHANGELOG.md | 1 + pkg/executor/scoped_env_test.go | 53 +++++++++++++++++++++++++++++++++ pkg/executor/scripting.go | 14 +++------ 3 files changed, 58 insertions(+), 10 deletions(-) create mode 100644 pkg/executor/scoped_env_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 05497ce1..b1c10c8e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **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 diff --git a/pkg/executor/scoped_env_test.go b/pkg/executor/scoped_env_test.go new file mode 100644 index 00000000..7b495bb6 --- /dev/null +++ b/pkg/executor/scoped_env_test.go @@ -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) + } +} diff --git a/pkg/executor/scripting.go b/pkg/executor/scripting.go index 0804fa7d..05fac506 100644 --- a/pkg/executor/scripting.go +++ b/pkg/executor/scripting.go @@ -667,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