◉RORK LABJP
●GPT-6.1 SOL — Added to Rork's model menu (09-29) with Low, Medium and High effort; included on Pro and Max●SONNET 5.5 — Claude Sonnet 5.5 is selectable: strong at UI design and faster than Sonnet 5 (09-28)●SDK 58 — Beta since 09/15 with no stable date yet; running projects in Expo Go has required login since 09/03●WIDGET — A write-up traces an iOS widget that renders black only in production builds●OBSERVE — EAS Observe now records native crashes too (SDK 57.0.21 or later, 10/07)●NEW — My App Open Ad Only Showed When the App…●GPT-6.1 SOL — Added to Rork's model menu (09-29) with Low, Medium and High effort; included on Pro and Max●SONNET 5.5 — Claude Sonnet 5.5 is selectable: strong at UI design and faster than Sonnet 5 (09-28)●SDK 58 — Beta since 09/15 with no stable date yet; running projects in Expo Go has required login since 09/03●WIDGET — A write-up traces an iOS widget that renders black only in production builds●OBSERVE — EAS Observe now records native crashes too (SDK 57.0.21 or later, 10/07)●NEW — My App Open Ad Only Showed When the App…
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-cdflaky-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-10-15
When EXPO_PUBLIC_ Values Read Fine in Dev but Come Back undefined in Production: Telling the Spellings Apart
The API URL reads fine while developing and turns undefined once the app is built. The cause was how process.env was written. I lined up babel-preset-expo's output for dev and production and tabulated which spellings get replaced and which are left behind.
⬡ Dev Tools2026-10-14
My App Open Ad Only Showed When the App Came Back From Background. The 'Returned to Foreground' Signal Fired Before the Ad Had Loaded
On a cold start, the app open ad in my Android app never showed. The foreground signal came before the load began. Here is how I split the signal from the load and rebuilt it as logic you can test ordering by ordering.
⬡ Dev Tools2026-10-13
Before You Ship: Go Looking for Your Own Keys Inside the Exported Expo Bundle
A key can be missing from your source and still be sitting inside the finished app. I ran expo export on Expo SDK 57, searched the bundle with grep and a short Node script, and recorded which secrets show up and which don't.