<?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: 堀正明</title>
    <description>The latest articles on DEV Community by 堀正明 (@horimasaaki).</description>
    <link>https://dev.to/horimasaaki</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%2F4227487%2Fa74f2805-c849-4806-8332-f57e195d76c4.png</url>
      <title>DEV Community: 堀正明</title>
      <link>https://dev.to/horimasaaki</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/horimasaaki"/>
    <language>en</language>
    <item>
      <title>Making Appium Inspector Work with Flutter Apps: Record, Replay, and Run on Real Devices</title>
      <dc:creator>堀正明</dc:creator>
      <pubDate>Thu, 05 Nov 2026 13:26:23 +0000</pubDate>
      <link>https://dev.to/horimasaaki/making-appium-inspector-work-with-flutter-apps-record-replay-and-run-on-real-devices-1a3b</link>
      <guid>https://dev.to/horimasaaki/making-appium-inspector-work-with-flutter-apps-record-replay-and-run-on-real-devices-1a3b</guid>
      <description>&lt;p&gt;Appium can drive Flutter apps through the &lt;a href="https://github.com/appium/appium-flutter-driver" rel="noopener noreferrer"&gt;Appium Flutter Driver&lt;/a&gt;, which speaks the same protocol as Flutter's own &lt;code&gt;flutter_driver&lt;/code&gt;. Appium Inspector, the GUI most people use to explore an app and record a first test, does not work with it. In the Flutter context there is no page source to inspect, you can't tap by widget, and the recorder can only produce coordinate-based steps. Those steps break as soon as the test runs on a phone with a different screen size.&lt;/p&gt;

