// `rnx assert ` // // thin wrapper that runs any read verb with --json forced on, captures the // JSON payload, and converts predicate flags into a bisect-friendly exit // code. see docs for the full flag surface — this file intentionally stays // small: every assertion is a pure function over the parsed JSON, and the // only runtime work is the retry loop for --within and the optional shell // out to `jq` for --jq. // // exit codes: // 0 assertion passed (bisect: good) // 1 assertion evaluated and failed (bisect: bad) // 125 could not evaluate (bisect: skip) // 2 misuse — bad flags, unknown verb (treated as bad by bisect) import { spawn } from 'child_process' import { rnxExit } from '../run-rnx' import { rnxSelfInvocation } from '../self-invocation' // READ_VERBS lists every command whose stdout we know is valid JSON under // --json. keeping this explicit means `rnx assert tap 100 200` fails // fast with a clear message instead of silently running a write verb and // trying to parse " tapped 100,200" as JSON. const READ_VERBS = new Set([ // top-level 'describe', 'find', 'list', 'network', 'logs', // get 'count', 'tree', 'url', 'a11y', 'node', 'layout', 'keyboard', 'errors', 'warnings', 'requests', 'animations', 'animation', 'state', 'sample-color', ]) // assertion flags that assert recognizes. everything else is passed through // to the inner verb untouched. keeping this as a set (rather than positional // parsing) means `rnx assert find --testid X --count 4` reads naturally. const BOOL_ASSERT_FLAGS = new Set([ '--exists', '--empty', '--negate', '--quiet', '--allow-timeout', '!', ]) const VALUE_ASSERT_FLAGS = new Set([ '--count', '--count-at-least', '--count-at-most', '--contains', '--not-contains', '--matches', '--not-matches', '--equals', '--jq', '--has-path', '--within', ]) // --path-equals takes TWO values: const DUAL_VALUE_ASSERT_FLAGS = new Set(['--path-equals']) interface Predicate { kind: string args: string[] } interface AssertArgs { verbArgs: string[] predicates: Predicate[] withinMs: number negate: boolean quiet: boolean allowTimeout: boolean } function parseAssertArgs(raw: string[]): AssertArgs | { error: string } { const verbArgs: string[] = [] const predicates: Predicate[] = [] let withinMs = 0 let negate = false let quiet = false let allowTimeout = false for (let i = 0; i < raw.length; i++) { const a = raw[i] if (a === '--within') { const v = Number(raw[i + 1]) if (!Number.isFinite(v) || v < 0) { return { error: `--within requires a non-negative number, got: ${raw[i + 1]}` } } withinMs = v i++ continue } if (a === '--negate' || a === '!') { negate = true continue } if (a === '--quiet') { quiet = true continue } if (a === '--allow-timeout') { allowTimeout = true continue } if (BOOL_ASSERT_FLAGS.has(a)) { predicates.push({ kind: a, args: [] }) continue } if (VALUE_ASSERT_FLAGS.has(a)) { const v = raw[i + 1] if (v == null) return { error: `${a} requires a value` } predicates.push({ kind: a, args: [v] }) i++ continue } if (DUAL_VALUE_ASSERT_FLAGS.has(a)) { const v1 = raw[i + 1] const v2 = raw[i + 2] if (v1 == null || v2 == null) return { error: `${a} requires ` } predicates.push({ kind: a, args: [v1, v2] }) i += 2 continue } verbArgs.push(a) } if (verbArgs.length === 0) return { error: 'assert requires a verb (e.g. find, describe, get errors)' } return { verbArgs, predicates, withinMs, negate, quiet, allowTimeout } } // resolve the verb name the user typed. handles `get `, `do `, // and plain top-level verbs. returns null if the verb is unknown or is a // known write verb (which would never produce parseable json). function resolveReadVerb(verbArgs: string[]): string | null { if (verbArgs[0] === 'get' || verbArgs[0] === 'debug') { return READ_VERBS.has(verbArgs[1]) ? verbArgs[1] : null } if (verbArgs[0] === 'do' || verbArgs[0] === 'wait') return null return READ_VERBS.has(verbArgs[0]) ? verbArgs[0] : null } interface VerbResult { ok: boolean payload: unknown reason?: string } async function runVerbOnce(verbArgs: string[]): Promise { // re-invoke self through the real CLI entry, so every code path the user's // verb would take, assert takes too. const { executable, prefixArgs } = rnxSelfInvocation() const args = [...prefixArgs, ...verbArgs, '--json'] return new Promise((resolve) => { const proc = spawn(executable, args, { stdio: ['ignore', 'pipe', 'pipe'], env: process.env, }) let stdout = '' let stderr = '' proc.stdout.on('data', (d) => (stdout += d.toString())) proc.stderr.on('data', (d) => (stderr += d.toString())) proc.on('error', (err) => { resolve({ ok: false, payload: null, reason: `spawn failed: ${err.message}` }) }) proc.on('close', (code) => { if (code !== 0) { // stderr tail usually has the bridge diagnostic — include a short // slice so the skip message is actionable. const tail = stderr.trim().split('\n').slice(-2).join(' | ').slice(0, 200) resolve({ ok: false, payload: null, reason: `verb exited ${code}${tail ? `: ${tail}` : ''}`, }) return } if (!stdout.trim()) { resolve({ ok: false, payload: null, reason: 'verb produced no output' }) return } try { const payload = JSON.parse(stdout) resolve({ ok: true, payload }) } catch { resolve({ ok: false, payload: null, reason: `verb output was not valid json: ${stdout.slice(0, 120)}`, }) } }) }) } // read a dotted path against a JSON value. numeric segments index arrays, // anything else reads object keys. returns { found: false } for any missing // segment so callers can distinguish "path exists, value is null" from // "path doesn't exist." function readPath( value: unknown, path: string, ): { found: true; value: unknown } | { found: false } { const parts = path.split('.').filter((s) => s.length > 0) let cur: unknown = value for (const part of parts) { if (cur == null) return { found: false } if (Array.isArray(cur)) { const idx = Number(part) if (!Number.isInteger(idx) || idx < 0 || idx >= cur.length) return { found: false } cur = cur[idx] continue } if (typeof cur === 'object') { const obj = cur as Record if (!(part in obj)) return { found: false } cur = obj[part] continue } return { found: false } } return { found: true, value: cur } } interface PredicateResult { ok: boolean reason: string } function lengthOf(payload: unknown): number | null { if (Array.isArray(payload)) return payload.length if (payload && typeof payload === 'object') return Object.keys(payload).length return null } async function evalPredicate( pred: Predicate, payload: unknown, ): Promise { const { kind, args } = pred switch (kind) { case '--exists': { if (payload == null) return { ok: false, reason: 'payload is null' } const len = lengthOf(payload) if (len === 0) return { ok: false, reason: 'payload is empty' } return { ok: true, reason: 'exists' } } case '--empty': { if (payload == null) return { ok: true, reason: 'payload is null' } const len = lengthOf(payload) if (len === 0) return { ok: true, reason: 'payload is empty' } return { ok: false, reason: `payload has ${len} item(s)` } } case '--count': { const want = Number(args[0]) const got = lengthOf(payload) if (got == null) return { ok: false, reason: `payload not countable (${typeof payload})` } return { ok: got === want, reason: `count=${got}, want=${want}`, } } case '--count-at-least': { const want = Number(args[0]) const got = lengthOf(payload) if (got == null) return { ok: false, reason: `payload not countable` } return { ok: got >= want, reason: `count=${got}, want>=${want}` } } case '--count-at-most': { const want = Number(args[0]) const got = lengthOf(payload) if (got == null) return { ok: false, reason: `payload not countable` } return { ok: got <= want, reason: `count=${got}, want<=${want}` } } case '--contains': { const needle = args[0] const hay = JSON.stringify(payload) return { ok: hay.includes(needle), reason: hay.includes(needle) ? `contains "${needle}"` : `missing "${needle}"`, } } case '--not-contains': { const needle = args[0] const hay = JSON.stringify(payload) return { ok: !hay.includes(needle), reason: !hay.includes(needle) ? `not contains "${needle}"` : `unexpected "${needle}"`, } } case '--matches': { const re = new RegExp(args[0]) const hay = JSON.stringify(payload) const ok = re.test(hay) return { ok, reason: ok ? `matches /${args[0]}/` : `no match for /${args[0]}/`, } } case '--not-matches': { const re = new RegExp(args[0]) const hay = JSON.stringify(payload) const ok = !re.test(hay) return { ok, reason: ok ? `not matches /${args[0]}/` : `unexpected /${args[0]}/`, } } case '--equals': { const want = args[0] const got = typeof payload === 'string' ? payload : typeof payload === 'number' || typeof payload === 'boolean' ? String(payload) : JSON.stringify(payload) return { ok: got === want, reason: `got=${JSON.stringify(got)}, want=${JSON.stringify(want)}`, } } case '--has-path': { const r = readPath(payload, args[0]) return { ok: r.found, reason: r.found ? `path ${args[0]} present` : `path ${args[0]} missing`, } } case '--path-equals': { const [path, want] = args const r = readPath(payload, path) if (!r.found) return { ok: false, reason: `path ${path} missing` } const got = typeof r.value === 'string' || typeof r.value === 'number' || typeof r.value === 'boolean' ? String(r.value) : JSON.stringify(r.value) return { ok: got === want, reason: `${path}=${got}, want=${want}` } } case '--jq': { // shell out to `jq`. if jq isn't installed we can't evaluate, so we // bubble that up as a 125-skip rather than treating it as a failure. const expr = args[0] try { const out = await runJq(expr, payload) const trimmed = out.trim() const truthy = trimmed.length > 0 && trimmed !== 'null' && trimmed !== 'false' && trimmed !== '""' return { ok: truthy, reason: `jq: ${trimmed.slice(0, 120)}` } } catch (err: any) { // throw so the caller can turn this into a 125 — consistent with how // "verb crashed" is treated. we don't want bisect marking commits bad // because jq isn't installed. throw new Error(`jq evaluation failed: ${err.message}`) } } default: return { ok: false, reason: `unknown predicate ${kind}` } } } function runJq(expr: string, payload: unknown): Promise { return new Promise((resolve, reject) => { const proc = spawn('jq', [expr], { stdio: ['pipe', 'pipe', 'pipe'] }) let out = '' let err = '' proc.stdout.on('data', (d) => (out += d.toString())) proc.stderr.on('data', (d) => (err += d.toString())) proc.on('error', reject) proc.on('close', (code) => { if (code !== 0) { reject(new Error(err.trim() || `jq exited ${code}`)) return } resolve(out) }) proc.stdin.end(JSON.stringify(payload)) }) } export async function runAssert(rawArgs: string[]): Promise { if (rawArgs[0] === '--help' || rawArgs[0] === '-h' || rawArgs.length === 0) { printHelp() rnxExit(rawArgs.length === 0 ? 2 : 0) } const parsed = parseAssertArgs(rawArgs) if ('error' in parsed) { process.stderr.write(` assert: ${parsed.error}\n`) rnxExit(2) } const { verbArgs, predicates, withinMs, negate, quiet, allowTimeout } = parsed const verb = resolveReadVerb(verbArgs) if (!verb) { process.stderr.write( ` assert: unknown or non-read verb: ${verbArgs.join(' ')}\n` + ` supported: ${Array.from(READ_VERBS).sort().join(', ')}\n`, ) rnxExit(2) } // default assertion when none given is --exists. matches the common // "does this node / error / entry even show up?" check. const effectivePredicates = predicates.length > 0 ? predicates : [{ kind: '--exists', args: [] }] const deadline = withinMs > 0 ? Date.now() + withinMs : 0 let attempts = 0 let lastFailReason = '' let lastVerbSkipReason = '' while (true) { attempts++ const verbResult = await runVerbOnce(verbArgs) if (!verbResult.ok) { lastVerbSkipReason = verbResult.reason || 'verb failed' if (Date.now() < deadline) { await sleep(150) continue } // out of retry budget. default is skip (125) because "verb couldn't // evaluate" is distinct from "assertion evaluated and failed". // --allow-timeout flips this to fail (1) — used when an auto-wait // timeout inside the verb is a meaningful negative signal, not a // "please bisect-skip this commit". const exitCode = allowTimeout ? 1 : 125 if (!quiet) { const label = exitCode === 1 ? 'fail' : 'skip' process.stderr.write(` assert: ${label} — ${lastVerbSkipReason}\n`) } rnxExit(exitCode) } let allPass = true const reasons: string[] = [] try { for (const pred of effectivePredicates) { const r = await evalPredicate(pred, verbResult.payload) reasons.push(`${pred.kind}: ${r.reason}`) if (!r.ok) { allPass = false break } } } catch (err: any) { if (!quiet) process.stderr.write(` assert: skip — ${err.message}\n`) rnxExit(125) } const pass = negate ? !allPass : allPass if (pass) { if (!quiet) { process.stderr.write( ` assert: pass${attempts > 1 ? ` (attempt ${attempts})` : ''} — ${reasons.join('; ')}\n`, ) } rnxExit(0) } lastFailReason = reasons.join('; ') if (Date.now() < deadline) { await sleep(150) continue } if (!quiet) { process.stderr.write( ` assert: fail${attempts > 1 ? ` after ${attempts} attempt(s)` : ''} — ${lastFailReason}\n`, ) } rnxExit(1) } } function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)) } function printHelp(): void { process.stdout.write(` rnx assert — run any read verb and convert its output into a bisect-friendly exit code. usage: rnx assert exit codes: 0 assertion passed (git bisect: good) 1 assertion failed (git bisect: bad) 125 could not evaluate (git bisect: skip — app not loaded, verb crashed) 2 misuse (bad flags/verb) assertion flags (AND'd when multiple are given): --count result array has exactly n items --count-at-least length >= n --count-at-most length <= n --exists non-null, non-empty (default if no flag given) --empty null or zero length --contains JSON.stringify(result) contains str (repeatable) --not-contains opposite --matches result (stringified) matches regex --not-matches opposite --equals scalar equality --has-path nested path exists --path-equals nested path equals value --jq jq expression is truthy (requires jq on PATH) --within retry verb + predicates until pass or deadline --allow-timeout treat verb auto-wait timeout as a fail, not a skip --negate, ! flip pass/fail (does not flip skip) --quiet suppress the one-line pass/fail summary examples: rnx assert find --testid set-max-input --count 4 rnx assert describe --contains "swap-form-header" rnx assert get errors --count 0 rnx assert find --testid submit --exists --within 3000 rnx assert describe --jq '.tree | contains("swap-form-header")' `) }