<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Duchan</title>
    <description>The latest articles on DEV Community by Duchan (@joduchan).</description>
    <link>https://dev.to/joduchan</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3944002%2F9c302bbd-5e43-4d17-8f43-df93b23c5dd5.png</url>
      <title>DEV Community: Duchan</title>
      <link>https://dev.to/joduchan</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/joduchan"/>
    <language>en</language>
    <item>
      <title>Most of a test phone's carbon is emitted before the first test runs</title>
      <dc:creator>Duchan</dc:creator>
      <pubDate>Mon, 10 Aug 2026 14:23:14 +0000</pubDate>
      <link>https://dev.to/joduchan/most-of-a-test-phones-carbon-is-emitted-before-the-first-test-runs-lmn</link>
      <guid>https://dev.to/joduchan/most-of-a-test-phones-carbon-is-emitted-before-the-first-test-runs-lmn</guid>
      <description>&lt;p&gt;There is a drawer in most mobile teams' offices. Mine had fourteen phones in it.&lt;/p&gt;

&lt;p&gt;Two iPhones were frozen on old iOS versions with updates blocked, because you can't roll iOS back. An iPad nobody had charged in months. A few Android devices we kept around because one manufacturer had shipped an interesting bug.&lt;/p&gt;

&lt;p&gt;And the drawer only helped people who could already build the app. Everyone else had to interrupt a mobile developer just to see a build running.&lt;/p&gt;

&lt;p&gt;That's what we set out to fix. Carbon had nothing to do with it.&lt;/p&gt;

&lt;p&gt;We looked at hosted device clouds first and stopped before signing up. The price scaled with the team, and our app binary would have to leave our network. So we built &lt;a href="https://github.com/jo-duchan/tapflow" rel="noopener noreferrer"&gt;tapflow&lt;/a&gt; instead: an open-source, self-hosted tool that streams iOS simulators and Android emulators into the browser, so anyone on the team can test a build from a URL.&lt;/p&gt;

&lt;p&gt;But while working on it, I realized that if the answer to "we don't have enough test devices" is "buy fewer test devices," there's another consequence besides the budget. Most of a phone's carbon footprint is already there before anyone starts using it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Most of a device's carbon comes from manufacturing
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Device&lt;/th&gt;
&lt;th&gt;Total LCA&lt;/th&gt;
&lt;th&gt;Production share&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;iPhone 17 (256 GB)&lt;/td&gt;
&lt;td&gt;55 kg&lt;/td&gt;
&lt;td&gt;76%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;iPad (A16, 128 GB)&lt;/td&gt;
&lt;td&gt;74 kg&lt;/td&gt;
&lt;td&gt;74%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Mac mini (M4)&lt;/td&gt;
&lt;td&gt;32 kg&lt;/td&gt;
&lt;td&gt;74%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;MacBook Pro 14" (M2 Pro)&lt;/td&gt;
&lt;td&gt;243 kg&lt;/td&gt;
&lt;td&gt;79%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Three quarters or more of each device's lifetime footprint comes from production.&lt;/p&gt;

&lt;p&gt;That changes how I think about test hardware. Using a phone more often doesn't make its manufacturing footprint worse, and leaving it in a drawer doesn't make it better. That carbon was already emitted when the device was manufactured.&lt;/p&gt;

&lt;p&gt;So a test phone used twice a month is particularly inefficient from a hardware-utilization perspective: almost the full manufacturing footprint, for very little actual use.&lt;/p&gt;

&lt;p&gt;The two sides also age differently.&lt;/p&gt;

&lt;p&gt;Test devices get replaced because of OS support windows and the need to validate new releases. That's the same reason two iPhones in my drawer had their updates permanently blocked.&lt;/p&gt;

&lt;p&gt;A machine hosting simulators has less of that pressure. It's mostly memory-bound, runs headless, and can stay in service until its macOS version can no longer run the Xcode version you need.&lt;/p&gt;

&lt;p&gt;Over eight years, replacing four test devices accounts for 444 kg of manufacturing carbon. One MacBook Pro accounts for 196.8 kg once.&lt;/p&gt;

&lt;p&gt;So the question I became interested in wasn't how efficiently we test. It was how much hardware we had to buy to test at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  The arithmetic
&lt;/h2&gt;

&lt;p&gt;Take a team that needs coverage equivalent to four test devices — say three iPhones and an iPad.&lt;/p&gt;

&lt;p&gt;They can buy those devices, or run four simulators on a Mac they already own. Four is about the practical ceiling on a 32 GB host.&lt;/p&gt;

&lt;p&gt;For comparison, I annualised everything.&lt;/p&gt;

&lt;p&gt;Manufacturing carbon comes from Apple's per-product environmental reports, divided by expected service life using Apple's own assumptions: three years for an iPhone, four years for an iPad and a Mac.&lt;/p&gt;

&lt;p&gt;For electricity, I used Korea's grid factor of 417.3 gCO₂e/kWh.&lt;/p&gt;

&lt;p&gt;The model assumes 1,000 session hours per year, with 30% of that time spent actively interacting with the app. The active power figure is measured rather than estimated; the commands to reproduce that measurement are near the end of this post.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;4 physical devices&lt;/th&gt;
&lt;th&gt;tapflow (Mac already owned)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Manufacturing, annualised&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;55.5 kg&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0 kg&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Electricity&lt;/td&gt;
&lt;td&gt;12.7 kg (30.4 kWh)&lt;/td&gt;
&lt;td&gt;17.9 kg (42.9 kWh)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Total&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;68.2 kg/yr&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;17.9 kg/yr&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That's 3.8× less in this model.&lt;/p&gt;

&lt;p&gt;Almost all of the difference is the manufacturing row: 55.5 kg versus zero. Not because simulators use less electricity, but because the Mac already exists.&lt;/p&gt;

&lt;h2&gt;
  
  
  Electricity actually goes the other way
&lt;/h2&gt;

&lt;p&gt;A Mac left powered on uses more electricity over a year than charging four phones.&lt;/p&gt;

&lt;p&gt;I think that's important to keep in the comparison. If I removed it, the result would look better, but it would also hide one of the more useful findings from the measurement.&lt;/p&gt;

&lt;p&gt;It also doesn't change the result much.&lt;/p&gt;

&lt;p&gt;About 86% of the Mac's 42.9 kWh comes from the machine simply being powered on, not from simulator activity. Even if I scale the measured per-simulator cost linearly to four simulators, the annual total only moves by about a kilogram of CO₂e.&lt;/p&gt;

&lt;p&gt;So this isn't really an energy-efficiency argument.&lt;/p&gt;

&lt;p&gt;It's a hardware-utilization argument, and most of the carbon in this comparison comes from manufacturing the hardware.&lt;/p&gt;

&lt;h2&gt;
  
  
  I tried changing the weak assumptions
&lt;/h2&gt;

&lt;p&gt;There are two inputs I'm least confident about: how per-simulator power scales beyond one simulator, and how often a test device actually gets charged.&lt;/p&gt;

&lt;p&gt;I varied both.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Simulator increment&lt;/th&gt;
&lt;th&gt;Device charging&lt;/th&gt;
&lt;th&gt;tapflow&lt;/th&gt;
&lt;th&gt;Devices&lt;/th&gt;
&lt;th&gt;Ratio&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;×1&lt;/td&gt;
&lt;td&gt;daily&lt;/td&gt;
&lt;td&gt;16.0 kg&lt;/td&gt;
&lt;td&gt;68.2 kg&lt;/td&gt;
&lt;td&gt;4.3×&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;×2&lt;/td&gt;
&lt;td&gt;daily&lt;/td&gt;
&lt;td&gt;16.7 kg&lt;/td&gt;
&lt;td&gt;68.2 kg&lt;/td&gt;
&lt;td&gt;4.1×&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;×4 (linear, most conservative)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;daily&lt;/td&gt;
&lt;td&gt;17.9 kg&lt;/td&gt;
&lt;td&gt;68.2 kg&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;3.8×&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;×4&lt;/td&gt;
&lt;td&gt;3×/week&lt;/td&gt;
&lt;td&gt;17.9 kg&lt;/td&gt;
&lt;td&gt;60.9 kg&lt;/td&gt;
&lt;td&gt;3.4×&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The result stays between 3.4× and 4.3× across those cases.&lt;/p&gt;

&lt;p&gt;The 3.8× headline number uses linear scaling for four simulators, which is the conservative assumption here.&lt;/p&gt;

&lt;p&gt;There are also a few reasons the measurement may overstate tapflow's actual cost.&lt;/p&gt;

&lt;p&gt;I measured with the browser decoding the stream on the same Mac, while in normal use decoding happens on the tester's machine. The active measurement was sixty seconds of continuous scrolling, which is much heavier than typical QA. And I treated multi-simulator power as linear even though the measurements are dominated by idle power.&lt;/p&gt;

&lt;p&gt;An earlier version of this calculation didn't measure the active cost at all. I guessed 30 W. The measurement came back at 4.52 W — wrong by more than a factor of six, in the direction that made our own case look worse. That's why the number in the table is measured rather than estimated.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this stops being true
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;If you buy a Mac for this, you've bought hardware.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The zero in the manufacturing row only applies when the Mac already exists. If you buy a host specifically for tapflow, its manufacturing carbon has to be included. Against the four devices' 55.5 kg/year, a Mac mini breaks even in roughly six months and a MacBook Pro in about three and a half years.&lt;/p&gt;

&lt;p&gt;The difference is large because a laptop includes a display and battery that a simulator host doesn't need. Its manufacturing footprint is roughly seven times that of a Mac mini. If you're buying a dedicated host, the Mac mini is the better choice.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It doesn't replace every device.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;tapflow can't test features that depend on device hardware such as cameras, NFC, or biometrics. Teams that need those still need real devices. The question is how many devices you need, not whether you can eliminate them entirely.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Grid factors are regional.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;These numbers use Korea's electricity mix. On a cleaner grid, both electricity rows shrink while the manufacturing comparison stays the same.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Old Macs have a floor.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;An iOS simulator needs a recent Xcode, and Xcode needs a recent macOS. A Mac that can't run the required versions can't be used as an iOS simulator host, even if the hardware itself still works.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;And tapflow is v0.x.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;We use it every day and it's under active development. Plan accordingly.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'm deliberately not claiming
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;This is not an SCI score.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The Software Carbon Intensity specification, now ISO/IEC 21031:2024, measures emissions attributable to software: the energy it consumes plus the embodied carbon of the hardware allocated to it. It doesn't include a term for hardware that was never purchased.&lt;/p&gt;

&lt;p&gt;Avoided emissions are outside that model, and the specification explicitly doesn't allow offsets or market measures to reduce the score. So the comparison in this post and an SCI score answer different questions.&lt;/p&gt;

&lt;p&gt;If you run tapflow, its own SCI score isn't zero. There's still a Mac consuming electricity and carrying a share of its manufacturing footprint. Whether avoided emissions should count is a separate question, and I don't want to hide that distinction by folding them into a metric that wasn't designed for it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;I also can't give you a number against hosted device clouds.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;It's an obvious comparison to want to make. A hosted service runs cloud infrastructure plus Mac hosts, while a self-hosted relay and agent can share one machine and stay on the LAN.&lt;/p&gt;

&lt;p&gt;But the providers I looked at don't publish the information I'd need to calculate it: PUE, regional grid mix, or sessions per host. Without those numbers, a kilogram-for-kilogram comparison would be made up, so I'm leaving that one qualitative.&lt;/p&gt;

&lt;h2&gt;
  
  
  Measure it on your own host
&lt;/h2&gt;

&lt;p&gt;The per-session power cost is the one input above that I measured directly.&lt;/p&gt;

&lt;p&gt;I ran the test on a MacBook Pro 14" with an M2 Pro and 32 GB of memory, running macOS 26.5.2. One iOS simulator, the relay, and the agent were all running on the same host.&lt;/p&gt;

&lt;p&gt;Each point uses 60 one-second samples from &lt;code&gt;Combined Power (CPU + GPU + ANE)&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;powermetrics &lt;span class="nt"&gt;--samplers&lt;/span&gt; cpu_power &lt;span class="nt"&gt;-i&lt;/span&gt; 1000 &lt;span class="nt"&gt;-n&lt;/span&gt; 60 &lt;span class="se"&gt;\&lt;/span&gt;
  | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="s1"&gt;'Combined Power (CPU + GPU + ANE)'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Point&lt;/th&gt;
