// maestro-compatible flow JS engine — mirrors maestro's GraalJsEngine // (maestro-client/src/main/java/maestro/js/GraalJsEngine.kt) on top of // node's vm module. one context per flow run, shared by the whole flow // including runFlow sub-flows, exactly like upstream: // // globals: http, output (persistent map), maestro ({copiedText, platform}), // json(), relativePoint(), env vars as bare globals (scope-stacked per // sub-flow), console.log routed to the flow log. undeclared identifiers // evaluate to undefined for `${MY_VAR || 'default'}` patterns (upstream uses // a has-trapping Proxy as globalThis's prototype; node/V8 forbids that, so we // get the same semantics from a with-scope proxy — see the constructor). // // `${expr}` string templates are full JS evaluation at command-execution // time (maestro-orchestra-models util/Env.kt): regex (? "undefined"). // 3. `faker` is not implemented — flows using it fail loudly rather than // getting a half-faithful reimplementation of datafaker. import { spawnSync } from 'child_process' import * as vm from 'vm' import { RNX_INTERNAL_CHILDREN, RNX_INTERNAL_COMMAND } from './internal-child' import { rnxSelfInvocation } from './self-invocation' const HTTP_TIMEOUT_MS = 300_000 // upstream: okhttp read/write/call timeout 5min interface HttpParams { body?: string headers?: Record method?: string multipartForm?: unknown } function executeSyncHttp( url: string, method: string, params?: HttpParams, ): { ok: boolean; status: number; body: string; headers: Record } { if (params?.multipartForm) { throw new Error('http: multipartForm is not supported by rnx yet') } // maestro's `http` API is synchronous (flows do `var res = http.post(...)`) // and there is no synchronous fetch, so each request round-trips through a // blocking spawn of this CLI's own fetch worker: JSON in on stdin, JSON out // on stdout. // // the child must be hermetic: when the runner runs under `bun` (or any // process with a debug inspector), the inherited BUN_INSPECT*/NODE_OPTIONS // env makes the child attach to the parent's inspector/IPC socket and exit 1 // with no output instead of running our fetch. scrub those so the child is a // clean fetch worker regardless of how the parent was launched. const childEnv = { ...process.env } for (const key of Object.keys(childEnv)) { if (key.startsWith('BUN_INSPECT') || key === 'NODE_OPTIONS') { delete childEnv[key] } } const self = rnxSelfInvocation() const child = spawnSync( self.executable, [...self.prefixArgs, RNX_INTERNAL_COMMAND, RNX_INTERNAL_CHILDREN.syncHttp], { input: JSON.stringify({ url, method, headers: params?.headers, body: params?.body, }), encoding: 'utf8', timeout: HTTP_TIMEOUT_MS, maxBuffer: 64 * 1024 * 1024, env: childEnv, }, ) if (child.error) { throw new Error(`http ${method} ${url} failed: ${child.error.message}`) } let parsed: unknown try { parsed = JSON.parse(child.stdout || '') } catch { throw new Error( `http ${method} ${url} failed: no response (${(child.stderr || '').trim().slice(0, 200)})`, ) } const result = parsed as { __error?: string ok: boolean status: number body: string headers: Record } if (result.__error) { throw new Error(`http ${method} ${url} failed: ${result.__error}`) } return result } // matches upstream Env.kt: negative-lookbehind for the \ escape, [^$]* body. const TEMPLATE_RE = /(?() private readonly envScopeStack: Array> = [] // env keys currently materialized as context globals — cleared and re-set // before each evaluation, mirroring upstream syncBindingsToContext. private syncedEnvKeys = new Set() readonly output: Record = {} readonly maestro: { copiedText: string | null; platform: string } constructor(opts: { platform?: string; onLog?: (msg: string) => void } = {}) { this.maestro = { copiedText: null, platform: opts.platform ?? 'ios' } const onLog = opts.onLog ?? ((msg: string) => console.log(`[flow] js: ${msg}`)) const sandbox: Record = { output: this.output, maestro: this.maestro, http: { get: (url: string, params?: HttpParams) => executeSyncHttp(url, 'GET', params), post: (url: string, params?: HttpParams) => executeSyncHttp(url, 'POST', params), put: (url: string, params?: HttpParams) => executeSyncHttp(url, 'PUT', params), delete: (url: string, params?: HttpParams) => executeSyncHttp(url, 'DELETE', params), request: (url: string, params?: HttpParams) => executeSyncHttp(url, (params?.method ?? 'GET').toUpperCase(), params), }, console: { log: (...args: unknown[]) => onLog(args.map((a) => (typeof a === 'string' ? a : stringifyJs(a))).join(' ')), }, } this.context = vm.createContext(sandbox) // upstream GraalJsEngine resolves undeclared identifiers to undefined via // a has-trapping Proxy installed as globalThis's prototype, enabling the // `${VAR || 'default'}` idiom. node/V8's vm forbids a Proxy in the global // prototype chain ("Proxy is not allowed in the global prototype chain"), // so we get the identical semantics with a `with`-scope proxy instead: // evalRaw wraps every script in `with(__maestroScope){…}`, and the proxy's // has→true makes any bare identifier resolve through the scope (returning // the real global, or undefined for a name that isn't defined) rather than // throwing ReferenceError. plus the json/relativePoint helpers. vm.runInContext( ` globalThis.__maestroScope = new Proxy(globalThis, { has() { return true }, get(target, key) { return target[key] }, }) function json(text) { return JSON.parse(text) } function relativePoint(x, y) { var xPercent = Math.ceil(x * 100) + '%' var yPercent = Math.ceil(y * 100) + '%' return xPercent + ',' + yPercent } `, this.context, ) // divergence #1: seed env from the shell so `${SOOTSIM_*}` keeps working. for (const [key, value] of Object.entries(process.env)) { if (value !== undefined) this.envBinding.set(key, value) } } putEnv(key: string, value: string) { this.envBinding.set(key, value) } setCopiedText(text: string | null) { this.maestro.copiedText = text } enterEnvScope() { this.envScopeStack.push(new Map(this.envBinding)) } leaveEnvScope() { const previous = this.envScopeStack.pop() if (previous) { this.envBinding.clear() for (const [k, v] of previous) this.envBinding.set(k, v) } } // evaluate a JS snippet in the shared context. mirrors upstream // evalWithIIFE: the script is embedded in a template literal and run // through eval inside an IIFE so var/let/const stay scoped to this // evaluation while the last expression's value is returned. evaluate(script: string, opts: { env?: Record } = {}): unknown { const env = opts.env if (env && Object.keys(env).length > 0) { this.enterEnvScope() try { for (const [k, v] of Object.entries(env)) this.envBinding.set(k, v) return this.evalRaw(script) } finally { this.leaveEnvScope() } } return this.evalRaw(script) } private evalRaw(script: string): unknown { this.syncEnvToContext() const escaped = script .replace(/\\/g, '\\\\') .replace(/`/g, '\\`') .replace(/\$\{/g, '\\${') // `with(__maestroScope)` gives undeclared identifiers the upstream // undefined semantics (see constructor); the IIFE + direct eval keep // var/let/const scoped to this evaluation while returning the last value. return vm.runInContext( `(function(){ with(__maestroScope){ return eval(\`${escaped}\`) } })()`, this.context, ) } private syncEnvToContext() { const globals = vm.runInContext('globalThis', this.context) as Record for (const key of this.syncedEnvKeys) { if (!this.envBinding.has(key)) delete globals[key] } this.syncedEnvKeys = new Set() for (const [key, value] of this.envBinding) { try { globals[key] = value this.syncedEnvKeys.add(key) } catch { // non-writable global (e.g. a frozen builtin name in env) — skip } } } // upstream Env.kt String.evaluateScripts: replace every unescaped `${expr}` // with the stringified evaluation result, then unescape `\${...}`. evaluateStringTemplate(value: string): string { const replaced = value.replace(TEMPLATE_RE, (_match, expr: string) => { if (expr.trim().length === 0) return '' const result = this.evaluate(expr) // divergence #2: a bare `${NAME}` that comes back undefined is a flow // bug 100% of the time — fail loudly instead of typing "undefined". if (result === undefined && BARE_IDENTIFIER_RE.test(expr.trim())) { throw new Error(`missing variable for flow placeholder: ${expr.trim()}`) } return stringifyJs(result) }) return replaced.replace(ESCAPED_TEMPLATE_RE, (_match, inner: string) => inner) } // walk a parsed step and evaluate every string template. nested command // lists are skipped — each nested command evaluates its own strings when // it executes (matching upstream per-command evaluateScripts timing, and // required for values that change between iterations, e.g. copyTextFrom // inside repeat). interpolateStep(step: T): T { return this.interpolateValue(step, null) as T } private interpolateValue(value: unknown, parentKey: string | null): unknown { if (typeof value === 'string') { // evalScript IS the evaluation — the runner template-evaluates it in // its handler. interpolating here too would run the script twice. if (parentKey === 'evalScript') return value return this.evaluateStringTemplate(value) } if (Array.isArray(value)) { if ( parentKey === 'commands' || parentKey === 'onFlowStart' || parentKey === 'onFlowComplete' ) { return value } return value.map((entry) => this.interpolateValue(entry, parentKey)) } if (value && typeof value === 'object') { return Object.fromEntries( Object.entries(value).map(([key, entry]) => [ key, this.interpolateValue(entry, key), ]), ) } return value } } function stringifyJs(value: unknown): string { if (value === undefined) return 'undefined' if (value === null) return 'null' if (typeof value === 'object') { try { return JSON.stringify(value) } catch { return String(value) } } return String(value) } // upstream Orchestra.evaluateCondition scriptCondition semantics: the value // arrives already template-evaluated; false when blank / "false" (any case) / // "undefined" / "null" / numerically zero. yaml may also hand us a real // boolean or number before stringification — normalize first. export function scriptConditionIsTruthy(value: unknown): boolean { if (value === undefined || value === null) return false if (typeof value === 'boolean') return value const text = String(value) if (text.trim().length === 0) return false if (text.toLowerCase() === 'false') return false if (text === 'undefined' || text === 'null') return false const num = Number(text) if (!Number.isNaN(num) && num === 0) return false return true }