<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:atom="http://www.w3.org/2005/Atom">
  <channel>
    <title>Spectra Blog</title>
    <link>https://usespectra.dev/blog</link>
    <description>Practical mobile QA: locators, Appium, Android and iOS inspection, and what we learn building Spectra.</description>
    <language>en</language>
    <lastBuildDate>Tue, 18 Aug 2026 22:03:12 GMT</lastBuildDate>
    <atom:link href="https://usespectra.dev/rss.xml" rel="self" type="application/rss+xml"/>
    <item>
      <title>What Spectra is, how it's built, and how it compares to Appium Inspector</title>
      <link>https://usespectra.dev/blog/what-is-spectra</link>
      <guid isPermaLink="true">https://usespectra.dev/blog/what-is-spectra</guid>
      <pubDate>Wed, 19 Aug 2026 08:00:00 GMT</pubDate>
      <dc:creator>Filip Gajić</dc:creator>
      <category>spectra</category><category>appium</category><category>tauri</category><category>mobile-testing</category>
      <description>Spectra is a native mobile UI inspector for Android and iOS. Here is the problem behind it, the architecture, the feature list, and an honest comparison.</description>
      <content:encoded><![CDATA[<p>I&#39;m a QA engineer. For years my working day was Appium Inspector on one half of the screen and a test file on the other: tap the device, hit refresh, wait for the page source, click through the tree, copy a locator, paste it, run, repeat. On a good screen the refresh took a couple of seconds. On a screen with a spinner it took ten or more, or timed out. Hundreds of times a day.</p>
<p>Spectra is the inspector I built to get that time back. It&#39;s a native desktop app for inspecting Android and iOS app UIs, roughly 10MB, for macOS (Apple Silicon and Intel) and Windows. It generates locators and grades them, mirrors the device screen live, records gestures into test code, and works either through your existing Appium server or directly over ADB and WDA.</p>
<p>This post is the long version of that paragraph: what it is, how it&#39;s put together, what it does, and where Appium Inspector is still the better choice.</p>
<h2 id="what-spectra-is">What Spectra is</h2>
<p>Spectra sits where Appium Inspector, UIAutomator Viewer or the Xcode Accessibility Inspector sit in your workflow. You connect a device or simulator, you see the screenshot and the element tree side by side, you click an element and get its attributes and a list of locators. From there you copy a locator into your test, or export a snippet, or record a flow.</p>
<p>A typical session: plug in the phone, pick it in the connect dialog, and in Turbo Mode the screenshot and tree appear without an Appium server being started first. I click the button I care about, the Locators tab shows an A-graded accessibility ID at the top and a D-graded absolute XPath at the bottom, and I copy the one at the top. If the screen changes, the view follows without me touching anything.</p>
<p>What it doesn&#39;t do is replace your Appium server or your test framework. In Appium mode Spectra is a client of the same server your tests use, so what you see is what your tests will see. The direct mode is an addition for when you don&#39;t want a server in the middle, and you can switch between the two per session.</p>
<h2 id="how-its-built">How it&#39;s built</h2>
<p>The desktop shell is Tauri 2.0 with a Rust backend; the UI is React 18 with TypeScript, Zustand for state and Tailwind with Radix for components. Tauri uses the OS webview instead of bundling a browser, which is where the ~10MB and sub-second startup come from. Every ADB call, WDA request, screenshot capture and file operation runs in Rust and reaches the UI over Tauri&#39;s IPC.</p>
<p>There are two ways to talk to a device.</p>
<p>Appium mode speaks the WebDriver protocol to any Appium server, local or remote, typically with the UiAutomator2 or XCUITest driver behind it. Nothing about your Appium setup changes.</p>
<p>Turbo Mode skips the server. On Android, Spectra drives ADB directly for screenshots, input and the UI tree. On iOS it talks to WebDriverAgent over its HTTP API. Without the extra hop, inspection is 2 to 5x faster in my testing, and it&#39;s what makes live mirroring practical.</p>
<p>The third piece is the Spectra Agent, and it exists because of the spinner problem from the first paragraph. <code>uiautomator dump</code> calls <code>waitForIdle()</code> before it reads anything, and a screen with an animation is never idle, so the dump times out. The Agent is a small Android instrumentation APK (about a megabyte, some 450 lines of Kotlin) that Spectra installs when you connect in Turbo Mode. It reads the tree straight from <code>UiAutomation.getRootInActiveWindow()</code>, with no idle wait, and hands it back over a local socket forwarded through <code>adb forward</code>. It sends nothing anywhere else. If you don&#39;t want it on a device, <code>adb uninstall com.spectra.agent</code> removes it. There&#39;s a separate post with the details and the workarounds you can use without Spectra.</p>
<p>Licensing runs through Polar. A license key activates Pro on up to two machines. There&#39;s no account inside the app and no signup to download.</p>
<h2 id="what-it-does">What it does</h2>
<p>Turbo Mode (Pro). Direct ADB on Android and direct WDA on iOS, as described above, with no Appium server in the path.</p>
<p>LiveTrack (Pro). The screenshot and tree refresh on their own, with polling that adapts to how fast the device answers, and pause while the window is hidden. No refresh button in the loop.</p>
<p>Locator generation (Free). Eleven strategies: accessibility ID, resource-id, content-desc, text, XPath, iOS class chain, iOS predicate string, and Flutter key, type, text and semantics. Every candidate gets a grade from A to D based on how likely it is to survive a release (F-graded candidates are hidden). A Locator Builder lets you compose composite locators, and search covers text, attributes and locators.</p>
<p>Test Recorder (Pro). Record taps, swipes, long presses and typing on the real device, edit the steps, add assertions, replay, then export as executable test code. Think of it as codegen for mobile.</p>
<p>Code generation (Pro). Snippets and Page Object Model classes for WebdriverIO, Appium Python, Appium Java, Appium JS, XCUITest (Swift), Espresso (Kotlin), Detox (JS) and Maestro (YAML). As far as I know, no other inspector exports a recording to that many targets.</p>
<p>Multi-device (Pro). Two devices side by side, for example an Android phone and an iPhone on the same screen.</p>
<p>XPath Axis Builder (Pro). A visual builder for parent, sibling, ancestor and descendant relationships, for the times a plain attribute isn&#39;t enough.</p>
<p>Also in Free: device interactions (tap, swipe, long press, type, back, home), the Console, Logs, History and Network panels, a Prerequisite Checker that finds and installs missing ADB, Java or Appium pieces, auto-update with a &quot;What&#39;s New&quot; panel, and dark and light themes.</p>
<h2 id="how-it-compares">How it compares</h2>
<p>Facts as of the current release, from the tools&#39; own documentation and my use of them:</p>
<div class="table-wrap"><table><thead><tr><th></th><th>Spectra</th><th>Appium Inspector</th><th>UIAutomator Viewer</th><th>Xcode Accessibility Inspector</th><th>Maestro Studio</th></tr></thead><tbody><tr><td>Platforms</td><td>Android + iOS</td><td>Android + iOS</td><td>Android only</td><td>iOS only</td><td>Android + iOS</td></tr>
<tr><td>Install size</td><td>~10MB native</td><td>~500MB (Electron)</td><td>Ships with the Android SDK</td><td>Ships with Xcode</td><td>Bundled with the Maestro CLI</td></tr>
<tr><td>Needs an Appium server</td><td>Optional (Turbo Mode goes direct)</td><td>Yes</td><td>No</td><td>No</td><td>No</td></tr>
<tr><td>Real-time refresh</td><td>Yes (LiveTrack, Pro)</td><td>Manual</td><td>Manual</td><td>Manual</td><td>Yes</td></tr>
<tr><td>Multi-device in one window</td><td>Yes, 2 devices (Pro)</td><td>No</td><td>No</td><td>No</td><td>No</td></tr>
<tr><td>Graded locators</td><td>Yes, A to D</td><td>No</td><td>No</td><td>No</td><td>No</td></tr>
<tr><td>Code export</td><td>8 frameworks + POM (Pro)</td><td>Recorder output only</td><td>No</td><td>No</td><td>Maestro YAML</td></tr>
<tr><td>Recorder</td><td>Yes, exports to 8 frameworks (Pro)</td><td>Yes, exports Appium client boilerplate</td><td>No</td><td>No</td><td>Partial</td></tr>
<tr><td>Price</td><td>Free; Pro $7/mo or $50/yr</td><td>Free</td><td>Free</td><td>Free</td><td>Free</td></tr></tbody></table></div>
<p>What Appium Inspector does better, and this matters: it&#39;s the reference client for the Appium project, it works with every Appium driver including ones Spectra doesn&#39;t target, most teams already know it, and it has no paid tier at all. If your team is standardised on it and the refresh wait isn&#39;t a daily cost for you, there is no reason to move. It also runs on Linux; Spectra ships for macOS and Windows only.</p>
<h2 id="who-its-for-and-who-should-stay-put">Who it&#39;s for, and who should stay put</h2>
<p>Spectra is for QA engineers and SDETs who inspect mobile UIs many times a day and feel the wait; for automation engineers maintaining Appium, WebdriverIO, Python or Java suites who want a locator ranked before it goes into a page object; for Detox, Maestro, XCUITest and Espresso users who still need locators and don&#39;t want an Appium session just to read a tree; and for mobile developers checking accessibility IDs and layouts.</p>
<p>Stay on Appium Inspector if you inspect a few times a week, if you depend on a driver Spectra doesn&#39;t cover, or if you&#39;re not allowed to install a helper APK on test devices (you can still use Appium mode, but Turbo Mode&#39;s animated-screen support needs the Agent). Stay on UIAutomator Viewer if you&#39;re Android-only and it does what you need; it comes with the SDK and it&#39;s fine.</p>
<h2 id="free-and-pro">Free and Pro</h2>
<p>Free is free forever and covers inspection: one device session, Appium mode, all inspection features, the eleven locator strategies with grades, the Locator Builder, search, device interactions, all four panels, the Prerequisite Checker, auto-update, both themes. Download and use it, no signup.</p>
<p>Pro is $7 a month or $50 a year, with a 14-day trial and two device activations per license. It adds Turbo Mode, LiveTrack, multi-device, code generation for the eight frameworks with Page Object Model output, the Test Recorder and the XPath Axis Builder. Teams that want volume licensing or a roadmap conversation can write to <a href="mailto:support@usespectra.dev">support@usespectra.dev</a>.</p>
<h2 id="whats-next">What&#39;s next</h2>
<p>Additional platforms and features are on the roadmap at <a href="https://usespectra.dev/roadmap">https://usespectra.dev/roadmap</a>, and there&#39;s a feature request form on the same page. I read every submission.</p>
<p>Spectra is free for inspection. Download at <a href="https://usespectra.dev">https://usespectra.dev</a>.</p>
]]></content:encoded>
    </item>
    <item>
      <title>Why your mobile XPath breaks: a practical ranking of locator strategies for Android, iOS and Flutter</title>
      <link>https://usespectra.dev/blog/mobile-locator-strategies-ranked</link>
      <guid isPermaLink="true">https://usespectra.dev/blog/mobile-locator-strategies-ranked</guid>
      <pubDate>Tue, 18 Aug 2026 08:00:00 GMT</pubDate>
      <dc:creator>Filip Gajić</dc:creator>
      <category>locators</category><category>xpath</category><category>android</category><category>ios</category><category>flutter</category><category>appium</category>
      <description>A ranked guide to mobile locators (accessibility ID, resource-id, text, iOS predicate and class chain, XPath, Flutter keys) with before/after examples.</description>
      <content:encoded><![CDATA[<p>A lot of what gets filed as &quot;flaky mobile test&quot; is a locator that was never stable in the first place. The test passed for three weeks, a developer wrapped a view in a <code>FrameLayout</code>, and an absolute XPath that encoded the entire hierarchy fell over. Nobody touched the test. It still broke.</p>
<p>This is the order I pick locators in, why, and what each one looks like when it fails. Android and iOS first, then a short Flutter section, then the part that actually fixes the problem long-term: getting IDs added where the feature is written.</p>
<h2 id="the-rule-underneath-all-of-it">The rule underneath all of it</h2>
<p>Pick the attribute a developer set on purpose. Avoid the one the render tree happened to produce.</p>
<p>An <code>accessibilityIdentifier</code>, an <code>android:id</code>, a <code>testID</code>, a Flutter <code>Key</code>: someone typed those. They change when a human decides to change them. Position in a hierarchy, sibling index, the class name of a wrapper: those change whenever the layout is refactored, a design system component is swapped, or an OS update renders a control differently. Every tier below is a version of that rule.</p>
<h2 id="1-accessibility-id">1. Accessibility ID</h2>
<p>It&#39;s the same strategy on both platforms and both drivers index it, so lookups are fast. By convention it&#39;s unique per screen. On Android it&#39;s <code>content-desc</code> (set with <code>contentDescription</code>). On iOS it&#39;s <code>accessibilityIdentifier</code>. Appium calls both <code>accessibility id</code>.</p>
<pre><code class="hljs language-python"><span class="hljs-comment"># Python (Appium)</span>
driver.find_element(AppiumBy.ACCESSIBILITY_ID, <span class="hljs-string">&quot;checkout_button&quot;</span>)
</code></pre><pre><code class="hljs language-java"><span class="hljs-comment">// Java</span>
driver.findElement(AppiumBy.accessibilityId(<span class="hljs-string">&quot;checkout_button&quot;</span>));
</code></pre><pre><code class="hljs language-javascript"><span class="hljs-comment">// WebdriverIO</span>
<span class="hljs-keyword">await</span> $(<span class="hljs-string">&quot;~checkout_button&quot;</span>);
</code></pre><p>It&#39;s also what screen-reader users rely on, at least on Android where <code>content-desc</code> is read aloud. That makes it the last thing a developer quietly deletes, and it means the automation team and the accessibility team want the same thing.</p>
<p>When it breaks: the ID was generated (<code>button_3f9a</code>), or the same ID was reused across a list, or on Android someone put a user-facing sentence in <code>contentDescription</code> for TalkBack and it now changes per locale. If your app has that last problem, ask for a <code>resource-id</code> and leave <code>content-desc</code> to accessibility.</p>
<h2 id="2-resource-id-android">2. resource-id (Android)</h2>
<p>Set in the layout as <code>android:id</code>, never shown to a user, and changed only on a deliberate rename. In the tree it appears as <code>com.example.app:id/checkout_button</code>.</p>
<pre><code class="hljs language-python">driver.find_element(AppiumBy.ID, <span class="hljs-string">&quot;com.example.app:id/checkout_button&quot;</span>)
</code></pre><p>If you&#39;re on React Native, <code>testID</code> surfaces as <code>resource-id</code> on Android and <code>accessibilityIdentifier</code> on iOS in current versions, so a single prop gives you both platforms.</p>
<p>When it breaks: the build obfuscates or strips IDs, the app was migrated to Compose without <code>testTag</code> plus <code>testTagsAsResourceId</code>, or an ID lives inside a reused component so twenty rows share it. In the last case combine it with something else rather than falling back to XPath by index; the UiSelector note in tier 5 covers that.</p>
<h2 id="3-text-with-care">3. Text, with care</h2>
<p>Fine for a static label in a single-language app with no near-duplicates on screen. Borrowed stability everywhere else.</p>
<pre><code class="hljs language-python">driver.find_element(AppiumBy.XPATH, <span class="hljs-string">&quot;//*[@text=&#x27;Continue&#x27;]&quot;</span>)
</code></pre><p>The day someone ships localisation, &quot;Continue&quot; becomes &quot;Nastavi&quot; for the Serbian locale and every text locator dies at once. A/B copy tests do the same thing on a smaller scale. Dynamic strings (&quot;3 items&quot;, &quot;Hi, Filip&quot;) change per user. If you have a translations file in the repo, you already know text is a temporary anchor.</p>
<p>Text is still useful as a secondary condition: an ID that&#39;s shared across a list plus the row&#39;s label narrows to one element without touching the hierarchy.</p>
<h2 id="4-ios-predicate-string-and-class-chain">4. iOS predicate string and class chain</h2>
<p>This is where iOS pays you back for not having <code>resource-id</code>. Both are native XCUITest queries and both are materially faster than XPath on a deep tree.</p>
<p>Predicate string filters on attributes and supports partial matching without dropping to XPath:</p>
<pre><code class="hljs language-python">driver.find_element(AppiumBy.IOS_PREDICATE, <span class="hljs-string">&#x27;label CONTAINS &quot;Continue&quot; AND visible == 1&#x27;</span>)
</code></pre><p>Class chain adds hierarchy and indexing on top of predicates:</p>
<pre><code class="hljs language-python">driver.find_element(AppiumBy.IOS_CLASS_CHAIN,
    <span class="hljs-string">&#x27;**/XCUIElementTypeCell[`name == &quot;cart_row&quot;`]/XCUIElementTypeButton[1]&#x27;</span>)
</code></pre><p>The backticks are part of the class chain syntax. The way I keep them apart: if I&#39;m filtering, predicate; if I&#39;m navigating, class chain.</p>
<p>When they break: a predicate on <code>label</code> breaks with localisation just like text does, so prefer <code>name</code> (which is the accessibility identifier). Class chain with an index breaks when a cell gains a child, same as XPath, only cheaper.</p>
<h2 id="5-xpath-last-and-relative-only">5. XPath, last, and relative only</h2>
<p>Absolute paths break the moment someone wraps a view in another layout. This is the one that comes out of an inspector when you click &quot;copy XPath&quot; without thinking:</p>
<pre><code class="hljs language-text">/hierarchy/android.widget.FrameLayout[1]/android.widget.LinearLayout[2]/android.widget.FrameLayout[1]/android.view.ViewGroup[1]/android.widget.Button[2]
</code></pre><p>Every segment is a dependency on the current layout. One wrapper view, one reordered sibling, and it points at nothing.</p>
<p>If you need XPath, anchor it to a real attribute and move one hop:</p>
<pre><code class="hljs language-text">//*[@resource-id=&#x27;com.example.app:id/cart&#x27;]/following-sibling::*[1]
</code></pre><p>Nothing breaks that except deleting the anchor. It&#39;s still slower than the tiers above (XPath means the driver has to build the whole tree as XML and evaluate the query against it on every lookup), so on iOS especially I reach for class chain before this.</p>
<p>An Android footnote that deserves more attention than it gets: <code>-android uiautomator</code> with <code>UiSelector</code> handles a lot of what people reach for XPath for, and it runs through UiAutomator rather than the XML tree.</p>
<pre><code class="hljs language-java">driver.findElement(AppiumBy.androidUIAutomator(
    <span class="hljs-string">&quot;new UiSelector().resourceIdMatches(\&quot;.*:id/cart_row\&quot;).textContains(\&quot;Shoes\&quot;)&quot;</span>));
</code></pre><p>I&#39;d rank it between resource-id and text on Android when you need a partial match or a combination.</p>
<h2 id="one-element-five-locators">One element, five locators</h2>
<p>Here is one checkout button on an Android screen, as it might come out of an inspector, worst to best:</p>
<pre><code class="hljs language-text">/hierarchy/android.widget.FrameLayout/android.widget.LinearLayout[2]/android.widget.Button[1]
//android.widget.Button[@text=&#x27;Continue to payment&#x27;]
//*[@resource-id=&#x27;com.example.app:id/summary_card&#x27;]/following-sibling::android.widget.Button[1]
com.example.app:id/checkout_button
checkout_button          (accessibility id)
</code></pre><p>Line one breaks on any layout change. Line two breaks when marketing renames the button or the app gets a second language. Line three survives both and breaks only if the summary card goes away. The last two survive everything short of a deliberate rename, and the accessibility ID also works on iOS if the developer used the same identifier there. When I open a screen the question is only &quot;which of these does this element actually have&quot;, and the ranking answers itself.</p>
<h2 id="flutter">Flutter</h2>
<p>Flutter draws its own widgets, so out of the box the native tree sees one big canvas with whatever the app&#39;s semantics layer exposes. Newer Flutter versions push semantics labels through to the platform accessibility APIs, so some elements are reachable with plain <code>accessibility id</code> through UiAutomator2 or XCUITest. For the rest you want the Appium Flutter integration driver, which talks to the running app through the Dart VM.</p>
<p>Its locators, best first: <code>key</code> (a <code>ValueKey</code> on the widget), <code>semantics label</code>, <code>type</code> (widget class name), and <code>text</code>.</p>
<pre><code class="hljs language-dart"><span class="hljs-comment">// In the app</span>
<span class="hljs-title class_">ElevatedButton</span>(key: <span class="hljs-keyword">const</span> <span class="hljs-title class_">ValueKey</span>(<span class="hljs-string">&#x27;checkout_button&#x27;</span>), ...)
<span class="hljs-title class_">Semantics</span>(label: <span class="hljs-string">&#x27;cart_row&#x27;</span>, child: ...)
</code></pre><pre><code class="hljs language-java"><span class="hljs-comment">// Java, appium-flutter-integration-driver</span>
driver.findElement(FlutterBy.key(<span class="hljs-string">&quot;checkout_button&quot;</span>));
driver.findElement(FlutterBy.semanticsLabel(<span class="hljs-string">&quot;cart_row&quot;</span>));
</code></pre><p>Same reasoning as native: <code>key</code> and <code>semantics label</code> are things a developer typed, <code>type</code> is a class name that survives most refactors but not a component swap, and <code>text</code> inherits every localisation problem from tier 3. One caveat from the driver&#39;s own README: <code>semanticsLabel</code> lookups on real iOS devices have a known Flutter issue, so verify on hardware before you standardise on it.</p>
<h2 id="how-to-talk-to-developers-about-adding-test-ids">How to talk to developers about adding test IDs</h2>
<p>The thing that fixes locator stability long-term is a conversation, and it&#39;s easier than most QA people expect. What has worked for me:</p>
<p>Ask for something specific and small: &quot;these six controls on the checkout screen need stable IDs; here&#39;s the list and the names I&#39;d like.&quot; A ten-line PR gets merged. A policy discussion doesn&#39;t.</p>
<p>Name the mechanism per stack so nobody has to look it up: <code>android:id</code> in XML or <code>Modifier.testTag</code> (with <code>testTagsAsResourceId</code>) in Compose, <code>accessibilityIdentifier</code> in UIKit and SwiftUI, <code>testID</code> in React Native, <code>Key</code> in Flutter.</p>
<p>Agree on a naming convention once (<code>screen_element_role</code>, all lowercase, no spaces) and put it in the PR template. Half the value is that IDs stop being invented ad hoc.</p>
<p>Point at the accessibility overlap. On Android, <code>content-desc</code> serves TalkBack users and your tests at the same time, so the work counts twice.</p>
<p>Show the cost. One example of an absolute XPath from your suite next to the ID version, with the git blame of the layout change that broke it, does more than any argument.</p>
<p>Every hour spent here is worth ten spent hardening XPath.</p>
<h2 id="summary">Summary</h2>
<div class="table-wrap"><table><thead><tr><th>Strategy</th><th>Platform</th><th>Stability</th><th>When it breaks</th></tr></thead><tbody><tr><td>Accessibility ID (<code>content-desc</code>, <code>accessibilityIdentifier</code>)</td><td>Android, iOS</td><td>High</td><td>Generated or reused IDs; localised <code>contentDescription</code> on Android</td></tr>
<tr><td><code>resource-id</code></td><td>Android</td><td>High</td><td>Obfuscated builds; Compose without <code>testTag</code>; shared IDs in lists</td></tr>
<tr><td>Text</td><td>Android, iOS</td><td>Low to medium</td><td>Localisation, copy edits, dynamic strings</td></tr>
<tr><td>iOS predicate string</td><td>iOS</td><td>High on <code>name</code>, low on <code>label</code></td><td>Same as text when filtering on <code>label</code></td></tr>
<tr><td>iOS class chain</td><td>iOS</td><td>Medium</td><td>Indexed hops break when hierarchy changes, but cheaper than XPath</td></tr>
<tr><td><code>-android uiautomator</code> (UiSelector)</td><td>Android</td><td>Medium to high</td><td>Same inputs as the attributes it matches on</td></tr>
<tr><td>Relative XPath (anchored, one hop)</td><td>Android, iOS</td><td>Medium</td><td>Anchor removed</td></tr>
<tr><td>Absolute XPath</td><td>Android, iOS</td><td>Very low</td><td>Any wrapper, reorder or component swap</td></tr>
<tr><td>Flutter <code>key</code> / <code>semantics label</code></td><td>Flutter</td><td>High</td><td>Key removed or renamed; <code>semanticsLabel</code> on real iOS devices (known issue)</td></tr>
<tr><td>Flutter <code>type</code> / <code>text</code></td><td>Flutter</td><td>Low to medium</td><td>Component swap; localisation</td></tr></tbody></table></div>
<p>This hierarchy is why Spectra grades every generated locator A to D: same logic, made visible next to each candidate instead of living in your head. The grading is in the free tier at <a href="https://usespectra.dev">https://usespectra.dev</a>.</p>
<p>Spectra is free for inspection. Download at <a href="https://usespectra.dev">https://usespectra.dev</a>.</p>
]]></content:encoded>
    </item>
    <item>
      <title>Spectra 1.2.4 and the road here: what shipped in 2026 so far</title>
      <link>https://usespectra.dev/blog/spectra-1-2-4-what-shipped</link>
      <guid isPermaLink="true">https://usespectra.dev/blog/spectra-1-2-4-what-shipped</guid>
      <pubDate>Tue, 18 Aug 2026 08:00:00 GMT</pubDate>
      <dc:creator>Filip Gajić</dc:creator>
      <category>release</category><category>changelog</category>
      <description>A plain summary of Spectra releases 1.0 through 1.2.4: what landed, what was fixed, what is free and what is Pro, sourced from the release notes.</description>
      <content:encoded><![CDATA[<p>Spectra 1.0.0 went out on 25 February 2026. The current release is 1.2.4, from 13 April. This blog didn&#39;t exist for any of it, so here is the catch-up, taken from the release notes on GitHub rather than from memory. If a release isn&#39;t mentioned, it was a version bump with nothing user-facing.</p>
<p>All releases, with the .dmg (Apple Silicon and Intel) and Windows installers, are at <a href="https://github.com/Morph93/spectra-releases/releases" target="_blank" rel="noopener noreferrer">https://github.com/Morph93/spectra-releases/releases</a>.</p>
<h2 id="1-0-to-1-1-the-base-and-the-recorder">1.0 to 1.1: the base and the recorder</h2>
<p>1.0.0 and 1.0.1 (25 and 27 February) were the first public builds: the inspector itself, Appium mode, Turbo Mode over direct ADB and WDA, LiveTrack, multi-device, graded locators, the XPath Axis Builder, code generation for three frameworks (WebdriverIO, Appium Python, Appium Java), and the Spectra Agent for Android. I didn&#39;t write release notes for those two, which I regret now; the list above comes from the v1.0.0 tag in my repo.</p>
<p>1.1.0 (5 March) also went out without notes on the release page. The tag commit in my repo lists what it carried: the Test Recorder (record real-device gestures, replay them, add assertions, export as test code) and five more code generation frameworks, taking the total from three to eight. That list is WebdriverIO, Appium Python, Appium Java, Appium JS, XCUITest (Swift), Espresso (Kotlin), Detox (JS) and Maestro (YAML). It also included iOS reliability work: zombie <code>xcodebuild</code> processes blocking device switching, port conflicts when a simulator and a physical device were both active, and USB keep-alive for idle iOS sessions.</p>
<p>1.1.1 and 1.1.2 (6 March) were license fixes: Pro activation not persisting across restarts, a brief &quot;Not Activated&quot; flash before the keychain loaded, revoked activations still granting Pro until restart. 1.1.2 moved key storage to the native backends (macOS Keychain, Windows Credential Manager).</p>
<h2 id="1-2-0-to-1-2-2-theme-stability-profiles">1.2.0 to 1.2.2: theme, stability, profiles</h2>
<p>1.2.0 (6 March) added the light theme, with OS detection and a manual toggle, and fixed escape handling for accessibility IDs that contain newlines.</p>
<p>1.2.1 (10 March) was about running for a long time. Screenshot handling was redesigned so memory stays flat instead of climbing over hours with multiple devices connected, a blank-screen bug after extended multi-device use was fixed, and device polling became adaptive to connection speed.</p>
<p>1.2.2 (11 March) added Connection Profiles (save, load, rename and delete connection configurations), Full Reset in Turbo Mode (reinstall the app before connecting; Android via ADB, iOS via <code>xcrun simctl</code> or <code>ideviceinstaller</code>), auto-restore of the last used connection config, and a prerequisite check for <code>ideviceinstaller</code> with a Homebrew auto-fix. It also stopped a double-click on Connect from starting two connection attempts.</p>
<h2 id="1-2-3-the-fixes-release">1.2.3: the fixes release</h2>
<p>1.2.3 (19 March) is the one I&#39;d point at if you asked which release changed daily use the most. From the notes:</p>
<p>LiveTrack polling now pauses while the window is hidden and resumes on restore, which fixed a UI freeze after minimising. Connections no longer drop after minutes of inactivity; there are keep-alive pings for both iOS Direct and Appium sessions. Search results show the true sibling-based XPath index, include elements with <code>visible=false</code>, and distinguish hidden elements from off-screen ones. Error toasts persist until dismissed, and connection errors moved in-dialog with actionable guidance, including a &quot;Why does this happen?&quot; section for WDA and <code>xcodebuild</code> failures.</p>
<p>Under the hood, locator generation was deduplicated: the Properties and Locators tabs now share one single-pass engine, which fixed three match-counting bugs. The default theme changed from Dark to System.</p>
<h2 id="1-2-4-recorder-polish">1.2.4: recorder polish</h2>
<p>1.2.4 (13 April) is mostly Test Recorder work. You can type a custom locator strategy and value directly in the recorder&#39;s locator picker. Recorded steps are easier to edit (pencil icons, single-click text editing, &quot;Add note...&quot; placeholders). Assert mode exits automatically when you switch to Tap, Swipe or Hold. Exported flow code no longer produces duplicate variable names, so Java and JS exports compile as generated. On Windows, the console window no longer flashes during ADB commands and background polling.</p>
<h2 id="what-is-free-and-what-is-pro">What is free and what is Pro</h2>
<p>Free, no signup: one device session, Appium mode, all inspection features, the 11 locator strategies with A to D grading, the Locator Builder, search, device interactions (tap, swipe, long press, type, back, home), the Console, Logs, History and Network panels, the Prerequisite Checker, auto-update with &quot;What&#39;s New&quot;, and both themes.</p>
<p>Pro adds Turbo Mode (direct ADB and WDA, 2 to 5x faster in my testing), LiveTrack, multi-device (two devices side by side), code generation for the eight frameworks with Page Object Model output, the Test Recorder, and the XPath Axis Builder. Pro is $7 a month or $50 a year, with a 14-day trial and two device activations per license.</p>
<p>The Android helper that Turbo Mode installs for animated screens (Spectra Agent) has been in the app since 1.0.0; there&#39;s a separate post on why it exists.</p>
<h2 id="whats-next">What&#39;s next</h2>
<p>Additional platforms and features are on the roadmap at <a href="https://usespectra.dev/roadmap">https://usespectra.dev/roadmap</a>, and there&#39;s a feature request form on the same page. Release notes from here on will also appear on this blog.</p>
<p>Spectra is free for inspection. Download at <a href="https://usespectra.dev">https://usespectra.dev</a>.</p>
]]></content:encoded>
    </item>
    <item>
      <title>Why uiautomator dump fails with &quot;could not get idle state&quot; (and 4 ways around it)</title>
      <link>https://usespectra.dev/blog/uiautomator-dump-could-not-get-idle-state</link>
      <guid isPermaLink="true">https://usespectra.dev/blog/uiautomator-dump-could-not-get-idle-state</guid>
      <pubDate>Tue, 18 Aug 2026 08:00:00 GMT</pubDate>
      <dc:creator>Filip Gajić</dc:creator>
      <category>android</category><category>uiautomator</category><category>appium</category><category>debugging</category>
      <description>What the idle-state error in uiautomator dump actually means, why spinners trigger it, and four fixes with real adb and Appium settings.</description>
      <content:encoded><![CDATA[<p>If you do Android automation long enough you eventually hit this:</p>
<pre><code class="hljs language-text">$ adb shell uiautomator dump
ERROR: could not get idle state.
</code></pre><p>Almost always on a screen with a loading spinner, a shimmer placeholder, a Lottie animation, a marquee, a video, or a blinking cursor in a focused text field. The same screens make Appium&#39;s page source (and therefore Appium Inspector&#39;s refresh) take ten seconds or more, or time out entirely. I&#39;ve lost enough hours to this that I want to write down what is actually happening and what works.</p>
<h2 id="what-the-error-actually-means">What the error actually means</h2>
<p><code>uiautomator dump</code> doesn&#39;t just read the accessibility tree. Before it reads anything, it calls <code>UiAutomation.waitForIdle(1000, 10000)</code>. In plain terms: wait until there has been no accessibility event for one second, and give up after ten. You can see it in the AOSP source for <code>DumpCommand.java</code>; the <code>TimeoutException</code> from that call is what prints the error, and no XML gets written.</p>
<p>Every animating view fires <code>TYPE_WINDOW_CONTENT_CHANGED</code> on every frame. An indeterminate <code>ProgressBar</code> is a permanent animation. So the quiet second never comes, the ten-second budget runs out, and you get the error.</p>
<p>Appium&#39;s UiAutomator2 driver has the same wait, because UiAutomator itself does it. The driver exposes it as the <code>waitForIdleTimeout</code> setting, and as of the current driver docs the default is 10000 ms. The difference is that the driver doesn&#39;t error. It silently waits the full timeout, then proceeds with the action or the source fetch. This is why &quot;the inspector is slow on this screen&quot; and &quot;dump times out on this screen&quot; are the same bug wearing two hats. Espresso has a related story on the developer side: its &quot;disable animations before running tests&quot; advice exists because Espresso also waits for the main thread and its idling resources to settle.</p>
<p>Quick sanity check that this is your problem and not ADB: run <code>adb shell uiautomator dump</code> on a static screen like Settings. Instant there and hanging on your screen means it&#39;s the idle wait.</p>
<h2 id="way-1-stop-the-animation-at-the-source">Way 1: stop the animation at the source</h2>
<p>Best when you can. Developer options, then set Window animation scale, Transition animation scale and Animator duration scale to off. Or from the shell:</p>
<pre><code class="hljs language-bash">adb shell settings put global window_animation_scale 0
adb shell settings put global transition_animation_scale 0
adb shell settings put global animator_duration_scale 0
</code></pre><p>This kills system window and transition animations and most <code>Animator</code>-driven spinners. It does not stop everything. Lottie, video, GIF and WebP, shimmer libraries with their own timers, and custom <code>Canvas</code> or <code>Choreographer</code> loops keep going. Compose animations vary by version in my experience, so test on your app.</p>
<p>In Appium, the capability <code>appium:disableWindowAnimation: true</code> applies the same settings for the session and, as of the current driver docs, restores them afterwards on API 26 and higher.</p>
<p>The best version of this is a build flag or debug menu that swaps indeterminate spinners for a static state in test builds. Espresso teams do the same thing with idling resources. It&#39;s a small PR for the developers and it fixes the whole class of problem rather than one screen.</p>
<h2 id="way-2-shrink-the-wait-appium">Way 2: shrink the wait (Appium)</h2>
<p><code>waitForIdleTimeout</code> is a session setting rather than a capability (the driver README has a warning box about exactly this), so you change it through the Settings API:</p>
<pre><code class="hljs language-python"><span class="hljs-comment"># Python</span>
driver.update_settings({<span class="hljs-string">&quot;waitForIdleTimeout&quot;</span>: <span class="hljs-number">500</span>})   <span class="hljs-comment"># ms; 0 = don&#x27;t wait at all</span>
</code></pre><pre><code class="hljs language-javascript"><span class="hljs-comment">// WebdriverIO / JS</span>
<span class="hljs-keyword">await</span> driver.<span class="hljs-title function_">updateSettings</span>({ <span class="hljs-attr">waitForIdleTimeout</span>: <span class="hljs-number">500</span> });
</code></pre><pre><code class="hljs language-java"><span class="hljs-comment">// Java</span>
((HasSettings) driver).setSetting(<span class="hljs-string">&quot;waitForIdleTimeout&quot;</span>, <span class="hljs-number">500</span>);
</code></pre><p>If you want it from the first command of the session, Appium lets you initialise any setting through a capability:</p>
<pre><code class="hljs language-json"><span class="hljs-punctuation">{</span> <span class="hljs-attr">&quot;appium:settings[waitForIdleTimeout]&quot;</span><span class="hljs-punctuation">:</span> <span class="hljs-number">500</span> <span class="hljs-punctuation">}</span>
</code></pre><p>Trade-off: with a low value you can act before the UI has settled, so pair it with explicit waits for the element you need. The driver docs say the same, and add that a value of 0 disables the wait completely, which is exactly as risky as it sounds. <code>actionAcknowledgmentTimeout</code> (default 3000 ms) is the sibling setting for post-action waits; the docs suggest leaving it alone unless you have a reason.</p>
<p>iOS has the same disease with a different name. The XCUITest driver&#39;s <code>waitForIdleTimeout</code> is in seconds (default 10, 0 disables) and <code>animationCoolOffTimeout</code> (default 2 seconds) is the post-action equivalent. Both are settings; the first can also be set as a capability.</p>
<h2 id="a-note-on-compressed-and-ignoreunimportantviews">A note on <code>--compressed</code> and <code>ignoreUnimportantViews</code></h2>
<p>These get suggested a lot for this error, and they don&#39;t help with it. <code>uiautomator dump --compressed</code> drops nodes that aren&#39;t important for accessibility, and the UiAutomator2 driver&#39;s <code>ignoreUnimportantViews</code> setting does the same thing for page source. Both are good when dump is slow on a huge tree. Neither skips the idle wait. In <code>DumpCommand.java</code>, the compression flag is applied first and <code>waitForIdle</code> runs anyway, so you get a smaller tree ten seconds late, or the same error.</p>
<p>Use them for size. Don&#39;t expect them to fix idle.</p>
<h2 id="way-3-read-the-hierarchy-through-a-door-that-doesnt-wait">Way 3: read the hierarchy through a door that doesn&#39;t wait</h2>
<p>Two options that skip the accessibility idle wait entirely, with caveats:</p>
<p>Android Studio&#39;s Layout Inspector attaches to the app process. Debuggable builds only, and it isn&#39;t scriptable, but for a one-off &quot;what is on this screen&quot; it works on animated screens.</p>
<p><code>adb shell dumpsys activity top</code> dumps the foreground activity&#39;s view hierarchy: class, id, bounds. No text or content-desc, and the format shifts between Android versions, but it&#39;s instant and it&#39;s always there.</p>
<p>If you own the app, Espresso or UiAutomator inside an instrumentation test with animations disabled is the reliable in-house route.</p>
<h2 id="way-4-read-the-accessibility-tree-without-waitforidle">Way 4: read the accessibility tree without waitForIdle</h2>
<p>The idle wait is something <code>uiautomator dump</code> chooses to do before reading. The tree itself is available at any moment: <code>UiAutomation.getRootInActiveWindow()</code> returns the current tree immediately. A minimal instrumentation, shipped as its own tiny APK, no changes to the app under test:</p>
<pre><code class="hljs language-kotlin"><span class="hljs-keyword">class</span> <span class="hljs-title class_">TreeDump</span> : <span class="hljs-type">Instrumentation</span>() {
    <span class="hljs-keyword">override</span> <span class="hljs-function"><span class="hljs-keyword">fun</span> <span class="hljs-title">onCreate</span><span class="hljs-params">(args: <span class="hljs-type">Bundle</span>?)</span></span> { <span class="hljs-keyword">super</span>.onCreate(args); start() }
    <span class="hljs-keyword">override</span> <span class="hljs-function"><span class="hljs-keyword">fun</span> <span class="hljs-title">onStart</span><span class="hljs-params">()</span></span> {
        walk(uiAutomation.rootInActiveWindow, <span class="hljs-number">0</span>)   <span class="hljs-comment">// no waitForIdle here</span>
        finish(Activity.RESULT_OK, Bundle())
    }
    <span class="hljs-keyword">private</span> <span class="hljs-function"><span class="hljs-keyword">fun</span> <span class="hljs-title">walk</span><span class="hljs-params">(n: <span class="hljs-type">AccessibilityNodeInfo</span>?, depth: <span class="hljs-type">Int</span>)</span></span> {
        n ?: <span class="hljs-keyword">return</span>
        <span class="hljs-keyword">val</span> r = Rect().also(n::getBoundsInScreen)
        Log.i(<span class="hljs-string">&quot;dump&quot;</span>, <span class="hljs-string">&quot; &quot;</span>.repeat(depth) +
            <span class="hljs-string">&quot;<span class="hljs-subst">${n.className}</span> id=<span class="hljs-subst">${n.viewIdResourceName}</span> text=<span class="hljs-subst">${n.text}</span> desc=<span class="hljs-subst">${n.contentDescription}</span> <span class="hljs-variable">$r</span>&quot;</span>)
        <span class="hljs-keyword">for</span> (i <span class="hljs-keyword">in</span> <span class="hljs-number">0</span> until n.childCount) walk(n.getChild(i), depth + <span class="hljs-number">1</span>)
    }
}
</code></pre><p>Register it in the helper&#39;s manifest:</p>
<pre><code class="hljs language-xml"><span class="hljs-tag">&lt;<span class="hljs-name">instrumentation</span>
    <span class="hljs-attr">android:name</span>=<span class="hljs-string">&quot;.TreeDump&quot;</span>
    <span class="hljs-attr">android:targetPackage</span>=<span class="hljs-string">&quot;your.helper.package&quot;</span> /&gt;</span>
</code></pre><p>Then run <code>adb shell am instrument -w your.helper.package/.TreeDump</code> and read logcat, or write to a file or a socket instead.</p>
<p>Caveats I ran into. Only one process can hold <code>UiAutomation</code> at a time, so this fights with a running UiAutomator2 server session on the same device; it&#39;s either/or. The tree you get is &quot;right now&quot;, mid-animation, so bounds of moving things are a snapshot. And if you want it in the usual XML shape for existing tooling, you&#39;re writing that serialiser yourself.</p>
<h2 id="trade-offs">Trade-offs</h2>
<div class="table-wrap"><table><thead><tr><th>Approach</th><th>Fixes the idle error</th><th>Needs app changes</th><th>Scriptable</th><th>Works with a live Appium session</th><th>Main cost</th></tr></thead><tbody><tr><td>Animations off (adb settings, developer options, <code>disableWindowAnimation</code>)</td><td>Mostly; not for Lottie, video, custom loops</td><td>No (yes for the build-flag version)</td><td>Yes</td><td>Yes</td><td>Some spinners keep running</td></tr>
<tr><td>Lower <code>waitForIdleTimeout</code></td><td>Yes (Appium only)</td><td>No</td><td>Yes</td><td>Yes</td><td>You may act before the UI settles</td></tr>
<tr><td><code>--compressed</code> / <code>ignoreUnimportantViews</code></td><td>No</td><td>No</td><td>Yes</td><td>Yes</td><td>Smaller tree, same wait</td></tr>
<tr><td>Layout Inspector / <code>dumpsys activity top</code></td><td>Yes</td><td>Debuggable build for Layout Inspector</td><td>Partly</td><td>Yes</td><td>Missing text and content-desc, unstable format</td></tr>
<tr><td>Instrumentation without <code>waitForIdle</code></td><td>Yes</td><td>No</td><td>Yes</td><td>No (one <code>UiAutomation</code> holder at a time)</td><td>You write and maintain the helper</td></tr></tbody></table></div>
<h2 id="which-one-to-use">Which one to use</h2>
<p>You control the app: Way 1 (build flag or animations off) plus Way 2 with a small non-zero timeout.</p>
<p>Third-party or production app through Appium: Way 2 (500 to 2000 ms) plus explicit waits.</p>
<p>One-off &quot;what is on screen right now&quot;: Way 3.</p>
<p>You need the tree reliably on animated screens, scriptable, without an Appium session: Way 4.</p>
<p>If you have a better trick, especially for Compose apps where I get inconsistent results with animator scale 0 across versions, I&#39;d like to hear it. Email is <a href="mailto:support@usespectra.dev">support@usespectra.dev</a>.</p>
<h2 id="why-spectra-ships-an-agent-for-this">Why Spectra ships an agent for this</h2>
<p>Way 4 is what Spectra does. When you connect in Turbo Mode (direct ADB, no Appium server in the middle), Spectra installs a small Android instrumentation APK called Spectra Agent, about a megabyte and roughly 450 lines of Kotlin. It starts with <code>adb shell am instrument</code>, listens on a local port that Spectra reaches through <code>adb forward</code>, and answers with the tree read straight from <code>getRootInActiveWindow()</code>. No <code>waitForIdle</code>, so a spinner on screen no longer stalls the inspection. It answers with the tree and nothing else; there&#39;s no outbound connection and it collects no data. If you don&#39;t want it on a device, <code>adb uninstall com.spectra.agent</code> removes it.</p>
<p>To be upfront about scope: the free tier inspects through your existing Appium server, so it inherits Appium&#39;s idle wait, and Way 2 above is the fix there. The agent is part of Turbo Mode, which is in Pro (14-day trial). If this screen isn&#39;t a daily problem for you, Way 1 and Way 2 are the cheaper answer. More at <a href="https://usespectra.dev">https://usespectra.dev</a>.</p>
<p>Spectra is free for inspection. Download at <a href="https://usespectra.dev">https://usespectra.dev</a>.</p>
]]></content:encoded>
    </item>
  </channel>
</rss>