&lt;th&gt;State&lt;/th&gt;
&lt;th&gt;SoC power&lt;/th&gt;
&lt;th&gt;Increment&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;A&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;No session&lt;/td&gt;
&lt;td&gt;0.66 W (sd 0.14)&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;B&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Session open, screen static&lt;/td&gt;
&lt;td&gt;0.64 W (sd 0.29)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;≈ 0&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;C&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Session open, continuous scrolling&lt;/td&gt;
&lt;td&gt;5.16 W (sd 1.99)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;+4.52 W&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Keeping a stream open adds effectively nothing in this measurement. A and B differ by −0.02 W, which is well inside the noise. That's consistent with how the H.264 stream behaves when the screen isn't changing: we had already measured roughly 2 MB/s for a still screen and 12–16 MB/s while scrolling.&lt;/p&gt;

&lt;p&gt;During continuous interaction, the measured increment was 4.52 W at the SoC, or about 5.05 W at the wall after accounting for adapter efficiency.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;powermetrics&lt;/code&gt; reports SoC power rather than the display, SSD, fans, and the rest of the system. That's useful here because I'm measuring the &lt;em&gt;increment&lt;/em&gt; between states. The platform draw is present in A, B, and C, so it cancels out. For absolute draw, I used Apple's published wall-power figures instead.&lt;/p&gt;

&lt;p&gt;If you already run a host, tapflow's built-in Mac resource monitoring also exposes CPU and memory series you can compare against your own workload.&lt;/p&gt;

&lt;p&gt;The full calculation — inputs, sources, measurement conditions, sensitivity analysis, and the assumptions I discarded — is in &lt;a href="https://github.com/jo-duchan/tapflow/blob/main/contributing/sustainability-carbon-math.md" rel="noopener noreferrer"&gt;&lt;code&gt;sustainability-carbon-math.md&lt;/code&gt;&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;There's also a shorter summary in the &lt;a href="https://www.tapflow.dev/reference/sustainability" rel="noopener noreferrer"&gt;sustainability section&lt;/a&gt; of the docs.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-g&lt;/span&gt; tapflow
tapflow start
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Repo&lt;/strong&gt;: &lt;a href="https://github.com/jo-duchan/tapflow" rel="noopener noreferrer"&gt;https://github.com/jo-duchan/tapflow&lt;/a&gt; — MIT, self-hosted; builds and streams stay on your own network&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Docs&lt;/strong&gt;: &lt;a href="https://www.tapflow.dev" rel="noopener noreferrer"&gt;https://www.tapflow.dev&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The carbon in that drawer was spent years ago. Using those devices carefully can't undo it.&lt;/p&gt;

&lt;p&gt;What we can change is what happens when those devices need replacing.&lt;/p&gt;

&lt;p&gt;So if your team has a drawer like this, I'm curious: how many devices are in it, and how many days a month does each one actually get used?&lt;/p&gt;

&lt;p&gt;I only have my own numbers.&lt;/p&gt;

</description>
      <category>sustainability</category>
      <category>testing</category>
      <category>opensource</category>
      <category>webdev</category>
    </item>
    <item>
      <title>A UI test that guesses is worse than one that fails</title>
      <dc:creator>Duchan</dc:creator>
      <pubDate>Fri, 07 Aug 2026 07:22:10 +0000</pubDate>
      <link>https://dev.to/joduchan/a-ui-test-that-guesses-is-worse-than-one-that-fails-953</link>
      <guid>https://dev.to/joduchan/a-ui-test-that-guesses-is-worse-than-one-that-fails-953</guid>
      <description>&lt;p&gt;The worst bug I've seen in a test suite wasn't a failure. It was a pass.&lt;/p&gt;

&lt;p&gt;A login test tapped a button labeled "Continue". The app then shipped a second "Continue" inside a consent sheet, and the recorded test — which resolved its selector by taking the first match — started tapping the wrong one. It stayed green for two weeks, because the flow still ended up somewhere plausible, and a passing test is not something anyone goes back and reads.&lt;/p&gt;

&lt;p&gt;So that became the rule for tapflow's flow runner: anywhere the tooling has to choose for you, it has to say so. Finding those places took longer than fixing them — there were four.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://dev.to/joduchan/coordinate-based-ui-tests-break-so-we-read-the-accessibility-tree-instead-from-inside-the-3gl7"&gt;Last time&lt;/a&gt; I wrote about getting an accessibility tree out of a simulator that has no window, on both iOS and Android, in one schema. This post is about what that tree is for.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The automation axis here — the flow runner and the MCP server — is &lt;strong&gt;experimental&lt;/strong&gt;. The manual browser QA path is the mature one.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  The flow file
&lt;/h2&gt;

&lt;p&gt;A flow is YAML, replayed by &lt;code&gt;tapflow flow run&lt;/code&gt; with zero LLM calls. The same input produces the same steps in the same order, every time:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;login-smoke&lt;/span&gt;
&lt;span class="na"&gt;appId&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;com.example.app&lt;/span&gt;
&lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;clearState&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;launchApp&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;assertVisible&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Sign&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;in"&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;tapOn&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;com.example.app:id/email"&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;inputText&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;user@example.com"&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;tapOn&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Sign&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;in"&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;assertVisible&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;label&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Orders"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;timeout&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;15&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There are ten steps in the entire vocabulary — &lt;code&gt;clearState&lt;/code&gt;, &lt;code&gt;launchApp&lt;/code&gt;, &lt;code&gt;tapOn&lt;/code&gt;, &lt;code&gt;inputText&lt;/code&gt;, &lt;code&gt;pressKey&lt;/code&gt;, &lt;code&gt;swipe&lt;/code&gt;, &lt;code&gt;scroll&lt;/code&gt;, &lt;code&gt;openUrl&lt;/code&gt;, &lt;code&gt;assertVisible&lt;/code&gt;, and &lt;code&gt;assertNotVisible&lt;/code&gt;. That's deliberate. Every step we add is another way for a flow to end up meaning something other than what it looks like it says.&lt;/p&gt;




&lt;h2&gt;
  
  
  Ambiguity is a bug in the selector
&lt;/h2&gt;

&lt;p&gt;A bare string resolves in a fixed order — exact identifier, then exact label, then partial label — and once one of those stages matches, the later stages aren't tried. If more than one element survives that stage, a &lt;code&gt;tapOn&lt;/code&gt; fails on the spot and tells you what it found:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;2 elements match "New Orders" — add an index or a more specific role/label
  (candidates: button "New Orders" | text "New Orders")
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the case from the top of the post. Taking &lt;code&gt;matches[0]&lt;/code&gt; would let a suite silently retarget after a redesign, and there's no way to tell that apart from a suite that still works until something downstream breaks. The candidate list also turns a five-minute stare at a screenshot into a one-line edit.&lt;/p&gt;

&lt;p&gt;Two disambiguators resolve it. &lt;code&gt;role&lt;/code&gt; narrows by element kind, which handles the common case where a button and the text inside it carry the same label. &lt;code&gt;index&lt;/code&gt; picks the Nth remaining match, zero-based, for rows that have neither a label nor an identifier:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;tapOn&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;label&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;New&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Orders"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;role&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;button&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;tapOn&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;role&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;cell&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;index&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;2&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The object form needs at least one of &lt;code&gt;id&lt;/code&gt;, &lt;code&gt;label&lt;/code&gt;, or &lt;code&gt;role&lt;/code&gt;. An &lt;code&gt;index&lt;/code&gt; on its own would be a positional coordinate again, just written differently.&lt;/p&gt;

&lt;p&gt;(&lt;code&gt;assertVisible&lt;/code&gt; is checking presence rather than choosing a target, so it passes when at least one element matches.)&lt;/p&gt;




&lt;h2&gt;
  
  
  There is no sleep step
&lt;/h2&gt;

&lt;p&gt;Timing was the next place the runner was guessing. A &lt;code&gt;sleep&lt;/code&gt; records how slow CI happened to be on the day you wrote the test, and then every later run either wastes that time or fails because the machine was busier. So waiting is always a condition with a deadline instead: &lt;code&gt;assertVisible&lt;/code&gt; with a &lt;code&gt;timeout&lt;/code&gt;, which defaults to 10 seconds and can be set per selector.&lt;/p&gt;

&lt;p&gt;That was the design, and it had a hole in it that only showed up in use.&lt;/p&gt;

&lt;p&gt;Right after &lt;code&gt;launchApp&lt;/code&gt;, the app isn't in the foreground yet, so a tree query legitimately throws. The first version of the poll loop treated any thrown query as a step failure, which meant a flow that began with a launch failed immediately. People worked around it by adding a long-press somewhere as a stand-in for a sleep. We had removed sleeps and handed everyone a worse one in return.&lt;/p&gt;

&lt;p&gt;The loop now separates a failure worth waiting out from one that waiting cannot fix:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;queryOrRetry&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;driver&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;FlowDriver&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;deadline&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// bound the query by the remaining deadline: a stalled response&lt;/span&gt;
    &lt;span class="c1"&gt;// must not block the loop past the step's own timeout&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;tree&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;driver&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;queryUITree&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;AbortSignal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;deadline&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()))&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt; &lt;span class="k"&gt;instanceof&lt;/span&gt; &lt;span class="nx"&gt;TransientQueryError&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;transient&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;  &lt;span class="c1"&gt;// keep polling&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;   &lt;span class="c1"&gt;// bad request, auth, missing session → fail now&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A foreground race, an idle timeout, or a network blip is retried until the deadline. A malformed request or a missing session fails immediately, because waiting won't fix either one. And when a wait does time out, the last transient error is included in the message, so "no element matched X within 10s" can't hide the fact that every query along the way was erroring.&lt;/p&gt;




&lt;h2&gt;
  
  
  Was that a test failure, or a dead runner?
&lt;/h2&gt;

&lt;p&gt;The next one only shows up in CI, at 3am, when a red build tells you something broke but not whom to wake — the person who wrote the checkout screen, or the person who owns the Mac in the closet.&lt;/p&gt;