&lt;p&gt;Since early 2025 I have been fixing that with three pieces of open-source code:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://github.com/masaaki-hori/appium-handler" rel="noopener noreferrer"&gt;appium-handler&lt;/a&gt;&lt;/strong&gt;: a Dart package you add to the app under test. It builds an XML page source from the live widget tree, and it performs actions inside the app.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://github.com/masaaki-hori/appium-flutter-driver" rel="noopener noreferrer"&gt;appium-flutter-driver&lt;/a&gt;&lt;/strong&gt; (a fork): routes the standard W3C commands that Inspector sends to that handler.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://github.com/masaaki-hori/appium-inspector" rel="noopener noreferrer"&gt;appium-inspector&lt;/a&gt;&lt;/strong&gt; (a fork): records taps as Flutter widget finders instead of coordinates, adds a Flutter right-click menu, and generates Dart and JavaScript test code.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Today these tools run a production app's QA suite of about 15 test-spec sheets on an Android phone and an iPhone. This post explains how they work, what it took to make recorded tests replay reliably, and what I learned along the way, including three fixes that were merged into the upstream driver.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;With the handler embedded in your app, Appium Inspector shows a Flutter screen's widget tree, and taps are recorded as &lt;code&gt;find.byTooltip(...)&lt;/code&gt;, &lt;code&gt;find.text(...)&lt;/code&gt; and similar finders. Inspector generates Dart (&lt;code&gt;integration_test&lt;/code&gt;, Patrol) or JavaScript (WebdriverIO).&lt;/li&gt;
&lt;li&gt;Most of the work was not about tapping. It was about &lt;strong&gt;locators that looked fine when recorded but pointed at the wrong widget, or at nothing, on the next run.&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Text entry had three separate failure modes, and each one reported success. All three are fixed.&lt;/li&gt;
&lt;li&gt;Real iPhones work, not just simulators. Three driver fixes were merged upstream (#933, #936, #940).&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  How the pieces fit together
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Appium Inspector (GUI) / generated test scripts
  │  W3C: getPageSource, getWindowRect, performActions, findElement, click
  ▼
appium-flutter-driver (FLUTTER context)
  │  flutter:requestData "&amp;lt;command&amp;gt;:&amp;lt;args&amp;gt;"  (over the Dart VM service)
  ▼
appium-handler (inside the app under test)
     returns the page source as XML, runs tap / enterText / ...,
     and answers with {foundBy, value, ...} for the recorder
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The upstream driver already had a channel into the app: &lt;code&gt;flutter_driver&lt;/code&gt;'s &lt;code&gt;requestData&lt;/code&gt; lets a test send a string to a handler registered by the app. The fork uses that channel for the commands Inspector needs:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;W3C command&lt;/th&gt;
&lt;th&gt;Sent to the handler as&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;getWindowRect&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;getScreenSize&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;getPageSource&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;getPageSource&lt;/code&gt; (XML built from the widget tree)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;performActions&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;performActions:&amp;lt;json&amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;findElement&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;findElement:&amp;lt;args&amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;click&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;a &lt;code&gt;tap&lt;/code&gt; action on that element&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;When you tap a point on the screenshot in Inspector, the handler finds the widget under that point inside the app. It performs the action there and returns a locator for that widget, such as &lt;code&gt;{foundBy: "byTooltip", value: "Increment"}&lt;/code&gt;. Inspector records that locator, not the coordinates, so the generated code reads &lt;code&gt;find.byTooltip("Increment")&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you can do in Inspector
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fykjq7z0tzj3p4nyj0pv9.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fykjq7z0tzj3p4nyj0pv9.png" alt="Widget Tree" width="800" height="532"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Inspect the widget tree.&lt;/strong&gt; The App Source panel shows the XML built by the handler. In a Debug build, it lists only the widgets your app's code creates, not the hundreds of framework-internal ones. Each node has a &lt;code&gt;class&lt;/code&gt; (the widget type), &lt;code&gt;text&lt;/code&gt;, &lt;code&gt;key&lt;/code&gt;, &lt;code&gt;tooltip&lt;/code&gt;, &lt;code&gt;semanticLabel&lt;/code&gt;, &lt;code&gt;bounds&lt;/code&gt;, and a few Flutter-specific attributes described below. Inspector refreshes it every few seconds while you work.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Record taps as widget finders.&lt;/strong&gt; Start the recorder and tap on the screenshot. Each step is stored with the locator the handler returned, and the generated code can be switched between Dart and JavaScript from the language menu.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Use the Flutter right-click menu.&lt;/strong&gt; In a Flutter session, right-clicking the screenshot opens a menu with actions that &lt;code&gt;flutter_driver&lt;/code&gt; alone doesn't give you:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F424r50ddyst9owe6oqkm.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F424r50ddyst9owe6oqkm.png" alt="Context Menues" width="800" height="532"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Tap&lt;/strong&gt;: tap the widget and record it with a widget finder.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Enter text&lt;/strong&gt;: type into a &lt;code&gt;TextField&lt;/code&gt; or &lt;code&gt;TextFormField&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check text&lt;/strong&gt;: assert what a widget displays.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check existence&lt;/strong&gt;: assert that a widget is on screen.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;tapDirect&lt;/strong&gt;: tap a widget that something else is covering (more on this below).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Choose between overlapping widgets.&lt;/strong&gt; One point on the screen usually belongs to several widgets: a &lt;code&gt;Text&lt;/code&gt; inside an &lt;code&gt;InkWell&lt;/code&gt; inside a &lt;code&gt;Card&lt;/code&gt;. The menu lists every candidate at that point, most specific first. Picking one sends that widget's page-source &lt;code&gt;id&lt;/code&gt; along with the action, so the handler acts on exactly that widget. Getting this menu to behave took more than expected. The first version used a stock dropdown component, which closed itself whenever the periodic refresh re-rendered the screenshot. It is now a small &lt;code&gt;position: fixed&lt;/code&gt; menu with its own state. Long candidate lists scroll, and the submenus are positioned from the parent item's bounding box so the scrolling container doesn't clip them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Use "Tap By Element".&lt;/strong&gt; Upstream Inspector has a mode that taps an element by sending the standard WebDriver &lt;code&gt;click&lt;/code&gt; command. The Flutter driver didn't implement &lt;code&gt;click&lt;/code&gt; in the Flutter context, so nothing happened. The fork now routes &lt;code&gt;click&lt;/code&gt; to the handler's &lt;code&gt;tap&lt;/code&gt;, and the handler can resolve an element from its id alone, using the center of its bounds. Element ids that come from &lt;code&gt;appium-flutter-finder&lt;/code&gt; are base64-encoded finders. The fork detects those and uses &lt;code&gt;flutter_driver&lt;/code&gt;'s own &lt;code&gt;tap&lt;/code&gt; for them, so apps without the handler keep working.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Small fixes so Inspector treats the session as Flutter.&lt;/strong&gt; Inspector decides a lot from the session type. Flutter sessions were shown as "Native App Mode" because they have no &lt;code&gt;browserName&lt;/code&gt;, and the "Web/Hybrid App Mode" button failed because Inspector tried to tag WebViews. Flutter sessions are now recognized by &lt;code&gt;automationName: flutter&lt;/code&gt;, start in the &lt;code&gt;FLUTTER&lt;/code&gt; context, and skip WebView lookups.&lt;/p&gt;

&lt;h2&gt;
  
  
  Inside: a page source you can trust
&lt;/h2&gt;

&lt;p&gt;Inspector, the recorder, and your test scripts all read the same XML. When it is wrong, everything downstream is wrong in confusing ways.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Screens you can't see are not in the tree.&lt;/strong&gt; Flutter's &lt;code&gt;Navigator&lt;/code&gt; keeps every previous route in the element tree, offstage. They used to appear in the XML, often at a negative x where the iOS-style page transition parks them. Two things broke. &lt;code&gt;byText&lt;/code&gt; matched text on screens the user couldn't see. A recorded &lt;code&gt;Type#N&lt;/code&gt; index also counted widgets that the Flutter finder (which skips offstage widgets) never sees, so it pointed at a different widget on replay. The handler now uses the same &lt;code&gt;debugVisitOnstageChildren&lt;/code&gt; walk the finders use, but only for screen-level hiders: &lt;code&gt;Offstage&lt;/code&gt;, an unselected &lt;code&gt;IndexedStack&lt;/code&gt; child, &lt;code&gt;SliverOffstage&lt;/code&gt;, and the Overlay's offstage entries. List items scrolled out of view are kept, because tests need them as scroll targets.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Debug and Profile builds produce very different trees, so locators don't depend on the tree's shape.&lt;/strong&gt; The filter that hides framework-internal widgets relies on widget-creation tracking, which only exists in Debug (JIT) builds. One screen had about 80 nodes in Debug and about 1,600 for the same route in Profile. They cannot be made identical. Instead, everything a locator depends on is read from the live widget tree:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;typeIndex&lt;/code&gt;: the widget's index in Flutter finder order, the same order &lt;code&gt;find.byType(T).at(N)&lt;/code&gt; uses. It is not the position among same-typed nodes in the XML, which disagreed with the finder for framework-created types like &lt;code&gt;InkWell&lt;/code&gt; and &lt;code&gt;Icon&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;label&lt;/code&gt; and &lt;code&gt;hint&lt;/code&gt; on &lt;code&gt;TextField&lt;/code&gt;/&lt;code&gt;TextFormField&lt;/code&gt;, read from &lt;code&gt;InputDecoration&lt;/code&gt;. The rendered label is built inside the framework and never shows up in a Debug page source, and the hint disappears once you type.&lt;/li&gt;
&lt;li&gt;In Profile and Release, identity-less pass-through wrappers are collapsed so the tree stays readable.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;RichText&lt;/code&gt; text is included.&lt;/strong&gt; The &lt;code&gt;text&lt;/code&gt; attribute used to come only from a widget's &lt;code&gt;data&lt;/code&gt; property, so anything drawn with &lt;code&gt;Text.rich&lt;/code&gt; or an app-built &lt;code&gt;RichText&lt;/code&gt; was empty. You couldn't find it with &lt;code&gt;byText&lt;/code&gt;, and scripts couldn't read it. The handler now emits the span's plain text. It skips the &lt;code&gt;RichText&lt;/code&gt; that &lt;code&gt;Text&lt;/code&gt; and &lt;code&gt;Icon&lt;/code&gt; create internally, so strings aren't duplicated and icon glyphs don't leak in.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Keys are reported only when they exist.&lt;/strong&gt; Widgets without a key used to show &lt;code&gt;key="null"&lt;/code&gt;, the string, which then turned up as a "locator". Empty and &lt;code&gt;"null"&lt;/code&gt; keys are now left out.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The root element tells you which handler version you're talking to.&lt;/strong&gt; &lt;code&gt;&amp;lt;tree&amp;gt;&lt;/code&gt; carries &lt;code&gt;offstageFilter&lt;/code&gt;, &lt;code&gt;hiddenElements&lt;/code&gt;, &lt;code&gt;skippedNodes&lt;/code&gt;, &lt;code&gt;unresolvedNodes&lt;/code&gt; and &lt;code&gt;collapsedNodes&lt;/code&gt;. This sounds minor, but it saved hours. More than once a fix "didn't work" only because the phone still had an old build: with &lt;code&gt;appium:noReset: true&lt;/code&gt;, Appium does not reinstall an app that is already present. If &lt;code&gt;&amp;lt;tree&amp;gt;&lt;/code&gt; has none of these attributes, you are looking at an old handler.&lt;/p&gt;

&lt;h2&gt;
  
  
  Inside: locators that survive replay
&lt;/h2&gt;

&lt;p&gt;When the handler resolves a widget, it keeps two jobs apart:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Performing the action&lt;/strong&gt; uses the exact widget, looked up by its page-source &lt;code&gt;id&lt;/code&gt;. That is unambiguous. But the ids are re-minted on every &lt;code&gt;getPageSource&lt;/code&gt; call, so they can never be recorded, and because Inspector refreshes often, an id from the last refresh may already belong to another widget. The handler checks the widget it found against the clicked point, so a stale id fails instead of acting on the wrong widget.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reporting a locator&lt;/strong&gt; for generated code picks the most stable one available, in this order: tooltip → semantics label → value key → field label → text → tap-target label → &lt;code&gt;Type#N&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Most replay bugs were in step 2.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Only &lt;code&gt;ValueKey&amp;lt;String&amp;gt;&lt;/code&gt; is recorded as a key.&lt;/strong&gt; The key used to be recorded exactly as the widget tree prints it. Apps often attach a &lt;code&gt;GlobalKey&lt;/code&gt; to a button, for example to position a coach-mark tooltip. That button was recorded as &lt;code&gt;byValueKey: "[GlobalKey#8cb82]"&lt;/code&gt;, and the hash changes on every app launch. The recording replayed within the same session and failed on the next one. Even a plain &lt;code&gt;ValueKey('submit')&lt;/code&gt; came out as &lt;code&gt;Key("[&amp;lt;'submit'&amp;gt;]")&lt;/code&gt; in generated Dart. Now only a &lt;code&gt;ValueKey&amp;lt;String&amp;gt;&lt;/code&gt; is recorded, as its plain string. Other keys fall through to the next locator.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Text fields are found by their label (&lt;code&gt;byFieldLabel&lt;/code&gt;).&lt;/strong&gt; A text field without a key used to be recorded either as &lt;code&gt;TextFormField#N&lt;/code&gt;, which shifts whenever a field is added above it, or by the text typed into it, which is useless. If the field's label is unique on the screen, it is now recorded as &lt;code&gt;byFieldLabel: "TextFormField|Email"&lt;/code&gt;. Dart code generation emits &lt;code&gt;find.widgetWithText(TextFormField, "Email")&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Taps on icons are labeled by the nearby text.&lt;/strong&gt; A stock &lt;code&gt;BottomNavigationBar&lt;/code&gt; item's label is built by the framework, so it isn't in a Debug page source. Tapping the icon was recorded as &lt;code&gt;Container#N&lt;/code&gt;, which at replay pointed at an unrelated 1-pixel divider. The handler now walks up to the nearest &lt;code&gt;InkResponse&lt;/code&gt; or &lt;code&gt;GestureDetector&lt;/code&gt; and uses its single, screen-unique text as the locator.&lt;/p&gt;

&lt;p&gt;The same idea covers a common layout: a child-less &lt;code&gt;InkWell&lt;/code&gt; stretched over its content with &lt;code&gt;Stack&lt;/code&gt; and &lt;code&gt;Positioned.fill&lt;/code&gt;. The InkWell has no text of its own, so the handler looks for text drawn inside the tap target's area. Replaying such a tap uses the text's center. If it went through &lt;code&gt;flutter_driver&lt;/code&gt;'s &lt;code&gt;tap&lt;/code&gt; instead, it would wait forever for the covered text to become hit-testable.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When the exact widget can't be found, fall back step by step.&lt;/strong&gt; If the id is stale, the handler tries the same locator chain on the device. Plain &lt;code&gt;ByType&lt;/code&gt; usually fails with "found N widgets", so taps and scrolls continue to &lt;code&gt;ByTypeIndex&lt;/code&gt; and, as a last resort, a coordinate tap. A coordinate tap reports no locator, so the recorder never stores it as if it were stable. Some widgets really have nothing to identify them: a full-screen coach-mark overlay has no tooltip, label, key or text. Those still end up as coordinate taps, and the fix is to choose a more meaningful widget from the candidate menu.&lt;/p&gt;

&lt;h2&gt;
  
  
  Inside: actions that don't silently do nothing
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;tapDirect&lt;/code&gt; bypasses hit testing.&lt;/strong&gt; Coach marks, dialog barriers, and other overlays sit on top of the widget you want. A normal tap at that point hits the overlay. &lt;code&gt;tapDirect&lt;/code&gt; resolves the target widget and invokes its tap callback directly. Use it sparingly, because it can press a button a real user couldn't reach at that moment. Still, it is the difference between "this screen can be automated" and "it can't".&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Text entry had three separate traps, and all three reported success.&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;flutter_driver&lt;/code&gt; ignores the finder for text entry.&lt;/strong&gt; Its &lt;code&gt;EnterText&lt;/code&gt; command types into whatever currently has keyboard focus. Without a prior tap, that is nothing. The handler taps the resolved field first, for every kind of locator.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Text-entry emulation must be registered before that tap.&lt;/strong&gt; &lt;code&gt;TestTextInput.register()&lt;/code&gt; installs a fake text-input channel that learns which field is active by intercepting the &lt;code&gt;TextInput.setClient&lt;/code&gt; call made when the field gains focus. Tap first, and that call goes to the real platform channel, no client is captured, and the text is dropped without an error.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Drop the existing focus first.&lt;/strong&gt; If the field already had focus (you clicked it in Inspector, or an earlier step tapped it), tapping it again does not re-attach its input connection. No &lt;code&gt;setClient&lt;/code&gt; call reaches the fake channel, and the field stays empty. The handler unfocuses the current focus and waits a frame before registering and tapping.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;After typing, the handler sends the keyboard's &lt;strong&gt;Done&lt;/strong&gt; action, so &lt;code&gt;onSubmitted&lt;/code&gt; and &lt;code&gt;onFieldSubmitted&lt;/code&gt; run as they would for a real user. The response includes &lt;code&gt;submitted: true&lt;/code&gt;, and the Dart code generator emits a matching &lt;code&gt;tester.testTextInput.receiveAction(TextInputAction.done)&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Animated screens don't hang.&lt;/strong&gt; &lt;code&gt;flutter_driver&lt;/code&gt;'s &lt;code&gt;waitFor&lt;/code&gt; can hang forever on a screen with a looping animation (a spinner, a shimmer), in two independent ways. With frame sync on, it waits for transient callbacks to reach zero, which never happens while something keeps ticking. Separately, it waits for the finder itself, which never succeeds if the target never becomes hit-testable. The handler turns frame sync off for that call when an animation is running and gives up after 2 seconds, so the fallback chain can move on. Timing logs showed that building the page source was not the problem (20–134 ms at 800+ nodes). The real signal was a multi-minute gap between two log lines.&lt;/p&gt;

&lt;h2&gt;
  
  
  Generated code you can keep
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fb9jl7dl5o58aod5l70u0.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fb9jl7dl5o58aod5l70u0.png" alt="Generated code" width="799" height="532"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The recorder generates:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Dart&lt;/strong&gt;: &lt;code&gt;integration_test&lt;/code&gt; and Patrol variants, so recorded steps can run as Flutter integration tests. They share one finder mapping: &lt;code&gt;byType "InkWell#3"&lt;/code&gt; becomes &lt;code&gt;find.byType(InkWell).at(3)&lt;/code&gt;, and &lt;code&gt;byFieldLabel&lt;/code&gt; becomes &lt;code&gt;find.widgetWithText(...)&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;JavaScript (WebdriverIO)&lt;/strong&gt;: each step becomes a &lt;code&gt;retryFlutterAction(...)&lt;/code&gt; call. It re-reads the page source, sends the action, and retries until the device reports that it found the widget:
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;retryFlutterAction&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="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;tap&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;foundBy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;byText&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Sign in&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;foundBy&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;Boolean&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;foundBy&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Could not resolve the widget to tap (foundBy=byText, value=Sign in)&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The JavaScript boilerplate deliberately contains only app-independent helpers: logging, a page-source dump on failure, the retry, and a prompt for manual input. Helpers that only make sense for one app (dismissing its coach marks, finding a button by its fixed position) belong in that app's own test project. A unit test in the Inspector fork keeps them out.&lt;/p&gt;

&lt;p&gt;One thing is intentionally not supported. Steps recorded in "Tap By Element" mode show "Code generation for action 'findAndAssign' is not currently supported", because their XPath locators embed the volatile page-source ids. Use the Flutter right-click menu for steps you want to keep.&lt;/p&gt;

&lt;h2&gt;
  
  
  Real devices, and fixes sent upstream
&lt;/h2&gt;

&lt;p&gt;An emulator and a simulator were enough to build all of this, but a real QA suite runs on real phones, and the iPhone surfaced a series of driver problems. Three were general enough to send upstream, and the maintainers merged them:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://github.com/appium/appium-flutter-driver/pull/933" rel="noopener noreferrer"&gt;#933&lt;/a&gt;&lt;/strong&gt; &lt;code&gt;appium:dartVmServicePort&lt;/code&gt; never reached the app on iOS. The launch flags were added to a copy of the capabilities that XCUITest never saw, so the engine picked a random port and the driver connected to a port nobody was listening on.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://github.com/appium/appium-flutter-driver/pull/936" rel="noopener noreferrer"&gt;#936&lt;/a&gt;&lt;/strong&gt; &lt;code&gt;mobile: launchApp&lt;/code&gt; (the XCUITest way to relaunch an app with arguments) was not usable. In the &lt;code&gt;FLUTTER&lt;/code&gt; context it was unsupported. From &lt;code&gt;NATIVE_APP&lt;/code&gt; the app relaunched, but the Dart VM socket kept pointing at the dead process.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://github.com/appium/appium-flutter-driver/pull/940" rel="noopener noreferrer"&gt;#940&lt;/a&gt;&lt;/strong&gt; While &lt;code&gt;mobile: activateApp&lt;/code&gt; or &lt;code&gt;mobile: launchApp&lt;/code&gt; waited for the app's VM service, the session's &lt;code&gt;newCommandTimeout&lt;/code&gt; kept running, so a slow relaunch could end the session in the middle of the command.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Fixes that stayed in the fork:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F91yz2nypc8a2uujz8si0.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F91yz2nypc8a2uujz8si0.png" alt="with Boilerplate Code" width="800" height="851"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;An iPhone paired over Wi-Fi made every connection fail with "socket hang up".&lt;/strong&gt; usbmuxd lists such a device twice under the same UDID, once for USB and once for the network. The library call that forwards the VM-service port took the first entry, which could be the network one. The forwarded connection then hit a service bound to the phone's loopback interface and failed with no hint why. The fork prefers the USB entry and logs why a forwarded connection could not be opened.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A clear error when the app wasn't built for testing.&lt;/strong&gt; If the VM service is reachable but no isolate registers &lt;code&gt;ext.flutter.driver&lt;/code&gt;, session creation used to fail with a generic "cannot connect" message. The real cause, usually a build without the flag that enables the extension or an old build still installed, was buried in a debug line. The error now says so directly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A reconnect race after relaunching the app on iOS.&lt;/strong&gt; WDA's launch command waits for the app to become idle before it returns. By then the app had already logged its new VM-service URL. The driver cleared the log &lt;em&gt;after&lt;/em&gt; the launch, so it waited for a line that had already arrived and failed with "No observatory URL". The fork fixed this by forgetting the old URL &lt;em&gt;before&lt;/em&gt; the launch. Upstream later fixed the same race independently (#945, #947), and the fork now uses upstream's version.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That last one taught me something about forks. The connection code in this driver changes upstream every few weeks. Every fork-only change in that area is a merge conflict waiting to happen, and every general fix merged upstream is one fewer. I now merge upstream locally, run the same checks CI runs (&lt;code&gt;npm test &amp;amp;&amp;amp; npm run lint &amp;amp;&amp;amp; npm run format:check&lt;/code&gt;) before pushing, and plan to send the remaining general fixes upstream.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lessons from running it every day
&lt;/h2&gt;

&lt;p&gt;The suite covers sign-up, payments, account recovery, and cancellation on a Pixel phone and an iPhone SE. Most steps were recorded in Inspector and then edited by hand. A few lessons apply to any Appium-plus-Flutter setup:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Check that your change actually reached the device before debugging it.&lt;/strong&gt; For the handler, that means rebuilding the app and &lt;strong&gt;uninstalling&lt;/strong&gt; it before the run, because &lt;code&gt;noReset&lt;/code&gt; keeps the old build. For the Inspector plugin, it means a hard reload in the browser. For the driver, it means restarting the Appium server, which keeps the driver it loaded at startup. "I fixed it and nothing changed" was, most of the time, one of these.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The protocol between the three repos is just strings.&lt;/strong&gt; &lt;code&gt;foundBy&lt;/code&gt; values, action types, and page-source attribute names are never validated at either end, so a mismatch fails silently. A &lt;code&gt;byToolTip&lt;/code&gt; vs &lt;code&gt;byTooltip&lt;/code&gt; capitalization bug was found exactly that way. The handler repo's &lt;code&gt;CLAUDE.md&lt;/code&gt; keeps one "protocol contract" list, and a rename means searching for the same literal in all three repos.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Don't trust one build type.&lt;/strong&gt; Debug (Android) and Profile (iOS) page sources differ by an order of magnitude. Check a failure against both before deciding what the tree "looks like".&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;On iOS, match Xcode to macOS, and keep the iPhone's iOS no newer than Xcode.&lt;/strong&gt; When the phone updated to an iOS newer than Xcode supported, the WebDriverAgent build failed. An old cached build hid the problem until a dependency upgrade cleaned it out. For iOS 17 and later, keep the RemoteXPC tunnel running, and keep the phone unlocked while it starts.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Watch the browser cache.&lt;/strong&gt; At least one "the bug is still there" in the Inspector turned out to be the old bundle still loaded in the browser.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Working with Claude Code
&lt;/h2&gt;

&lt;p&gt;Almost all of this was written together with &lt;a href="https://claude.com/claude-code" rel="noopener noreferrer"&gt;Claude Code&lt;/a&gt;, across Dart, TypeScript and React in three repos plus the test suite. What made that work:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Each repo has a &lt;code&gt;CLAUDE.md&lt;/code&gt;&lt;/strong&gt; with the rules: the protocol contract, the fork's customizations, how to verify a change on a device. A separate design-notes file explains why the rules exist. The rules file stays short, and the history isn't lost.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Evidence before fixes.&lt;/strong&gt; The most expensive mistakes were confident diagnoses made from logs that didn't actually show the cause. Asking for a page-source dump, a device log or a timing log first, and reading it before changing code, was always cheaper.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Small, verified steps.&lt;/strong&gt; Plan first, change one thing, run it on the device, record the result. A fix isn't done until a real run shows it.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Limitations
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;The app under test must include &lt;code&gt;appium-handler&lt;/code&gt; and be built in Debug or Profile mode. Release builds can't be driven through &lt;code&gt;flutter_driver&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;OS-level dialogs on iOS (such as the notification permission prompt) are not always detectable from the Flutter context. The suite falls back to asking the tester to tap them.&lt;/li&gt;
&lt;li&gt;"Tap By Element" can't generate reusable code (see above). Merging it with the Flutter right-click menu is on the list.&lt;/li&gt;
&lt;li&gt;The driver is a fork. Next, I plan to send the remaining general fixes upstream (the USB-entry preference, the connection retry, the clearer errors) so the fork stays small.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;You need Node.js, Appium 3, and for iOS, Xcode on the same version as macOS.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm i &lt;span class="nt"&gt;-g&lt;/span&gt; appium@3.6.0

&lt;span class="c"&gt;# Driver (fork)&lt;/span&gt;
git clone https://github.com/masaaki-hori/appium-flutter-driver
&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;cd &lt;/span&gt;appium-flutter-driver/driver &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; npm &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; npm run build&lt;span class="o"&gt;)&lt;/span&gt;
appium driver &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;--source&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;local&lt;/span&gt; ./appium-flutter-driver/driver

