RORK LABJP
MAX — Rork Max is built on Claude Code and Claude Opus 4.6, generating native Swift apps directly instead of React NativeAPPLE — Rork Max targets the whole Apple ecosystem: iPhone, iPad, Apple Watch, Apple TV, Vision Pro, and iMessageWORKFLOW — In practice, users settle into letting the AI scaffold while they rewrite the state management and data layer themselvesSEED — Rork raised a $15M seed led by Left Lane Capital in April, with Peak XV, True Ventures, and a16z Speedrun joiningPAPERLINE — Rork acquired app builder Paperline and says it will stay acquisitive to bring in engineering talentREVIEW — Three-month revisit reviews are growing, clarifying where the tool shines and where it doesn'tMAX — Rork Max is built on Claude Code and Claude Opus 4.6, generating native Swift apps directly instead of React NativeAPPLE — Rork Max targets the whole Apple ecosystem: iPhone, iPad, Apple Watch, Apple TV, Vision Pro, and iMessageWORKFLOW — In practice, users settle into letting the AI scaffold while they rewrite the state management and data layer themselvesSEED — Rork raised a $15M seed led by Left Lane Capital in April, with Peak XV, True Ventures, and a16z Speedrun joiningPAPERLINE — Rork acquired app builder Paperline and says it will stay acquisitive to bring in engineering talentREVIEW — Three-month revisit reviews are growing, clarifying where the tool shines and where it doesn't
Articles/Dev Tools
Dev Tools/2026-07-13Advanced

Your Detox and Maestro E2E Suite Was All Green — but the Retries Were Hiding a Flaky Test That Crashed in Production

Your E2E suite is green, yet production still crashes. The culprit: auto-retries quietly swallowing flaky failures. Field notes on measuring per-test flake rate, quarantining, and keeping only real failures as release gates.

rork-max40e2e-testingdetoxmaestroci-cd2flaky-testquality-assurance

Premium Article

One morning every CI check was green. All 42 E2E tests passed. A few hours after I shipped the OTA update, a crash report landed on the payment confirmation screen — a flow the E2E suite tests on every single run.

Green, but not actually passing.

When I traced the logs and found the reason, my chest tightened a little. It wasn't a bug in the test code, nor a fresh defect in the app. Auto-retries had been quietly swallowing a flaky test's red. The checkout test failed roughly one in three runs, CI re-ran it up to three times, and it kept recording the one lucky pass as "green."

These are field notes on noticing that untrustworthy green, measuring flakiness, quarantining it, and leaving only real failures as the thing that gates a release. They're written for indie developers running Detox or Maestro E2E on apps built with Rork or Rork Max — in the order I actually worked through it across my own handful of apps.

How green stops meaning green — retries as an eraser

Most E2E runners ship auto-retry to cope with instability. With Detox on Jest it's jest.retryTimes(2); with Maestro it's a retry loop on the CI side. Both are common.

Retries aren't evil in themselves. On-device and emulator E2E can fail for reasons that have nothing to do with your code — an animation that hasn't settled, a network hiccup. The real problem is recording only whether the test eventually passed.

Retry three times, pass once, mark it green. That recording scheme can't tell these two apart:

StateThree attemptsRecorded asWhat it really means
Stable and healthy○ ○ ○GreenFine
Flaky× × ○GreenCould break in production any day

The second row is the trap. It looks green, so nobody notices. But a test failing 67% of the time will, on a real device under worse conditions than your test rig, eventually turn into a genuine red. My checkout flow was exactly that.

The mental shift that helped: treat pass/fail not as a boolean but as a history of attempts. Stop looking only at the final result. Keep the outcome of each attempt across retries, and flakiness turns into a number you can see.

Measuring flake rate — read JUnit XML per attempt

Both Detox (Jest) and Maestro can emit JUnit-format XML, with a <testcase> per test and a <failure> on failure. If you make each retry its own report, you can aggregate the attempt history mechanically.

First, keep retries by appending rather than overwriting. When running Maestro on GitHub Actions, the reliable move is writing each retry to a separate file.

# .github/workflows/e2e.yml (excerpt)
- name: Run Maestro E2E (3 attempts, keep every report)
  run: |
    for attempt in 1 2 3; do
      maestro test .maestro/ \
        --format junit \
        --output "reports/e2e-attempt-${attempt}.xml" || true
    done
  # "|| true" lets all attempts run to completion even after a failure;
  # the pass/fail decision moves to the aggregation step below

