// maestro-compatible run reports: junit xml, per-flow commands.json, and the // session/flow maestro.log text. pure builders over playback receipts — no // bridge, no filesystem — so the workspace tests cover them without a sim. // // the shapes mirror maestro's own outputs (test-reports-and-artifacts): one // per invocation, flow-header `properties:` // as children, junitId/junitClassname reserved for the testcase // attributes, commands.json carrying one entry per executed step. import type { SootSimFlowTraceStep } from './bridge-flow-runner' export function escapeXml(value: string): string { return value .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, ''') } export interface JUnitCaseInput { // flow path as printed in the run (relative to the invocation cwd) file: string name: string id: string classname: string // custom flow-header properties with the reserved junit keys removed properties: Record tags: string[] timeSeconds: number failure: { message: string; body: string } | null } export interface JUnitSuiteInput { device: string cases: JUnitCaseInput[] } function formatJUnitTime(seconds: number): string { return (Number.isFinite(seconds) && seconds >= 0 ? seconds : 0).toFixed(3) } export function buildJUnitXml(suite: JUnitSuiteInput): string { const failures = suite.cases.filter((c) => c.failure !== null).length const totalTime = suite.cases.reduce( (sum, c) => sum + (Number.isFinite(c.timeSeconds) ? c.timeSeconds : 0), 0, ) const lines = [ '', '', ` `, ] for (const c of suite.cases) { lines.push( ` `, ) const properties: Array<{ name: string; value: string }> = [] if (c.tags.length > 0) { properties.push({ name: 'tags', value: c.tags.join(',') }) } for (const [name, value] of Object.entries(c.properties)) { properties.push({ name, value }) } if (properties.length > 0) { lines.push(' ') for (const p of properties) { lines.push( ` `, ) } lines.push(' ') } if (c.failure) { lines.push(` `) lines.push(escapeXml(c.failure.body)) lines.push(' ') } lines.push(' ') } lines.push(' ') lines.push('') return `${lines.join('\n')}\n` } // the first failed trace step as a junit failure. a flow that died before // producing steps (parse error, sim never attached) has no failed step, so // the caller passes its own fallback text instead of an empty failure. export function failureFromTraceSteps( steps: SootSimFlowTraceStep[], fallbackMessage: string, ): { message: string; body: string } { const failed = steps.find((step) => step.status === 'failure') if (!failed) { return { message: fallbackMessage, body: fallbackMessage } } const error = failed.error || 'flow step failed' const message = error.split('\n')[0] || fallbackMessage const bodyLines = [`step ${failed.stepIndex + 1} (${failed.stepName}): ${error}`] if (failed.screenshotPath) { bodyLines.push(`screenshot: ${failed.screenshotPath}`) } return { message, body: bodyLines.join('\n') } } export interface FlowCommandsEntry { sequenceNumber: number command: string status: SootSimFlowTraceStep['status'] durationMs: number error?: string artifacts: string[] } export function buildFlowCommandsJson(steps: SootSimFlowTraceStep[]): string { const entries: FlowCommandsEntry[] = steps.map((step) => ({ sequenceNumber: step.stepIndex + 1, command: step.targetLabel ? `${step.stepName} ${step.targetLabel}` : step.stepName, status: step.status, durationMs: step.durationMs, ...(step.error ? { error: step.error } : {}), artifacts: step.screenshotPath ? [step.screenshotPath] : [], })) return `${JSON.stringify(entries, null, 2)}\n` } export interface SessionLogFlow { file: string passed: boolean durationMs: number steps: SootSimFlowTraceStep[] } function formatLogDuration(ms: number): string { return `${(Math.max(0, ms) / 1000).toFixed(1)}s` } function formatLogStep(step: SootSimFlowTraceStep): string { const label = step.targetLabel ? `${step.stepName} ${step.targetLabel}` : step.stepName const base = ` ${step.stepIndex + 1}. ${label} - ${step.status} (${formatLogDuration(step.durationMs)})` if (step.status === 'failure' && step.error) { return `${base}: ${step.error.split('\n')[0]}` } return base } // one flow's section, shared by the session log and the per-flow logs file // so the two never disagree about what a flow did. export function buildFlowLogLines(flow: SessionLogFlow): string[] { const lines = [ `# flow: ${flow.file} - ${flow.passed ? 'pass' : 'FAIL'} (${formatLogDuration(flow.durationMs)}, ${flow.steps.length} steps)`, ] for (const step of flow.steps) { lines.push(formatLogStep(step)) } return lines } export function buildSessionLog(input: { startedAtIso: string root: string device: string flows: SessionLogFlow[] }): string { const passed = input.flows.filter((flow) => flow.passed).length const lines = [ '# rnx maestro session log', `# started: ${input.startedAtIso}`, `# root: ${input.root}`, `# device: ${input.device}`, `# result: ${passed}/${input.flows.length} passed`, ] for (const flow of input.flows) { lines.push('#') lines.push(...buildFlowLogLines(flow)) } return `${lines.join('\n')}\n` }