&lt;p&gt;So the exit codes are a contract:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Code&lt;/th&gt;
&lt;th&gt;Meaning&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;0&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;All flows passed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;1&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;At least one flow failed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;2&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Environment/config error (parse failure, relay unreachable, no device)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Run flows&lt;/span&gt;
  &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
    &lt;span class="s"&gt;tapflow flow run .tapflow/flows/*.yaml \&lt;/span&gt;
      &lt;span class="s"&gt;--relay "$TAPFLOW_RELAY_URL" \&lt;/span&gt;
      &lt;span class="s"&gt;--device "iPhone 16 Pro" \&lt;/span&gt;
      &lt;span class="s"&gt;--build "$BUILD_ID" \&lt;/span&gt;
      &lt;span class="s"&gt;--junit report.xml&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;--junit&lt;/code&gt; writes one &lt;code&gt;testcase&lt;/code&gt; per flow, and a failure drops a screenshot from the moment it failed into &lt;code&gt;.tapflow/artifacts/&lt;/code&gt;. The &lt;code&gt;launchApp&lt;/code&gt; step takes no argument and launches whatever &lt;code&gt;--build&lt;/code&gt; installed, which keeps build ids out of the flow file so the same flow can run against a fresh build every time.&lt;/p&gt;




&lt;h2&gt;
  
  
  Where a model is allowed to guess
&lt;/h2&gt;

&lt;p&gt;The last one came later, and it's the reason the runner does no inference at all.&lt;/p&gt;

&lt;p&gt;An agent can drive a session through the MCP tools — tap, type, read the tree — and author a flow out of what it just did. That part is exploratory and non-deterministic, and that's fine. What it produces is then replayed by the runner with no model in the loop, and the replay is what decides whether the flow was any good. Generation is allowed to be fast and occasionally wrong, because the deterministic pass is the gate it has to clear.&lt;/p&gt;

&lt;p&gt;The same engine backs the MCP &lt;code&gt;run_flow&lt;/code&gt; tool, so an agent can author a scenario once and replay it afterward instead of re-deriving it on every run. Replay costs nothing in API calls, which is also why it's cheap enough to run on every commit.&lt;/p&gt;




&lt;h2&gt;
  
  
  What this is really for
&lt;/h2&gt;

&lt;p&gt;Flow Capture is the reason any of this exists. A person tests a build by hand in the browser, the way they already do, and every tap is recorded as a selector read from the tree instead of a coordinate. The QA someone already performed becomes the flow that runs on every build after it.&lt;/p&gt;

&lt;p&gt;It isn't built yet, and I don't want to oversell it. What is done is the part it stands on: the tree, the shared schema, and a runner that reports an ambiguous situation instead of resolving it silently.&lt;/p&gt;




&lt;h2&gt;
  
  
  Limits worth knowing
&lt;/h2&gt;

&lt;p&gt;Selector matching is the roughest edge here, especially in the seconds right after a launch, and the automation axis as a whole is experimental. If your app has screens where nothing carries an identifier or a label, &lt;code&gt;role&lt;/code&gt; and &lt;code&gt;index&lt;/code&gt; will get you through them, but a flow written that way is more brittle than one written against a screen with accessibility identifiers. That's its own argument for adding them.&lt;/p&gt;




&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-g&lt;/span&gt; tapflow
tapflow start
tapflow flow run .tapflow/flows/login-smoke.yaml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Repo&lt;/strong&gt;: &lt;a href="https://github.com/jo-duchan/tapflow" rel="noopener noreferrer"&gt;https://github.com/jo-duchan/tapflow&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Docs&lt;/strong&gt;: &lt;a href="https://www.tapflow.dev/guide/writing-flows" rel="noopener noreferrer"&gt;flow reference&lt;/a&gt; · &lt;a href="https://www.tapflow.dev/guide/mcp-ci" rel="noopener noreferrer"&gt;MCP in CI/CD&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If your suite currently resolves selectors by taking the first match, it's worth going and looking at what it's tapping today.&lt;/p&gt;

</description>
      <category>testing</category>
      <category>webdev</category>
      <category>opensource</category>
      <category>devops</category>
    </item>
    <item>
      <title>Coordinate-based UI tests break. So we read the accessibility tree instead — from inside the simulator.</title>
      <dc:creator>Duchan</dc:creator>
      <pubDate>Thu, 30 Jul 2026 09:30:18 +0000</pubDate>
      <link>https://dev.to/joduchan/coordinate-based-ui-tests-break-so-we-read-the-accessibility-tree-instead-from-inside-the-3gl7</link>
      <guid>https://dev.to/joduchan/coordinate-based-ui-tests-break-so-we-read-the-accessibility-tree-instead-from-inside-the-3gl7</guid>
      <description>&lt;p&gt;Every recorded mobile test I have ever inherited died the same way: someone moved a button.&lt;/p&gt;

&lt;p&gt;The recording said "tap at (340, 712)". The redesign moved that button up by one row, and the test kept tapping — now on empty space, or whatever happened to land there instead. It didn't fail right away. Three sprints later, it started failing in confusing ways, and by then nobody trusted the suite anymore.&lt;/p&gt;

&lt;p&gt;The fix isn't a better recorder. It's recording a different thing: not &lt;em&gt;where&lt;/em&gt; you tapped, but &lt;em&gt;what&lt;/em&gt; you tapped. That needs an element tree, and for a while we didn't have one.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/jo-duchan/tapflow" rel="noopener noreferrer"&gt;tapflow&lt;/a&gt; is an open-source, self-hosted tool that streams iOS simulators and Android emulators into a browser, so a whole team can test builds without installing anything. Until now, everything it moved was pixels in one direction and taps in the other. This post is about getting an element tree out of a simulator with no window, on both platforms. What we do with that tree — replaying flows that survive a redesign — is the next post in this series.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The automation axis this feeds — the flow runner and the MCP server — is &lt;strong&gt;experimental&lt;/strong&gt;. The manual browser QA path is the mature one.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  The constraint: no WebDriverAgent, and no simulator window
&lt;/h2&gt;

&lt;p&gt;tapflow already injects touches into the iOS simulator without WebDriverAgent — it loads &lt;code&gt;CoreSimulator.framework&lt;/code&gt; and pushes HID events through &lt;code&gt;SimDeviceLegacyHIDClient&lt;/code&gt; (that story is &lt;a href="https://dev.to/joduchan/your-whole-team-can-now-run-mobile-qa-from-the-browser-heres-how-we-built-it-3fmn"&gt;ep.1&lt;/a&gt;). Streaming reads the framebuffer &lt;code&gt;IOSurface&lt;/code&gt; directly. Neither path needs Simulator.app on screen, and that's deliberate: an agent Mac in a closet running four simulators shouldn't be babysitting four windows.&lt;/p&gt;

&lt;p&gt;So whatever we used for the tree had to follow the same rule. No WDA to install and keep in sync with Xcode. No simulator window on screen.&lt;/p&gt;

&lt;p&gt;Our first attempt ran into exactly that limitation. macOS exposes an accessibility API (&lt;code&gt;AXUIElement&lt;/code&gt;), and Simulator.app publishes its content through it. We wrote a helper around it, and it worked perfectly on a developer's laptop. On the headless path it returned nothing, because the AX bridge only exists while the simulator window is being rendered. A simulator started with &lt;code&gt;simctl boot&lt;/code&gt; in current Xcode doesn't open a window at all.&lt;/p&gt;

&lt;p&gt;So we ended up with a tree reader that only worked in the one situation where we didn't actually need it.&lt;/p&gt;




&lt;h2&gt;
  
  
  A resident XCUITest runner, living inside the simulator
&lt;/h2&gt;

&lt;p&gt;XCUITest reads the tree of any app by bundle id — that's what it's designed to do — and it runs &lt;em&gt;inside&lt;/em&gt; the simulator, so no window is involved. The catch is that &lt;code&gt;xcodebuild test&lt;/code&gt; is built around "run a test, print results, exit," while we need something that can answer queries for hours.&lt;/p&gt;

&lt;p&gt;So the test target doesn't actually test anything. It starts an HTTP server and blocks:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="c1"&gt;// TreeRunner — the UI test that never finishes&lt;/span&gt;
&lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;testServeTree&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;server&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;try!&lt;/span&gt; &lt;span class="kt"&gt;TreeServer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;port&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;port&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;server&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;start&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="c1"&gt;// block forever: the process stays resident and keeps serving&lt;/span&gt;
    &lt;span class="kt"&gt;RunLoop&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;current&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The server is about 100 lines of &lt;code&gt;Network.framework&lt;/code&gt; with two routes: &lt;code&gt;GET /health&lt;/code&gt; for readiness, and &lt;code&gt;GET /tree?bundleId=&amp;lt;id&amp;gt;&lt;/code&gt;, which returns &lt;code&gt;XCUIApplication(bundleIdentifier:).debugDescription&lt;/code&gt; — the full element subtree as text, including role, label, identifier, and frame for everything on screen. It's not a documented API contract, and I'll come back to that in the limits section.&lt;/p&gt;

&lt;p&gt;The network binding was the detail I most underestimated. The iOS simulator shares the host's network stack, so a default &lt;code&gt;NWListener&lt;/code&gt; bind listens on all interfaces. That would expose every screen of the app under test to the local network with no authentication, which is exactly what a self-hosted setup is supposed to avoid. &lt;code&gt;requiredInterfaceType&lt;/code&gt; isn't enough. You have to pin the local endpoint:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;requiredLocalEndpoint&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;NWEndpoint&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;hostPort&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nv"&gt;host&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;NWEndpoint&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="kt"&gt;Host&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"127.0.0.1"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nv"&gt;port&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;nwPort&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A failed bind also exits the process immediately, because a listener quietly sitting in &lt;code&gt;.failed&lt;/code&gt; looks alive to the parent process and turns a stale-port collision into a two-minute mystery after a 90-second readiness poll.&lt;/p&gt;

&lt;p&gt;On the Node side, &lt;code&gt;XCUITreeReader&lt;/code&gt; builds the runner once with &lt;code&gt;build-for-testing&lt;/code&gt; (cached, so only the first query pays for it), launches it detached with &lt;code&gt;test-without-building&lt;/code&gt;, polls &lt;code&gt;/health&lt;/code&gt;, then serves queries from the resident process. It starts lazily, on the first tree query, so manual QA — the primary path, where a designer just wants to check a build — never pays the startup cost for a runner it isn't going to use.&lt;/p&gt;

&lt;p&gt;Shutdown kills the detached process group and then runs &lt;code&gt;simctl terminate&lt;/code&gt; on the in-simulator host app too, since that process isn't part of the group and would otherwise keep holding the port:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// detached spawn → the child leads its own group; kill the whole group&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pid&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;kill&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nx"&gt;pid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;SIGTERM&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;execFile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;xcrun&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;simctl&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;terminate&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;udid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;RUNNER_HOST_BUNDLE&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One rule matters more than anything else: a tree read never returns an empty array to mean "something went wrong." A garbage response body, a wrong bundle id, or an app that's no longer in the foreground all produce explicit errors. "The screen has no elements" should only ever mean exactly that. If it can also mean the infrastructure failed, every test built on top of it becomes ambiguous.&lt;/p&gt;




&lt;h2&gt;
  
  
  Android was the easy one
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;uiautomator dump&lt;/code&gt; already gives you an XML hierarchy, and the parser is straightforward. The only thing worth watching for is that &lt;code&gt;uiautomator&lt;/code&gt; waits for the window to go idle before dumping, so an app with a continuous animation — a spinner or a looping splash screen — never reaches that state and the dump hangs.&lt;/p&gt;

&lt;p&gt;The timeout has to run on the device, not on the host:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;adb&lt;/span&gt; &lt;span class="nx"&gt;exec&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nx"&gt;out&lt;/span&gt; &lt;span class="nx"&gt;timeout&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt; &lt;span class="nx"&gt;uiautomator&lt;/span&gt; &lt;span class="nx"&gt;dump&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="nx"&gt;dev&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="nx"&gt;tty&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That &lt;code&gt;timeout&lt;/code&gt; comes from Android's toybox. The same rule applies as on iOS: infrastructure failures should never look like valid UI output.&lt;/p&gt;




&lt;h2&gt;
  
  
  One schema, and why frame normalization matters
&lt;/h2&gt;

&lt;p&gt;Both backends parse into the same element shape:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;UIElement&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;role&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;button&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;text&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;input&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;cell&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;image&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;
  &lt;span class="nx"&gt;label&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;
  &lt;span class="nx"&gt;identifier&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;
  &lt;span class="nx"&gt;frame&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;x&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;y&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="c1"&gt;// 0–1&lt;/span&gt;
  &lt;span class="nl"&gt;enabled&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;role&lt;/code&gt; normalizes two vocabularies into one: iOS &lt;code&gt;XCUIElementType&lt;/code&gt; and Android class names (&lt;code&gt;AppCompatButton&lt;/code&gt;, &lt;code&gt;AutoCompleteTextView&lt;/code&gt;, &lt;code&gt;RecyclerView&lt;/code&gt;), ordered so composite names resolve before their generic substrings — &lt;code&gt;ToggleButton&lt;/code&gt; has to match &lt;code&gt;switch&lt;/code&gt; before &lt;code&gt;Button&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The biggest simplification comes from normalizing every frame into the same 0–1 coordinate space that the touch path already uses. iOS &lt;code&gt;debugDescription&lt;/code&gt; frames arrive in points and are divided by the window frame. Android bounds are divided by the root node, which already spans the display, so landscape mode works without an extra &lt;code&gt;wm size&lt;/code&gt; query.&lt;/p&gt;

&lt;p&gt;After that, the whole pipeline becomes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;tree query → element.frame center → tap(x, y) → the same HID path a human tap uses
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No second connection to the device, no separate driver, and no extra coordinate conversion.&lt;/p&gt;




&lt;h2&gt;
  
  
  What this enables
&lt;/h2&gt;

&lt;p&gt;A test used to have exactly one way to describe where to tap:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;(340, 712)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now it can simply say:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;tapOn&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Sign&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;in"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That resolves against the tree — exact identifier first, then exact label, then partial label — and the matched element's frame center goes through the same tap path shown above.&lt;/p&gt;

&lt;p&gt;The tree isn't only for the flow runner, either:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;GET /api/v1/sessions/:sessionId/ui-tree     # REST
query_ui_tree                               # MCP tool
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An LLM agent driving a session reads exactly the same schema a flow does. &lt;a href="https://dev.to/joduchan/-giving-an-llm-eyes-and-hands-on-a-mobile-simulator-5963"&gt;ep.4&lt;/a&gt; gave an agent eyes through screenshots. This gives it names for what it's looking at.&lt;/p&gt;




&lt;h2&gt;
  
  
  Limits worth knowing
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The iOS tree comes from &lt;code&gt;debugDescription&lt;/code&gt;.&lt;/strong&gt; It's a text format, not a stable API. The parser is a pure function with fixture tests, so an Xcode format change fails a unit test before production — but the format can still change.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;enabled&lt;/code&gt; is approximate on iOS.&lt;/strong&gt; &lt;code&gt;debugDescription&lt;/code&gt; doesn't expose it, so elements default to &lt;code&gt;enabled: true&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One resident runner at a time.&lt;/strong&gt; It listens on a fixed port (&lt;code&gt;xcodebuild&lt;/code&gt; doesn't propagate host environment variables into the in-simulator runner, so per-device ports aren't available that way). Concurrent multi-device tree queries are deferred.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-g&lt;/span&gt; tapflow
tapflow start          &lt;span class="c"&gt;# → http://localhost:4000&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Repo&lt;/strong&gt;: &lt;a href="https://github.com/jo-duchan/tapflow" rel="noopener noreferrer"&gt;https://github.com/jo-duchan/tapflow&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Docs&lt;/strong&gt;: &lt;a href="https://www.tapflow.dev" rel="noopener noreferrer"&gt;https://www.tapflow.dev&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Earlier in this series: &lt;a href="https://dev.to/joduchan/your-whole-team-can-now-run-mobile-qa-from-the-browser-heres-how-we-built-it-3fmn"&gt;why we built it and how the touch path works&lt;/a&gt;, &lt;a href="https://dev.to/joduchan/we-switched-simulator-streaming-to-h264-and-it-felt-worse-heres-how-we-fixed-the-latency-pk9"&gt;driving down streaming latency&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Next in the series: what you can build once a test can name the thing it taps, and the four places we made the runner refuse to guess.&lt;/p&gt;

&lt;p&gt;If you've solved the headless tree problem another way, especially something more stable than &lt;code&gt;debugDescription&lt;/code&gt; that doesn't require WebDriverAgent, I'd be interested to hear about it.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>testing</category>
      <category>opensource</category>
      <category>ios</category>
    </item>
    <item>
      <title>Your React Native build is done. Now your whole team can test it in the browser.</title>
      <dc:creator>Duchan</dc:creator>
      <pubDate>Fri, 03 Jul 2026 08:12:44 +0000</pubDate>
      <link>https://dev.to/joduchan/your-react-native-build-is-done-now-your-whole-team-can-test-it-in-the-browser-1jpo</link>
      <guid>https://dev.to/joduchan/your-react-native-build-is-done-now-your-whole-team-can-test-it-in-the-browser-1jpo</guid>
      <description>&lt;p&gt;If your team ships a React Native app, you already know the build is the easy part. The friction shows up &lt;em&gt;after&lt;/em&gt; the build — when everyone who isn't a mobile developer needs to actually look at it.&lt;/p&gt;

&lt;p&gt;The PM wants to compare two versions of a flow. The designer wants to check layout across screen sizes. The backend engineer wants to confirm what the staging build actually does with their API change. And every one of them ends up in the same back-and-forth:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Can you share the build with me?" — and then, "wait, how do I install this?"&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Because installing and running a build means having Xcode or Android Studio and a simulator set up, which non-developers don't. So&lt;br&gt;
a mobile developer becomes a human deployment pipeline — installing builds for people, or booting the app on their own machine to s&lt;br&gt;
how it, a dozen times a day.&lt;/p&gt;

&lt;p&gt;We built &lt;a href="https://github.com/jo-duchan/tapflow" rel="noopener noreferrer"&gt;tapflow&lt;/a&gt; — an open-source, self-hosted tool that streams iOS simulators and Android emulators straight to the browser — to get rid of that interruption. This post is the whole picture, told from a React Native team's point of view.&lt;/p&gt;


&lt;h2&gt;
  
  
  Why this fits React Native so naturally
&lt;/h2&gt;

&lt;p&gt;tapflow isn't a React Native tool. It's &lt;em&gt;framework-agnostic&lt;/em&gt; — and that's exactly why it covers React Native so completely.&lt;/p&gt;

&lt;p&gt;tapflow works one layer below the framework. It doesn't care whether your app was built with RN, Expo, or bare native code. It operates on the &lt;strong&gt;built artifact&lt;/strong&gt; — the &lt;code&gt;.app&lt;/code&gt; or &lt;code&gt;.apk&lt;/code&gt; — and the simulator running it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;RN / Expo / native        ← your framework
      │ build
      ▼
  .app / .apk              ← tapflow starts here
      │
      ▼
  simulator / emulator  ──stream──►  browser (your whole team)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Since a React Native build compiles down to an ordinary native app, it lands inside tapflow's umbrella with nothing special required — an Expo/EAS build or a bare RN build, tapflow treats them all as the native artifacts they are. It never needs to know it's React Native. (More on wiring up EAS below.)&lt;/p&gt;

&lt;p&gt;And because React Native teams are cross-platform by nature — one codebase, iOS &lt;em&gt;and&lt;/em&gt; Android — the payoff is sharper than for a single-platform native team. One dashboard, both platforms, anyone on the team, no local setup.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-g&lt;/span&gt; tapflow
tapflow start
&lt;span class="c"&gt;# → http://localhost:4000&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the whole install. Anyone opens the dashboard, picks a device, and starts interacting.&lt;/p&gt;

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/BfoS-i5aMcM"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;




&lt;h2&gt;
  
  
  Works with Expo — and bare React Native
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Turn every EAS build into a browser-based mobile preview.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If you're on Expo, add one profile to your &lt;code&gt;eas.json&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"build"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"tapflow"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"ios"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"simulator"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"android"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"buildType"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"apk"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;eas build &lt;span class="nt"&gt;--profile&lt;/span&gt; tapflow &lt;span class="nt"&gt;--platform&lt;/span&gt; all
&lt;span class="c"&gt;# iOS → .tar.gz (simulator .app inside)   ·   Android → .apk&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That one profile produces exactly what tapflow runs: for iOS, a simulator build — which EAS hands you as a &lt;code&gt;.tar.gz&lt;/code&gt; — not the default device &lt;code&gt;.ipa&lt;/code&gt; (which can't run on the Simulator); for Android, an &lt;code&gt;.apk&lt;/code&gt;. tapflow takes both formats as-is, so there's nothing to repackage:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  .tar.gz (iOS sim) · .apk (Android)
        │  CI uploads it as-is
        ▼
     tapflow
        │
        ▼
  browser preview — whole team, no install
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Your CI (or a small webhook receiver) downloads the finished artifact and uploads it to tapflow automatically, so wiring it to an EAS Build webhook turns every green build into a browser-testable preview the whole team can open, &lt;em&gt;before&lt;/em&gt; TestFlight or an internal APK share.&lt;/p&gt;

&lt;p&gt;On bare React Native, there's no EAS step to think about: point tapflow at the simulator &lt;code&gt;.app&lt;/code&gt; / &lt;code&gt;.apk&lt;/code&gt; your existing &lt;code&gt;react-native run-ios&lt;/code&gt; or Gradle build already produces.&lt;/p&gt;

&lt;p&gt;This is a complement, not a replacement. EAS builds, signs, and ships your app; tapflow slots in right after the build so everyone can &lt;em&gt;see&lt;/em&gt; it without installing anything.&lt;/p&gt;




&lt;h2&gt;
  
  
  Metro stays. This is for the build, not the dev loop
&lt;/h2&gt;

&lt;p&gt;React Native developers will reasonably ask: &lt;em&gt;does this replace Metro / Fast Refresh?&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;No — and it isn't trying to. Your inner dev loop (edit → Fast Refresh → repeat on your own machine) stays exactly as it is. tapflow covers the &lt;em&gt;other&lt;/em&gt; half: the built artifact that the rest of the team needs to review — the staging build, the EAS build, the "does the sandbox behave right" check.&lt;/p&gt;

&lt;p&gt;Metro is for the developer writing the code. tapflow is for the ten other people who need to see the result without becoming React Native developers first.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why self-hosted (and why we didn't use a cloud lab)
&lt;/h2&gt;

&lt;p&gt;Cloud simulator services solve the browser-access problem too. We evaluated them seriously before building anything, and stopped at two blockers:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Cost.&lt;/strong&gt; Per-seat, per-minute pricing scales badly for a whole team just &lt;em&gt;reviewing&lt;/em&gt; builds.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Data.&lt;/strong&gt; They require uploading your app binary to an external service. For a proprietary app, that's a non-starter.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;tapflow runs on Macs you already own. The architecture keeps everything in your network:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Browser (your team)  ←─ WebSocket ─→  Relay  ←─ WebSocket (outbound) ─→  Mac Agent
                                    (Linux/Mac)                        (iOS · Android)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Mac Agent connects &lt;strong&gt;outbound&lt;/strong&gt; to the relay, so there's no firewall or NAT setup. The relay runs on the Mac you already own — or another machine on the same network — so there's nothing extra to buy, and your app binary and test data stay on infrastructure you control. It's MIT-licensed and fully self-hosted.&lt;/p&gt;




&lt;h2&gt;
  
  
  What React Native teams actually reach for: deep links
&lt;/h2&gt;

&lt;p&gt;The feature that comes up most in real QA sessions maps directly onto something React Native leans on heavily — &lt;strong&gt;deep links&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;RN navigation, &lt;code&gt;Linking&lt;/code&gt;, notification payloads, OAuth redirects — verifying a specific app state usually means firing a deep link. The old workflow always pulled in a developer: trigger it from their machine, or build a debug menu into the app just for testing.&lt;/p&gt;

&lt;p&gt;In tapflow you fire a deep link straight from the QA session toolbar. Hit &lt;code&gt;⌘K&lt;/code&gt;, paste the URL, and it runs on the active device:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Browser ──open-url──► Relay ──open-url──► Mac Agent
                                             │
                          iOS:     xcrun simctl openurl booted &amp;lt;url&amp;gt;
                          Android: adb shell am start -a VIEW -d &amp;lt;url&amp;gt;
Browser ◄──open-url:done/error── Relay ◄─────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Under the hood it's a symmetric &lt;code&gt;openUrl(url)&lt;/code&gt; on the device interface, so iOS and Android behave the same way and the dashboard shows a toast either way. A PM can now reproduce the exact screen behind a &lt;code&gt;myapp://product/42&lt;/code&gt; link without touching a terminal.&lt;/p&gt;

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/MQaikcQd37w"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

&lt;p&gt;The same session toolbar covers the rest of the repetitive QA actions with keyboard shortcuts:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Shortcut&lt;/th&gt;
&lt;th&gt;Action&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;⌘K&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Open deep link dialog&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;⌘S&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Take screenshot&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;⌘⇧Y&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Start / stop recording&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;⌘⇧O&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Rotate simulator&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Screenshots are also exposed as an API, so a designer doing layout review across screen sizes can capture without asking anyone.&lt;/p&gt;




&lt;h2&gt;
  
  
  Where it's headed
&lt;/h2&gt;

&lt;p&gt;There's an experimental MCP server that gives an LLM agent eyes and hands on the simulator — it can see the screen and drive taps directly. It's opt-in and completely separate from the manual testing path, so it doesn't change anything above. If that's interesting, it has &lt;a href="https://dev.to/joduchan/-giving-an-llm-eyes-and-hands-on-a-mobile-simulator-5963"&gt;its own write-up&lt;/a&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  Getting started
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-g&lt;/span&gt; tapflow
tapflow start
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On Expo, produce a tapflow-ready build with the profile from earlier:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;eas build &lt;span class="nt"&gt;--profile&lt;/span&gt; tapflow &lt;span class="nt"&gt;--platform&lt;/span&gt; all
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then drop the build in and share the dashboard URL with your team.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Repo&lt;/strong&gt;: &lt;a href="https://github.com/jo-duchan/tapflow" rel="noopener noreferrer"&gt;https://github.com/jo-duchan/tapflow&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Docs&lt;/strong&gt;: &lt;a href="https://www.tapflow.dev" rel="noopener noreferrer"&gt;https://www.tapflow.dev&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;If you want the deeper cuts: &lt;a href="https://dev.to/joduchan/your-whole-team-can-now-run-mobile-qa-from-the-browser-heres-how-we-built-it-3fmn"&gt;how we built the browser-streaming touch pipeline&lt;/a&gt;, &lt;a href="https://dev.to/joduchan/we-switched-simulator-streaming-to-h264-and-it-felt-worse-heres-how-we-fixed-the-latency-pk9"&gt;how we drove down streaming latency&lt;/a&gt;, and &lt;a href="https://dev.to/joduchan/the-dx-we-wanted-for-tapflow-setup-a-host-ready-mac-in-one-command-3cb0"&gt;one-command host setup&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If your team ships React Native, the build was never the hard part. Letting everyone &lt;em&gt;see&lt;/em&gt; it was. That's the part tapflow removes.&lt;/p&gt;

</description>
      <category>reactnative</category>
      <category>testing</category>
      <category>opensource</category>
      <category>webdev</category>
    </item>
    <item>
      <title>The DX we wanted for tapflow setup: a host-ready Mac in one command</title>
      <dc:creator>Duchan</dc:creator>
      <pubDate>Wed, 17 Jun 2026 13:49:47 +0000</pubDate>
      <link>https://dev.to/joduchan/the-dx-we-wanted-for-tapflow-setup-a-host-ready-mac-in-one-command-3cb0</link>
      <guid>https://dev.to/joduchan/the-dx-we-wanted-for-tapflow-setup-a-host-ready-mac-in-one-command-3cb0</guid>
      <description>&lt;p&gt;&lt;a href="https://github.com/jo-duchan/tapflow" rel="noopener noreferrer"&gt;tapflow&lt;/a&gt; streams iOS simulators and Android emulators into a browser, so a whole team can test an app without installing anything. The simulators run on a Mac that hosts the tapflow agent, and that Mac needs the mobile toolchain: Xcode, a simulator runtime, the Android SDK, an emulator, AVDs.&lt;/p&gt;

&lt;p&gt;tapflow already gives the people who &lt;em&gt;use&lt;/em&gt; it a zero-install experience. We wanted the person who &lt;em&gt;hosts&lt;/em&gt; it to get the same one. So &lt;code&gt;tapflow setup&lt;/code&gt; brings the whole host environment up in as close to one command as the toolchain allows.&lt;/p&gt;

&lt;p&gt;Here are the DX decisions behind it.&lt;/p&gt;




&lt;h2&gt;
  
  
  &lt;code&gt;doctor&lt;/code&gt; diagnoses, &lt;code&gt;setup&lt;/code&gt; installs and configures
&lt;/h2&gt;

&lt;p&gt;We split diagnosis from the steps that install and configure.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;tapflow doctor&lt;/code&gt; is read-only: it checks the prerequisites — Xcode, &lt;code&gt;simctl&lt;/code&gt;, a simulator runtime; the SDK, &lt;code&gt;adb&lt;/code&gt;, an AVD — and reports. It never changes your machine, so it's safe to run anywhere, anytime. A clean run looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;tapflow doctor

  ✓  Node v20.11.0

  iOS
  ✓  Xcode 16.2
  ✓  xcrun simctl
  ✓  Simulator available (8)

  Android
  ✓  Android SDK: ~/Library/Android/sdk
  ✓  adb found: ~/Library/Android/sdk/platform-tools/adb
  ✓  AVD available: tapflow-phone

  All checks passed.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When something's missing, each failing line carries the exact fix — &lt;code&gt;⚠  AVD → No AVD found. Run: tapflow setup android&lt;/code&gt; — so &lt;code&gt;doctor&lt;/code&gt; always points straight at &lt;code&gt;setup&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;tapflow setup&lt;/code&gt; is the one command allowed to install and configure. The two mirror each other, so the mutating verb lives in exactly one place. Run &lt;code&gt;setup&lt;/code&gt; with no argument and it reads the environment:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;platform&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;darwin&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;platforms&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ios&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;resolveAdb&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;platforms&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;android&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;macOS implies iOS; an existing &lt;code&gt;adb&lt;/code&gt; implies you care about Android. If neither signal is there, it asks instead of guessing.&lt;/p&gt;

&lt;h2&gt;
  
  
  iOS: installed ≠ usable
&lt;/h2&gt;

&lt;p&gt;Xcode can only come from the App Store, so &lt;code&gt;setup&lt;/code&gt; doesn't fake it — it opens the right page and waits.&lt;/p&gt;

&lt;p&gt;The part we kept getting wrong was that a freshly installed Xcode isn't a working one. Three steps stand between "the app exists" and "&lt;code&gt;xcodebuild&lt;/code&gt; runs": point the active developer directory at Xcode (&lt;code&gt;xcode-select -s&lt;/code&gt;), accept the license, and finish first launch. &lt;code&gt;setup&lt;/code&gt; runs them for you, after asking, since they need &lt;code&gt;sudo&lt;/code&gt;. The check that matters isn't "does Xcode.app exist" — it's whether &lt;code&gt;xcodebuild -version&lt;/code&gt; actually runs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Android: a self-contained SDK
&lt;/h2&gt;

&lt;p&gt;This is the decision we're happiest with.&lt;/p&gt;

&lt;p&gt;The obvious path is "install Android Studio." We didn't. The host doesn't need a GUI IDE, and depending on one means fighting whatever SDK location, &lt;code&gt;ANDROID_HOME&lt;/code&gt;, and AVDs the user already has. Instead &lt;code&gt;setup&lt;/code&gt; builds a self-contained SDK under one path we own:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;sdkmanager &lt;span class="nt"&gt;--sdk_root&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;~/Library/Android/sdk &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"cmdline-tools;latest"&lt;/span&gt; &lt;span class="s2"&gt;"platform-tools"&lt;/span&gt; &lt;span class="s2"&gt;"emulator"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"system-images;android-35;google_apis;arm64-v8a"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After that, every Android binary tapflow touches comes from inside that directory. A couple of details that make it reliable:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;sdkmanager&lt;/code&gt; needs a JDK or it won't run, so a Temurin check happens first.&lt;/li&gt;
&lt;li&gt;The system image is &lt;code&gt;google_apis&lt;/code&gt;, not the Play Store one, which is unstable the way we drive it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The one thing that has to outlive the process is &lt;code&gt;ANDROID_HOME&lt;/code&gt; on your &lt;code&gt;PATH&lt;/code&gt;. &lt;code&gt;setup&lt;/code&gt; writes it into your shell rc inside a marker block, and only if it isn't already there — so re-running never duplicates it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# &amp;gt;&amp;gt;&amp;gt; tapflow android sdk &amp;gt;&amp;gt;&amp;gt;&lt;/span&gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;ANDROID_HOME&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$HOME&lt;/span&gt;&lt;span class="s2"&gt;/Library/Android/sdk"&lt;/span&gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;PATH&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$ANDROID_HOME&lt;/span&gt;&lt;span class="s2"&gt;/platform-tools:&lt;/span&gt;&lt;span class="nv"&gt;$ANDROID_HOME&lt;/span&gt;&lt;span class="s2"&gt;/emulator:&lt;/span&gt;&lt;span class="nv"&gt;$PATH&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="c"&gt;# &amp;lt;&amp;lt;&amp;lt; tapflow android sdk &amp;lt;&amp;lt;&amp;lt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The catch it can't remove: the variable isn't in your &lt;em&gt;current&lt;/em&gt; shell, so &lt;code&gt;setup&lt;/code&gt; tells you to open a new terminal before &lt;code&gt;doctor&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  setup prepares; the relay boots
&lt;/h2&gt;

&lt;p&gt;For both platforms, &lt;code&gt;setup&lt;/code&gt; stops at "a bootable device exists." It never boots a simulator or emulator. That's the relay's job — it boots the right device on demand when a teammate joins a QA session. Two components owning device lifecycle would just race each other.&lt;/p&gt;

&lt;h2&gt;
  
  
  One rail under all of it
&lt;/h2&gt;

&lt;p&gt;Every step that changes the machine asks first, and only auto-runs in an interactive terminal. Run &lt;code&gt;setup&lt;/code&gt; in CI and instead of curling an install script as root, it prints guidance and exits clean. Nothing in tapflow deletes your data — the only teardown command is &lt;code&gt;reset&lt;/code&gt;, which shuts down running simulators.&lt;/p&gt;




&lt;h2&gt;
  
  
  Honest limitations
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Xcode is still a manual App Store download. There's no API for it; &lt;code&gt;setup&lt;/code&gt; automates everything around it.&lt;/li&gt;
&lt;li&gt;The first run usually needs a new shell for the Android &lt;code&gt;PATH&lt;/code&gt; to take effect.&lt;/li&gt;
&lt;li&gt;The agent host is a Mac, since that's the only place iOS simulators run. (The relay itself runs on Linux.)&lt;/li&gt;
&lt;li&gt;Still v0.x, so the steps will keep moving as the toolchain shifts.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Takeaway
&lt;/h2&gt;

&lt;p&gt;The downloads were never the hard part. The DX lives in the glue around them — Xcode activation, the &lt;code&gt;PATH&lt;/code&gt; that needs a fresh shell, knowing to stop at "bootable" and let the relay boot the rest. Automating a dev environment means automating everything that isn't the install.&lt;/p&gt;




&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;tapflow is MIT licensed.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-g&lt;/span&gt; tapflow
tapflow doctor     &lt;span class="c"&gt;# what's missing?&lt;/span&gt;
tapflow setup      &lt;span class="c"&gt;# set it up&lt;/span&gt;
tapflow start
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;🔗 GitHub: &lt;a href="https://github.com/jo-duchan/tapflow" rel="noopener noreferrer"&gt;https://github.com/jo-duchan/tapflow&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;📖 Docs: &lt;a href="https://www.tapflow.dev" rel="noopener noreferrer"&gt;https://www.tapflow.dev&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>node</category>
      <category>cli</category>
      <category>opensource</category>
      <category>devtools</category>
    </item>
    <item>
      <title>We switched simulator streaming to H.264 and it felt worse. Here's how we fixed the latency.</title>
      <dc:creator>Duchan</dc:creator>
      <pubDate>Wed, 10 Jun 2026 07:06:31 +0000</pubDate>
      <link>https://dev.to/joduchan/we-switched-simulator-streaming-to-h264-and-it-felt-worse-heres-how-we-fixed-the-latency-pk9</link>
      <guid>https://dev.to/joduchan/we-switched-simulator-streaming-to-h264-and-it-felt-worse-heres-how-we-fixed-the-latency-pk9</guid>
      <description>&lt;p&gt;In an earlier post I described how &lt;a href="https://github.com/jo-duchan/tapflow" rel="noopener noreferrer"&gt;tapflow&lt;/a&gt; streams iOS simulators to the browser: pull frames off the simulator's &lt;code&gt;IOSurface&lt;/code&gt;, JPEG-encode them on the Mac, push them over WebSocket at ~30fps.&lt;/p&gt;

&lt;p&gt;JPEG has one great property for interactive streaming: every frame is independent and decodes instantly. There's no buffer, no inter-frame dependency. On localhost it feels like you're touching the simulator directly.&lt;/p&gt;

&lt;p&gt;It also has one terrible property: size. A full-frame JPEG of a scrolling screen is ~590KB. On a LAN that's 12–16 MB/s, and our relay started dropping 16–27 frames a second under backpressure — visible tearing.&lt;/p&gt;

&lt;p&gt;So we did the obvious thing and moved to H.264. Bandwidth dropped roughly 140× on a still screen and 5× while scrolling. Drops nearly vanished.&lt;/p&gt;

&lt;p&gt;And the stream felt &lt;em&gt;worse&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;This post is about why, and the two fixes that got H.264 back to "feels like direct touch."&lt;/p&gt;




&lt;h2&gt;
  
  
  The bar: localhost JPEG
&lt;/h2&gt;

&lt;p&gt;Before touching anything I needed a number, not a vibe. So I instrumented the pipeline end to end — a per-stage panel that reports &lt;code&gt;decode→present&lt;/code&gt; and &lt;code&gt;glass→glass&lt;/code&gt; (capture timestamp to on-screen) latencies live.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;One caveat I'll repeat throughout: &lt;code&gt;glass→glass&lt;/code&gt; absolute values are only valid on localhost, where capture and display share one clock. &lt;code&gt;decode→present&lt;/code&gt; is a same-machine delta and valid anywhere, so I'll lean on it for the cross-environment claims.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Here's the baseline that mattered, measured on localhost:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Path&lt;/th&gt;
&lt;th&gt;decode→present p50/p95 (ms)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;JPEG still&lt;/td&gt;
&lt;td&gt;12.4 / 15.4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;JPEG scroll&lt;/td&gt;
&lt;td&gt;9.4 / 11.6&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;H.264 (WebCodecs) still&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;267 / 274&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;H.264 decode was &lt;strong&gt;~20× slower&lt;/strong&gt; than JPEG. On a hardware decoder. That made no sense — until I looked at what the decoder was actually doing.&lt;/p&gt;




&lt;h2&gt;
  
  
  Fix 1: the decoder was buffering 8 frames for no reason
&lt;/h2&gt;

&lt;p&gt;The transport was clean (~1ms), the input queue was empty. The latency was entirely inside the decoder: it was holding ~8 frames before emitting the first one.&lt;/p&gt;

&lt;p&gt;That's a DPB (decoded picture buffer). A decoder reorders frames when B-frames are present — it has to wait for future frames to arrive before it can output the current one in display order. So it buffers up to the level's maximum.&lt;/p&gt;

&lt;p&gt;But our encoder is &lt;strong&gt;baseline H.264, B-frames off&lt;/strong&gt;. There is no reordering. The actual reorder depth is zero. The decoder was buffering anyway because the bitstream never &lt;em&gt;told&lt;/em&gt; it the reorder depth was zero.&lt;/p&gt;

&lt;p&gt;The signal lives in the SPS (sequence parameter set), in the &lt;code&gt;bitstream_restriction&lt;/code&gt; flags inside VUI. Our VideoToolbox encoder wasn't setting them, so the decoder fell back to the worst case for the level — &lt;code&gt;max_dec_frame_buffering&lt;/code&gt; of ~8 frames at Level 5.0.&lt;/p&gt;

&lt;p&gt;The fix is to rewrite the SPS and inject the missing declaration:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight properties"&gt;&lt;code&gt;&lt;span class="py"&gt;max_num_reorder_frames&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;0&lt;/span&gt;
&lt;span class="py"&gt;max_dec_frame_buffering&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;num_ref_frames&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;We do this in the agent, on the keyframe SPS, before the frame ever leaves the Mac — so &lt;em&gt;every&lt;/em&gt; decoder downstream benefits, not just one browser path:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// agent-core/utils/sps.ts — rewrite the SPS to declare zero reordering&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;rewriteLowLatencySps&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sps&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Uint8Array&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Uint8Array&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;bits&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;BitstreamWriter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;parseSps&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sps&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
  &lt;span class="nx"&gt;bits&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;vui&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;bitstreamRestriction&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
  &lt;span class="nx"&gt;bits&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;vui&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;maxNumReorderFrames&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
  &lt;span class="nx"&gt;bits&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;vui&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;maxDecFrameBuffering&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;bits&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;numRefFrames&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;serialize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;bits&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Result on localhost:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Path&lt;/th&gt;
&lt;th&gt;decode→present p50/p95 (ms)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;H.264 WebCodecs still (before)&lt;/td&gt;
&lt;td&gt;267 / 274&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;H.264 WebCodecs still (after)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;2.5 / 4&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;H.264 WebCodecs scroll (after)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;2.1 / 3.9&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;code&gt;267 → 2.5ms&lt;/code&gt;, roughly 100×. The encoder was lying to the decoder by omission, and the decoder defended itself by buffering. One declaration fixed it.&lt;/p&gt;

&lt;p&gt;The browser confirms it's receiving the rewrite — the SPS now reports &lt;code&gt;bitstreamRestriction: true, maxNumReorderFrames: 0&lt;/code&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  Fix 2: MSE is a buffer you can't turn off
&lt;/h2&gt;

&lt;p&gt;Fix 1 only helps the WebCodecs path. And WebCodecs has a hard constraint: it only runs in a secure context — HTTPS or localhost.&lt;/p&gt;

&lt;p&gt;A team using tapflow over their LAN hits it at plain &lt;code&gt;http://&amp;lt;mac-ip&amp;gt;:4000&lt;/code&gt;. That's a non-secure context, so the browser can't use WebCodecs. The fallback at the time was MSE (Media Source Extensions): feed the H.264 into a &lt;code&gt;&amp;lt;video&amp;gt;&lt;/code&gt; element through a muxer.&lt;/p&gt;

&lt;p&gt;The problem is that &lt;code&gt;&amp;lt;video&amp;gt;&lt;/code&gt; &lt;em&gt;is&lt;/em&gt; a buffer. It's designed for media playback, where a jitter buffer is a feature. For interactive streaming it's structural latency you can't remove. I measured it on localhost by forcing the MSE tier:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Path&lt;/th&gt;
&lt;th&gt;decode→present p50/p95 (ms)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;H.264 MSE still&lt;/td&gt;
&lt;td&gt;239 / 254&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;H.264 MSE scroll&lt;/td&gt;
&lt;td&gt;229 / 244&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;~235ms, on the &lt;em&gt;same&lt;/em&gt; &lt;code&gt;reorder=0&lt;/code&gt; stream that WebCodecs decoded in 2.5ms. The SPS fix can't reach this — it's the media-element buffer, not the decoder's DPB. I'd already set the muxer's &lt;code&gt;flushingTime&lt;/code&gt; to 0. There was nothing left to shave.&lt;/p&gt;

&lt;p&gt;So I stopped trying to make MSE fast and removed it.&lt;/p&gt;

&lt;p&gt;The decoder layer is now two tiers, picked automatically per environment:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// pickDecoder — secure → WebCodecs, otherwise WASM&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;pickDecoder&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nx"&gt;Decoder&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;isSecureContext&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;VideoDecoder&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;WebCodecsDecoder&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;      &lt;span class="c1"&gt;// HW, lowest latency&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;webgl2Available&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;wasmSupported&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;WASMDecoder&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;           &lt;span class="c1"&gt;// tinyh264, zero-buffer&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;                          &lt;span class="c1"&gt;// → fall back to JPEG&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On non-secure LAN-HTTP, we decode H.264 in WASM (tinyh264). It's a software decoder, so it costs CPU — but it has &lt;strong&gt;no media-element buffer at all&lt;/strong&gt;. That's the whole point: it gives you JPEG's immediacy with H.264's bandwidth, on plain HTTP.&lt;/p&gt;

&lt;p&gt;Measured on localhost (the worst case — encoder and decoder share one Mac):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Path&lt;/th&gt;
&lt;th&gt;decode→present p50/p95 (ms)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;H.264 WASM still&lt;/td&gt;
&lt;td&gt;8.7 / 30.4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;H.264 WASM scroll&lt;/td&gt;
&lt;td&gt;14.3 / 37.9&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That's on par with the localhost-JPEG baseline (12.4 / 9.4) — the bar we set at the start. Removing MSE also let us drop the muxer dependency entirely.&lt;/p&gt;

&lt;p&gt;One constraint this introduces: tinyh264 only decodes baseline H.264. iOS already encodes baseline. For Android we pin scrcpy to baseline (&lt;code&gt;profile:int=1&lt;/code&gt;) so both platforms share the exact same HTTP→WASM path. High profile is still available on the WebCodecs (secure) tier.&lt;/p&gt;




&lt;h2&gt;
  
  
  One more thing: dropping H.264 isn't like dropping JPEG
&lt;/h2&gt;

&lt;p&gt;There's a subtlety the switch exposed. With JPEG, every frame is a keyframe, so dropping a frame under backpressure is harmless — the next one stands alone. With H.264, if you drop a P-frame, every following P-frame references something the decoder never received. A zero-buffer decoder like WASM tinyh264 shears until the next IDR arrives.&lt;/p&gt;

&lt;p&gt;So the relay had to become keyframe-aware: once it starts dropping under backpressure, it drops the whole GOP until the next keyframe, rather than handing the decoder a broken reference chain. The keyframe flag rides in our frame envelope, so this needs zero NAL parsing on the relay.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// relay — once dropping, drop until the next keyframe&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;backpressured&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;frame&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;isKeyframe&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;       &lt;span class="c1"&gt;// skip P-frames in a broken GOP&lt;/span&gt;
  &lt;span class="nx"&gt;dropping&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;                    &lt;span class="c1"&gt;// keyframe resets the chain&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Honest limitations
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;WASM decode is CPU-bound.&lt;/strong&gt; At high resolution × fps it hits a CPU ceiling. We mitigate by downscaling the encode resolution — the display is small, so it's a triple win on bandwidth, CPU, and latency.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The localhost numbers are best-case for latency and worst-case for CPU.&lt;/strong&gt; On a real LAN the decoder runs on a separate machine. In our cross-machine measurements, scroll p95 climbs to ~50ms on &lt;em&gt;both&lt;/em&gt; decoders — at that point the bottleneck is load/transport, not the codec. The &lt;code&gt;decode→present&lt;/code&gt; deltas above hold; the &lt;code&gt;glass→glass&lt;/code&gt; absolutes do not transfer across two clocks.&lt;/li&gt;
&lt;li&gt;Still v0.x. The decoder tiers and SPS rewrite are in &lt;code&gt;agent-core&lt;/code&gt;; expect them to keep moving.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Takeaway
&lt;/h2&gt;

&lt;p&gt;Two bugs, same symptom ("H.264 feels laggy"), completely different causes:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The decoder's DPB buffered 8 frames because the SPS didn't declare &lt;code&gt;reorder=0&lt;/code&gt;. Fix: rewrite the SPS at the encoder.&lt;/li&gt;
&lt;li&gt;The media-element buffer in MSE added ~235ms that no encoder flag can reach. Fix: remove MSE, decode in WASM on non-secure contexts.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The lesson I keep relearning: when streaming feels slow, measure each stage before you change the codec. The codec usually isn't the problem — the buffer you didn't know you had is.&lt;/p&gt;




&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;tapflow is MIT licensed.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-g&lt;/span&gt; tapflow
tapflow start
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;🔗 GitHub: &lt;a href="https://github.com/jo-duchan/tapflow" rel="noopener noreferrer"&gt;https://github.com/jo-duchan/tapflow&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;📖 Docs: &lt;a href="https://www.tapflow.dev" rel="noopener noreferrer"&gt;https://www.tapflow.dev&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>webdev</category>
      <category>performance</category>
      <category>javascript</category>
      <category>ios</category>
    </item>
    <item>
      <title>Giving an LLM Eyes and Hands on a Mobile Simulator</title>
      <dc:creator>Duchan</dc:creator>
      <pubDate>Sat, 30 May 2026 08:23:03 +0000</pubDate>
      <link>https://dev.to/joduchan/-giving-an-llm-eyes-and-hands-on-a-mobile-simulator-5963</link>
      <guid>https://dev.to/joduchan/-giving-an-llm-eyes-and-hands-on-a-mobile-simulator-5963</guid>
      <description>&lt;p&gt;Mobile QA has a scaling problem.&lt;/p&gt;

&lt;p&gt;Unit tests and API tests run in CI automatically. But the thing that actually matters to most users — does tapping this button do the right thing, does this screen look right after this flow, does the deeplink open the correct state — none of that runs automatically. Someone has to open the simulator, walk through the steps, and verify. Every time.&lt;/p&gt;

&lt;p&gt;The usual answer is Appium or XCUITest. But those require engineers to write and maintain test code that mirrors the UI, breaks whenever the screen changes, and only runs against builds developers already have locally.&lt;/p&gt;

&lt;p&gt;We had a different idea. tapflow already lets humans control a simulator through a browser. What if we gave an LLM the same interface?&lt;/p&gt;




&lt;h2&gt;
  
  
  The interface a human uses
&lt;/h2&gt;

&lt;p&gt;When a person does QA in tapflow, the loop is:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Look at the simulator screen&lt;/li&gt;
&lt;li&gt;Decide what to do (tap, swipe, type)&lt;/li&gt;
&lt;li&gt;Do it&lt;/li&gt;
&lt;li&gt;Look again&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This is exactly the perception-action loop that vision-capable LLMs are built for. The model sees a screenshot, reasons about what it shows, decides what action to take, and calls a tool to execute it.&lt;/p&gt;

&lt;p&gt;We didn't need to build a new automation layer. We just needed to expose tapflow's existing WebSocket and REST APIs as MCP tools.&lt;/p&gt;




&lt;h2&gt;
  
  
  What the MCP server does
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;@tapflowio/mcp-server&lt;/code&gt; connects to a running tapflow relay and registers 13 tools that any MCP-compatible client can call:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;list_devices       — see all simulators registered on the relay
connect_device     — join a device session
boot_device        — boot a simulator (waits up to 30s for ready state)
screenshot         — capture the current screen
tap                — tap at a pixel coordinate
swipe              — swipe between two coordinates
type_text          — type into the focused field
press_key          — press a keyboard key (Return, Delete, Escape...)
press_button       — press a hardware button (home, lock)
install_app        — install a build from App Center
launch_app         — launch an installed app
list_builds        — list available builds on the relay
disconnect_device  — end the session
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Setup is two environment variables:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;TAPFLOW_RELAY_URL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;wss://your-relay-url
&lt;span class="nv"&gt;TAPFLOW_TOKEN&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;your-pat-token
npx @tapflowio/mcp-server
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Add it as an MCP server in your client config, and those tools appear in the model's tool list.&lt;/p&gt;




&lt;h2&gt;
  
  
  How the tools are implemented
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Screenshot — the model's eyes
&lt;/h3&gt;

&lt;p&gt;The &lt;code&gt;screenshot&lt;/code&gt; tool calls the REST endpoint we added in v0.3.0 (&lt;code&gt;GET /api/v1/sessions/:id/screenshot&lt;/code&gt;), gets back a PNG or JPEG buffer, base64-encodes it, and returns it as MCP &lt;code&gt;image&lt;/code&gt; content alongside the pixel dimensions:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;image&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;buf&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;base64&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nx"&gt;mimeType&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;text&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Screenshot saved: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;filePath&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; (&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;×&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;height&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;px)`&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The model receives the actual image. It can read text on screen, identify UI elements, notice error states — the same things a human would.&lt;/p&gt;

&lt;h3&gt;
  
  
  Tap and swipe — normalized coordinates
&lt;/h3&gt;

&lt;p&gt;Here's the part that took a few iterations to get right. The simulator's logical coordinate space is different from screenshot pixel coordinates, and it changes with screen resolution, device type, and scale factor.&lt;/p&gt;

&lt;p&gt;Rather than exposing logical coordinates (which the model can't reason about without device-specific knowledge), we have the model work entirely in screenshot pixel space. The &lt;code&gt;tap&lt;/code&gt; tool takes pixel coordinates plus the screenshot dimensions, then normalizes internally:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// tools.ts&lt;/span&gt;
&lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;tap&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sessionId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;x&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;screenshotWidth&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;y&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;screenshotHeight&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The model calls &lt;code&gt;screenshot&lt;/code&gt; first, reads the dimensions from the response, then uses those same dimensions when calling &lt;code&gt;tap&lt;/code&gt;. This means the model can identify "the button is at roughly pixel 200, 450" from the image and tap it directly — no coordinate system translation required.&lt;/p&gt;

&lt;p&gt;Swipe works the same way, with 8 interpolated &lt;code&gt;touch:move&lt;/code&gt; events across the duration to simulate a natural gesture:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// client.ts — swipe interpolation&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;STEPS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;interval&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;durationMs&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;STEPS&lt;/span&gt;

&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;input:touch:start&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;sessionId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;x&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;startX&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;y&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;startY&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;STEPS&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;delay&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;interval&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;STEPS&lt;/span&gt;
  &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;input:touch:move&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;sessionId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;x&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;startX&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;endX&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;startX&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
      &lt;span class="na"&gt;y&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;startY&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;endY&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;startY&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Async operations over WebSocket
&lt;/h3&gt;

&lt;p&gt;Several tools involve async operations — booting a device, installing an app — where the relay sends a confirmation back over WebSocket after the operation completes.&lt;/p&gt;

&lt;p&gt;The client uses a &lt;code&gt;waitFor&lt;/code&gt; pattern: register a predicate against incoming messages, return a promise that resolves when a matching message arrives, and reject if a timeout fires first.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// client.ts — waitFor&lt;/span&gt;
&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="nf"&gt;waitFor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;predicate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;timeoutMs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;RelayMsg&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;reject&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;timer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;waiters&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;splice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;waiters&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findIndex&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;w&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;w&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;resolve&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="nf"&gt;reject&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Request timed out&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="nx"&gt;timeoutMs&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;waiters&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;predicate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;reject&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;timer&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
  &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;boot_device&lt;/code&gt; waits up to 30 seconds. &lt;code&gt;install_app&lt;/code&gt; waits 60 seconds. Each resolves on the confirmation message or rejects with the error payload.&lt;/p&gt;




&lt;h2&gt;
  
  
  What a session looks like
&lt;/h2&gt;

&lt;p&gt;A model running a login flow might do this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1. list_devices → pick a session
2. connect_device
3. list_builds → find the build to test
4. boot_device
5. install_app
6. launch_app
7. screenshot → see the login screen
8. tap(email field coordinates) → focus the input
9. type_text("test@example.com")
10. tap(password field coordinates)
11. type_text("password")
12. tap(login button coordinates)
13. screenshot → verify the home screen loaded
14. disconnect_device
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each screenshot gives the model a chance to verify state before proceeding. If step 13 shows an error message instead of the home screen, the model knows something went wrong.&lt;/p&gt;




&lt;h2&gt;
  
  
  Where we are: experimental
&lt;/h2&gt;

&lt;p&gt;The version says &lt;code&gt;0.3.1-experimental.1&lt;/code&gt; for a reason. The tools work, but the layer needs more hardening before we'd call it reliable.&lt;/p&gt;

&lt;p&gt;The core issue is consistency. The same sequence of tool calls should produce predictable behavior every time. Right now it doesn't always — there are timing edge cases where an action fires before the UI has fully settled, device state can drift between steps without the model noticing, and error recovery when something unexpected happens mid-flow is rough.&lt;/p&gt;

&lt;p&gt;These are solvable problems, but we want to solve them before presenting this as something teams should build pipelines on.&lt;/p&gt;




&lt;h2&gt;
  
  
  Where we're going: CI/CD without a QA script
&lt;/h2&gt;

&lt;p&gt;The direction we're aiming at is using the MCP server as the foundation for LLM-driven smoke tests in CI.&lt;/p&gt;

&lt;p&gt;The scenario: a new build passes unit tests and gets uploaded to App Center. A CI step spins up the MCP server, points it at the relay, and gives a model a natural-language test spec:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Install the latest build. Log in with test credentials. Navigate to the cart, add an item, and confirm the checkout screen shows the correct total. Take a screenshot at each step."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The model does the steps, captures evidence, and reports what it saw. No automation code to write. No selectors to maintain when the UI changes. The spec is just a description of what a human would do.&lt;/p&gt;

&lt;p&gt;This isn't production-ready yet. The stability work comes first. But the pieces — browser-controllable simulators, screenshot REST endpoint, MCP tool layer — are in place. The question is whether the model can run a flow reliably enough to be trusted in CI without a human verifying each run.&lt;/p&gt;

&lt;p&gt;We think it can. That's what we're building toward.&lt;/p&gt;




&lt;h2&gt;
  
  
  Try the MCP server (experimental)
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-g&lt;/span&gt; @tapflowio/mcp-server@experimental
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You'll need a running tapflow relay and a PAT token with viewer scope. Configure it in your MCP client:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"mcpServers"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"tapflow"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"npx"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"args"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"@tapflowio/mcp-server"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"env"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"TAPFLOW_RELAY_URL"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"wss://your-relay-url"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"TAPFLOW_TOKEN"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"your-pat-token"&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you try it and hit rough edges, open an issue — that feedback is exactly what's shaping the stability work.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;🔗 GitHub: &lt;a href="https://github.com/jo-duchan/tapflow" rel="noopener noreferrer"&gt;https://github.com/jo-duchan/tapflow&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;📖 Docs: &lt;a href="https://www.tapflow.dev/guide/mcp-server" rel="noopener noreferrer"&gt;https://www.tapflow.dev/guide/mcp-server&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>ai</category>
      <category>machinelearning</category>
      <category>opensource</category>
      <category>mcp</category>
    </item>
    <item>
      <title>tapflow v0.3.x: Deeplinks, Keyboard Shortcuts, Screenshot API, and an Experimental MCP Server</title>
      <dc:creator>Duchan</dc:creator>
      <pubDate>Fri, 29 May 2026 07:38:22 +0000</pubDate>
      <link>https://dev.to/joduchan/tapflow-v03x-deeplinks-keyboard-shortcuts-screenshot-api-and-an-experimental-mcp-server-4lg1</link>
      <guid>https://dev.to/joduchan/tapflow-v03x-deeplinks-keyboard-shortcuts-screenshot-api-and-an-experimental-mcp-server-4lg1</guid>
      <description>&lt;p&gt;tapflow started as a simple idea: stream iOS simulators and Android emulators to the browser so anyone on the team can do mobile QA without touching Xcode or Android Studio. v0.2.x got the core working — streaming, touch input, App Center, session recording.&lt;/p&gt;

&lt;p&gt;v0.3.x is about filling in the gaps that matter during actual QA sessions. This post covers what shipped and ends with something we're still figuring out: an experimental MCP server that lets LLM agents control simulators directly.&lt;/p&gt;




&lt;h2&gt;
  
  
  Deeplink execution from the browser
&lt;/h2&gt;

&lt;p&gt;The one that came up most in real usage: testers frequently need to trigger deeplinks to verify specific app states — product detail pages, notification payloads, OAuth redirects. The old workflow always involved a mobile developer — either having them trigger it on their machine or building a debug menu inside the app specifically for this purpose.&lt;/p&gt;

&lt;p&gt;In v0.3.0 you can now fire a deeplink directly from the QA session toolbar. Click the link icon (or &lt;code&gt;⌘K&lt;/code&gt;), enter the URL, and it executes on the active device.&lt;/p&gt;

&lt;p&gt;Under the hood it's a new &lt;code&gt;open-url&lt;/code&gt; WebSocket message type that routes browser → relay → agent:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Browser ──open-url──► Relay ──open-url──► Mac Agent
                                              │
                           iOS: xcrun simctl openurl booted &amp;lt;url&amp;gt;
                           Android: adb shell am start -a VIEW -d &amp;lt;url&amp;gt;
Browser ◄──open-url:done/error── Relay ◄──────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;DeviceAgent&lt;/code&gt; interface got a new &lt;code&gt;openUrl(url)&lt;/code&gt; method, so both iOS and Android agents implement it symmetrically. The relay routes it and returns either &lt;code&gt;open-url:done&lt;/code&gt; or &lt;code&gt;open-url:error&lt;/code&gt; with the failure reason. The dashboard shows a toast either way.&lt;/p&gt;




&lt;h2&gt;
  
  
  Keyboard shortcuts for simulator controls
&lt;/h2&gt;

&lt;p&gt;QA sessions are repetitive. Reaching for the toolbar icons on every screenshot or rotation adds up. v0.3.0 adds keyboard shortcuts to all the common actions:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Shortcut&lt;/th&gt;
&lt;th&gt;Action&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;⌘K&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Open deeplink dialog&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;⌘S&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Take screenshot&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;⌘⇧Y&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Start / stop recording&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;⌘⇧O&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Rotate simulator&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;⌘⇧U&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;iOS: press Home&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;⌘⇧K&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;iOS: toggle software keyboard&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Tooltips now show the shortcut hint inline, so they're discoverable without reading docs. One implementation detail worth noting: key detection uses &lt;code&gt;e.code&lt;/code&gt; instead of &lt;code&gt;e.key&lt;/code&gt;. This matters for IME input — Korean, Japanese, and Chinese users composing text would otherwise trigger shortcuts mid-composition.&lt;/p&gt;




&lt;h2&gt;
  
  
  Screenshot REST endpoint
&lt;/h2&gt;

&lt;p&gt;This one unlocks a new class of CI usage.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;GET /api/v1/sessions/:sessionId/screenshot&lt;/code&gt; returns a PNG or JPEG of the current simulator screen. You can call it with a PAT token from any CI step — before asserting a visual state, during an automated flow, after a build install.&lt;/p&gt;

&lt;p&gt;The tricky part was the request/response pattern. The relay communicates with agents over WebSocket (long-lived, multiplexed), but HTTP is request/response. Screenshots are taken on the Mac, not the relay.&lt;/p&gt;

&lt;p&gt;We introduced a requestId-based pending map: the relay generates a unique ID, sends a &lt;code&gt;take-screenshot&lt;/code&gt; message to the agent over WebSocket, registers a promise keyed by requestId, and resolves it when &lt;code&gt;screenshot:result&lt;/code&gt; comes back. The HTTP handler awaits that promise and sends the binary payload:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;GET /api/v1/sessions/:id/screenshot
    │
    ▼
Relay: generate requestId, push to pending map
    │
    ├──screenshot-request──► Mac Agent
    │                            │ simctl io screenshot (iOS)
    │                            │ ADB screencap (Android)
    ◄──screenshot:result─────────┘
    │
    ▼
HTTP 200 (binary image)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;iOS supports both PNG and JPEG via &lt;code&gt;--type&lt;/code&gt;. Android returns PNG regardless — ADB doesn't offer format selection at this layer.&lt;/p&gt;




&lt;h2&gt;
  
  
  PAT scope enforcement
&lt;/h2&gt;

&lt;p&gt;Personal Access Tokens existed before v0.3.0, but the scope field wasn't actually enforced on API routes. A &lt;code&gt;developer&lt;/code&gt; scoped token could call any endpoint.&lt;/p&gt;

&lt;p&gt;v0.3.0 adds proper scope checks to all builds endpoints. PATs are now enforced at the middleware layer: a token issued for &lt;code&gt;builds&lt;/code&gt; access can upload and manage builds, but can't touch team settings or session data. This makes it safe to issue narrow tokens for CI pipelines without giving them broader access than they need.&lt;/p&gt;




&lt;h2&gt;
  
  
  Frame performance instrumentation
&lt;/h2&gt;

&lt;p&gt;For anyone debugging streaming latency: v0.3.x adds per-frame hop timestamps via a binary header (&lt;code&gt;TFFE&lt;/code&gt; — tapflow frame envelope). Each frame now carries the capture time, relay-received time, and client-received time in an 8-byte prefix before the JPEG/H.264 payload.&lt;/p&gt;

&lt;p&gt;The dashboard can surface a live performance overlay showing frame latency broken down by segment (agent → relay, relay → browser). Useful when diagnosing whether a slowdown is in the network leg or the browser decode path.&lt;/p&gt;




&lt;h2&gt;
  
  
  Experimental: an MCP server
&lt;/h2&gt;

&lt;p&gt;v0.3.x also ships &lt;code&gt;@tapflowio/mcp-server&lt;/code&gt; (&lt;code&gt;0.3.1-experimental.1&lt;/code&gt;) — it exposes tapflow's WebSocket/REST APIs as MCP tools so an LLM agent can drive a simulator the same way a human does in the browser: screenshot → reason → tap/type → screenshot again.&lt;/p&gt;

&lt;p&gt;It's early (the &lt;code&gt;experimental&lt;/code&gt; suffix is literal — consistency and error-recovery still need work), and it's a big enough topic to have its own write-up: &lt;strong&gt;&lt;a href="https://dev.to/joduchan/-giving-an-llm-eyes-and-hands-on-a-mobile-simulator-5963"&gt;Giving an LLM Eyes and Hands on a Mobile Simulator&lt;/a&gt;&lt;/strong&gt; covers the full tool list, the normalized-coordinate tap/swipe, and where this is headed (LLM-driven smoke tests in CI).&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-g&lt;/span&gt; @tapflowio/mcp-server@experimental
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-g&lt;/span&gt; tapflow
tapflow start
&lt;span class="c"&gt;# http://localhost:4000&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;🔗 GitHub: &lt;a href="https://github.com/jo-duchan/tapflow" rel="noopener noreferrer"&gt;https://github.com/jo-duchan/tapflow&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;📖 Docs: &lt;a href="https://www.tapflow.dev" rel="noopener noreferrer"&gt;https://www.tapflow.dev&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>webdev</category>
      <category>opensource</category>
      <category>devtools</category>
      <category>ios</category>
    </item>
    <item>
      <title>Your whole team can now run mobile QA from the browser. Here's how we built it.</title>
      <dc:creator>Duchan</dc:creator>
      <pubDate>Wed, 27 May 2026 05:08:18 +0000</pubDate>
      <link>https://dev.to/joduchan/your-whole-team-can-now-run-mobile-qa-from-the-browser-heres-how-we-built-it-3fmn</link>
      <guid>https://dev.to/joduchan/your-whole-team-can-now-run-mobile-qa-from-the-browser-heres-how-we-built-it-3fmn</guid>
      <description>&lt;p&gt;If you work on a mobile product, you've probably seen this.&lt;/p&gt;

&lt;p&gt;Physical devices are never enough. Covering every OS version is even harder — iOS doesn't support downgrading, so maintaining a range of versions means managing a pool of locked devices, which is overhead nobody wants.&lt;/p&gt;

&lt;p&gt;But the bigger friction is access. Simulators only run on a developer's Mac, behind complex toolchains. Anyone on the team who isn't a mobile developer has to ask one every single time they need to verify something:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Server / FE developer&lt;/strong&gt; — "How do I install the sandbox build to check what was deployed?"&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Product manager&lt;/strong&gt; — "I keep having to install and remove different versions just to compare behavior."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Designer&lt;/strong&gt; — "I need to check the layout across screen sizes, but I don't have the right devices."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Cloud simulator services exist. But uploading internal app builds to an external service — and paying monthly fees for simulators already running on Macs you own — was never something we wanted to do.&lt;/p&gt;

&lt;p&gt;So we built &lt;a href="https://github.com/jo-duchan/tapflow" rel="noopener noreferrer"&gt;tapflow&lt;/a&gt;: an open-source, self-hosted tool that streams iOS simulators and Android emulators to the browser. Anyone on your team opens the dashboard, picks a device, and starts interacting — no Xcode, no Android Studio, no setup.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-g&lt;/span&gt; tapflow
tapflow start
&lt;span class="c"&gt;# → http://localhost:4000&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This post is about how we built it — specifically the parts that weren't obvious.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why we didn't just use Appetize or BrowserStack
&lt;/h2&gt;

&lt;p&gt;Both services solve the browser access problem. We evaluated them seriously. Before signing up, we hit two blockers:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Cost.&lt;/strong&gt; Appetize starts at $59/month and scales with team size.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Data.&lt;/strong&gt; Both require uploading your app binary to external servers. For anything with sensitive business logic, that's a non-starter.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;We already had Macs in the office. So we built tapflow instead.&lt;/p&gt;




&lt;h2&gt;
  
  
  Architecture
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Browser (your team)  ←─ WebSocket ─→  Relay Server  ←─ WebSocket (outbound) ─→  Mac Agent
                                     (Linux / Mac)                           (iOS · Android)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Mac Agent connects &lt;strong&gt;outbound&lt;/strong&gt; to the relay — no firewall or NAT configuration needed. The relay can run on a small Linux server (a ~$5/month Fly.io instance handles it). App data never leaves your infrastructure.&lt;/p&gt;




&lt;h2&gt;
  
  
  iOS touch — without WebDriverAgent
&lt;/h2&gt;

&lt;p&gt;WebDriverAgent was the obvious starting point. We didn't use it.&lt;/p&gt;

&lt;p&gt;The problems: WDA breaks on Xcode updates, requires provisioning profiles, needs the app to be in the foreground, and adds a layer of process management complexity we didn't want to own.&lt;/p&gt;

&lt;p&gt;Instead, we load &lt;code&gt;CoreSimulator.framework&lt;/code&gt; dynamically via &lt;code&gt;dlopen&lt;/code&gt; in a Swift binary (&lt;code&gt;touch-helper&lt;/code&gt;), then inject HID events directly through &lt;code&gt;SimDeviceLegacyHIDClient&lt;/code&gt; and &lt;code&gt;IndigoHID&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="c1"&gt;// touch-helper — HID event injection into the simulator&lt;/span&gt;
&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;SimDeviceLegacyHIDClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;device&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;device&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;event&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;IndigoHIDEvent&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;touch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;x&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;y&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;y&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;phase&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;began&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This bypasses WDA entirely. It works independently of the app lifecycle and doesn't break on Xcode updates.&lt;/p&gt;

&lt;p&gt;The tradeoff: these are private APIs. They've been stable across Xcode versions in our testing, but Apple could remove them. We think that's a better bet than WDA's reliability track record.&lt;/p&gt;




&lt;h2&gt;
  
  
  iOS streaming — IOSurface
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;xcrun simctl io screenshot&lt;/code&gt; works, but the latency is too high for interactive use.&lt;/p&gt;

&lt;p&gt;Instead, we access &lt;code&gt;IOSurface&lt;/code&gt; directly through SimulatorKit, pulling frames straight from the simulator's GPU surface. &lt;del&gt;Frames are JPEG-encoded on the Mac and streamed over WebSocket at ~30fps.&lt;/del&gt;&lt;/p&gt;

&lt;p&gt;For slow clients, we drop frames rather than buffering — backpressure is handled at the WebSocket layer to prevent memory accumulation on the relay when a client can't keep up.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Update:&lt;/strong&gt; JPEG was the first version. The default is now H.264 with a buffer-free 2-tier browser decoder (WebCodecs on secure contexts, WASM on plain HTTP). The full teardown — why H.264 first felt &lt;em&gt;worse&lt;/em&gt;, and the two fixes that solved it — is a separate post: &lt;a href="https://dev.to/joduchan/we-switched-simulator-streaming-to-h264-and-it-felt-worse-heres-how-we-fixed-the-latency-pk9"&gt;We switched simulator streaming to H.264 and it felt worse&lt;/a&gt;.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Android — scrcpy H.264 → WebGL
&lt;/h2&gt;

&lt;p&gt;Android was cleaner. scrcpy already does the hard work of capturing the emulator display as an H.264 stream.&lt;/p&gt;

&lt;p&gt;We receive the H.264 Annex B stream from scrcpy over a local TCP socket, relay it through WebSocket, then decode and render it in the browser. Android now shares the same buffer-free 2-tier decoder as iOS (see the update above).&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;scrcpy server (emulator)
    → TCP socket
    → Mac Agent
    → WebSocket
    → Browser (WebGL2)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Pinch gestures
&lt;/h3&gt;

&lt;p&gt;scrcpy's &lt;code&gt;INJECT_TOUCH_EVENT&lt;/code&gt; supports multiple pointer IDs. Pinch is implemented by sending two simultaneous touch events:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// ScrcpyControl — multi-touch injection&lt;/span&gt;
&lt;span class="nf"&gt;pinchStart&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;x1&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;y1&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;x2&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;y2&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="k"&gt;void&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;touchDown&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;x1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;y1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;touchDown&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;x2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;y2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  What's included
&lt;/h2&gt;

&lt;p&gt;Beyond streaming and input:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;App Center&lt;/strong&gt; — upload &lt;code&gt;.app.zip&lt;/code&gt; (iOS) or &lt;code&gt;.apk&lt;/code&gt; (Android), manage build status (Backlog / In Progress / Done / Rejected), REST API + Personal Access Tokens for CI/CD integration&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Session recording&lt;/strong&gt; — record and share QA sessions, kept for ~72 hours before automatic cleanup&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Team management&lt;/strong&gt; — invite links, role-based access (Admin / Developer / QA / Viewer)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Mac resource monitoring&lt;/strong&gt; — CPU and RAM time-series charts per agent&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Honest limitations
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;iOS simulators require macOS — Apple's constraint, not ours&lt;/li&gt;
&lt;li&gt;One Mac typically handles 2–4 simultaneous simulators depending on RAM; connect multiple Macs to pool devices&lt;/li&gt;
&lt;li&gt;Still v0.x — breaking changes may appear before v1.0&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;tapflow is MIT licensed.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-g&lt;/span&gt; tapflow
tapflow start
tapflow init  &lt;span class="c"&gt;# create the first admin account&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For team deployments with a shared relay:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Relay server (Linux/macOS)&lt;/span&gt;
&lt;span class="nv"&gt;JWT_SECRET&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;openssl rand &lt;span class="nt"&gt;-hex&lt;/span&gt; 32&lt;span class="si"&gt;)&lt;/span&gt; tapflow relay start

&lt;span class="c"&gt;# Each Mac agent&lt;/span&gt;
tapflow agent start &lt;span class="nt"&gt;--relay&lt;/span&gt; wss://your-relay-url
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;🔗 GitHub: &lt;a href="https://github.com/jo-duchan/tapflow" rel="noopener noreferrer"&gt;https://github.com/jo-duchan/tapflow&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;📖 Docs: &lt;a href="https://www.tapflow.dev" rel="noopener noreferrer"&gt;https://www.tapflow.dev&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>webdev</category>
      <category>opensource</category>
      <category>testing</category>
      <category>ios</category>
    </item>
  </channel>
</rss>
