import * as fs from 'fs' import yaml from 'yaml' export interface FlowFrontmatter { // raw `rnx open` target: port, dev-server URL, bundle URL, or shell URL app?: string | number device?: string // pin the sim color scheme for this flow run (screenshot baselines are // theme-sensitive; a dark capture can never match a light baseline) theme?: 'light' | 'dark' electron?: boolean // maestro-compatible: appId is accepted but not used by rnx (ios bundle id) appId?: string // maestro-compatible: flow-level env vars, defined into the flow's JS // context before the first step runs env?: Record // maestro-compatible: workspace tag filtering (config.yml includeTags/excludeTags) tags?: string[] // maestro-compatible: display name for reports. defaults the junit // testcase id, name, and classname when properties does not override them. name?: string // maestro-compatible: custom junit entries. junitId and // junitClassname are reserved: they set the testcase attributes and are // not emitted as properties. properties?: Record } // parse flow file. accepts three shapes: // 1. plain yaml array of steps (no frontmatter) // 2. rnx style: ---\n\n---\n // 3. maestro style: \n---\n (multi-doc yaml, fm first) // maestro compat matters because real users have real .maestro/*.yaml files // and shouldn't have to rewrite them to drive rnx. // // `${...}` templates are NOT resolved here — interpolation happens per-step // at execution time in the flow runner's JS context (matching maestro), so // values produced mid-flow (runScript output.*, copyTextFrom) resolve in // later steps and a missing variable fails loudly at the exact step. export function parseFlowFile(content: string): { frontmatter: FlowFrontmatter steps: any[] } { let frontmatter: FlowFrontmatter = {} let body = content const normalizeSteps = (steps: unknown): any[] => { return Array.isArray(steps) ? steps : [] } // shape 2: leading ---\n\n---\n const fmMatch = content.match(/^---\n([\s\S]*?)\n---\n?/) if (fmMatch) { try { frontmatter = (yaml.parse(fmMatch[1]) as FlowFrontmatter) ?? {} } catch (e) { console.warn(` warn: could not parse frontmatter: ${(e as Error).message}`) } body = content.slice(fmMatch[0].length) const steps = yaml.parse(body) return { frontmatter, steps: normalizeSteps(steps) } } // shape 3: maestro multi-doc (fm-first, then `---`, then steps) // detect cheaply: a `---` separator on its own line not at position 0 if (/\n---\s*\n/.test(content)) { try { const docs = yaml.parseAllDocuments(content) if (docs.length >= 2) { const fm = docs[0].toJS() as FlowFrontmatter | null const st = docs[docs.length - 1].toJS() return { frontmatter: fm && typeof fm === 'object' ? fm : {}, steps: normalizeSteps(st), } } } catch (e) { console.warn(` warn: could not parse multi-doc flow: ${(e as Error).message}`) } } // shape 1: plain steps array const steps = yaml.parse(body) return { frontmatter, steps: normalizeSteps(steps) } } const INTERACTION_KEYS = new Set([ 'tapOn', 'inputText', 'pressKey', 'dispatchKey', 'hideKeyboard', 'swipe', 'pinch', 'scroll', 'scrollUntilVisible', ]) export function validateFlowSteps(steps: any[]): string[] { const issues: string[] = [] if (!Array.isArray(steps) || steps.length === 0) { issues.push('flow body must be a non-empty YAML array of steps') return issues } let interactions = 0 for (const step of steps) { if (step && typeof step === 'object') { const keys = Object.keys(step) if (keys.some((key) => INTERACTION_KEYS.has(key))) interactions += 1 } else if (typeof step === 'string' && INTERACTION_KEYS.has(step)) { interactions += 1 } } if (interactions === 0) { issues.push( 'no interaction steps found (expected at least one tapOn / inputText / pressKey / swipe / scroll)', ) } return issues } // structural-only validator used by CI runners before uploading the flow // artifact. we only check: file exists, parseable yaml, has at least one // real interaction step. playback validation is a separate concern handled // by `rnx maestro end --validate` during the authoring session. export function validateFlowFile(flowPath: string): string[] { if (!fs.existsSync(flowPath)) { return [`file not found: ${flowPath}`] } let content: string try { content = fs.readFileSync(flowPath, 'utf8') } catch (e) { return [`cannot read file: ${(e as Error).message}`] } if (content.trim().length === 0) { return ['file is empty'] } try { const parsed = parseFlowFile(content) return validateFlowSteps(parsed.steps) } catch (e) { return [`yaml parse error: ${(e as Error).message}`] } }