The || true matters. Fail-fast here would stop the job before the flaky "×" is ever recorded. The point is to pull the verdict out of the runner and hand it to an aggregation script.

Here's a Node script that reads all the XML files and computes a per-test flake rate. Its only dependency is one lightweight XML parser.

// scripts/flake-rate.mjs
import { readdirSync, readFileSync } from "node:fs";
import { XMLParser } from "fast-xml-parser";
 
const DIR = "reports";
const parser = new XMLParser({ ignoreAttributes: false, attributeNamePrefix: "@_" });
 
// test name -> { runs: total attempts, fails: failed attempts }
const stats = new Map();
 
for (const file of readdirSync(DIR).filter((f) => f.endsWith(".xml"))) {
  const xml = parser.parse(readFileSync(`${DIR}/${file}`, "utf8"));
  const suites = [].concat(xml.testsuites?.testsuite ?? xml.testsuite ?? []);
  for (const suite of suites) {
    for (const tc of [].concat(suite.testcase ?? [])) {
      const name = `${tc["@_classname"] ?? ""} › ${tc["@_name"]}`;
      const rec = stats.get(name) ?? { runs: 0, fails: 0 };
      rec.runs += 1;
      if (tc.failure || tc.error) rec.fails += 1; // any red attempt counts as a failure
      stats.set(name, rec);
    }
  }
}
 
const rows = [...stats.entries()]
  .map(([name, { runs, fails }]) => ({ name, runs, fails, rate: fails / runs }))
  .filter((r) => r.fails > 0 && r.fails < r.runs) // drop all-pass and all-fail; keep only flaky
  .sort((a, b) => b.rate - a.rate);
 
for (const r of rows) {
  console.log(`${(r.rate * 100).toFixed(0).padStart(3)}%  ${r.fails}/${r.runs}  ${r.name}`);
}
process.exitCode = 0; // measurement always succeeds; a separate job owns the gate

The condition r.fails > 0 && r.fails < r.runs is the crux. A test that fails on every attempt is a real failure (it should go red elsewhere); one that passes on every attempt is healthy. Only the middle — passes sometimes, fails sometimes — is flaky. Get this wrong and you'll wave a genuine bug through as "just flaky."

The first time I ran this across six apps, the top flake was the checkout flow at 67% (two failures in three attempts). Next was waiting on the push-notification permission dialog, around 40%. Only as numbers did the shape of the swallowed instability finally show.

Thank you for reading this far.

Continue Reading

What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.

WHAT YOU'LL LEARN
A small Node script that reads JUnit XML across retries and computes per-test flake rate
A quarantine workflow (isolate at 20% flake rate) plus a retry budget capped at 3 rescues per build
CI gates and a weekly review cadence that keep green trustworthy again
Secure payment via Stripe · Cancel anytime

Unlock This Article

Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.

or
Unlock all articles with Membership →
Share

Thank You for Reading

Rork Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.

  • Copy-paste ready implementation code
  • New advanced guides published daily
  • $5/mo or $10 for lifetime access
View Membership →

Related Articles

Dev Tools2026-07-01
Rork Max Cloud Compilation — Shipping Native Apps Without Owning a Mac
How Rork Max's cloud compilation lets you build, device-test, and publish native iOS/iPadOS apps without a local Mac — including how to triage failed builds and when you should still keep a real Mac around.
Dev Tools2026-06-17
Checking Age Without Collecting Birthdays — Wiring the Declared Age Range API into a Rork App
How to use the iOS 26 Declared Age Range API to receive an age band without ever storing a birthdate, with both the Rork Max native Swift path and the standard Rork (Expo) native-module bridge, plus where to draw the responsibility boundary.
Dev Tools2026-05-25
Implementation Notes: Adding StoreKit 2 In-App Purchases to a Rork iOS App
Notes from grafting StoreKit 2 in-app purchases onto Swift/SwiftUI code generated by Rork, drawing on the StoreKit 1-to-2 migration done across a 50M-cumulative-download wallpaper-app portfolio. Covers ProductID design, transaction verification, paywall UI, and production gotchas.
📚RECOMMENDED BOOKS
Build a Large Language Model (From Scratch)
Sebastian Raschka
LLM Dev
Prompt Engineering for LLMs
Berryman & Ziegler
Prompting
AI Engineering
Chip Huyen
AI Eng
* Contains affiliate links
See all →