&lt;span class="c"&gt;# Inspector (fork), used as an Appium plugin&lt;/span&gt;
git clone https://github.com/masaaki-hori/appium-inspector
&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;cd &lt;/span&gt;appium-inspector &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; npm ci &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; npm run build:plugin&lt;span class="o"&gt;)&lt;/span&gt;
appium plugin &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;--source&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;local&lt;/span&gt; ./appium-inspector/plugins

appium server &lt;span class="nt"&gt;--use-drivers&lt;/span&gt; flutter &lt;span class="nt"&gt;--use-plugins&lt;/span&gt; inspector
&lt;span class="c"&gt;# then open http://127.0.0.1:4723/inspector&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In the app under test, add &lt;code&gt;flutter_driver&lt;/code&gt; (from the SDK) and &lt;code&gt;appium_handler&lt;/code&gt; (&lt;code&gt;path:&lt;/code&gt; to your clone of &lt;a href="https://github.com/masaaki-hori/appium-handler" rel="noopener noreferrer"&gt;appium-handler&lt;/a&gt;) to &lt;code&gt;pubspec.yaml&lt;/code&gt;, and register the handler. Guard it behind a compile-time flag so it never ships:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="s"&gt;'package:appium_handler/appium_handler.dart'&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="s"&gt;'package:flutter_driver/driver_extension.dart'&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

&lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;main&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="kd"&gt;const&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;fromEnvironment&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;'ENABLE_APPIUM'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;handler&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;AppiumHandler&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="n"&gt;enableFlutterDriverExtension&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nl"&gt;handler:&lt;/span&gt; &lt;span class="n"&gt;handler&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;appiumHandler&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;handler&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;buildDriverExtension&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="n"&gt;runApp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;MyApp&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;Build with &lt;code&gt;--dart-define=ENABLE_APPIUM=true&lt;/code&gt;. Use a Debug APK on Android and a Profile build on iOS. Start a session with these capabilities (Android shown):&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;"platformName"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Android"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"appium:automationName"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"flutter"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"appium:deviceName"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"&amp;lt;your device&amp;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;"appium:app"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"/path/to/app-debug.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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After rebuilding the app, uninstall the old one before the next run.&lt;/p&gt;

&lt;p&gt;If you try it, I'd like to hear what breaks. Issues and PRs are welcome on any of the three repositories.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Earlier write-ups of this work are on Zenn, in Japanese: &lt;a href="https://zenn.dev/baleenstudio/articles/a4a3dee21753da" rel="noopener noreferrer"&gt;making Inspector work with the Flutter driver&lt;/a&gt; and &lt;a href="https://zenn.dev/baleenstudio/articles/ae0c8b75bf6ea5" rel="noopener noreferrer"&gt;the follow-up fixes&lt;/a&gt;. The code they link to under `baleen-studio/&lt;/em&gt;&lt;code&gt; is out of date; use the &lt;/code&gt;masaaki-hori/&lt;em&gt;` repositories above.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>flutter</category>
      <category>appium</category>
      <category>testing</category>
      <category>opensource</category>
    </item>
  </channel>
</rss>
