<?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>Forem: Om Narayan</title>
    <description>The latest articles on Forem by Om Narayan (@omnarayan).</description>
    <link>https://forem.com/omnarayan</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.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3501073%2F9114ad46-cd26-4a6d-9605-727fa1a8c90e.png</url>
      <title>Forem: Om Narayan</title>
      <link>https://forem.com/omnarayan</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://forem.com/feed/omnarayan"/>
    <language>en</language>
    <item>
      <title>HIPAA Mobile QA Checklist: Your Testing Pipeline is a Compliance Risk</title>
      <dc:creator>Om Narayan</dc:creator>
      <pubDate>Sun, 28 Dec 2025 23:31:28 +0000</pubDate>
      <link>https://forem.com/omnarayan/hipaa-mobile-qa-checklist-your-testing-pipeline-is-a-compliance-risk-3dp</link>
      <guid>https://forem.com/omnarayan/hipaa-mobile-qa-checklist-your-testing-pipeline-is-a-compliance-risk-3dp</guid>
      <description>&lt;p&gt;In HealthTech, "move fast and break things" is not a strategy. It is a lawsuit.&lt;/p&gt;

&lt;p&gt;In 2024, healthcare data breaches compromised &lt;strong&gt;275 million patient records&lt;/strong&gt;. The &lt;a href="https://www.hhs.gov/hipaa/for-professionals/special-topics/change-healthcare-cybersecurity-incident/index.html" rel="noopener noreferrer"&gt;Change Healthcare ransomware attack&lt;/a&gt; alone affected 192 million individuals—the largest healthcare breach in history. &lt;a href="https://www.hhs.gov/hipaa/for-professionals/compliance-enforcement/index.html" rel="noopener noreferrer"&gt;OCR&lt;/a&gt; closed &lt;strong&gt;22 HIPAA investigations&lt;/strong&gt; with financial penalties, and 2025's enforcement initiative has one laser focus: &lt;strong&gt;Risk Analysis Failures&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;They are no longer just auditing production systems. They are auditing everything.&lt;/p&gt;

&lt;p&gt;Including your testing pipeline.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Myth of "Fake" Data
&lt;/h2&gt;

&lt;p&gt;&lt;em&gt;"We don't need to worry—we use synthetic patient data."&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;I hear this every week. It sounds logical. If the data is not real, HIPAA does not apply, right?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Wrong.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Security research found that &lt;strong&gt;77% of mHealth apps contained hardcoded API keys&lt;/strong&gt;. Even if your patient names are "John Doe," your application binary contains:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Hardcoded API Keys&lt;/strong&gt; granting access to production-adjacent environments&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Business Logic&lt;/strong&gt; revealing how you process diagnoses and claims&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Staging Endpoints&lt;/strong&gt; connecting to real (or realistic) patient data&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Accidental Leaks&lt;/strong&gt; from "anonymized" production dumps that were not scrubbed properly&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Upload that binary to a third-party cloud. That cloud gets breached. Now explain to an auditor why your proprietary health algorithms and PHI system credentials were on a server in another country.&lt;/p&gt;




&lt;h2&gt;
  
  
  The "Shared Responsibility" Trap
&lt;/h2&gt;

&lt;p&gt;Cloud providers operate on a &lt;strong&gt;Shared Responsibility Model&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The Vendor&lt;/strong&gt; secures the physical infrastructure&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You&lt;/strong&gt; are responsible for the data you put on it&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;When you run a test on a cloud device, you generate &lt;strong&gt;artifacts&lt;/strong&gt; that live in vendor storage for 30-90 days:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Screenshots of patient dashboards&lt;/li&gt;
&lt;li&gt;Video recordings of intake flows&lt;/li&gt;
&lt;li&gt;HTTP logs containing JSON bodies with medical records&lt;/li&gt;
&lt;li&gt;Crash dumps showing memory states&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you cannot guarantee that every artifact is scrubbed of PHI, you are at risk. And "the vendor handles security" is not a compliant answer.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does Your Cloud Provider Even Offer a BAA?
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Provider&lt;/th&gt;
&lt;th&gt;BAA Available?&lt;/th&gt;
&lt;th&gt;Reality&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;AWS Device Farm&lt;/td&gt;
&lt;td&gt;Yes (via AWS BAA)&lt;/td&gt;
&lt;td&gt;Only if configured correctly with encryption + audit logs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;BrowserStack&lt;/td&gt;
&lt;td&gt;Enterprise only&lt;/td&gt;
&lt;td&gt;"Contact Sales"—expect 5-10x standard pricing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sauce Labs&lt;/td&gt;
&lt;td&gt;Enterprise only&lt;/td&gt;
&lt;td&gt;Requires explicit request&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;LambdaTest&lt;/td&gt;
&lt;td&gt;No public BAA&lt;/td&gt;
&lt;td&gt;Not suitable for PHI&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Firebase Test Lab&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Not HIPAA eligible&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Critical&lt;/strong&gt;: A BAA does not make you compliant. AWS explicitly states compliance is "conditional on services being configured correctly by the customer." Misconfigure, and the BAA does not protect you.&lt;/p&gt;




&lt;h2&gt;
  
  
  The 5-Point HIPAA QA Audit
&lt;/h2&gt;

&lt;p&gt;Your testing infrastructure must pass these five checks based on the &lt;a href="https://www.hhs.gov/hipaa/for-professionals/security/index.html" rel="noopener noreferrer"&gt;HIPAA Security Rule&lt;/a&gt;:&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Data Residency Control
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;✅ &lt;strong&gt;Pass:&lt;/strong&gt; Data never leaves your corporate firewall&lt;/li&gt;
&lt;li&gt;❌ &lt;strong&gt;Fail:&lt;/strong&gt; Binaries uploaded to vendor S3 buckets in Virginia/Frankfurt&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  2. Transmission Security
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;✅ &lt;strong&gt;Pass:&lt;/strong&gt; Test commands travel over encrypted tunnels you control&lt;/li&gt;
&lt;li&gt;❌ &lt;strong&gt;Fail:&lt;/strong&gt; Binaries travel over public internet to shared cloud infrastructure&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  3. Right to Audit
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;✅ &lt;strong&gt;Pass:&lt;/strong&gt; You can physically inspect devices running your tests&lt;/li&gt;
&lt;li&gt;❌ &lt;strong&gt;Fail:&lt;/strong&gt; You trust a vendor's SOC2 report but cannot audit their device wiping&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  4. Data Destruction
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;✅ &lt;strong&gt;Pass:&lt;/strong&gt; You factory reset devices immediately after tests&lt;/li&gt;
&lt;li&gt;❌ &lt;strong&gt;Fail:&lt;/strong&gt; You rely on vendor cleanup scripts (which often miss cached logs)&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  5. Access Logging
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;✅ &lt;strong&gt;Pass:&lt;/strong&gt; Every engineer has unique credentials; all access is logged&lt;/li&gt;
&lt;li&gt;❌ &lt;strong&gt;Fail:&lt;/strong&gt; Team shares a generic cloud login (violates HIPAA unique user ID requirement)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Score 5/5?&lt;/strong&gt; You are probably audit-ready.&lt;br&gt;&lt;br&gt;
&lt;strong&gt;Score 3-4?&lt;/strong&gt; You have gaps to address.&lt;br&gt;&lt;br&gt;
&lt;strong&gt;Below 3?&lt;/strong&gt; Schedule a risk analysis review immediately.&lt;/p&gt;


&lt;h2&gt;
  
  
  Real Enforcement: What Gets Organizations Fined
&lt;/h2&gt;

&lt;p&gt;Every organization penalized in early 2025 had the same finding:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Failure to conduct a compliant risk analysis."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;A risk analysis under HIPAA requires identifying &lt;strong&gt;every system&lt;/strong&gt; that creates, receives, or transmits ePHI. This includes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Production databases (obviously)&lt;/li&gt;
&lt;li&gt;Backup systems (usually covered)&lt;/li&gt;
&lt;li&gt;Developer laptops (sometimes covered)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Testing infrastructure&lt;/strong&gt; (almost never covered)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;When an auditor asks "Where does PHI exist?" and you do not list your QA pipeline, you have a gap. When that gap involves third-party cloud infrastructure, you have a &lt;strong&gt;$3 million problem&lt;/strong&gt;—the penalty one national supplier paid in 2025 for risk analysis failures.&lt;/p&gt;
&lt;h3&gt;
  
  
  The Cost Math
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Violation Tier&lt;/th&gt;
&lt;th&gt;Penalty Range&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Tier 1: Unknowing&lt;/td&gt;
&lt;td&gt;$141 - $71,162 per violation&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tier 2: Reasonable cause&lt;/td&gt;
&lt;td&gt;$1,424 - $71,162 per violation&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tier 3: Willful neglect (corrected)&lt;/td&gt;
&lt;td&gt;$14,232 - $71,162 per violation&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tier 4: Willful neglect (not corrected)&lt;/td&gt;
&lt;td&gt;$71,162 - $2,134,831 per violation&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A single test run with improperly handled PHI, multiplied by affected records, generates catastrophic liability.&lt;/p&gt;


&lt;h2&gt;
  
  
  The Three Paths to Compliance
&lt;/h2&gt;
&lt;h3&gt;
  
  
  Path 1: De-Identified Data Only
&lt;/h3&gt;

&lt;p&gt;Never use PHI in testing. Remove all 18 HIPAA identifiers.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Reality check&lt;/strong&gt;: Most healthcare apps cannot fully test this way. Patient matching, insurance verification, and clinical workflows require realistic data structures.&lt;/p&gt;
&lt;h3&gt;
  
  
  Path 2: Cloud Testing with Full Controls
&lt;/h3&gt;

&lt;p&gt;Get a signed BAA. Configure encryption. Enable audit logging. Verify device wiping. Document everything.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Reality check&lt;/strong&gt;: Enterprise tiers cost 5-10x standard pricing. And your binary—with embedded keys and logic—still sits on a server you do not control.&lt;/p&gt;
&lt;h3&gt;
  
  
  Path 3: Zero-Upload On-Premise Testing
&lt;/h3&gt;

&lt;p&gt;Keep everything on infrastructure you control.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[Your CI/CD] ◄──P2P Encrypted──► [Your Devices]
     │                                 │
     ▼                                 ▼
 Your Logs                        Your Storage

[DeviceLab Cloud] ◄── Signaling only
                      (no PHI, no binaries)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Result:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;✅ PHI Exposure: None external&lt;/li&gt;
&lt;li&gt;✅ BAA Required: No&lt;/li&gt;
&lt;li&gt;✅ Audit Scope: Your existing infrastructure only&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;With DeviceLab, your binary moves directly from CI/CD to your devices via encrypted P2P. We handle signaling only—no PHI, no binaries, no test data ever touches our servers.&lt;/p&gt;

&lt;p&gt;We are the pipe, not the bucket.&lt;/p&gt;




&lt;h2&gt;
  
  
  The BAA Headache (Solved)
&lt;/h2&gt;

&lt;p&gt;If you use a cloud vendor with PHI, you need a Business Associate Agreement:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Standard cloud plans ($150/month) &lt;strong&gt;do not include BAAs&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Enterprise plans with BAAs often cost &lt;strong&gt;5-10x more&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Negotiating a BAA takes &lt;strong&gt;2-4 weeks minimum&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;With DeviceLab, you do not need a BAA with us. We never process your data. Your PHI stays on your network, streaming P2P between your devices.&lt;/p&gt;

&lt;p&gt;Nothing to negotiate. Nothing to declare. Nothing to audit beyond your existing infrastructure.&lt;/p&gt;




&lt;h2&gt;
  
  
  Week 1 Action Items
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Days 1-2: Inventory&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;List every system touching test builds&lt;/li&gt;
&lt;li&gt;Identify all third-party vendors in your QA pipeline&lt;/li&gt;
&lt;li&gt;Document what data type (PHI, de-identified, synthetic) each test uses&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Days 3-4: Gap Analysis&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Run through the 5-point audit&lt;/li&gt;
&lt;li&gt;Identify missing BAAs&lt;/li&gt;
&lt;li&gt;Flag unencrypted PHI transmission points&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Days 5-7: Remediation&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Choose your path: de-identified, compliant cloud, or on-premise&lt;/li&gt;
&lt;li&gt;If cloud: initiate BAA process (expect 2-4 weeks)&lt;/li&gt;
&lt;li&gt;If on-premise: &lt;a href="https://devicelab.dev/blog/certified-hardware-list" rel="noopener noreferrer"&gt;order hardware&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Update your risk analysis to include testing infrastructure&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  The Bottom Line
&lt;/h2&gt;

&lt;p&gt;You would not host your production database on a public FTP server.&lt;/p&gt;

&lt;p&gt;Do not host your test infrastructure on a public device cloud.&lt;/p&gt;

&lt;p&gt;OCR is auditing more aggressively than ever. Your testing pipeline is either:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;In scope and documented&lt;/strong&gt; → Compliant&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;In scope and undocumented&lt;/strong&gt; → Violation&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Out of scope because PHI never leaves your network&lt;/strong&gt; → Ideal&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;DeviceLab helps you achieve option 3. No BAA required. No PHI on our servers. No new audit scope.&lt;/p&gt;

&lt;p&gt;The safest place for patient data—even mock data—is inside your house.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;275 million records breached. $3 million penalties. 22 enforcement actions. Your auditor will ask about your testing pipeline.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Will you have an answer?&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;&lt;a href="https://devicelab.dev/docs/getting-started" rel="noopener noreferrer"&gt;Build a Zero-Trust Lab →&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;&lt;a href="https://devicelab.dev/blog/binary-sovereignty-no-upload" rel="noopener noreferrer"&gt;Read the Security Architecture →&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;&lt;a href="https://devicelab.dev/blog/certified-hardware-list" rel="noopener noreferrer"&gt;See the Certified Hardware List →&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Disclaimer: This article is for informational purposes only and does not constitute legal advice. You should contact your attorney to obtain advice with respect to any particular issue or problem.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://devicelab.dev" class="crayons-btn crayons-btn--primary" rel="noopener noreferrer"&gt;Learn More&lt;/a&gt;
&lt;/p&gt;

</description>
      <category>security</category>
      <category>healthcare</category>
      <category>testing</category>
      <category>compliance</category>
    </item>
    <item>
      <title>Renting Test Devices is Financially Irresponsible. Here's the Math.</title>
      <dc:creator>Om Narayan</dc:creator>
      <pubDate>Sun, 28 Dec 2025 23:28:40 +0000</pubDate>
      <link>https://forem.com/omnarayan/renting-test-devices-is-financially-irresponsible-heres-the-math-2iln</link>
      <guid>https://forem.com/omnarayan/renting-test-devices-is-financially-irresponsible-heres-the-math-2iln</guid>
      <description>&lt;p&gt;Your finance team would never approve renting laptops at a 300% markup. So why are you doing it with test devices?&lt;/p&gt;

&lt;p&gt;Every month, engineering teams wire thousands of dollars to cloud testing providers for infrastructure they could own outright. Not because the cloud is better—but because nobody ran the numbers.&lt;/p&gt;

&lt;p&gt;Let's run them.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Math Your CFO Needs to See
&lt;/h2&gt;

&lt;p&gt;Here is the actual cost breakdown for a standard 10-device lab:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Cost Factor&lt;/th&gt;
&lt;th&gt;Cloud (BrowserStack)&lt;/th&gt;
&lt;th&gt;DeviceLab (Own Hardware)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Monthly cost&lt;/td&gt;
&lt;td&gt;$2,500&lt;/td&gt;
&lt;td&gt;$891 (9 paid + 1 free)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Annual cost&lt;/td&gt;
&lt;td&gt;$30,000&lt;/td&gt;
&lt;td&gt;$10,692&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hardware (one-time)&lt;/td&gt;
&lt;td&gt;$0&lt;/td&gt;
&lt;td&gt;$779 (Mac Mini + Hub + Cables)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Year 1 Total&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;$30,000&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;$11,471&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Year 2 Total&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;$60,000&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;$22,163&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Year 3 Total&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;$90,000&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;$32,855&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;3-Year Savings: $57,145&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That is $19,000+ per year saved on just 10 phones. Not a rounding error—enough to hire a junior engineer or upgrade every developer's laptop.&lt;/p&gt;




&lt;h2&gt;
  
  
  The 100-Device Reality
&lt;/h2&gt;

&lt;p&gt;When you scale this to a full enterprise lab, the "Cloud Tax" becomes indefensible:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Devices&lt;/th&gt;
&lt;th&gt;Cloud Cost (3 Years)&lt;/th&gt;
&lt;th&gt;DeviceLab Cost (3 Years)&lt;/th&gt;
&lt;th&gt;You Keep&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;$90,000&lt;/td&gt;
&lt;td&gt;$32,855&lt;/td&gt;
&lt;td&gt;$57,145&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;25&lt;/td&gt;
&lt;td&gt;$225,000&lt;/td&gt;
&lt;td&gt;$83,940&lt;/td&gt;
&lt;td&gt;$141,060&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;50&lt;/td&gt;
&lt;td&gt;$450,000&lt;/td&gt;
&lt;td&gt;$167,880&lt;/td&gt;
&lt;td&gt;$282,120&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;100&lt;/td&gt;
&lt;td&gt;$900,000&lt;/td&gt;
&lt;td&gt;$335,760&lt;/td&gt;
&lt;td&gt;$564,240&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;At 100 devices, you are saving $188,000 per year. That is pure bottom-line profit.&lt;/p&gt;




&lt;h2&gt;
  
  
  "But We Need Instant Access to Any Device"
&lt;/h2&gt;

&lt;p&gt;No, you don't.&lt;/p&gt;

&lt;p&gt;Look at your test logs. 90% of your runs happen on the same 15 devices. The iPhone 15. The Galaxy S24. The Pixel 8. Your core regression suite does not need a Finnish Nokia from 2019.&lt;/p&gt;

&lt;p&gt;The smart play is not all-or-nothing. It is the Hybrid Model:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;┌─────────────────────────────────┐  ┌──────────────┐
│                                 │  │              │
│     CORE REGRESSION (90%)       │  │ EDGE CASES   │
│                                 │  │    (10%)     │
│         DeviceLab               │  │ BrowserStack │
│                                 │  │              │
│         $0/minute               │  │  $$$/minute  │
│                                 │  │              │
│   • Daily CI/CD                 │  │ • Rare OS    │
│   • Smoke tests                 │  │ • Old devices│
│   • Regression suite            │  │ • One-offs   │
│                                 │  │              │
└─────────────────────────────────┘  └──────────────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Keep your cloud subscription for the weird stuff. Run your daily workload on hardware you own.&lt;/p&gt;

&lt;p&gt;You are not killing your safety net. You are right-sizing it.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Hidden Costs Nobody Talks About
&lt;/h2&gt;

&lt;h3&gt;
  
  
  The Parallelism Tax
&lt;/h3&gt;

&lt;p&gt;Want to run 10 tests simultaneously? That is 10 parallel slots. At $250 each. Per month.&lt;/p&gt;

&lt;p&gt;Your developers are waiting in queues because you are rationing access to save money. That is not a testing problem—it is a procurement problem.&lt;/p&gt;

&lt;p&gt;With owned devices, parallelism is free. Run 50 tests on 50 devices. No slots. No queues. No artificial scarcity.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Latency Problem
&lt;/h3&gt;

&lt;p&gt;Cloud devices sit in Virginia or Frankfurt. Your staging server sits in your VPC.&lt;/p&gt;

&lt;p&gt;Every test command travels: Your CI → Cloud Provider → Their Device → Back.&lt;/p&gt;

&lt;p&gt;That is 100-500ms of latency per interaction. Multiply by thousands of UI actions. Your test suite is slow because of geography, not code.&lt;/p&gt;

&lt;p&gt;Local devices respond in under 50ms. That is not incrementally better—it is a different experience entirely.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Security Liability
&lt;/h3&gt;

&lt;p&gt;Every test run uploads your unreleased binary to someone else's server. Your staging credentials. Your test data. Your intellectual property.&lt;/p&gt;

&lt;p&gt;"But they're &lt;a href="https://www.aicpa-cima.com/topic/audit-assurance/audit-and-assurance-greater-than-soc-2" rel="noopener noreferrer"&gt;SOC2&lt;/a&gt; compliant!"&lt;/p&gt;

&lt;p&gt;SOC2 means they have security policies. It does not mean your data is not sitting on shared infrastructure, passing through their network, visible in their logs.&lt;/p&gt;

&lt;p&gt;With on-premise devices, your binary never leaves your building. Zero trust is not a marketing term—it is an architecture decision.&lt;/p&gt;




&lt;h2&gt;
  
  
  What You Actually Need to Buy
&lt;/h2&gt;

&lt;p&gt;Stop overcomplicating this. A production-ready 10-device lab costs less than one month of cloud:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Item&lt;/th&gt;
&lt;th&gt;Product&lt;/th&gt;
&lt;th&gt;Cost&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Host&lt;/td&gt;
&lt;td&gt;
&lt;a href="https://www.apple.com/shop/buy-mac/mac-mini" rel="noopener noreferrer"&gt;Mac Mini M4&lt;/a&gt; (16GB)&lt;/td&gt;
&lt;td&gt;$599&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;USB Hub&lt;/td&gt;
&lt;td&gt;&lt;a href="https://www.amazon.com/SABRENT-10-Port-Individual-Switches-Adapter/dp/B0797NZFYP" rel="noopener noreferrer"&gt;Sabrent HB-BU10&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;$60&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cables&lt;/td&gt;
&lt;td&gt;Anker PowerLine III (10x)&lt;/td&gt;
&lt;td&gt;$120&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;/td&gt;
&lt;td&gt;&lt;strong&gt;$779&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;No server room. No dedicated IT. No ongoing hardware maintenance contracts.&lt;/p&gt;

&lt;p&gt;Plug it in. Install DeviceLab. Run tests.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://devicelab.dev/blog/certified-hardware-list" rel="noopener noreferrer"&gt;→ See the complete certified hardware list&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  The Objections (And Why They're Wrong)
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;"We don't have DevOps bandwidth for hardware"&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Modern device labs are software-defined. DeviceLab auto-recovers disconnected devices, monitors battery health, and reboots hung phones. You are not hiring someone to babysit USB cables.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"What about device coverage?"&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;You are already testing on 15 devices. You will still test on 15 devices. The only difference is who owns them.&lt;/p&gt;

&lt;p&gt;Buy refurbished. An iPhone 13 tests iOS apps just fine—and costs $400 instead of $1,200.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"Our enterprise security team won't approve random hardware"&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;They will approve it faster than they will approve uploading proprietary code to a third-party cloud. On-prem is the security team's preferred answer.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"Scaling is easier in the cloud"&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Scaling cloud costs is easy. Just add more zeros to the invoice.&lt;/p&gt;

&lt;p&gt;Scaling owned hardware means buying another Mac Mini. Once. Then it is yours forever.&lt;/p&gt;




&lt;h2&gt;
  
  
  Calculate Your Exact Savings
&lt;/h2&gt;

&lt;p&gt;Every team is different. Here is how to run your own numbers:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Count your current cloud devices (check your invoice)&lt;/li&gt;
&lt;li&gt;Multiply by $250/month (conservative cloud cost)&lt;/li&gt;
&lt;li&gt;Compare to DeviceLab: $99/device/month + one-time hardware&lt;/li&gt;
&lt;li&gt;Calculate 3-year difference&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Or use this formula:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Annual Savings = (Devices × $250 × 12) - (Devices × $99 × 12) - Hardware Cost
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For 25 devices with $2,800 hardware:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;= (25 × $250 × 12) - (25 × $99 × 12) - $2,800
= $75,000 - $29,700 - $2,800
= $42,500 saved in Year 1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Year 2 and beyond? That $2,800 hardware cost disappears. You save $45,300 every year after.&lt;/p&gt;




&lt;h2&gt;
  
  
  Who Already Made the Switch
&lt;/h2&gt;

&lt;p&gt;Teams running 50+ devices on DeviceLab instead of cloud:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Fintech apps that cannot upload binaries to third-party servers&lt;/li&gt;
&lt;li&gt;Healthcare platforms with HIPAA data in test scenarios&lt;/li&gt;
&lt;li&gt;Enterprise teams tired of explaining cloud costs to finance&lt;/li&gt;
&lt;li&gt;Startups that refused to accept "testing is expensive" as a given&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;They did not switch because they hate BrowserStack. They switched because the math stopped making sense.&lt;/p&gt;




&lt;h2&gt;
  
  
  What Happens Next
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Option 1:&lt;/strong&gt; Keep paying the cloud tax. Budget $30,000/year for 10 devices. Explain to finance why testing costs more than the developers writing the tests.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Option 2:&lt;/strong&gt; Spend $779 on hardware. Pay $99/device/month. Save $18,500+ in Year 1 alone. Redeploy that budget to hiring, features, or literally anything else.&lt;/p&gt;

&lt;p&gt;The cloud made sense in 2015 when the alternative was building a data center. It does not make sense in 2025 when the alternative is a Mac Mini under your desk.&lt;/p&gt;




&lt;h2&gt;
  
  
  Frequently Asked Questions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  How much does BrowserStack cost per device?
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://www.browserstack.com/pricing" rel="noopener noreferrer"&gt;BrowserStack&lt;/a&gt; charges approximately $250-300 per device per month for dedicated real device access, translating to $3,000+ per device annually.&lt;/p&gt;

&lt;h3&gt;
  
  
  What is a cloud exit in mobile testing?
&lt;/h3&gt;

&lt;p&gt;A cloud exit is the strategic move from renting cloud-based device testing infrastructure to owning your own private device lab, reducing costs by over 60%.&lt;/p&gt;

&lt;h3&gt;
  
  
  How long does it take to recoup device lab hardware costs?
&lt;/h3&gt;

&lt;p&gt;Most teams break even within 2 months. A 10-device lab saves over $1,600/month compared to cloud rentals, paying off the hardware investment in roughly 45 days.&lt;/p&gt;

&lt;h3&gt;
  
  
  What hardware do I need to build a 10-device lab?
&lt;/h3&gt;

&lt;p&gt;A Mac Mini M4 ($599), Sabrent HB-BU10 USB hub ($60), and 10 Anker cables ($120). Total: $779 one-time cost.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is the hybrid model better than full cloud exit?
&lt;/h3&gt;

&lt;p&gt;For many teams, yes. Keep 10% of devices on cloud for rare OS versions and edge cases, move 90% of daily regression to owned hardware. You get cost savings without losing coverage.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Your test infrastructure is either an asset or an expense. Renting makes it an expense. Owning makes it an asset. The math is simple. The decision should be too.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://devicelab.dev/docs/getting-started" class="crayons-btn crayons-btn--primary" rel="noopener noreferrer"&gt;Get Started&lt;/a&gt;
&lt;/p&gt;

</description>
      <category>testing</category>
      <category>mobile</category>
      <category>devops</category>
      <category>startup</category>
    </item>
    <item>
      <title>Sub-50ms Latency: The Physics of Fast Mobile Automation</title>
      <dc:creator>Om Narayan</dc:creator>
      <pubDate>Sun, 28 Dec 2025 23:15:06 +0000</pubDate>
      <link>https://forem.com/omnarayan/sub-50ms-latency-the-physics-of-fast-mobile-automation-fgd</link>
      <guid>https://forem.com/omnarayan/sub-50ms-latency-the-physics-of-fast-mobile-automation-fgd</guid>
      <description>&lt;p&gt;Have you ever watched an Appium test run on a cloud provider?&lt;/p&gt;

&lt;p&gt;You see the command in your terminal.&lt;br&gt;
&lt;em&gt;...pause...&lt;/em&gt;&lt;br&gt;
&lt;em&gt;...pause...&lt;/em&gt;&lt;br&gt;
The button on the screen finally clicks.&lt;/p&gt;

&lt;p&gt;That pause isn't your code. It isn't "processing time." It's the speed of light, and it's killing your test stability.&lt;/p&gt;

&lt;p&gt;If you're running tests on &lt;strong&gt;GitHub Actions&lt;/strong&gt; targeting a cloud device farm, you're fighting a losing battle against physics.&lt;/p&gt;

&lt;p&gt;This is the deep dive into why—and the architectural fix that makes tests run 3x faster.&lt;/p&gt;


&lt;h2&gt;
  
  
  The Human Perception Threshold
&lt;/h2&gt;

&lt;p&gt;In 1968, IBM researcher Robert Miller established response time thresholds that still guide UX design today. &lt;a href="https://www.nngroup.com/articles/response-times-3-important-limits/" rel="noopener noreferrer"&gt;Jakob Nielsen&lt;/a&gt; popularized them in 1993, and they remain the gold standard:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Latency&lt;/th&gt;
&lt;th&gt;Human Perception&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;&amp;lt; 100ms&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Feels instantaneous—direct manipulation&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;100-300ms&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Noticeable delay, but flow uninterrupted&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;300-1000ms&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;System feels sluggish, user waits&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;&amp;gt; 1000ms&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Context switch—user loses focus&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The 1982 Doherty Threshold research went further: productivity &lt;strong&gt;soars&lt;/strong&gt; when response time drops below 400ms, and continues improving down to 50ms.&lt;/p&gt;

&lt;p&gt;For interactive tasks—dragging, swiping, debugging gestures—recent research shows users can perceive latencies as low as &lt;strong&gt;16-33ms&lt;/strong&gt; in direct touch interactions. The 100ms "threshold" was always a ceiling, not a target.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why this matters for testing&lt;/strong&gt;: When you're debugging a swipe-to-delete gesture, you need to &lt;em&gt;feel&lt;/em&gt; the physics. A 200ms delay between your swipe and the animation makes it nearly impossible to diagnose timing issues. You're debugging blind.&lt;/p&gt;


&lt;h2&gt;
  
  
  The Cloud Latency Stack
&lt;/h2&gt;

&lt;p&gt;When you interact with a cloud device farm, your input travels through:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Your Input → Local Network → ISP → Cloud Data Center → Server Processing → Device → Screen Capture → Video Encoding → Return Path → Your Screen

Round-trip: 150-400ms (typical)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each hop adds latency:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Component&lt;/th&gt;
&lt;th&gt;Added Latency&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Geographic distance (speed of light)&lt;/td&gt;
&lt;td&gt;30-100ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ISP routing and peering&lt;/td&gt;
&lt;td&gt;10-50ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Data center network&lt;/td&gt;
&lt;td&gt;5-20ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Server processing&lt;/td&gt;
&lt;td&gt;10-30ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Video encoding&lt;/td&gt;
&lt;td&gt;10-30ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Video decoding (your browser)&lt;/td&gt;
&lt;td&gt;5-15ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Display rendering&lt;/td&gt;
&lt;td&gt;5-15ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Best case&lt;/strong&gt;: A developer in Virginia testing on Virginia-based cloud devices might see 80-120ms.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Typical case&lt;/strong&gt;: A developer in Bangalore testing on US-based cloud infrastructure sees 200-350ms.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Worst case&lt;/strong&gt;: Complex routing, congested networks, or transcoding issues can push latency to 400ms+.&lt;/p&gt;

&lt;p&gt;This is physics. No amount of engineering can make light travel faster through fiber.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Protocol Problem: Why WebDriver is "Chatty"
&lt;/h2&gt;

&lt;p&gt;To understand the bottleneck, you have to look at the &lt;a href="https://www.w3.org/TR/webdriver/" rel="noopener noreferrer"&gt;WebDriver Protocol&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Appium doesn't send your entire test script to the device at once. It sends &lt;strong&gt;individual HTTP commands&lt;/strong&gt; for every single action. Synchronous. Blocking. One at a time.&lt;/p&gt;

&lt;p&gt;A typical "Login" flow 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;1. POST /element (Find 'username')  → Request ✈️ Cloud → Response
2. POST /element/value (Type text)  → Request ✈️ Cloud → Response  
3. POST /element (Find 'password')  → Request ✈️ Cloud → Response
4. POST /element/value (Type text)  → Request ✈️ Cloud → Response
5. POST /element (Find 'submit')    → Request ✈️ Cloud → Response
6. POST /element/click (Click it)   → Request ✈️ Cloud → Response
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Six commands. Six round-trips. If your runner is in Virginia (Azure US-East) and the device cloud is in California (US-West), that's &lt;strong&gt;~80ms round-trip latency per command&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The Math of Slowness:&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Test Complexity&lt;/th&gt;
&lt;th&gt;Commands&lt;/th&gt;
&lt;th&gt;RTT @ 100ms&lt;/th&gt;
&lt;th&gt;RTT @ 20ms (Local)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Simple login flow&lt;/td&gt;
&lt;td&gt;50&lt;/td&gt;
&lt;td&gt;5 seconds&lt;/td&gt;
&lt;td&gt;1 second&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Standard regression&lt;/td&gt;
&lt;td&gt;500&lt;/td&gt;
&lt;td&gt;50 seconds&lt;/td&gt;
&lt;td&gt;10 seconds&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Full E2E suite&lt;/td&gt;
&lt;td&gt;2,000&lt;/td&gt;
&lt;td&gt;3.3 minutes&lt;/td&gt;
&lt;td&gt;40 seconds&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;This is why your local test runs in 2 minutes, but the cloud test takes 8 minutes. &lt;strong&gt;60% of that time is pure network waiting.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Over thousands of test runs per month, this compounds into hours of wasted compute time—and slower feedback loops for your team.&lt;/p&gt;




&lt;h2&gt;
  
  
  The 3 Architectures: Good, Better, Best
&lt;/h2&gt;

&lt;p&gt;Most teams assume "CI/CD" just means "GitHub Actions Cloud." But where you put the &lt;strong&gt;Runner&lt;/strong&gt;—the computer executing the test script—defines your speed.&lt;/p&gt;

&lt;h3&gt;
  
  
  Architecture A: The "Double Hop" (Legacy Cloud) 🔴
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Setup:&lt;/strong&gt; GitHub-Hosted Runner (Azure) → BrowserStack/Sauce Labs (AWS/GCP)&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;GitHub Actions (Azure US-East)
        ↓ ~50ms
BrowserStack Hub (AWS US-West)
        ↓ ~30ms
Device Data Center
        ↓ ~20ms
Actual Device
        ↓
[Response travels back: +100ms]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Latency per command:&lt;/strong&gt; 150-300ms&lt;/p&gt;

&lt;p&gt;Your command leaves Microsoft's cloud, travels across the open internet to the vendor's cloud, hits the device, and travels all the way back. Packet loss and jitter cause frequent "flaky" failures.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The hidden cost:&lt;/strong&gt; You can't control which Azure region GitHub picks. One day your runner is in US-East, the next it's in US-West. Latency variance causes tests to pass one run and timeout the next.&lt;/p&gt;

&lt;h3&gt;
  
  
  Architecture B: The "Hybrid" (GitHub Cloud + DeviceLab) 🟡
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Setup:&lt;/strong&gt; GitHub-Hosted Runner (Azure) → DeviceLab Tunnel → Your Office&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;GitHub Actions (Azure US-East)
        ↓ ~80ms (internet tunnel)
Your Mac Mini (Office/Home)
        ↓ ~5ms (USB)
Your Devices
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Latency per command:&lt;/strong&gt; 80-150ms&lt;/p&gt;

&lt;p&gt;Your test script runs in GitHub's cloud. Commands travel over the internet (via DeviceLab's secure tunnel) to your Mac Mini, which executes them on the phone.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The verdict:&lt;/strong&gt; It works, and it's convenient because you don't manage runners. But you still pay the &lt;strong&gt;"Tunnel Tax"&lt;/strong&gt;—physics dictates that a command from Azure to your office takes time.&lt;/p&gt;

&lt;p&gt;Still faster than Architecture A (no shared resource contention, no vendor queue times), but not instant.&lt;/p&gt;

&lt;h3&gt;
  
  
  Architecture C: The "Edge Runner" (God Mode) ⚡🟢
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Setup:&lt;/strong&gt; &lt;a href="https://docs.github.com/en/actions/hosting-your-own-runners/managing-self-hosted-runners/about-self-hosted-runners" rel="noopener noreferrer"&gt;Self-Hosted GitHub Runner&lt;/a&gt; &lt;em&gt;on the same Mac Mini&lt;/em&gt; hosting the devices&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;GitHub sends "Start Job" signal
        ↓
Mac Mini picks up job
        ↓
Test runs on localhost:4723
        ↓ ~2ms (USB cable)
iPhone/Android Device
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Latency per command:&lt;/strong&gt; &amp;lt;5ms&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Network hops:&lt;/strong&gt; 0&lt;br&gt;
&lt;strong&gt;Stability:&lt;/strong&gt; Near-perfect&lt;/p&gt;

&lt;p&gt;You install the GitHub Actions Runner agent directly on your Mac Mini. GitHub triggers the job, but execution happens locally. The Appium command travels over a USB cable, not the internet.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;┌─────────────────────────────────────┐
│  YOUR MAC MINI (Device Node)        │
│                                     │
│  [GitHub Runner] ──► [Appium] ──────┼──► USB ──► iPhone 15
│                                     │
└─────────────────────────────────────┘

Round-trip latency: &amp;lt;5ms
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;The math changes completely:&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Architecture&lt;/th&gt;
&lt;th&gt;Latency/Cmd&lt;/th&gt;
&lt;th&gt;500-Cmd Test Overhead&lt;/th&gt;
&lt;th&gt;Flakiness&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;A: Double Hop&lt;/td&gt;
&lt;td&gt;200ms&lt;/td&gt;
&lt;td&gt;100 seconds&lt;/td&gt;
&lt;td&gt;High&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;B: Hybrid&lt;/td&gt;
&lt;td&gt;100ms&lt;/td&gt;
&lt;td&gt;50 seconds&lt;/td&gt;
&lt;td&gt;Medium&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;C: Edge Runner&lt;/td&gt;
&lt;td&gt;5ms&lt;/td&gt;
&lt;td&gt;2.5 seconds&lt;/td&gt;
&lt;td&gt;Near-zero&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Architecture C is 40x faster on network overhead alone.&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Screen Streaming: WebRTC vs MJPEG
&lt;/h2&gt;

&lt;p&gt;The architecture diagrams above focus on &lt;em&gt;commands&lt;/em&gt;. But there's another latency source: &lt;strong&gt;screen video&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Legacy cloud farms stream device screens via MJPEG—essentially a series of JPEG images over HTTP. This adds &lt;strong&gt;500ms-2 seconds&lt;/strong&gt; of video latency on top of command latency.&lt;/p&gt;

&lt;p&gt;DeviceLab uses &lt;strong&gt;WebRTC&lt;/strong&gt; (the same protocol powering Zoom/Google Meet):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Protocol&lt;/th&gt;
&lt;th&gt;Latency&lt;/th&gt;
&lt;th&gt;Why&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;MJPEG&lt;/td&gt;
&lt;td&gt;500ms-2s&lt;/td&gt;
&lt;td&gt;TCP, buffered, server-transcoded&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;WebRTC&lt;/td&gt;
&lt;td&gt;&amp;lt;50ms&lt;/td&gt;
&lt;td&gt;UDP, P2P, no transcoding&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;For manual testing&lt;/strong&gt;, this matters enormously. Debugging a gesture on a 2-second delayed video is impossible. With WebRTC, the device screen feels like it's plugged into your monitor.&lt;/p&gt;




&lt;h2&gt;
  
  
  What Sub-50ms Testing Feels Like
&lt;/h2&gt;

&lt;p&gt;At sub-50ms latency:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Manual debugging&lt;/strong&gt;: You swipe, and the screen responds. The device feels like it's in your hand. You can debug gesture physics, animation timing, and scroll behavior by &lt;em&gt;feel&lt;/em&gt;, not by inference.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Interactive sessions&lt;/strong&gt;: App review, QA walkthroughs, and stakeholder demos run smoothly. No awkward pauses explaining "there's a bit of lag."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Automated tests&lt;/strong&gt;: Appium commands execute immediately. Test suites run 20-40% faster just from eliminating network overhead.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;CI pipelines&lt;/strong&gt;: Faster test execution means faster feedback. Developers find out about failures in minutes, not hours.&lt;/p&gt;

&lt;p&gt;The difference isn't subtle. Teams that switch from cloud to local consistently report that testing "feels different"—like upgrading from a video call to being in the same room.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Latency Measurement
&lt;/h2&gt;

&lt;p&gt;Don't take claims at face value. Measure it yourself.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;For manual testing&lt;/strong&gt;, use this rough approach:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Start a screen recording on your computer&lt;/li&gt;
&lt;li&gt;Tap a button that triggers a visual change&lt;/li&gt;
&lt;li&gt;Frame-by-frame, count the time between your tap and the screen update&lt;/li&gt;
&lt;li&gt;30fps video = 33ms per frame; 60fps = 16ms per frame&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;For automated testing&lt;/strong&gt;, instrument your test code:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;

&lt;span class="n"&gt;start&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;time&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;driver&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find_element&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;by&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;click&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;end&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;time&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Command latency: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;end&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;start&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;ms&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run this on cloud infrastructure and local infrastructure. The difference will be stark.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What you'll likely see&lt;/strong&gt;:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Setup&lt;/th&gt;
&lt;th&gt;Measured Latency&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Cloud device farm (cross-region)&lt;/td&gt;
&lt;td&gt;180-350ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cloud device farm (same region)&lt;/td&gt;
&lt;td&gt;100-180ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DeviceLab P2P (cross-network)&lt;/td&gt;
&lt;td&gt;50-100ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DeviceLab P2P (same network)&lt;/td&gt;
&lt;td&gt;20-50ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;USB-connected device&lt;/td&gt;
&lt;td&gt;10-30ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;DeviceLab on the same network approaches USB-connected performance—without the cable.&lt;/p&gt;




&lt;h2&gt;
  
  
  When Cloud Latency Is Acceptable
&lt;/h2&gt;

&lt;p&gt;Let's be fair: not every use case requires sub-50ms latency.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cloud latency is fine for&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Screenshot comparison tests (no interaction timing)&lt;/li&gt;
&lt;li&gt;Smoke tests that verify basic flows&lt;/li&gt;
&lt;li&gt;Device coverage testing (checking if it works on Device X)&lt;/li&gt;
&lt;li&gt;Batch execution where speed isn't critical&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Cloud latency hurts for&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Debugging gesture interactions&lt;/li&gt;
&lt;li&gt;Testing animations and transitions&lt;/li&gt;
&lt;li&gt;Interactive QA sessions&lt;/li&gt;
&lt;li&gt;Manual exploratory testing&lt;/li&gt;
&lt;li&gt;CI pipelines where feedback speed matters&lt;/li&gt;
&lt;li&gt;Any test where you're diagnosing &lt;em&gt;why&lt;/em&gt; something fails&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If your testing is purely "does it crash?"—cloud latency is fine. If you're trying to understand &lt;em&gt;behavior&lt;/em&gt;, you need responsive devices.&lt;/p&gt;




&lt;h2&gt;
  
  
  Setting Up the Edge Runner
&lt;/h2&gt;

&lt;p&gt;Moving to the Edge Runner architecture doesn't require rearchitecting your test suite.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 1: Hardware&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A Mac Mini M2 and USB hub can host 10-15 devices. Total cost: ~$800. (See the &lt;a href="https://devicelab.dev/blog/certified-hardware-list" rel="noopener noreferrer"&gt;Certified Hardware List&lt;/a&gt;)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 2: Install Self-Hosted Runner&lt;/strong&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="c"&gt;# Download and configure GitHub Actions runner&lt;/span&gt;
./config.sh &lt;span class="nt"&gt;--url&lt;/span&gt; https://github.com/your-org/your-repo &lt;span class="se"&gt;\&lt;/span&gt;
            &lt;span class="nt"&gt;--token&lt;/span&gt; YOUR_RUNNER_TOKEN
./run.sh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Step 3: Install DeviceLab Node&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;DeviceLab's agent runs alongside the GitHub runner. Your devices appear in a browser dashboard, accessible from anywhere—for interactive sessions while CI runs locally.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 4: Update Workflow&lt;/strong&gt;&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;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;test&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;self-hosted&lt;/span&gt;  &lt;span class="c1"&gt;# ← Changed from 'ubuntu-latest'&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="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v4&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;npm test&lt;/span&gt;  &lt;span class="c1"&gt;# Appium hits localhost:4723&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Same Appium scripts. Same Maestro flows. Same XCUITest suites.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;You're changing &lt;em&gt;where&lt;/em&gt; they execute, not &lt;em&gt;how&lt;/em&gt; they're written.&lt;/p&gt;




&lt;h2&gt;
  
  
  Frequently Asked Questions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Why is Appium slow on BrowserStack/Sauce Labs?
&lt;/h3&gt;

&lt;p&gt;The WebDriver protocol is synchronous and chatty. A single test involves hundreds of HTTP requests. When each request must travel 3,000 miles to a cloud server and back, network latency adds minutes to execution time.&lt;/p&gt;

&lt;h3&gt;
  
  
  What is the fastest GitHub Actions setup for mobile testing?
&lt;/h3&gt;

&lt;p&gt;The "Edge Runner" architecture. Install a Self-Hosted GitHub Runner directly on the Mac Mini hosting the phones. This reduces network latency to &amp;lt;5ms per command.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can I use GitHub-Hosted Runners with DeviceLab?
&lt;/h3&gt;

&lt;p&gt;Yes. This is the "Hybrid" setup. It's slower than Edge Runners due to the "Tunnel Tax" (commands travel from Azure to your office), but still faster than cloud farms because you eliminate shared-resource contention and queue times.&lt;/p&gt;

&lt;h3&gt;
  
  
  What is the latency difference between MJPEG and WebRTC?
&lt;/h3&gt;

&lt;p&gt;Legacy cloud farms stream video via MJPEG (500ms-2s latency). DeviceLab uses WebRTC P2P, delivering &amp;lt;50ms screen latency—which feels like the device is plugged into your monitor.&lt;/p&gt;

&lt;h3&gt;
  
  
  How much faster is the Edge Runner architecture?
&lt;/h3&gt;

&lt;p&gt;For a 500-command test: Cloud Double-Hop adds 100 seconds of network overhead. Edge Runner adds 2.5 seconds. That's 40x less waiting.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Bottom Line
&lt;/h2&gt;

&lt;p&gt;Cloud device testing adds 150-400ms of latency per command. That's not a bug—it's physics. The WebDriver protocol is chatty, and every HTTP request must travel thousands of miles.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The three architectures:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Double Hop&lt;/strong&gt; (GitHub Cloud → Cloud Farm): 200ms/command, high flakiness&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hybrid&lt;/strong&gt; (GitHub Cloud → DeviceLab Tunnel): 100ms/command, medium flakiness
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Edge Runner&lt;/strong&gt; (Self-Hosted → Local Devices): 5ms/command, near-zero flakiness&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The Edge Runner is &lt;strong&gt;40x faster&lt;/strong&gt; on network overhead. For a 500-command test suite, that's the difference between 100 seconds of waiting and 2.5 seconds.&lt;/p&gt;

&lt;p&gt;If your tests are slow and flaky on cloud infrastructure, you're not doing anything wrong. You're just fighting the speed of light.&lt;/p&gt;

&lt;p&gt;Stop fighting. Move the runner to the edge.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Debugging gestures through 300ms of lag is like conducting surgery wearing oven mitts. Your tools should feel like extensions of your hands, not obstacles between you and your work.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Disclaimer: Latency measurements vary based on network conditions, geographic location, and device configuration. The figures in this article represent typical ranges observed in real-world testing scenarios.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://devicelab.dev/docs/getting-started" class="crayons-btn crayons-btn--primary" rel="noopener noreferrer"&gt;Set Up an Edge Runner&lt;/a&gt;
&lt;/p&gt;

</description>
      <category>testing</category>
      <category>performance</category>
      <category>mobile</category>
      <category>cicd</category>
    </item>
    <item>
      <title>Binary Sovereignty: Stop Uploading Your Unreleased App to Strangers</title>
      <dc:creator>Om Narayan</dc:creator>
      <pubDate>Sun, 28 Dec 2025 23:08:23 +0000</pubDate>
      <link>https://forem.com/omnarayan/binary-sovereignty-stop-uploading-your-unreleased-app-to-strangers-4lfp</link>
      <guid>https://forem.com/omnarayan/binary-sovereignty-stop-uploading-your-unreleased-app-to-strangers-4lfp</guid>
      <description>&lt;p&gt;Here is a contradiction I see every day in enterprise engineering:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Security Team:&lt;/strong&gt; Locks down the staging environment. Enforces strict VPNs. Rotates API keys weekly. Mandates 2FA for every repo. Scrutinizes every npm package.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;DevOps Team:&lt;/strong&gt; Takes the compiled, unreleased binary—containing all those keys, logic, and intellectual property—and uploads it to a public cloud server owned by a third-party vendor.&lt;/p&gt;

&lt;p&gt;We call this "Cloud Testing." In any other context, we would call it a data leak.&lt;/p&gt;




&lt;h2&gt;
  
  
  The "Secure Tunnel" Fallacy
&lt;/h2&gt;

&lt;p&gt;Most teams justify this workflow because they use a "Secure Tunnel" (like BrowserStack Local or Sauce Connect). They believe that because the traffic between the device and their staging server is encrypted, they are safe.&lt;/p&gt;

&lt;p&gt;They are missing the elephant in the room.&lt;/p&gt;

&lt;p&gt;The tunnel protects the network requests. It does not protect the Application Binary.&lt;/p&gt;

&lt;p&gt;To run an Appium or Espresso test on a cloud device, you must first upload your &lt;code&gt;.apk&lt;/code&gt; or &lt;code&gt;.ipa&lt;/code&gt; file to the vendor's storage. That binary sits on their servers. It is installed on their devices.&lt;/p&gt;

&lt;h3&gt;
  
  
  What is actually in that binary?
&lt;/h3&gt;

&lt;p&gt;Even with ProGuard or R8 obfuscation, your binary is a treasure map of your business. As the &lt;a href="https://owasp.org/www-project-mobile-security-testing-guide/" rel="noopener noreferrer"&gt;OWASP Mobile Security Testing Guide&lt;/a&gt; warns, reverse engineering is trivial:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Staging API Endpoints:&lt;/strong&gt; Often hardcoded or in config files&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Feature Flags:&lt;/strong&gt; Unreleased features your competitors would kill to see&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Third-Party Keys:&lt;/strong&gt; API keys for analytics, crash reporting, and internal tools&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Business Logic:&lt;/strong&gt; The actual algorithms that make your product unique&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You are handing the keys to the castle to a vendor, simply because you do not want to manage a USB hub.&lt;/p&gt;




&lt;h2&gt;
  
  
  "But They Are SOC2 Compliant!"
&lt;/h2&gt;

&lt;p&gt;I hear this defense constantly. "The vendor has a SOC2 Type II report! They are secure."&lt;/p&gt;

&lt;p&gt;Let's be clear about what &lt;a href="https://www.aicpa-cima.com/topic/audit-assurance/audit-and-assurance-greater-than-soc-2" rel="noopener noreferrer"&gt;SOC2&lt;/a&gt; means.&lt;/p&gt;

&lt;p&gt;SOC2 means the vendor has processes in place. It means they have promised not to look at your data. It means they have background checks for their employees.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;SOC2 is a legal shield, not a physical barrier.&lt;/strong&gt; For a deeper analysis of cloud provider security risks, see our &lt;a href="https://devicelab.dev/blog/cloud-device-lab-security-compliance-risk" rel="noopener noreferrer"&gt;cloud device lab security guide&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;It does not change the physics of the situation: Your proprietary code is sitting on a hard drive you do not own, accessible by admins you do not know, in a data center you cannot visit.&lt;/p&gt;

&lt;p&gt;If that vendor gets breached (and vendors get breached every week), your unreleased app is compromised. If a rogue employee at the vendor wants to inspect your binary, the only thing stopping them is a policy document.&lt;/p&gt;

&lt;p&gt;Ask your cloud provider these questions:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;How long is my binary retained after a test run?&lt;/li&gt;
&lt;li&gt;Who at your company can access uploaded binaries?&lt;/li&gt;
&lt;li&gt;Are devices wiped between customers, or just between sessions?&lt;/li&gt;
&lt;li&gt;Where exactly are my test logs stored?&lt;/li&gt;
&lt;li&gt;Can you provide a data flow diagram for my uploads?&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Most cannot answer all five. That should concern you.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Credentials Problem
&lt;/h2&gt;

&lt;p&gt;Your app does not run in isolation. Tests need to authenticate.&lt;/p&gt;

&lt;p&gt;Every test run includes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Staging API keys&lt;/li&gt;
&lt;li&gt;Test user credentials&lt;/li&gt;
&lt;li&gt;Internal endpoint URLs&lt;/li&gt;
&lt;li&gt;OAuth tokens&lt;/li&gt;
&lt;li&gt;Push notification certificates&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These appear in:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Environment variables (logged)&lt;/li&gt;
&lt;li&gt;CI/CD environment variables (often dumped in logs on failure)&lt;/li&gt;
&lt;li&gt;Network traffic (captured)&lt;/li&gt;
&lt;li&gt;Test output (stored)&lt;/li&gt;
&lt;li&gt;Crash reports (uploaded)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Cloud providers capture this data for debugging features—the same features you use to figure out why a test failed. That means your staging credentials exist in their logging infrastructure.&lt;/p&gt;

&lt;p&gt;"We mask sensitive data in logs."&lt;/p&gt;

&lt;p&gt;Masking catches known patterns. It does not catch custom credential formats, hardcoded tokens in debug builds, internal URLs that reveal architecture, or error messages that leak implementation details.&lt;/p&gt;

&lt;p&gt;Security theater looks like security. It is not the same thing.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Alternative: Zero Trust Architecture
&lt;/h2&gt;

&lt;p&gt;True security is not about trusting a vendor's "pinky promise." It is about architecture that makes trust unnecessary.&lt;/p&gt;

&lt;p&gt;We built DeviceLab on a Zero Trust / Peer-to-Peer (P2P) model.&lt;/p&gt;

&lt;h3&gt;
  
  
  How It Works
&lt;/h3&gt;

&lt;p&gt;When you run a test with DeviceLab, the architecture is fundamentally different:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Your CI/CD Runner&lt;/strong&gt; (GitHub Actions, Jenkins) has the binary&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Your Device Node&lt;/strong&gt; (the Mac Mini under your desk) has the phone&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;DeviceLab&lt;/strong&gt; establishes a direct, encrypted P2P pipe between them&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The command &lt;code&gt;adb install app.apk&lt;/code&gt; happens inside your network. The binary moves from your build server to your test device.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[YOUR INFRASTRUCTURE]                        [DEVICELAB CLOUD]

┌─────────────┐                              ┌─────────────┐
│  CI/CD      │                              │             │
│  Runner     │ ◄─────── (Signaling) ──────► │ Orchestrator│
│ (Has Binary)│                              │ (No Binary) │
└──────┬──────┘                              └─────────────┘
       │
       │  ◄────── (P2P Encrypted Pipe) ──────►
       │          Binary Transfer
       ▼
┌─────────────┐
│ Device Node │
│ (Mac Mini)  │
└─────────────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It never touches DeviceLab's cloud.&lt;/p&gt;

&lt;p&gt;We could not steal your app if we wanted to. We do not have the storage for it. We do not have the access rights. We simply broker the handshake, then get out of the way.&lt;/p&gt;




&lt;h2&gt;
  
  
  Binary Sovereignty for Regulated Industries
&lt;/h2&gt;

&lt;p&gt;For most startups, this is a matter of IP protection. For FinTech and HealthTech, it is often a matter of compliance.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Banking Scenario
&lt;/h3&gt;

&lt;p&gt;If you are building a banking app, your test build often points to a "Staging" environment that mimics production. While you scrub customer data, the mechanisms of your fraud detection and transaction logic are in that binary.&lt;/p&gt;

&lt;p&gt;Uploading that to a public cloud is a massive unnecessary risk.&lt;/p&gt;

&lt;p&gt;Many fintech security teams prohibit cloud device testing entirely. The compliance exposure under &lt;a href="https://www.pcisecuritystandards.org/" rel="noopener noreferrer"&gt;PCI-DSS&lt;/a&gt;, SOX, and state banking regulations is simply not worth it. Learn more about &lt;a href="https://devicelab.dev/blog/why-fintech-teams-cant-use-shared-device-clouds" rel="noopener noreferrer"&gt;why fintech teams can't use shared device clouds&lt;/a&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  The HIPAA Scenario
&lt;/h3&gt;

&lt;p&gt;Healthcare apps often contain hardcoded test user credentials for E2E scenarios. Even if these are "fake" patients, the structure of the data and the API calls reveal how you handle &lt;a href="https://www.cdc.gov/phlp/php/resources/health-insurance-portability-and-accountability-act-of-1996-hipaa.html" rel="noopener noreferrer"&gt;PHI (Protected Health Information)&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Keeping this traffic and data strictly on-premise is the only way to ensure 100% compliance. See our complete &lt;a href="https://devicelab.dev/blog/hipaa-mobile-qa-checklist" rel="noopener noreferrer"&gt;HIPAA mobile QA checklist&lt;/a&gt; for detailed requirements.&lt;/p&gt;

&lt;p&gt;Can you prove under audit that no real patient data ever leaked into a test build? With on-premise testing, the answer is simple: the data never left your network.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Competitive Intelligence Scenario
&lt;/h3&gt;

&lt;p&gt;Your unreleased features are competitive intelligence. A test build reveals:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Upcoming features (UI strings, new screens)&lt;/li&gt;
&lt;li&gt;Architecture decisions (API endpoints, service names)&lt;/li&gt;
&lt;li&gt;Performance characteristics (what you are optimizing)&lt;/li&gt;
&lt;li&gt;Bug patterns (what breaks under test)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This information has value. Uploading it to shared infrastructure is a choice.&lt;/p&gt;




&lt;h2&gt;
  
  
  The "Crown Jewels" Test
&lt;/h2&gt;

&lt;p&gt;Ask your CISO this simple question:&lt;/p&gt;

&lt;p&gt;"Would you be comfortable emailing our unreleased source code to a vendor?"&lt;/p&gt;

&lt;p&gt;The answer is always "No."&lt;/p&gt;

&lt;p&gt;So why are we comfortable uploading the compiled version of that code to the same vendor?&lt;/p&gt;

&lt;p&gt;The binary is the source code. A decompiler turns it back into readable Java or Swift in seconds.&lt;/p&gt;




&lt;h2&gt;
  
  
  Making the Switch
&lt;/h2&gt;

&lt;p&gt;Moving to zero-trust testing is not complicated. This is &lt;a href="https://devicelab.dev/blog/why-enterprises-use-private-device-labs" rel="noopener noreferrer"&gt;why enterprises use private device labs&lt;/a&gt;.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Hardware&lt;/strong&gt;: Mac Mini + USB hub + your devices (~$1,500 for 10 devices). See our &lt;a href="https://devicelab.dev/blog/certified-hardware-list" rel="noopener noreferrer"&gt;certified hardware list&lt;/a&gt; for tested configurations.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Software&lt;/strong&gt;: DeviceLab connects your devices to your CI&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Network&lt;/strong&gt;: Devices on your network, or P2P tunnels for remote access&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Process&lt;/strong&gt;: Same Appium/Maestro/XCUITest scripts, different execution target&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Your test code does not change. Your CI configuration changes slightly. Your security posture changes completely.&lt;/p&gt;




&lt;h2&gt;
  
  
  Frequently Asked Questions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Is BrowserStack secure for banking apps?
&lt;/h3&gt;

&lt;p&gt;Public clouds like BrowserStack are SOC2 compliant, but they require you to upload your binary to their servers. For strict FinTech compliance, keeping binaries on-premise via DeviceLab is safer.&lt;/p&gt;

&lt;h3&gt;
  
  
  What is Binary Sovereignty?
&lt;/h3&gt;

&lt;p&gt;Binary Sovereignty is the practice of keeping your application compiled code (APK/IPA) strictly within your own infrastructure, never uploading it to third-party vendors for testing.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does DeviceLab store my app?
&lt;/h3&gt;

&lt;p&gt;No. DeviceLab uses a peer-to-peer architecture. Your binary streams directly from your CI/CD to your device nodes. We physically cannot access or store your application.&lt;/p&gt;

&lt;h3&gt;
  
  
  What data is exposed during cloud device testing?
&lt;/h3&gt;

&lt;p&gt;Your unreleased binary, staging API credentials, test user data, OAuth tokens, push notification certificates, and debug logs with internal endpoints—all uploaded to third-party infrastructure.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can someone decompile my uploaded APK/IPA?
&lt;/h3&gt;

&lt;p&gt;Yes. A decompiler turns your binary back into readable Java or Swift in seconds. Your binary IS your source code—just in a different format.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Bottom Line
&lt;/h2&gt;

&lt;p&gt;Every cloud test is a binary upload to third-party infrastructure. That upload includes your unreleased app, your credentials, and your business logic.&lt;/p&gt;

&lt;p&gt;You can accept that risk. Many companies do.&lt;/p&gt;

&lt;p&gt;Or you can eliminate it entirely by testing on devices you own, connected through infrastructure you control.&lt;/p&gt;

&lt;p&gt;It is time to take your binaries back.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Your competitors would love to see your unreleased features. Your auditors want to know where your test data lives. One of these groups can access cloud testing infrastructure. Think about which one.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://devicelab.dev/blog/getting-started-with-devicelab" class="crayons-btn crayons-btn--primary" rel="noopener noreferrer"&gt;Build a Zero-Trust Lab in 5 Minutes&lt;/a&gt;
&lt;/p&gt;

</description>
      <category>security</category>
      <category>mobile</category>
      <category>testing</category>
      <category>devops</category>
    </item>
    <item>
      <title>Maestro Flakiness: Source Code Analysis</title>
      <dc:creator>Om Narayan</dc:creator>
      <pubDate>Thu, 25 Dec 2025 07:36:17 +0000</pubDate>
      <link>https://forem.com/omnarayan/maestro-flakiness-source-code-analysis-13ng</link>
      <guid>https://forem.com/omnarayan/maestro-flakiness-source-code-analysis-13ng</guid>
      <description>&lt;p&gt;Maestro markets itself as a test framework that "embraces the instability of mobile applications." But what does that actually mean in code? I dug into the source to find out.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Marketing Promise
&lt;/h2&gt;

&lt;p&gt;From Maestro's documentation:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"UI elements will not always be where you expect them, screen tap will not always go through, etc. Maestro embraces the instability of mobile applications and devices and tries to counter it."&lt;/p&gt;

&lt;p&gt;"No need to pepper your tests with &lt;code&gt;sleep()&lt;/code&gt; calls. Maestro knows that it might take time to load the content and automatically waits for it (but no longer than required)."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Sounds great. Let's see what the code actually does.&lt;/p&gt;




&lt;h2&gt;
  
  
  1. Element Finding: The Hardcoded 17-Second Timeout
&lt;/h2&gt;

&lt;p&gt;When you write &lt;code&gt;tapOn: "Login"&lt;/code&gt;, Maestro doesn't look once and fail. It polls continuously. But for how long?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Source&lt;/strong&gt;: &lt;a href="https://github.com/mobile-dev-inc/maestro/blob/main/maestro-orchestra/src/main/java/maestro/orchestra/Orchestra.kt" rel="noopener noreferrer"&gt;Orchestra.kt&lt;/a&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Orchestra&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;maestro&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Maestro&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;lookupTimeoutMs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Long&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;17000L&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;        &lt;span class="c1"&gt;// Hardcoded: 17 seconds&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;optionalLookupTimeoutMs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Long&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;7000L&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// Hardcoded: 7 seconds for optional&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;What this means:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Every element lookup waits up to &lt;strong&gt;17 seconds&lt;/strong&gt; by default&lt;/li&gt;
&lt;li&gt;Optional elements wait &lt;strong&gt;7 seconds&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;You &lt;strong&gt;cannot&lt;/strong&gt; change this per-command&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Want to wait 30 seconds for a slow API response? Too bad. Want to fail-fast in 3 seconds for performance testing? Also no.&lt;/p&gt;




&lt;h2&gt;
  
  
  2. The Polling Mechanism: Simple but Rigid
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Source&lt;/strong&gt;: &lt;a href="https://github.com/mobile-dev-inc/maestro/blob/main/maestro-utils/src/main/kotlin/MaestroTimer.kt" rel="noopener noreferrer"&gt;MaestroTimer.kt&lt;/a&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;T&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;withTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;timeoutMs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Long&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;block&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;T&lt;/span&gt;&lt;span class="p"&gt;?):&lt;/span&gt; &lt;span class="nc"&gt;T&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;endTime&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;System&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;currentTimeMillis&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;+&lt;/span&gt; &lt;span class="n"&gt;timeoutMs&lt;/span&gt;

    &lt;span class="k"&gt;do&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;result&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;block&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="n"&gt;result&lt;/span&gt; &lt;span class="p"&gt;!=&lt;/span&gt; &lt;span class="k"&gt;null&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="n"&gt;result&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;System&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;currentTimeMillis&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;endTime&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;null&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is a tight polling loop with &lt;strong&gt;no configurable delay between iterations&lt;/strong&gt;. It just hammers the view hierarchy until it finds the element or times out.&lt;/p&gt;

&lt;p&gt;Compare to Appium's FluentWait where you can set custom timeout, custom polling interval, and exceptions to ignore.&lt;/p&gt;




&lt;h2&gt;
  
  
  3. Tap Retries: Only 2 Attempts, Non-Configurable
&lt;/h2&gt;

&lt;p&gt;Maestro has a clever feature: if a tap doesn't change the UI, it retries. But how many times?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Source&lt;/strong&gt;: &lt;a href="https://github.com/mobile-dev-inc/maestro/blob/main/maestro-client/src/main/java/maestro/Maestro.kt" rel="noopener noreferrer"&gt;Maestro.kt&lt;/a&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;getNumberOfRetries&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;retryIfNoChange&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Boolean&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nc"&gt;Int&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;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;retryIfNoChange&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;  &lt;span class="c1"&gt;// That's it. Just 2.&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Two retries. Hardcoded. Not configurable.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;And the explicit &lt;code&gt;retry&lt;/code&gt; command in YAML? Also capped:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Source&lt;/strong&gt;: &lt;a href="https://github.com/mobile-dev-inc/maestro/blob/main/maestro-orchestra/src/main/java/maestro/orchestra/Orchestra.kt" rel="noopener noreferrer"&gt;Orchestra.kt&lt;/a&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;MAX_RETRIES_ALLOWED&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So even if you write &lt;code&gt;retry: 10&lt;/code&gt; in your YAML, you get &lt;strong&gt;3 maximum&lt;/strong&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  4. Screenshot Diff Threshold: Magic Number 0.5%
&lt;/h2&gt;

&lt;p&gt;When Maestro decides whether a tap "worked," it compares screenshots pixel-by-pixel:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Source&lt;/strong&gt;: &lt;a href="https://github.com/mobile-dev-inc/maestro/blob/main/maestro-client/src/main/java/maestro/Maestro.kt" rel="noopener noreferrer"&gt;Maestro.kt&lt;/a&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;SCREENSHOT_DIFF_THRESHOLD&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.005&lt;/span&gt;  &lt;span class="c1"&gt;// 0.5% pixel difference&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If less than 0.5% of pixels changed, Maestro assumes the tap failed and retries.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Problems:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A small spinner animation might trigger false "success"&lt;/li&gt;
&lt;li&gt;A full-screen color change of 0.4% would be considered "no change"&lt;/li&gt;
&lt;li&gt;You can't adjust this threshold&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  5. Wait For App To Settle: Fixed 200ms Polling
&lt;/h2&gt;

&lt;p&gt;After interactions, Maestro waits for the UI to stabilize:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Source&lt;/strong&gt;: &lt;a href="https://github.com/mobile-dev-inc/maestro/blob/main/maestro-client/src/main/java/maestro/utils/ScreenshotUtils.kt" rel="noopener noreferrer"&gt;ScreenshotUtils.kt&lt;/a&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="nf"&gt;repeat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;  &lt;span class="c1"&gt;// Poll up to 10 times&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;hierarchyAfter&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;viewHierarchy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;driver&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="n"&gt;latestHierarchy&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="n"&gt;hierarchyAfter&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;isLoading&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;latestHierarchy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;root&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;attributes&lt;/span&gt;
            &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getOrDefault&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"is-loading"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"false"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toBoolean&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="n"&gt;isLoading&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="n"&gt;hierarchyAfter&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;latestHierarchy&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;hierarchyAfter&lt;/span&gt;

    &lt;span class="nc"&gt;MaestroTimer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;MaestroTimer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Reason&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;WAIT_TO_SETTLE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;// Fixed 200ms&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Hardcoded values:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;10 iterations maximum&lt;/li&gt;
&lt;li&gt;200ms between polls&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Total maximum wait: 2 seconds&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Fast app? You're wasting 200ms per poll. Slow app? 2 seconds might not be enough.&lt;/p&gt;




&lt;h2&gt;
  
  
  6. waitUntilVisible: 10 Seconds, Take It or Leave It
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Source&lt;/strong&gt;: &lt;a href="https://github.com/mobile-dev-inc/maestro/blob/main/maestro-client/src/main/java/maestro/Maestro.kt" rel="noopener noreferrer"&gt;Maestro.kt&lt;/a&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;waitUntilVisible&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;element&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;UiElement&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nc"&gt;ViewHierarchy&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="py"&gt;hierarchy&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;ViewHierarchy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;TreeNode&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="nf"&gt;repeat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;  &lt;span class="c1"&gt;// 10 attempts&lt;/span&gt;
        &lt;span class="n"&gt;hierarchy&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;viewHierarchy&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="n"&gt;hierarchy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isVisible&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;element&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;treeNode&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nc"&gt;LOGGER&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;info&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Element is not visible yet. Waiting."&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="nc"&gt;MaestroTimer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;MaestroTimer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Reason&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;WAIT_UNTIL_VISIBLE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;// 1 second&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nc"&gt;LOGGER&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;info&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Element became visible."&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;hierarchy&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="n"&gt;hierarchy&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;10 polls × 1 second = &lt;strong&gt;10 seconds maximum&lt;/strong&gt;. Not configurable.&lt;/p&gt;




&lt;h2&gt;
  
  
  7. Platform Differences: Android vs iOS
&lt;/h2&gt;

&lt;p&gt;Maestro handles settling differently per platform:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Android&lt;/strong&gt; (&lt;a href="https://github.com/mobile-dev-inc/maestro/blob/main/maestro-client/src/main/java/maestro/drivers/AndroidDriver.kt" rel="noopener noreferrer"&gt;AndroidDriver.kt&lt;/a&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Checks if window is still updating&lt;/span&gt;
&lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;windowUpdating&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;blockingStubWithTimeout&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isWindowUpdating&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&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;strong&gt;iOS&lt;/strong&gt; (&lt;a href="https://github.com/mobile-dev-inc/maestro/blob/main/maestro-client/src/main/java/maestro/drivers/IOSDriver.kt" rel="noopener noreferrer"&gt;IOSDriver.kt&lt;/a&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Uses screenshot comparison to detect animation end&lt;/span&gt;
&lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;didFinishOnTime&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;waitUntilScreenIsStatic&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;SCREEN_SETTLE_TIMEOUT_MS&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Different approaches, different reliability characteristics, &lt;strong&gt;same lack of configurability&lt;/strong&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  Summary: The Hardcoded Reality
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Parameter&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;th&gt;Configurable?&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Element lookup timeout&lt;/td&gt;
&lt;td&gt;17,000ms&lt;/td&gt;
&lt;td&gt;❌ No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Optional element timeout&lt;/td&gt;
&lt;td&gt;7,000ms&lt;/td&gt;
&lt;td&gt;❌ No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tap retry attempts&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;❌ No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Max retry command&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;❌ No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Screenshot diff threshold&lt;/td&gt;
&lt;td&gt;0.5%&lt;/td&gt;
&lt;td&gt;❌ No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Settle polling interval&lt;/td&gt;
&lt;td&gt;200ms&lt;/td&gt;
&lt;td&gt;❌ No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Settle max iterations&lt;/td&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;❌ No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;waitUntilVisible timeout&lt;/td&gt;
&lt;td&gt;10,000ms&lt;/td&gt;
&lt;td&gt;❌ No&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  The Verdict
&lt;/h2&gt;

&lt;p&gt;Maestro's "built-in flakiness handling" is real—it does more than raw XCUITest or Espresso. But it's a &lt;strong&gt;one-size-fits-all solution with hardcoded values&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The code is clean and the approach is sound. The problem is the lack of escape hatches. When the defaults don't work for your app, you're stuck.&lt;/p&gt;

&lt;p&gt;This isn't necessarily bad—it's a trade-off for simplicity. But the marketing implies more intelligence and adaptability than the code delivers.&lt;/p&gt;




&lt;h2&gt;
  
  
  Key Source Files
&lt;/h2&gt;

&lt;p&gt;If you want to explore further:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://github.com/mobile-dev-inc/maestro/blob/main/maestro-orchestra/src/main/java/maestro/orchestra/Orchestra.kt" rel="noopener noreferrer"&gt;Orchestra.kt&lt;/a&gt; — Command execution and timeouts&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/mobile-dev-inc/maestro/blob/main/maestro-client/src/main/java/maestro/Maestro.kt" rel="noopener noreferrer"&gt;Maestro.kt&lt;/a&gt; — Tap logic and retries&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/mobile-dev-inc/maestro/blob/main/maestro-utils/src/main/kotlin/MaestroTimer.kt" rel="noopener noreferrer"&gt;MaestroTimer.kt&lt;/a&gt; — Polling primitives&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/mobile-dev-inc/maestro/blob/main/maestro-client/src/main/java/maestro/utils/ScreenshotUtils.kt" rel="noopener noreferrer"&gt;ScreenshotUtils.kt&lt;/a&gt; — Screenshot comparison&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/mobile-dev-inc/maestro/blob/main/maestro-orchestra-models/src/main/java/maestro/orchestra/Commands.kt" rel="noopener noreferrer"&gt;Commands.kt&lt;/a&gt; — Command definitions&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  We're Not Just Pointing Out Problems
&lt;/h2&gt;

&lt;p&gt;We love Maestro's YAML syntax—it's the best thing to happen to mobile test automation in years. Simple, readable, version-control friendly.&lt;/p&gt;

&lt;p&gt;But the execution engine has real limitations. Hardcoded timeouts. No configurability. Platform inconsistencies.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;So we're building something to fix it.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;An open-source engine that runs Maestro YAML tests on Appium's battle-tested infrastructure. Configurable timeouts. Real device support. No magic numbers.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Watch this space.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://devicelab.dev" class="crayons-btn crayons-btn--primary" rel="noopener noreferrer"&gt;Learn More&lt;/a&gt;
&lt;/p&gt;

</description>
      <category>testing</category>
      <category>mobile</category>
      <category>opensource</category>
      <category>automation</category>
    </item>
    <item>
      <title>BrowserStack Alternative: Your Own Devices</title>
      <dc:creator>Om Narayan</dc:creator>
      <pubDate>Thu, 25 Dec 2025 07:30:07 +0000</pubDate>
      <link>https://forem.com/omnarayan/browserstack-alternative-your-own-devices-17h3</link>
      <guid>https://forem.com/omnarayan/browserstack-alternative-your-own-devices-17h3</guid>
      <description>&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; BrowserStack is great for quick access to thousands of devices, but the costs add up fast, latency can hurt interactive testing, and some teams can't upload their app binaries to third-party servers. If you already own devices or need a private testing cloud, there's another approach: turn your own hardware into a device farm with P2P streaming. No rentals, no tunnels, no third-party uploads.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Real Cost of Cloud Device Testing
&lt;/h2&gt;

&lt;p&gt;BrowserStack has become the default choice for cross-device testing, and for good reason. Access to 20,000+ real devices without buying hardware sounds compelling. But let's look at what teams actually pay.&lt;/p&gt;

&lt;p&gt;According to Vendr's internal transaction data, the average BrowserStack contract runs about &lt;strong&gt;$32,433 per year&lt;/strong&gt;, with enterprise deals reaching into six figures. Even smaller teams face significant costs:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Plan&lt;/th&gt;
&lt;th&gt;Monthly Cost&lt;/th&gt;
&lt;th&gt;Annual Cost&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;App Automate (1 parallel)&lt;/td&gt;
&lt;td&gt;$199-$249&lt;/td&gt;
&lt;td&gt;~$2,400-$3,000&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5 parallels&lt;/td&gt;
&lt;td&gt;~$1,000&lt;/td&gt;
&lt;td&gt;~$12,000&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;10 parallels&lt;/td&gt;
&lt;td&gt;~$2,000+&lt;/td&gt;
&lt;td&gt;~$24,000+&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;For a team running CI tests on every pull request, those parallel slots fill up fast. And when they do, tests queue—or fail.&lt;/p&gt;

&lt;h3&gt;
  
  
  What Users Actually Say
&lt;/h3&gt;

&lt;p&gt;Capterra's analysis of BrowserStack reviews (Value for Money rating: 4.2/5, below the 4.5 category average) surfaces recurring themes:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Browserstack is very convenient and powerful, but the price is prohibitive for me as I use it sparingly."&lt;/p&gt;

&lt;p&gt;"It can be costly to use BrowserStack, particularly for small businesses or lone developers."&lt;/p&gt;

&lt;p&gt;"The cost can quickly escalate, especially when additional licenses or concurrent sessions are needed."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The pattern is clear: BrowserStack works well, but teams consistently cite pricing as a barrier—especially for startups, smaller QA teams, and high-frequency testing scenarios.&lt;/p&gt;




&lt;h2&gt;
  
  
  Three Problems Cloud Testing Creates
&lt;/h2&gt;

&lt;p&gt;Beyond cost, cloud-based device farms introduce structural challenges that no pricing plan can fix.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Latency You Can't Engineer Away
&lt;/h3&gt;

&lt;p&gt;When you interact with a device in BrowserStack's data center, every tap travels from your machine to their servers and back. BrowserStack's own documentation acknowledges this:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"This additional routing introduces multiple network hops, which increase page load and interaction response times."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;For automated tests, this manifests as slower execution times. For manual testing, the lag makes interactive debugging frustrating—especially for gesture-heavy apps or games requiring responsive input.&lt;/p&gt;

&lt;p&gt;The physics are simple: a device in Mumbai can't respond as quickly to a tester in Bangalore if every interaction routes through a US data center. BrowserStack has added regional hubs, but network latency remains fundamentally bound by distance.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Local Testing Requires Tunnels
&lt;/h3&gt;

&lt;p&gt;Testing your &lt;code&gt;localhost:3000&lt;/code&gt; development server on BrowserStack requires their Local binary—a tunnel that routes traffic through your machine. This works, but introduces complexity:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Port conflicts (45690/45691 commonly collide with other services)&lt;/li&gt;
&lt;li&gt;Proxy and firewall configuration (corporate networks often block tunnel connections)&lt;/li&gt;
&lt;li&gt;VPN incompatibility (many enterprise VPNs break tunnel functionality)&lt;/li&gt;
&lt;li&gt;Binary version mismatches (keeping the local binary updated across team machines)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Their support documentation includes extensive troubleshooting guides for these exact issues. The tunnel approach is a necessary workaround for cloud architecture—not a feature.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Your App Binary Lives on Their Servers
&lt;/h3&gt;

&lt;p&gt;To test your iOS or Android app, you upload the &lt;code&gt;.ipa&lt;/code&gt; or &lt;code&gt;.apk&lt;/code&gt; to BrowserStack's infrastructure. For many teams, this is fine. For others, it's a dealbreaker.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Who can't use cloud device farms:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Banking and financial apps&lt;/strong&gt; — CISOs often prohibit uploading app binaries to third-party servers&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Healthcare apps&lt;/strong&gt; — HIPAA compliance may restrict where PHI-handling code can reside&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Government contractors&lt;/strong&gt; — Security clearance requirements may mandate on-premise testing&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pre-release apps&lt;/strong&gt; — Competitive sensitivity around unreleased features&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Apps with embedded secrets&lt;/strong&gt; — API keys, certificates, or proprietary algorithms in the binary&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;BrowserStack offers SOC 2 compliance and security certifications, which satisfy many enterprise requirements. But compliance frameworks differ, and some security policies simply prohibit third-party code hosting regardless of certifications.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Alternative: Build Your Own Device Farm
&lt;/h2&gt;

&lt;p&gt;What if instead of renting device time, you used hardware you already have?&lt;/p&gt;

&lt;p&gt;Most mobile development teams accumulate devices: old test phones, devices from bug reports, the drawer of "we might need this model someday" hardware. These devices typically sit unused between manual test sessions.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;DeviceLab turns your existing Android and iOS devices into a private testing cloud.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The core idea is simple: instead of uploading your app to a data center, stream the device screen to your browser while the device stays on your desk, in your office, or at a teammate's home.&lt;/p&gt;

&lt;h3&gt;
  
  
  How It Works
&lt;/h3&gt;

&lt;p&gt;DeviceLab uses peer-to-peer WebRTC connections—the same technology powering video calls—to stream device screens with sub-50ms latency. The architecture is fundamentally different from cloud device farms:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Aspect&lt;/th&gt;
&lt;th&gt;Cloud Device Farm&lt;/th&gt;
&lt;th&gt;DeviceLab P2P&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Device location&lt;/td&gt;
&lt;td&gt;Data center (1000s of miles away)&lt;/td&gt;
&lt;td&gt;Your desk/office&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Interaction latency&lt;/td&gt;
&lt;td&gt;200-500ms+&lt;/td&gt;
&lt;td&gt;Sub-50ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;App binary&lt;/td&gt;
&lt;td&gt;Uploaded to third-party servers&lt;/td&gt;
&lt;td&gt;Stays on your device&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Localhost testing&lt;/td&gt;
&lt;td&gt;Requires tunnel binary&lt;/td&gt;
&lt;td&gt;Just works (same network)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Device availability&lt;/td&gt;
&lt;td&gt;Shared pool, may queue&lt;/td&gt;
&lt;td&gt;Dedicated to your team&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hardware cost&lt;/td&gt;
&lt;td&gt;$0 (rental model)&lt;/td&gt;
&lt;td&gt;One-time purchase&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Monthly cost&lt;/td&gt;
&lt;td&gt;Per-minute or per-parallel&lt;/td&gt;
&lt;td&gt;Per-device flat rate&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  Zero-Trust Architecture
&lt;/h3&gt;

&lt;p&gt;With DeviceLab, your app binary never leaves your network. The P2P connection streams video frames from device to browser—the actual &lt;code&gt;.ipa&lt;/code&gt; or &lt;code&gt;.apk&lt;/code&gt; stays installed locally on hardware you control.&lt;/p&gt;

&lt;p&gt;For compliance-constrained teams, this eliminates the third-party upload question entirely. Your CISO doesn't need to evaluate another vendor's security posture because your code never touches their servers.&lt;/p&gt;

&lt;h3&gt;
  
  
  No Tunnels for Localhost
&lt;/h3&gt;

&lt;p&gt;Because your device and development machine are on the same network (or connected via WebRTC), testing &lt;code&gt;localhost:3000&lt;/code&gt; requires no configuration. The device can reach your local server directly, just like a USB-connected device would.&lt;/p&gt;

&lt;p&gt;No binary to install. No ports to configure. No firewall rules to add.&lt;/p&gt;




&lt;h2&gt;
  
  
  Cost Comparison: Rental vs. Ownership
&lt;/h2&gt;

&lt;p&gt;Let's model a realistic scenario: a team needing 10-device concurrency for CI runs.&lt;/p&gt;

&lt;h3&gt;
  
  
  Cloud Rental (BrowserStack)
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;10 parallel slots on App Automate: ~$24,000/year&lt;/li&gt;
&lt;li&gt;Additional costs for Live testing, Percy, etc.&lt;/li&gt;
&lt;li&gt;Costs scale linearly with usage&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Own Devices (DeviceLab)
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;10 used/refurbished phones: ~$2,000-$4,000 one-time&lt;/li&gt;
&lt;li&gt;DeviceLab subscription: $99/device/month = $990/month = $11,880/year&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Year 1 total:&lt;/strong&gt; ~$14,000-$16,000&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Year 2+ total:&lt;/strong&gt; ~$12,000&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The math shifts further in ownership's favor as you scale:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Devices&lt;/th&gt;
&lt;th&gt;BrowserStack (Annual)&lt;/th&gt;
&lt;th&gt;DeviceLab (Annual after Y1)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;~$12,000&lt;/td&gt;
&lt;td&gt;~$6,000&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;~$24,000&lt;/td&gt;
&lt;td&gt;~$12,000&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;20&lt;/td&gt;
&lt;td&gt;~$48,000+&lt;/td&gt;
&lt;td&gt;~$24,000&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;50&lt;/td&gt;
&lt;td&gt;Custom pricing&lt;/td&gt;
&lt;td&gt;~$60,000&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;And with owned devices, you get &lt;strong&gt;unlimited test runs&lt;/strong&gt;. There's no metering, no queue, no waiting for a device to become available.&lt;/p&gt;




&lt;h2&gt;
  
  
  When Cloud Testing Still Makes Sense
&lt;/h2&gt;

&lt;p&gt;To be fair, BrowserStack and similar services solve real problems that DeviceLab doesn't address:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Choose cloud device farms when:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;You need access to hundreds of device models you don't own&lt;/li&gt;
&lt;li&gt;One-off testing on obscure devices (that specific Samsung tablet from 2019)&lt;/li&gt;
&lt;li&gt;You have no physical devices and don't want to buy any&lt;/li&gt;
&lt;li&gt;Your security policies allow third-party binary uploads&lt;/li&gt;
&lt;li&gt;You need features like network simulation across global regions&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Choose own-device testing when:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;You already have devices sitting unused&lt;/li&gt;
&lt;li&gt;You run high-frequency CI tests (cost scales better)&lt;/li&gt;
&lt;li&gt;Your security team prohibits third-party app uploads&lt;/li&gt;
&lt;li&gt;Latency matters (games, gesture-heavy apps, interactive debugging)&lt;/li&gt;
&lt;li&gt;You need guaranteed device availability (no queuing)&lt;/li&gt;
&lt;li&gt;Your team is distributed and devices are in multiple locations&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The approaches can also complement each other. Use BrowserStack for broad compatibility checks across 50 device models. Use DeviceLab for your CI pipeline's core test suite on the 5-10 devices that matter most.&lt;/p&gt;




&lt;h2&gt;
  
  
  Getting Started
&lt;/h2&gt;

&lt;p&gt;If you're exploring alternatives to cloud device testing, here's a practical path:&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Audit Your Existing Hardware
&lt;/h3&gt;

&lt;p&gt;Most teams have more devices than they realize. Check desk drawers, old test stations, and that box in the storage closet. Functional devices from the last 3-4 years are usually sufficient for testing.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Calculate Your Current Costs
&lt;/h3&gt;

&lt;p&gt;What are you actually paying for cloud device testing? Include:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Base subscription costs&lt;/li&gt;
&lt;li&gt;Overage charges from parallel limits&lt;/li&gt;
&lt;li&gt;Time spent waiting for device availability&lt;/li&gt;
&lt;li&gt;Engineering time troubleshooting tunnel issues&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  3. Run a Pilot
&lt;/h3&gt;

&lt;p&gt;Start small. Connect 2-3 devices to DeviceLab and run your existing test suite. Compare:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Execution time (latency impact)&lt;/li&gt;
&lt;li&gt;Reliability (flakiness rates)&lt;/li&gt;
&lt;li&gt;Developer experience (interactive testing feel)&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  4. Evaluate Security Fit
&lt;/h3&gt;

&lt;p&gt;If data privacy is a driver, involve your security team early. The zero-trust architecture may simplify their review compared to evaluating a cloud vendor's entire infrastructure.&lt;/p&gt;




&lt;h2&gt;
  
  
  Framework Support
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Framework&lt;/th&gt;
&lt;th&gt;Android&lt;/th&gt;
&lt;th&gt;iOS&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Appium&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Maestro&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;✅ (only platform with real device support)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Espresso&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;XCUITest&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;WebDriverIO&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Manual Testing&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Can I use DeviceLab with Appium?
&lt;/h3&gt;

&lt;p&gt;Yes. DeviceLab supports Appium, Espresso, WebDriverIO, XCUITest, and Maestro. Your existing test scripts work without modification.&lt;/p&gt;

&lt;h3&gt;
  
  
  What about Maestro?
&lt;/h3&gt;

&lt;p&gt;DeviceLab supports Maestro on both Android and iOS—including real iOS devices, which no other platform currently offers.&lt;/p&gt;

&lt;h3&gt;
  
  
  Do I need devices connected to my machine?
&lt;/h3&gt;

&lt;p&gt;No. Devices can be anywhere—connected to any machine running the DeviceLab agent. Team members can contribute devices from home offices, and you access them all through your browser.&lt;/p&gt;

&lt;h3&gt;
  
  
  What happens if a device goes offline?
&lt;/h3&gt;

&lt;p&gt;The dashboard shows device status in real-time. Devices automatically reconnect when they come back online. For CI, you can configure test distribution to skip unavailable devices.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is there a free tier?
&lt;/h3&gt;

&lt;p&gt;We offer a free trial to connect your first device and run tests. See &lt;a href="https://devicelab.dev" rel="noopener noreferrer"&gt;devicelab.dev&lt;/a&gt; for current plans.&lt;/p&gt;




&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;p&gt;BrowserStack built the market for cloud device testing, and their product works well for many teams. But the rental model has inherent tradeoffs: recurring costs that scale with usage, latency from remote devices, tunnel complexity for local testing, and binary uploads that some security policies prohibit.&lt;/p&gt;

&lt;p&gt;If you own devices—or are willing to buy them once—there's now an alternative. DeviceLab's P2P architecture delivers low-latency testing on hardware you control, with no third-party binary uploads and no tunnels for localhost.&lt;/p&gt;

&lt;p&gt;The question isn't whether cloud device testing is bad. It's whether it's the right model for &lt;em&gt;your&lt;/em&gt; team's constraints.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://devicelab.dev" class="crayons-btn crayons-btn--primary" rel="noopener noreferrer"&gt;Try DeviceLab Free&lt;/a&gt;
&lt;/p&gt;

</description>
      <category>testing</category>
      <category>mobile</category>
      <category>devops</category>
      <category>automation</category>
    </item>
    <item>
      <title>Migrate BrowserStack to DeviceLab: Appium</title>
      <dc:creator>Om Narayan</dc:creator>
      <pubDate>Thu, 25 Dec 2025 07:21:17 +0000</pubDate>
      <link>https://forem.com/omnarayan/migrate-browserstack-to-devicelab-appium-57hi</link>
      <guid>https://forem.com/omnarayan/migrate-browserstack-to-devicelab-appium-57hi</guid>
      <description>&lt;p&gt;Your Appium tests work. You've spent months building a reliable test suite. It runs against local devices without issues.&lt;/p&gt;

&lt;p&gt;Then you moved to BrowserStack.&lt;/p&gt;

&lt;p&gt;Suddenly you're dealing with &lt;code&gt;browserstack.yml&lt;/code&gt;, &lt;code&gt;bstack:options&lt;/code&gt;, app upload APIs, capability generators, and vendor-specific configurations. Your clean test code now has BrowserStack scattered throughout.&lt;/p&gt;

&lt;p&gt;Here's how to undo all of that.&lt;/p&gt;

&lt;h2&gt;
  
  
  What BrowserStack Made You Do
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Option A: The SDK Approach
&lt;/h3&gt;

&lt;p&gt;BrowserStack's "recommended" approach requires a &lt;code&gt;browserstack.yml&lt;/code&gt; file:&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="c1"&gt;# browserstack.yml&lt;/span&gt;
&lt;span class="na"&gt;userName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;YOUR_USERNAME&lt;/span&gt;
&lt;span class="na"&gt;accessKey&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;YOUR_ACCESS_KEY&lt;/span&gt;
&lt;span class="na"&gt;framework&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;testng&lt;/span&gt;
&lt;span class="na"&gt;app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;bs://j3c874f21852ba57957a3fdc33f47514288c4ba4&lt;/span&gt;

&lt;span class="na"&gt;platforms&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;platformName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;android&lt;/span&gt;
    &lt;span class="na"&gt;deviceName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Samsung Galaxy S23&lt;/span&gt;
    &lt;span class="na"&gt;platformVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;13.0'&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;platformName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;android&lt;/span&gt;
    &lt;span class="na"&gt;deviceName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Google Pixel &lt;/span&gt;&lt;span class="m"&gt;8&lt;/span&gt;
    &lt;span class="na"&gt;platformVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;14.0'&lt;/span&gt;

&lt;span class="na"&gt;parallelsPerPlatform&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;2&lt;/span&gt;
&lt;span class="na"&gt;browserstackLocal&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;

&lt;span class="na"&gt;buildName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Regression Suite&lt;/span&gt;
&lt;span class="na"&gt;projectName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;MyApp Android&lt;/span&gt;

&lt;span class="na"&gt;debug&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;span class="na"&gt;networkLogs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;span class="na"&gt;deviceLogs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;span class="na"&gt;appiumLogs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;span class="na"&gt;video&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;

&lt;span class="na"&gt;browserStackLocalOptions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;forcelocal&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
  &lt;span class="na"&gt;localIdentifier&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;randomstring&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Plus their SDK installed in your project:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="c"&gt;&amp;lt;!-- pom.xml --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;dependency&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;groupId&amp;gt;&lt;/span&gt;com.browserstack&lt;span class="nt"&gt;&amp;lt;/groupId&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;artifactId&amp;gt;&lt;/span&gt;browserstack-java-sdk&lt;span class="nt"&gt;&amp;lt;/artifactId&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;version&amp;gt;&lt;/span&gt;1.13.3&lt;span class="nt"&gt;&amp;lt;/version&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/dependency&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Plus 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="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;BROWSERSTACK_USERNAME&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"your_username"&lt;/span&gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;BROWSERSTACK_ACCESS_KEY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"your_access_key"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Option B: Direct Capabilities
&lt;/h3&gt;

&lt;p&gt;If you're not using the SDK, your test code looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;BrowserStackTest&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="nc"&gt;AndroidDriver&lt;/span&gt; &lt;span class="n"&gt;driver&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

    &lt;span class="nd"&gt;@BeforeMethod&lt;/span&gt;
    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;setUp&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="kd"&gt;throws&lt;/span&gt; &lt;span class="nc"&gt;Exception&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="nc"&gt;DesiredCapabilities&lt;/span&gt; &lt;span class="n"&gt;capabilities&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;DesiredCapabilities&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;

        &lt;span class="c1"&gt;// Standard Appium capabilities&lt;/span&gt;
        &lt;span class="n"&gt;capabilities&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setCapability&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"platformName"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"android"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="n"&gt;capabilities&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setCapability&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"appium:platformVersion"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"13.0"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="n"&gt;capabilities&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setCapability&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"appium:deviceName"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"Samsung Galaxy S23"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="n"&gt;capabilities&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setCapability&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"appium:automationName"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"UiAutomator2"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

        &lt;span class="c1"&gt;// BrowserStack-specific capabilities&lt;/span&gt;
        &lt;span class="nc"&gt;HashMap&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Object&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;browserstackOptions&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;HashMap&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&amp;gt;();&lt;/span&gt;
        &lt;span class="n"&gt;browserstackOptions&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;put&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"userName"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"YOUR_USERNAME"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="n"&gt;browserstackOptions&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;put&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"accessKey"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"YOUR_ACCESS_KEY"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="n"&gt;browserstackOptions&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;put&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"appiumVersion"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"2.4.1"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="n"&gt;browserstackOptions&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;put&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"projectName"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"MyApp"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="n"&gt;browserstackOptions&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;put&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"buildName"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"Build 1.0"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="n"&gt;browserstackOptions&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;put&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"sessionName"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"Login Tests"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="n"&gt;browserstackOptions&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;put&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"debug"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="n"&gt;browserstackOptions&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;put&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"networkLogs"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="n"&gt;browserstackOptions&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;put&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"video"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="n"&gt;capabilities&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setCapability&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"bstack:options"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;browserstackOptions&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

        &lt;span class="c1"&gt;// App uploaded to BrowserStack&lt;/span&gt;
        &lt;span class="n"&gt;capabilities&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setCapability&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"app"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"bs://j3c874f21852ba57957a3fdc33f47514288c4ba4"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

        &lt;span class="c1"&gt;// BrowserStack's hub URL&lt;/span&gt;
        &lt;span class="n"&gt;driver&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;AndroidDriver&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
            &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;URL&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"https://hub-cloud.browserstack.com/wd/hub"&lt;/span&gt;&lt;span class="o"&gt;),&lt;/span&gt;
            &lt;span class="n"&gt;capabilities&lt;/span&gt;
        &lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Before you can run tests, you also need to upload your app:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-u&lt;/span&gt; &lt;span class="s2"&gt;"YOUR_USERNAME:YOUR_ACCESS_KEY"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="s2"&gt;"https://api-cloud.browserstack.com/app-automate/upload"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-F&lt;/span&gt; &lt;span class="s2"&gt;"file=@/path/to/app.apk"&lt;/span&gt;

&lt;span class="c"&gt;# Response:&lt;/span&gt;
&lt;span class="c"&gt;# {"app_url":"bs://j3c874f21852ba57957a3fdc33f47514288c4ba4"}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then reference that &lt;code&gt;bs://&lt;/code&gt; hash in your capabilities. Every time you update your app, you need a new hash.&lt;/p&gt;




&lt;h2&gt;
  
  
  What DeviceLab Requires
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Your Test Code
&lt;/h3&gt;

&lt;p&gt;Same as your local test code. Just comment out the &lt;code&gt;app&lt;/code&gt; capability:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;DeviceLabTest&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="nc"&gt;AndroidDriver&lt;/span&gt; &lt;span class="n"&gt;driver&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

    &lt;span class="nd"&gt;@BeforeMethod&lt;/span&gt;
    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;setUp&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="kd"&gt;throws&lt;/span&gt; &lt;span class="nc"&gt;Exception&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="nc"&gt;DesiredCapabilities&lt;/span&gt; &lt;span class="n"&gt;capabilities&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;DesiredCapabilities&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;

        &lt;span class="c1"&gt;// Same capabilities as local testing&lt;/span&gt;
        &lt;span class="n"&gt;capabilities&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setCapability&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"platformName"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"Android"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="n"&gt;capabilities&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setCapability&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"appium:automationName"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"UiAutomator2"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

        &lt;span class="c1"&gt;// Comment out app capability - DeviceLab handles it via CLI&lt;/span&gt;
        &lt;span class="c1"&gt;// capabilities.setCapability("app", "./app.apk");&lt;/span&gt;

        &lt;span class="c1"&gt;// Connect to localhost - same as local&lt;/span&gt;
        &lt;span class="n"&gt;driver&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;AndroidDriver&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
            &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;URL&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"http://localhost:4723/wd/hub"&lt;/span&gt;&lt;span class="o"&gt;),&lt;/span&gt;
            &lt;span class="n"&gt;capabilities&lt;/span&gt;
        &lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;That's it.&lt;/strong&gt; Same code you use locally. Comment one line. No SDK. No &lt;code&gt;bstack:options&lt;/code&gt;. No app upload API.&lt;/p&gt;

&lt;h3&gt;
  
  
  Running Tests
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Terminal 1: Start DeviceLab test node&lt;/span&gt;
curl &lt;span class="nt"&gt;-fsSL&lt;/span&gt; https://app.devicelab.dev/test-node/KEY | sh &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--framework&lt;/span&gt; appium &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--app&lt;/span&gt; ./app.apk

&lt;span class="c"&gt;# Wait for: ✅ Appium server ready on http://localhost:4723&lt;/span&gt;

&lt;span class="c"&gt;# Terminal 2: Run your tests&lt;/span&gt;
mvn clean &lt;span class="nb"&gt;test&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;DeviceLab handles app transfer to the device, app installation, Appium server setup, and port tunneling to localhost:4723.&lt;/p&gt;

&lt;p&gt;You just run your tests against &lt;code&gt;localhost&lt;/code&gt; like you always did locally.&lt;/p&gt;




&lt;h2&gt;
  
  
  Side-by-Side Comparison
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Capabilities
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Capability&lt;/th&gt;
&lt;th&gt;BrowserStack&lt;/th&gt;
&lt;th&gt;DeviceLab&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;platformName&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;automationName&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;deviceName&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No (selected via CLI)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;platformVersion&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;app&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Yes (&lt;code&gt;bs://hash&lt;/code&gt;, upload first)&lt;/td&gt;
&lt;td&gt;No (passed via CLI)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;bstack:options&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Yes (10+ keys)&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;userName/accessKey&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No (in org key)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  Server URL
&lt;/h3&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;BrowserStack&lt;/th&gt;
&lt;th&gt;DeviceLab&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;URL&lt;/td&gt;
&lt;td&gt;&lt;code&gt;https://hub-cloud.browserstack.com/wd/hub&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;http://localhost:4723/wd/hub&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Same URL you use for local testing.&lt;/p&gt;

&lt;h3&gt;
  
  
  Files Changed
&lt;/h3&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;BrowserStack&lt;/th&gt;
&lt;th&gt;DeviceLab&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;browserstack.yml&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;pom.xml&lt;/code&gt; (SDK)&lt;/td&gt;
&lt;td&gt;Add dependency&lt;/td&gt;
&lt;td&gt;No changes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Test code&lt;/td&gt;
&lt;td&gt;Add 15+ lines of caps&lt;/td&gt;
&lt;td&gt;Remove &lt;code&gt;app&lt;/code&gt; capability&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CI/CD&lt;/td&gt;
&lt;td&gt;Configure secrets&lt;/td&gt;
&lt;td&gt;One secret (org key)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  The Migration
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Step 1: Remove BrowserStack Dependencies
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;pom.xml:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="c"&gt;&amp;lt;!-- DELETE THIS --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;dependency&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;groupId&amp;gt;&lt;/span&gt;com.browserstack&lt;span class="nt"&gt;&amp;lt;/groupId&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;artifactId&amp;gt;&lt;/span&gt;browserstack-java-sdk&lt;span class="nt"&gt;&amp;lt;/artifactId&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;version&amp;gt;&lt;/span&gt;1.13.3&lt;span class="nt"&gt;&amp;lt;/version&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/dependency&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Delete:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;browserstack.yml&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Any BrowserStack-specific configuration files&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Step 2: Clean Up Capabilities
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Before (BrowserStack):&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nc"&gt;DesiredCapabilities&lt;/span&gt; &lt;span class="n"&gt;capabilities&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;DesiredCapabilities&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
&lt;span class="n"&gt;capabilities&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setCapability&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"platformName"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"android"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="n"&gt;capabilities&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setCapability&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"appium:platformVersion"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"13.0"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="n"&gt;capabilities&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setCapability&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"appium:deviceName"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"Samsung Galaxy S23"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="n"&gt;capabilities&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setCapability&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"appium:automationName"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"UiAutomator2"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

&lt;span class="nc"&gt;HashMap&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Object&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;browserstackOptions&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;HashMap&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&amp;gt;();&lt;/span&gt;
&lt;span class="n"&gt;browserstackOptions&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;put&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"userName"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"YOUR_USERNAME"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="n"&gt;browserstackOptions&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;put&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"accessKey"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"YOUR_ACCESS_KEY"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="n"&gt;browserstackOptions&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;put&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"appiumVersion"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"2.4.1"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="n"&gt;browserstackOptions&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;put&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"projectName"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"MyApp"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="n"&gt;browserstackOptions&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;put&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"buildName"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"Build 1.0"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="n"&gt;browserstackOptions&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;put&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"sessionName"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"Login Tests"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="n"&gt;browserstackOptions&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;put&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"debug"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="n"&gt;browserstackOptions&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;put&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"networkLogs"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="n"&gt;browserstackOptions&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;put&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"video"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="n"&gt;capabilities&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setCapability&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"bstack:options"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;browserstackOptions&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

&lt;span class="n"&gt;capabilities&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setCapability&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"app"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"bs://j3c874f21852ba57957a3fdc33f47514288c4ba4"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

&lt;span class="n"&gt;driver&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;AndroidDriver&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
    &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;URL&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"https://hub-cloud.browserstack.com/wd/hub"&lt;/span&gt;&lt;span class="o"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;capabilities&lt;/span&gt;
&lt;span class="o"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;After (DeviceLab):&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nc"&gt;DesiredCapabilities&lt;/span&gt; &lt;span class="n"&gt;capabilities&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;DesiredCapabilities&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
&lt;span class="n"&gt;capabilities&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setCapability&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"platformName"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"Android"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="n"&gt;capabilities&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setCapability&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"appium:automationName"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"UiAutomator2"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

&lt;span class="n"&gt;driver&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;AndroidDriver&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
    &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;URL&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"http://localhost:4723/wd/hub"&lt;/span&gt;&lt;span class="o"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;capabilities&lt;/span&gt;
&lt;span class="o"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Lines of capability code:&lt;/strong&gt; BrowserStack: 22 lines → DeviceLab: 6 lines&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 3: Update CI/CD
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Before (GitHub Actions with BrowserStack):&lt;/strong&gt;&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;Mobile Tests&lt;/span&gt;
&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;push&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;

&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;test&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&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="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v4&lt;/span&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;Setup Java&lt;/span&gt;
        &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/setup-java@v4&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;java-version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;17'&lt;/span&gt;
          &lt;span class="na"&gt;distribution&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;temurin'&lt;/span&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;Upload App to BrowserStack&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;APP_URL=$(curl -u "${{ secrets.BROWSERSTACK_USERNAME }}:${{ secrets.BROWSERSTACK_ACCESS_KEY }}" \&lt;/span&gt;
            &lt;span class="s"&gt;-X POST "https://api-cloud.browserstack.com/app-automate/upload" \&lt;/span&gt;
            &lt;span class="s"&gt;-F "file=@app/build/outputs/apk/debug/app-debug.apk" \&lt;/span&gt;
            &lt;span class="s"&gt;| jq -r '.app_url')&lt;/span&gt;
          &lt;span class="s"&gt;echo "APP_URL=$APP_URL" &amp;gt;&amp;gt; $GITHUB_ENV&lt;/span&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 Tests&lt;/span&gt;
        &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;BROWSERSTACK_USERNAME&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.BROWSERSTACK_USERNAME }}&lt;/span&gt;
          &lt;span class="na"&gt;BROWSERSTACK_ACCESS_KEY&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.BROWSERSTACK_ACCESS_KEY }}&lt;/span&gt;
          &lt;span class="na"&gt;APP_URL&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ env.APP_URL }}&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;mvn clean test&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;After (GitHub Actions with DeviceLab):&lt;/strong&gt;&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;Mobile Tests&lt;/span&gt;
&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;push&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;

&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;test&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&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="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v4&lt;/span&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;Setup Java&lt;/span&gt;
        &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/setup-java@v4&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;java-version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;17'&lt;/span&gt;
          &lt;span class="na"&gt;distribution&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;temurin'&lt;/span&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;Start DeviceLab&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;curl -fsSL https://app.devicelab.dev/test-node/${{ secrets.DEVICELAB_ORG_KEY }} | sh -s -- \&lt;/span&gt;
            &lt;span class="s"&gt;--framework appium \&lt;/span&gt;
            &lt;span class="s"&gt;--app ./app/build/outputs/apk/debug/app-debug.apk &amp;amp;&lt;/span&gt;
          &lt;span class="s"&gt;sleep 30  # Wait for Appium server&lt;/span&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 Tests&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;mvn clean test&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Secrets needed:&lt;/strong&gt; BrowserStack: 2 → DeviceLab: 1&lt;/p&gt;




&lt;h2&gt;
  
  
  What You Gain
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. No More "Works Locally, Fails in Cloud" Debugging
&lt;/h3&gt;

&lt;p&gt;This is where teams lose the most time with BrowserStack.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The BrowserStack reality:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Your staging environment is behind a firewall. You need to open firewall rules to BrowserStack IPs (security risk), or run BrowserStack Local tunnel on every CI machine. Tunnel drops mid-test, causing random failures. DNS resolution works differently in their cloud. SSL certificates fail for internal services. Your staging server rate-limits BrowserStack IPs.&lt;/p&gt;

&lt;p&gt;Result: Teams spend &lt;strong&gt;more time debugging environment issues than actual test failures&lt;/strong&gt;. The test isn't flaky—the connection to your staging API is.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The DeviceLab reality:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Your devices sit on your network. They already have access to your staging servers, internal APIs, VPNs, and test databases. Same access as your laptop.&lt;/p&gt;

&lt;p&gt;No firewall changes. No tunnels to configure or maintain. No "works on my machine" debugging. Same network, same DNS, same access.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;If your test works locally, it works on DeviceLab.&lt;/strong&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Your Data Stays on Your Network
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;BrowserStack:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Your APK → BrowserStack servers → Their devices → Logs in their cloud
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;DeviceLab:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Your APK → Your device (via WebRTC P2P) → Results on your machine
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;DeviceLab never sees your app, your test data, or your credentials. We route the connection; we never see the content.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. No App Upload Dance
&lt;/h3&gt;

&lt;p&gt;Every time you update your app with BrowserStack:&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;# Upload new build&lt;/span&gt;
curl &lt;span class="nt"&gt;-u&lt;/span&gt; &lt;span class="s2"&gt;"user:key"&lt;/span&gt; &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="s2"&gt;".../upload"&lt;/span&gt; &lt;span class="nt"&gt;-F&lt;/span&gt; &lt;span class="s2"&gt;"file=@new-app.apk"&lt;/span&gt;
&lt;span class="c"&gt;# Get new hash: bs://abc123&lt;/span&gt;
&lt;span class="c"&gt;# Update capabilities or browserstack.yml with new hash&lt;/span&gt;
&lt;span class="c"&gt;# Or use custom_id and hope it picks the right build&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With DeviceLab:&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;# Just pass the new APK&lt;/span&gt;
curl ... &lt;span class="nt"&gt;--app&lt;/span&gt; ./new-app.apk
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No hashes. No upload API. No wondering if you're testing the right build.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Faster Feedback
&lt;/h3&gt;

&lt;p&gt;BrowserStack flow: Upload APK (30s-2min depending on size) → Wait for processing → Queue for device → Run test&lt;/p&gt;

&lt;p&gt;DeviceLab flow: APK transfers directly to your device → Run test&lt;/p&gt;

&lt;p&gt;Your devices. No queue. No shared infrastructure.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. Test on Your Actual Devices
&lt;/h3&gt;

&lt;p&gt;BrowserStack gives you devices in their data center. They're real, but they're not &lt;em&gt;your&lt;/em&gt; devices.&lt;/p&gt;

&lt;p&gt;DeviceLab lets you test on:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The exact devices your users have&lt;/li&gt;
&lt;li&gt;Devices with your company's MDM profiles&lt;/li&gt;
&lt;li&gt;Devices on your network with your backend access&lt;/li&gt;
&lt;li&gt;That weird Android 8 phone your CEO insists on using&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  6. Any Appium Version, Any Plugin
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;BrowserStack:&lt;/strong&gt; Limited to Appium versions 1.21.0 through 2.19.0. The current Appium release is 3.1.2. No custom plugins allowed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;DeviceLab:&lt;/strong&gt; You control the Appium server. Run version 3.1.2, pin to 2.x for stability, or use any version you need. Install any Appium plugins—images, gestures, or your own custom plugins.&lt;/p&gt;

&lt;p&gt;Your test infrastructure, your rules.&lt;/p&gt;




&lt;h2&gt;
  
  
  Device Selection
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;BrowserStack:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="n"&gt;capabilities&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setCapability&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"appium:deviceName"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"Samsung Galaxy S23"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="n"&gt;capabilities&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setCapability&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"appium:platformVersion"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"13.0"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;DeviceLab:&lt;/strong&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="c"&gt;# Specific device&lt;/span&gt;
curl ... &lt;span class="nt"&gt;--device-names&lt;/span&gt; &lt;span class="s2"&gt;"Samsung Galaxy S23"&lt;/span&gt;

&lt;span class="c"&gt;# Multiple devices (parallel)&lt;/span&gt;
curl ... &lt;span class="nt"&gt;--device-count&lt;/span&gt; 5

&lt;span class="c"&gt;# Any available device&lt;/span&gt;
curl ...  &lt;span class="c"&gt;# DeviceLab picks one&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Device selection moves from code to CLI. Your test code stays clean.&lt;/p&gt;




&lt;h2&gt;
  
  
  Parallel Testing
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;BrowserStack:&lt;/strong&gt;&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="c1"&gt;# browserstack.yml&lt;/span&gt;
&lt;span class="na"&gt;parallelsPerPlatform&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;5&lt;/span&gt;
&lt;span class="na"&gt;platforms&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;deviceName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Samsung Galaxy S23&lt;/span&gt;
    &lt;span class="na"&gt;platformVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;13.0'&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;deviceName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Google Pixel &lt;/span&gt;&lt;span class="m"&gt;8&lt;/span&gt;
    &lt;span class="na"&gt;platformVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;14.0'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;DeviceLab:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl ... &lt;span class="nt"&gt;--device-count&lt;/span&gt; 5
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same result. Less configuration.&lt;/p&gt;




&lt;h2&gt;
  
  
  Common Migration Issues
&lt;/h2&gt;

&lt;h3&gt;
  
  
  "I need video recordings and logs"
&lt;/h3&gt;

&lt;p&gt;DeviceLab captures test artifacts locally by default. You can optionally enable cloud storage in settings, but no logs are uploaded unless you choose to.&lt;/p&gt;

&lt;h3&gt;
  
  
  "I have tests spread across multiple files with different capabilities"
&lt;/h3&gt;

&lt;p&gt;With DeviceLab, capabilities are minimal. Just ensure all tests point to &lt;code&gt;http://localhost:4723/wd/hub&lt;/code&gt; and don't set the &lt;code&gt;app&lt;/code&gt; capability.&lt;/p&gt;

&lt;h3&gt;
  
  
  "What about iOS?"
&lt;/h3&gt;

&lt;p&gt;Same approach. DeviceLab supports iOS devices:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-fsSL&lt;/span&gt; https://app.devicelab.dev/test-node/KEY | sh &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--framework&lt;/span&gt; appium &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--platform&lt;/span&gt; ios &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--app&lt;/span&gt; ./MyApp.ipa
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Your XCUITest-based Appium tests work the same way.&lt;/p&gt;




&lt;h2&gt;
  
  
  Migration Checklist
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Step&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;1&lt;/td&gt;
&lt;td&gt;Remove &lt;code&gt;browserstack-java-sdk&lt;/code&gt; from &lt;code&gt;pom.xml&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;Delete &lt;code&gt;browserstack.yml&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;Remove all &lt;code&gt;bstack:options&lt;/code&gt; from capabilities&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;Remove &lt;code&gt;app&lt;/code&gt; capability (or leave blank)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;Change server URL to &lt;code&gt;http://localhost:4723/wd/hub&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;Update CI/CD to use DeviceLab test node&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;td&gt;Connect your devices with device node&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;Run tests&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Total time:&lt;/strong&gt; 30 minutes if your test code is well-organized.&lt;/p&gt;




&lt;h2&gt;
  
  
  Set Up Your Devices (Once)
&lt;/h2&gt;

&lt;p&gt;Before running tests, connect your devices to DeviceLab:&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;# On the machine with your devices&lt;/span&gt;
curl &lt;span class="nt"&gt;-fsSL&lt;/span&gt; https://app.devicelab.dev/device-node/KEY | sh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Devices appear in your dashboard. They're now available for testing from anywhere.&lt;/p&gt;




&lt;h2&gt;
  
  
  Summary
&lt;/h2&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;BrowserStack&lt;/th&gt;
&lt;th&gt;DeviceLab&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Setup time&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Hours (SDK, caps, yml)&lt;/td&gt;
&lt;td&gt;Minutes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Code changes&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;20+ lines of capabilities&lt;/td&gt;
&lt;td&gt;Remove 1 line&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;App handling&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Upload API → bs:// hash&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;--app&lt;/code&gt; flag&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Server URL&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Their hub URL&lt;/td&gt;
&lt;td&gt;localhost:4723&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Appium version&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;1.21.0–2.19.0 only&lt;/td&gt;
&lt;td&gt;Any (including 3.x)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Plugins&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Not allowed&lt;/td&gt;
&lt;td&gt;Any plugin&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Your data&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;On their servers&lt;/td&gt;
&lt;td&gt;Never leaves your network&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Your devices&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Theirs (shared)&lt;/td&gt;
&lt;td&gt;Yours (owned)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;p&gt;Your tests already work. Stop adapting them for someone else's platform.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://devicelab.dev" class="crayons-btn crayons-btn--primary" rel="noopener noreferrer"&gt;Try DeviceLab&lt;/a&gt;
&lt;/p&gt;

</description>
      <category>appium</category>
      <category>testing</category>
      <category>mobile</category>
      <category>automation</category>
    </item>
    <item>
      <title>Migrate BrowserStack to DeviceLab: Maestro</title>
      <dc:creator>Om Narayan</dc:creator>
      <pubDate>Thu, 25 Dec 2025 06:59:50 +0000</pubDate>
      <link>https://forem.com/omnarayan/migrate-browserstack-to-devicelab-maestro-45i2</link>
      <guid>https://forem.com/omnarayan/migrate-browserstack-to-devicelab-maestro-45i2</guid>
      <description>&lt;p&gt;Your Maestro flows work. Clean YAML, reliable tests, runs perfectly on your local device.&lt;/p&gt;

&lt;p&gt;Then you tried to run them on BrowserStack.&lt;/p&gt;

&lt;p&gt;Suddenly you're uploading apps via REST API, zipping test suites with specific folder structures, making multiple API calls, and tracking build IDs. What was &lt;code&gt;maestro test flow.yaml&lt;/code&gt; became a multi-step orchestration.&lt;/p&gt;

&lt;p&gt;Here's how to get back to simplicity.&lt;/p&gt;

&lt;h2&gt;
  
  
  What BrowserStack Made You Do
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Step 1: Upload Your App
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-u&lt;/span&gt; &lt;span class="s2"&gt;"YOUR_USERNAME:YOUR_ACCESS_KEY"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="s2"&gt;"https://api-cloud.browserstack.com/app-automate/maestro/v2/app"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-F&lt;/span&gt; &lt;span class="s2"&gt;"file=@/path/to/app.apk"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-F&lt;/span&gt; &lt;span class="s2"&gt;"custom_id=SampleApp"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Response:&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;"app_name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"app.apk"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"app_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;"bs://c8ddcb5649a8280ca800075bfd8f151115bba6b3"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"app_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"c8ddcb5649a8280ca800075bfd8f151115bba6b3"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"uploaded_at"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2025-01-05 14:52:54 UTC"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"custom_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"SampleApp"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"expiry"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2025-02-05 14:52:54 UTC"&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;Note that &lt;code&gt;app_url&lt;/code&gt;. You'll need it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2: Zip Your Test Suite (Correctly)
&lt;/h3&gt;

&lt;p&gt;BrowserStack requires a specific folder structure:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;sample_parent_folder/           ← Must have a parent folder
├── flow1.yaml                  ← Root flows run by default
├── flow2.yaml
├── common/
│   └── login.yaml              ← Won't run unless specified
└── subflows/
    └── checkout.yaml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Critical:&lt;/strong&gt; If you upload a .zip without a parent folder, tests fail.&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;# Wrong (will fail)&lt;/span&gt;
zip &lt;span class="nt"&gt;-r&lt;/span&gt; tests.zip &lt;span class="k"&gt;*&lt;/span&gt;.yaml

&lt;span class="c"&gt;# Right&lt;/span&gt;
&lt;span class="nb"&gt;mkdir &lt;/span&gt;test_suite
&lt;span class="nb"&gt;cp&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="k"&gt;*&lt;/span&gt;.yaml common/ subflows/ test_suite/
zip &lt;span class="nt"&gt;-r&lt;/span&gt; tests.zip test_suite
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then upload:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-u&lt;/span&gt; &lt;span class="s2"&gt;"YOUR_USERNAME:YOUR_ACCESS_KEY"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="s2"&gt;"https://api-cloud.browserstack.com/app-automate/maestro/v2/test-suite"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-F&lt;/span&gt; &lt;span class="s2"&gt;"file=@tests.zip"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-F&lt;/span&gt; &lt;span class="s2"&gt;"custom_id=SampleTest"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Step 3: Execute the Build
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-u&lt;/span&gt; &lt;span class="s2"&gt;"YOUR_USERNAME:YOUR_ACCESS_KEY"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="s2"&gt;"https://api-cloud.browserstack.com/app-automate/maestro/v2/android/build"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{
    "app": "bs://c8ddcb5649a8280ca800075bfd8f151115bba6b3",
    "testSuite": "bs://89c874f21852ba57957a3fdc33f47514288c4ba1",
    "project": "My_Project",
    "devices": ["Samsung Galaxy S23-13.0", "Google Pixel 8-14.0"],
    "networkLogs": "true",
    "deviceLogs": "true"
  }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Step 4: Check Results
&lt;/h3&gt;

&lt;p&gt;Poll the API or check the dashboard.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Total API calls:&lt;/strong&gt; 3 minimum (upload app, upload tests, execute)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Files modified:&lt;/strong&gt; Your YAML? None. Everything else around it? Everything.&lt;/p&gt;




&lt;h2&gt;
  
  
  What DeviceLab Requires
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Your Flows
&lt;/h3&gt;

&lt;p&gt;Same YAML. No changes.&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="c1"&gt;# login_flow.yaml&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.myapp&lt;/span&gt;
&lt;span class="nn"&gt;---&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;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;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;Email"&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;test@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;Password"&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;password123"&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;Login"&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;Welcome"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Running Tests
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-fsSL&lt;/span&gt; https://app.devicelab.dev/test-node/KEY | sh &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--framework&lt;/span&gt; maestro &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--app&lt;/span&gt; ./app.apk &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--tests&lt;/span&gt; ./flows/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;That's it.&lt;/strong&gt; One command. Same YAML files. No uploads. No zipping. No build IDs.&lt;/p&gt;




&lt;h2&gt;
  
  
  Side-by-Side Comparison
&lt;/h2&gt;

&lt;h3&gt;
  
  
  The Workflow
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Step&lt;/th&gt;
&lt;th&gt;BrowserStack&lt;/th&gt;
&lt;th&gt;DeviceLab&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Upload app&lt;/td&gt;
&lt;td&gt;REST API call → get bs:// URL&lt;/td&gt;
&lt;td&gt;Included in command&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Upload tests&lt;/td&gt;
&lt;td&gt;Zip correctly → REST API call&lt;/td&gt;
&lt;td&gt;Included in command&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Execute&lt;/td&gt;
&lt;td&gt;REST API call with both URLs&lt;/td&gt;
&lt;td&gt;Same command&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Track results&lt;/td&gt;
&lt;td&gt;Poll API / dashboard&lt;/td&gt;
&lt;td&gt;CLI output&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Total API calls&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;3&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  Your Files
&lt;/h3&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;BrowserStack&lt;/th&gt;
&lt;th&gt;DeviceLab&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;YAML flows&lt;/td&gt;
&lt;td&gt;No changes&lt;/td&gt;
&lt;td&gt;No changes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Folder structure&lt;/td&gt;
&lt;td&gt;Must have parent folder&lt;/td&gt;
&lt;td&gt;Use as-is&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Zip file&lt;/td&gt;
&lt;td&gt;Required&lt;/td&gt;
&lt;td&gt;Not needed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Build scripts&lt;/td&gt;
&lt;td&gt;Multi-step orchestration&lt;/td&gt;
&lt;td&gt;One command&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  The Migration
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Step 1: Remove BrowserStack Scripts
&lt;/h3&gt;

&lt;p&gt;If you have CI/CD scripts like this:&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;#!/bin/bash&lt;/span&gt;
&lt;span class="c"&gt;# upload_and_run.sh&lt;/span&gt;

&lt;span class="c"&gt;# Upload app&lt;/span&gt;
&lt;span class="nv"&gt;APP_URL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;curl &lt;span class="nt"&gt;-u&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$BS_USER&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="nv"&gt;$BS_KEY&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="s2"&gt;"https://api-cloud.browserstack.com/app-automate/maestro/v2/app"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-F&lt;/span&gt; &lt;span class="s2"&gt;"file=@&lt;/span&gt;&lt;span class="nv"&gt;$APK_PATH&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'.app_url'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;

&lt;span class="c"&gt;# Zip tests&lt;/span&gt;
&lt;span class="nb"&gt;mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; test_suite
&lt;span class="nb"&gt;cp&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; flows/&lt;span class="k"&gt;*&lt;/span&gt; test_suite/
zip &lt;span class="nt"&gt;-r&lt;/span&gt; tests.zip test_suite

&lt;span class="c"&gt;# Upload tests&lt;/span&gt;
&lt;span class="nv"&gt;TEST_URL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;curl &lt;span class="nt"&gt;-u&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$BS_USER&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="nv"&gt;$BS_KEY&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="s2"&gt;"https://api-cloud.browserstack.com/app-automate/maestro/v2/test-suite"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-F&lt;/span&gt; &lt;span class="s2"&gt;"file=@tests.zip"&lt;/span&gt; | jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'.test_suite_url'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;

&lt;span class="c"&gt;# Execute&lt;/span&gt;
curl &lt;span class="nt"&gt;-u&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$BS_USER&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="nv"&gt;$BS_KEY&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="s2"&gt;"https://api-cloud.browserstack.com/app-automate/maestro/v2/android/build"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"{
    &lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;app&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;: &lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="nv"&gt;$APP_URL&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;,
    &lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;testSuite&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;: &lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="nv"&gt;$TEST_URL&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;,
    &lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;devices&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;: [&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;Samsung Galaxy S23-13.0&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;]
  }"&lt;/span&gt;

&lt;span class="c"&gt;# Cleanup&lt;/span&gt;
&lt;span class="nb"&gt;rm&lt;/span&gt; &lt;span class="nt"&gt;-rf&lt;/span&gt; test_suite tests.zip
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Delete it.&lt;/strong&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2: Replace With One Command
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-fsSL&lt;/span&gt; https://app.devicelab.dev/test-node/KEY | sh &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--framework&lt;/span&gt; maestro &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--app&lt;/span&gt; ./app.apk &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--tests&lt;/span&gt; ./flows/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Step 3: Update CI/CD
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Before (GitHub Actions with BrowserStack):&lt;/strong&gt;&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;Maestro Tests&lt;/span&gt;
&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;push&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;

&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;test&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&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="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v4&lt;/span&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;Upload App to BrowserStack&lt;/span&gt;
        &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;upload-app&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;RESPONSE=$(curl -u "${{ secrets.BS_USER }}:${{ secrets.BS_KEY }}" \&lt;/span&gt;
            &lt;span class="s"&gt;-X POST "https://api-cloud.browserstack.com/app-automate/maestro/v2/app" \&lt;/span&gt;
            &lt;span class="s"&gt;-F "file=@app.apk")&lt;/span&gt;
          &lt;span class="s"&gt;APP_URL=$(echo $RESPONSE | jq -r '.app_url')&lt;/span&gt;
          &lt;span class="s"&gt;echo "app_url=$APP_URL" &amp;gt;&amp;gt; $GITHUB_OUTPUT&lt;/span&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;Prepare Test Suite&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;mkdir -p test_suite&lt;/span&gt;
          &lt;span class="s"&gt;cp -r flows/* test_suite/&lt;/span&gt;
          &lt;span class="s"&gt;zip -r tests.zip test_suite&lt;/span&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;Upload Tests to BrowserStack&lt;/span&gt;
        &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;upload-tests&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;RESPONSE=$(curl -u "${{ secrets.BS_USER }}:${{ secrets.BS_KEY }}" \&lt;/span&gt;
            &lt;span class="s"&gt;-X POST "https://api-cloud.browserstack.com/app-automate/maestro/v2/test-suite" \&lt;/span&gt;
            &lt;span class="s"&gt;-F "file=@tests.zip")&lt;/span&gt;
          &lt;span class="s"&gt;TEST_URL=$(echo $RESPONSE | jq -r '.test_suite_url')&lt;/span&gt;
          &lt;span class="s"&gt;echo "test_url=$TEST_URL" &amp;gt;&amp;gt; $GITHUB_OUTPUT&lt;/span&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 Tests&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;curl -u "${{ secrets.BS_USER }}:${{ secrets.BS_KEY }}" \&lt;/span&gt;
            &lt;span class="s"&gt;-X POST "https://api-cloud.browserstack.com/app-automate/maestro/v2/android/build" \&lt;/span&gt;
            &lt;span class="s"&gt;-H "Content-Type: application/json" \&lt;/span&gt;
            &lt;span class="s"&gt;-d '{&lt;/span&gt;
              &lt;span class="s"&gt;"app": "${{ steps.upload-app.outputs.app_url }}",&lt;/span&gt;
              &lt;span class="s"&gt;"testSuite": "${{ steps.upload-tests.outputs.test_url }}",&lt;/span&gt;
              &lt;span class="s"&gt;"devices": ["Samsung Galaxy S23-13.0"]&lt;/span&gt;
            &lt;span class="s"&gt;}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;After (GitHub Actions with DeviceLab):&lt;/strong&gt;&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;Maestro Tests&lt;/span&gt;
&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;push&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;

&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;test&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&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="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v4&lt;/span&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 Tests&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;curl -fsSL https://app.devicelab.dev/test-node/${{ secrets.DEVICELAB_ORG_KEY }} | sh -s -- \&lt;/span&gt;
            &lt;span class="s"&gt;--framework maestro \&lt;/span&gt;
            &lt;span class="s"&gt;--app ./app.apk \&lt;/span&gt;
            &lt;span class="s"&gt;--tests ./flows/&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Lines of YAML:&lt;/strong&gt; BrowserStack: 45 lines → DeviceLab: 15 lines&lt;/p&gt;




&lt;h2&gt;
  
  
  What You Gain
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. No More "Works Locally, Fails in Cloud" Debugging
&lt;/h3&gt;

&lt;p&gt;This is where teams lose the most time with cloud testing services.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The BrowserStack reality:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Your staging environment is behind a firewall&lt;/li&gt;
&lt;li&gt;You need to open firewall rules to BrowserStack IPs (security risk)&lt;/li&gt;
&lt;li&gt;Or configure BrowserStack Local tunnel&lt;/li&gt;
&lt;li&gt;Tunnel drops mid-test, causing random failures&lt;/li&gt;
&lt;li&gt;DNS resolution works differently in their cloud&lt;/li&gt;
&lt;li&gt;SSL certificates fail for internal services&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Result: Teams spend &lt;strong&gt;more time debugging environment issues than actual test failures&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The DeviceLab reality:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Your devices sit on your network. They already have access to your staging servers, internal APIs, VPNs, and test databases. Same access as your laptop.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;No firewall changes&lt;/li&gt;
&lt;li&gt;No tunnels to configure or maintain&lt;/li&gt;
&lt;li&gt;No "works on my machine" debugging&lt;/li&gt;
&lt;li&gt;Same network, same DNS, same access&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;If your test works locally, it works on DeviceLab.&lt;/strong&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  2. No Upload Dance
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;BrowserStack:&lt;/strong&gt; Every build requires uploading app → uploading tests → executing. App URLs expire in 30 days.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;DeviceLab:&lt;/strong&gt; Pass the file path. Done.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. No Zip Structure Anxiety
&lt;/h3&gt;

&lt;p&gt;BrowserStack's docs warn:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Incorrect folder structure may cause Maestro to fail in locating your flow files, resulting in test execution failures."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;DeviceLab: Point to your flows directory. It works.&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;# Your actual folder structure&lt;/span&gt;
flows/
├── login.yaml
├── checkout.yaml
└── profile/
    └── update.yaml

&lt;span class="c"&gt;# DeviceLab command&lt;/span&gt;
curl ... &lt;span class="nt"&gt;--tests&lt;/span&gt; ./flows/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  4. iOS Physical Device Support
&lt;/h3&gt;

&lt;p&gt;Maestro officially supports:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;✅ Android devices&lt;/li&gt;
&lt;li&gt;✅ Android emulators&lt;/li&gt;
&lt;li&gt;✅ iOS simulators&lt;/li&gt;
&lt;li&gt;❌ iOS physical devices&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;BrowserStack:&lt;/strong&gt; Uses their own closed-source fork of Maestro. Last checked, it was based on version 1.39—the current open-source version is 2.0.10.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;DeviceLab:&lt;/strong&gt; You choose your Maestro version. Plus, DeviceLab runs Maestro on your &lt;strong&gt;physical iOS devices&lt;/strong&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-fsSL&lt;/span&gt; https://app.devicelab.dev/test-node/KEY | sh &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--framework&lt;/span&gt; maestro &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--platform&lt;/span&gt; ios &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--app&lt;/span&gt; ./MyApp.ipa &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--tests&lt;/span&gt; ./flows/ &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--device-names&lt;/span&gt; &lt;span class="s2"&gt;"John's iPhone 15"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  5. Your Data Never Leaves
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;BrowserStack flow:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Your APK → Their servers (uploaded)
Your YAML → Their servers (uploaded)
Test execution → Their devices
Results → Their dashboard
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;DeviceLab flow:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Your APK → Your device (P2P transfer)
Your YAML → Your device (P2P transfer)
Test execution → Your device
Results → Your machine
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;DeviceLab never sees your app, your flows, or your test results.&lt;/p&gt;

&lt;h3&gt;
  
  
  6. Faster Iteration
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;BrowserStack:&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Make a change to flow.yaml&lt;/li&gt;
&lt;li&gt;Re-zip the test suite&lt;/li&gt;
&lt;li&gt;Upload new test suite&lt;/li&gt;
&lt;li&gt;Execute build&lt;/li&gt;
&lt;li&gt;Wait for results&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;DeviceLab:&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Make a change to flow.yaml&lt;/li&gt;
&lt;li&gt;Run command&lt;/li&gt;
&lt;li&gt;See results&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;No re-uploading. No waiting for processing. Your changes run immediately.&lt;/p&gt;




&lt;h2&gt;
  
  
  Device Selection
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;BrowserStack:&lt;/strong&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;"devices"&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;"Samsung Galaxy S23-13.0"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Google Pixel 8-14.0"&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;&lt;strong&gt;DeviceLab:&lt;/strong&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="c"&gt;# Specific device&lt;/span&gt;
curl ... &lt;span class="nt"&gt;--device-names&lt;/span&gt; &lt;span class="s2"&gt;"Samsung Galaxy S23"&lt;/span&gt;

&lt;span class="c"&gt;# Multiple devices (parallel)&lt;/span&gt;
curl ... &lt;span class="nt"&gt;--device-count&lt;/span&gt; 3

&lt;span class="c"&gt;# Specific OS version&lt;/span&gt;
curl ... &lt;span class="nt"&gt;--os-version&lt;/span&gt; &lt;span class="s2"&gt;"14"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Device selection is a CLI flag. Your test code stays clean.&lt;/p&gt;




&lt;h2&gt;
  
  
  Common Migration Issues
&lt;/h2&gt;

&lt;h3&gt;
  
  
  "I use execute parameter to run specific flows"
&lt;/h3&gt;

&lt;p&gt;BrowserStack:&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;"execute"&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;"path/to/flow1.yaml"&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/flow2.yaml"&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;DeviceLab:&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;# Run specific flows&lt;/span&gt;
curl ... &lt;span class="nt"&gt;--tests&lt;/span&gt; ./flows/flow1.yaml

&lt;span class="c"&gt;# Run a directory&lt;/span&gt;
curl ... &lt;span class="nt"&gt;--tests&lt;/span&gt; ./flows/smoke/

&lt;span class="c"&gt;# Run multiple&lt;/span&gt;
curl ... &lt;span class="nt"&gt;--tests&lt;/span&gt; ./flows/login.yaml &lt;span class="nt"&gt;--tests&lt;/span&gt; ./flows/checkout.yaml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  "My flows use environment variables"
&lt;/h3&gt;

&lt;p&gt;Both support them:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;BrowserStack (via config.yaml):&lt;/strong&gt;&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;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;USERNAME&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;user@example.com&lt;/span&gt;
  &lt;span class="na"&gt;API_KEY&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;12345&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;DeviceLab:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl ... &lt;span class="nt"&gt;--env&lt;/span&gt; &lt;span class="nv"&gt;USERNAME&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;user@example.com &lt;span class="nt"&gt;--env&lt;/span&gt; &lt;span class="nv"&gt;API_KEY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;12345

&lt;span class="c"&gt;# Or use a .env file&lt;/span&gt;
curl ... &lt;span class="nt"&gt;--env-file&lt;/span&gt; .env
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  "What if a flow fails?"
&lt;/h3&gt;

&lt;p&gt;DeviceLab shows real-time output in your terminal:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;✅ login.yaml - PASSED (12.3s)
❌ checkout.yaml - FAILED (8.1s)
   └── assertVisible "Order Confirmed" failed
✅ profile.yaml - PASSED (6.2s)

Results: 2/3 passed
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Migration Checklist
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Step&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;1&lt;/td&gt;
&lt;td&gt;Delete BrowserStack upload scripts&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;Remove zip/folder structure handling&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;Update CI/CD to single DeviceLab command&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;Connect your devices with device node&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;Run &lt;code&gt;curl ... --framework maestro&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Total time:&lt;/strong&gt; 15 minutes&lt;/p&gt;




&lt;h2&gt;
  
  
  Set Up Your Devices (Once)
&lt;/h2&gt;

&lt;p&gt;Before running tests, connect your devices to DeviceLab:&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;# On the machine with your devices&lt;/span&gt;
curl &lt;span class="nt"&gt;-fsSL&lt;/span&gt; https://app.devicelab.dev/device-node/KEY | sh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For iOS physical devices:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-fsSL&lt;/span&gt; https://app.devicelab.dev/device-node/KEY | sh &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--apple-team-id&lt;/span&gt; YOUR_TEAM_ID
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Summary
&lt;/h2&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;BrowserStack&lt;/th&gt;
&lt;th&gt;DeviceLab&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;API calls&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;3 (upload app, upload tests, execute)&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Zip handling&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Required, specific structure&lt;/td&gt;
&lt;td&gt;Not needed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;App reference&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;bs://&lt;/code&gt; hash (expires)&lt;/td&gt;
&lt;td&gt;Local path&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;YAML changes&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Maestro version&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Closed fork (1.39)&lt;/td&gt;
&lt;td&gt;You choose (latest 2.x)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;iOS physical&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Your data&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;On their servers&lt;/td&gt;
&lt;td&gt;Never leaves your network&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  Local to DeviceLab: Zero Changes
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Local Maestro:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;maestro &lt;span class="nb"&gt;test &lt;/span&gt;flow.yaml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;DeviceLab Maestro:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-fsSL&lt;/span&gt; https://app.devicelab.dev/test-node/KEY | sh &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--framework&lt;/span&gt; maestro &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--app&lt;/span&gt; ./app.apk &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--tests&lt;/span&gt; ./flows/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same YAML. Different device. Your network.&lt;/p&gt;




&lt;p&gt;Your Maestro flows already work. Stop orchestrating around someone else's infrastructure.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://devicelab.dev" class="crayons-btn crayons-btn--primary" rel="noopener noreferrer"&gt;Try DeviceLab&lt;/a&gt;
&lt;/p&gt;

</description>
      <category>maestro</category>
      <category>testing</category>
      <category>mobile</category>
      <category>devops</category>
    </item>
    <item>
      <title>Getting Started with DeviceLab (5-Min Setup)</title>
      <dc:creator>Om Narayan</dc:creator>
      <pubDate>Thu, 25 Dec 2025 06:54:58 +0000</pubDate>
      <link>https://forem.com/omnarayan/getting-started-with-devicelab-5-min-setup-3nmp</link>
      <guid>https://forem.com/omnarayan/getting-started-with-devicelab-5-min-setup-3nmp</guid>
      <description>&lt;p&gt;Running automated tests on real devices shouldn't require complex infrastructure. With DeviceLab, you connect your own devices and start testing in minutes—no SDKs to install, no agents to configure. Just two curl commands.&lt;/p&gt;

&lt;h2&gt;
  
  
  How It Works
&lt;/h2&gt;

&lt;p&gt;DeviceLab has two concepts:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Device Node&lt;/strong&gt; — Makes your physical devices available for testing&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Test Node&lt;/strong&gt; — Runs your tests on those devices&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Both run via curl commands. No installation required.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: Start a Device Node
&lt;/h2&gt;

&lt;p&gt;Connect your Android or iOS device via USB, then run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-fsSL&lt;/span&gt; https://app.devicelab.dev/device-node/KEY | sh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Replace &lt;code&gt;KEY&lt;/code&gt; with your team key from the &lt;a href="https://app.devicelab.dev" rel="noopener noreferrer"&gt;DeviceLab dashboard&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;This automatically detects your connected devices (physical, emulators, simulators) and keeps running until you stop it with Ctrl+C.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: Run Tests
&lt;/h2&gt;

&lt;p&gt;Download a sample test and run 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;# Download sample&lt;/span&gt;
curl &lt;span class="nt"&gt;-O&lt;/span&gt; https://app.devicelab.dev/samples/maestro-android-sample.zip
unzip maestro-android-sample.zip &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;cd &lt;/span&gt;maestro-android-sample

&lt;span class="c"&gt;# Run tests&lt;/span&gt;
curl &lt;span class="nt"&gt;-fsSL&lt;/span&gt; https://app.devicelab.dev/test-node/KEY | sh &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--framework&lt;/span&gt; maestro &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--platform&lt;/span&gt; android &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--app&lt;/span&gt; ./TestHiveApp.apk &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--tests&lt;/span&gt; ./maestro-tests
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's it. Your tests run on your connected device—the app binary transfers directly via WebRTC, never touching DeviceLab's servers.&lt;/p&gt;

&lt;h2&gt;
  
  
  Other Frameworks
&lt;/h2&gt;

&lt;p&gt;DeviceLab supports multiple test frameworks:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Maestro&lt;/strong&gt; — Shown above&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Appium (Java)&lt;/strong&gt; — &lt;a href="https://docs.devicelab.dev/quick-start#appium-java" rel="noopener noreferrer"&gt;See quick start guide&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Appium (Python)&lt;/strong&gt; — &lt;a href="https://docs.devicelab.dev/quick-start#appium-python" rel="noopener noreferrer"&gt;See quick start guide&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;What devices does DeviceLab support?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;DeviceLab supports Android phones and tablets, iPhones and iPads, Android emulators, and iOS simulators. Physical iOS devices require a Mac with Xcode for initial setup.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Do I need to install anything on my mobile device?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;No app installation is needed on the device. You just enable Developer Mode/USB Debugging and connect via USB. DeviceLab agent runs on the host machine.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can I run tests from a different machine than where devices are connected?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Yes. Once devices are connected to a machine running the DeviceLab agent, they can be accessed from any machine through the DeviceLab dashboard.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's Next?
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://docs.devicelab.dev/device-nodes" rel="noopener noreferrer"&gt;Add devices from multiple machines&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.devicelab.dev/test-runs" rel="noopener noreferrer"&gt;Run your own test suite&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.devicelab.dev/ci-cd" rel="noopener noreferrer"&gt;Set up CI/CD integration&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.devicelab.dev/teams" rel="noopener noreferrer"&gt;Invite your team&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;Your first device is free. &lt;a href="https://devicelab.dev" rel="noopener noreferrer"&gt;Get started with DeviceLab&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>mobile</category>
      <category>testing</category>
      <category>automation</category>
      <category>android</category>
    </item>
    <item>
      <title>Maestro on Real iOS Devices: Working Guide</title>
      <dc:creator>Om Narayan</dc:creator>
      <pubDate>Mon, 22 Dec 2025 05:48:24 +0000</pubDate>
      <link>https://forem.com/omnarayan/maestro-on-real-ios-devices-working-guide-5dfk</link>
      <guid>https://forem.com/omnarayan/maestro-on-real-ios-devices-working-guide-5dfk</guid>
      <description>&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;: Maestro doesn't officially support physical iOS devices. We built &lt;a href="https://github.com/devicelab-dev/maestro-ios-device" rel="noopener noreferrer"&gt;maestro-ios-device&lt;/a&gt; to fix that. Your existing YAML flows work unchanged on real iPhones.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Problem Everyone Hits
&lt;/h2&gt;

&lt;p&gt;You wrote your Maestro tests. They pass on the iOS Simulator. Now you want to run them on a real iPhone.&lt;/p&gt;

&lt;p&gt;You discover: &lt;strong&gt;Maestro doesn't support physical iOS devices.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;This has been the &lt;a href="https://github.com/mobile-dev-inc/maestro/issues/686" rel="noopener noreferrer"&gt;most requested feature&lt;/a&gt; since January 2023. Three years of GitHub issues. The Maestro team says official support won't land until sometime next year.&lt;/p&gt;

&lt;p&gt;If you're here, you probably don't want to wait.&lt;/p&gt;




&lt;h2&gt;
  
  
  Does Maestro Support Real iOS Devices?
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Not officially.&lt;/strong&gt; As of December 2025:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Platform&lt;/th&gt;
&lt;th&gt;Simulators&lt;/th&gt;
&lt;th&gt;Real Devices&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Android&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;iOS&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Cloud providers like BrowserStack offer workarounds by running Maestro on their infrastructure. But if you want to test on &lt;strong&gt;your own devices&lt;/strong&gt; — the ones sitting on your desk — you're stuck.&lt;/p&gt;

&lt;p&gt;Until now.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Solution: maestro-ios-device
&lt;/h2&gt;

&lt;p&gt;We built a bridge that connects Maestro to physical iOS devices.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What it does:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Runs your existing Maestro YAML flows on real iPhones&lt;/li&gt;
&lt;li&gt;No changes to your test files&lt;/li&gt;
&lt;li&gt;Supports parallel testing on multiple devices&lt;/li&gt;
&lt;li&gt;Works with iOS 16.x through 18.x&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;What it doesn't do:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Require a cloud subscription&lt;/li&gt;
&lt;li&gt;Send your app or test data anywhere&lt;/li&gt;
&lt;li&gt;Change how you write Maestro tests&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Quick Start
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;1. Install the tool:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-fsSL&lt;/span&gt; https://raw.githubusercontent.com/devicelab-dev/maestro-ios-device/main/setup.sh | bash
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;2. Start the device bridge:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;maestro-ios-device &lt;span class="nt"&gt;--team-id&lt;/span&gt; YOUR_TEAM_ID &lt;span class="nt"&gt;--device&lt;/span&gt; DEVICE_UDID
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;3. Run your tests:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;maestro &lt;span class="nt"&gt;--driver-host-port&lt;/span&gt; 6001 &lt;span class="nt"&gt;--device&lt;/span&gt; DEVICE_UDID &lt;span class="nt"&gt;--app-file&lt;/span&gt; /path/to/app.ipa &lt;span class="nb"&gt;test &lt;/span&gt;flow.yaml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Your existing flows work unchanged:&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;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="nn"&gt;---&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;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;Login"&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;Submit"&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;Welcome"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Parallel Testing on Multiple iPhones
&lt;/h2&gt;

&lt;p&gt;Here's something that wasn't possible before: running Maestro tests on multiple real iOS devices simultaneously.&lt;/p&gt;

&lt;p&gt;The original Maestro codebase had hardcoded port limitations. We removed them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Terminal 1:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;maestro-ios-device &lt;span class="nt"&gt;--team-id&lt;/span&gt; ABC123 &lt;span class="nt"&gt;--device&lt;/span&gt; IPHONE_12_UDID &lt;span class="nt"&gt;--driver-host-port&lt;/span&gt; 6001
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Terminal 2:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;maestro-ios-device &lt;span class="nt"&gt;--team-id&lt;/span&gt; ABC123 &lt;span class="nt"&gt;--device&lt;/span&gt; IPHONE_14_UDID &lt;span class="nt"&gt;--driver-host-port&lt;/span&gt; 6002
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now run your test suite split across devices. Cut your CI time in half (or more).&lt;/p&gt;




&lt;h2&gt;
  
  
  How It Works
&lt;/h2&gt;

&lt;p&gt;Maestro's iOS driver was built for simulators. We extended it to recognize and communicate with physical devices.&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.amazonaws.com%2Fuploads%2Farticles%2Fo8mqftmfu94qgaenqsq9.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.amazonaws.com%2Fuploads%2Farticles%2Fo8mqftmfu94qgaenqsq9.png" alt="Maestro iOS Architecture" width="800" height="436"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Four things happen under the hood:&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Device Detection&lt;/strong&gt; — Recognizes physical devices with Developer Mode enabled&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;XCTest Runner&lt;/strong&gt; — Builds and deploys the test driver to actual hardware (requires Apple Developer certificate)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Port Forwarding&lt;/strong&gt; — Bridges localhost to the device's HTTP server&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Session Management&lt;/strong&gt; — Handles real-device constraints gracefully (e.g., &lt;code&gt;clearState&lt;/code&gt; via app reinstall)&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  What Works, What Doesn't
&lt;/h2&gt;

&lt;p&gt;We're transparent about limitations. Some are ours, most are Apple's:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Command&lt;/th&gt;
&lt;th&gt;Status&lt;/th&gt;
&lt;th&gt;Notes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;launchApp&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;tapOn&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;inputText&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;assertVisible&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;clearState&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;Reinstalls app (requires &lt;code&gt;--app-file&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;setLocation&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;⚠️&lt;/td&gt;
&lt;td&gt;Requires additional setup&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;addMedia&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;iOS platform restriction&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  Why We Built This
&lt;/h2&gt;

&lt;p&gt;We run &lt;a href="https://devicelab.dev" rel="noopener noreferrer"&gt;DeviceLab&lt;/a&gt; — a platform that lets teams test on their own physical devices. Our customers kept asking for Maestro support.&lt;/p&gt;

&lt;p&gt;We looked at the Maestro codebase. The foundation for iOS device support existed — infrastructure PRs had been merged, but the last mile was missing.&lt;/p&gt;

&lt;p&gt;So we finished it.&lt;/p&gt;

&lt;p&gt;We submitted &lt;a href="https://github.com/mobile-dev-inc/Maestro/pull/2856" rel="noopener noreferrer"&gt;PR #2856&lt;/a&gt; to give it back to the community. But PRs take time to merge, and teams need solutions now.&lt;/p&gt;

&lt;p&gt;That's why we released &lt;code&gt;maestro-ios-device&lt;/code&gt; as a standalone tool.&lt;/p&gt;




&lt;h2&gt;
  
  
  Compatibility
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Maestro Version&lt;/th&gt;
&lt;th&gt;Status&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;2.0.10&lt;/td&gt;
&lt;td&gt;✅ Tested&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2.0.9&lt;/td&gt;
&lt;td&gt;✅ Tested&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Other versions&lt;/td&gt;
&lt;td&gt;Not tested&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Requirements:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;macOS (Xcode required for code signing)&lt;/li&gt;
&lt;li&gt;Apple Developer account (free or paid)&lt;/li&gt;
&lt;li&gt;iOS device with Developer Mode enabled&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Is this official Maestro?
&lt;/h3&gt;

&lt;p&gt;No. This is an unofficial community tool. Not supported by mobile.dev or the Maestro team. When official iOS device support ships, we recommend switching to it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Will my existing tests work?
&lt;/h3&gt;

&lt;p&gt;Yes. If your YAML flows run on the iOS Simulator, they'll run on real devices with no changes.&lt;/p&gt;

&lt;h3&gt;
  
  
  Do I need to pay for anything?
&lt;/h3&gt;

&lt;p&gt;The tool is free. You need an Apple Developer account (the free tier works for personal devices).&lt;/p&gt;

&lt;h3&gt;
  
  
  How is this different from BrowserStack/LambdaTest?
&lt;/h3&gt;

&lt;p&gt;Those services run Maestro on &lt;em&gt;their&lt;/em&gt; devices in &lt;em&gt;their&lt;/em&gt; cloud. This runs on &lt;em&gt;your&lt;/em&gt; devices in &lt;em&gt;your&lt;/em&gt; office. No data leaves your network.&lt;/p&gt;




&lt;h2&gt;
  
  
  Links
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Tool&lt;/strong&gt;: &lt;a href="https://github.com/devicelab-dev/maestro-ios-device" rel="noopener noreferrer"&gt;github.com/devicelab-dev/maestro-ios-device&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;PR&lt;/strong&gt;: &lt;a href="https://github.com/mobile-dev-inc/Maestro/pull/2856" rel="noopener noreferrer"&gt;github.com/mobile-dev-inc/Maestro/pull/2856&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Original Issue&lt;/strong&gt;: &lt;a href="https://github.com/mobile-dev-inc/maestro/issues/686" rel="noopener noreferrer"&gt;github.com/mobile-dev-inc/maestro/issues/686&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;Built by &lt;a href="https://devicelab.dev" rel="noopener noreferrer"&gt;DeviceLab&lt;/a&gt; — turn your devices into a testing lab without renting someone else's.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ios</category>
      <category>testing</category>
      <category>mobile</category>
      <category>automation</category>
    </item>
    <item>
      <title>OpenSTF is Dead: The Best Alternative for Mobile Device Labs in 2025</title>
      <dc:creator>Om Narayan</dc:creator>
      <pubDate>Tue, 16 Dec 2025 09:25:45 +0000</pubDate>
      <link>https://forem.com/omnarayan/openstf-is-dead-the-best-alternative-for-mobile-device-labs-in-2025-2j5o</link>
      <guid>https://forem.com/omnarayan/openstf-is-dead-the-best-alternative-for-mobile-device-labs-in-2025-2j5o</guid>
      <description>&lt;p&gt;If you've been running OpenSTF (Open Smartphone Test Farm) for your mobile testing infrastructure, you've probably hit a wall. Your Android 14 and 15 devices aren't working. Updates stopped years ago. And you're stuck wondering: what now?&lt;/p&gt;

&lt;p&gt;You're not alone. Thousands of QA teams and developers built their device labs on OpenSTF, and now they're scrambling for alternatives that actually work with modern devices.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Happened to OpenSTF?
&lt;/h2&gt;

&lt;p&gt;OpenSTF was originally developed at CyberAgent in Japan to manage their growing collection of 160+ test devices. It became the go-to open-source solution for teams wanting to build their own device labs.&lt;/p&gt;

&lt;p&gt;But in July 2020, the core team announced they were abandoning the project:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"This project along with other ones in OpenSTF organisation is provided as is for community, without active development."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The last official OpenSTF release was v3.4.1, which only supports up to &lt;strong&gt;Android 9&lt;/strong&gt;. If you're trying to test on Android 10, 11, 12, 13, 14, or 15 devices—you're out of luck.&lt;/p&gt;

&lt;h3&gt;
  
  
  The DeviceFarmer Fork: Not the Answer
&lt;/h3&gt;

&lt;p&gt;Development supposedly moved to a community fork called DeviceFarmer. But here's the reality:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;156+ open issues&lt;/strong&gt; sitting on GitHub&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Volunteer-maintained&lt;/strong&gt; with no funding or dedicated team&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Slow Android version support&lt;/strong&gt;—Android 14/15 support is incomplete&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Still no iOS support&lt;/strong&gt;—same limitation as original OpenSTF&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Same ancient tech stack&lt;/strong&gt;—RethinkDB, Node.js 8.x, ZeroMQ&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The project openly acknowledges: &lt;em&gt;"Development is still largely funded by individual team members and their unpaid free time, leading to slow progress."&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;One developer on Hacker News captured the sentiment perfectly: "They dropped support on it and moved to another product (DeviceFarmer) which never seems to have materialised at all. We just kept running OpenSTF until it stopped working."&lt;/p&gt;

&lt;h2&gt;
  
  
  Why OpenSTF's Architecture Was Always Flawed
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. The 12-Container Problem
&lt;/h3&gt;

&lt;p&gt;To run OpenSTF in production, you needed to orchestrate:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;RethinkDB (database)&lt;/li&gt;
&lt;li&gt;nginx (reverse proxy)&lt;/li&gt;
&lt;li&gt;stf-app (web interface)&lt;/li&gt;
&lt;li&gt;stf-auth (authentication)&lt;/li&gt;
&lt;li&gt;stf-processor (device processing)&lt;/li&gt;
&lt;li&gt;stf-triproxy-app (message routing)&lt;/li&gt;
&lt;li&gt;stf-triproxy-dev (device routing)&lt;/li&gt;
&lt;li&gt;stf-reaper (cleanup)&lt;/li&gt;
&lt;li&gt;stf-storage-plugin-apk&lt;/li&gt;
&lt;li&gt;stf-storage-plugin-image&lt;/li&gt;
&lt;li&gt;stf-storage-temp&lt;/li&gt;
&lt;li&gt;stf-websocket&lt;/li&gt;
&lt;li&gt;stf-api&lt;/li&gt;
&lt;li&gt;stf-provider (one per host machine)&lt;/li&gt;
&lt;li&gt;adbd (ADB daemon)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Setting this up took days. Maintaining it was a part-time job.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. No Data Privacy Architecture
&lt;/h3&gt;

&lt;p&gt;The OpenSTF team acknowledged this in their own docs:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"We have made certain assumptions about the trustworthiness of our users. As such, there is little to no security or encryption between the different processes."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;And:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Devices do not get completely reset between uses, potentially leaving accounts logged in or exposing other sensitive data."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  3. iOS Support: Never Happening
&lt;/h3&gt;

&lt;p&gt;iOS support exists through a separate project (stf_ios_support), but it's barely functional:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Only 1 iOS device per Mac machine&lt;/li&gt;
&lt;li&gt;1 FPS hardcoded framerate&lt;/li&gt;
&lt;li&gt;Clicking is slow due to Apple API limitations&lt;/li&gt;
&lt;li&gt;Devices frequently get stuck in "preparing" state&lt;/li&gt;
&lt;li&gt;WebDriverAgent has memory leaks requiring restarts every 4 hours&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  4. Test Automation Gap
&lt;/h3&gt;

&lt;p&gt;Here's what surprises many teams: &lt;strong&gt;OpenSTF is not a test automation platform&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;OpenSTF provides remote device access—you can view the screen, tap, type, and install apps. But it doesn't run your Appium tests, Espresso suites, XCUITest cases, or Maestro flows.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Modern Alternative: P2P Architecture
&lt;/h2&gt;

&lt;p&gt;After running enterprise device labs for 12+ years serving companies like Disney+Hotstar, Airtel, Swiggy, and Jio, we saw the gap clearly:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Small teams have the same security concerns as enterprises&lt;/li&gt;
&lt;li&gt;Cloud device farms require trusting third parties with sensitive data&lt;/li&gt;
&lt;li&gt;OpenSTF is dead and DeviceFarmer isn't a real solution&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Zero-Trust Architecture Difference
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;OpenSTF Architecture:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Your Device → OpenSTF Server → Your Browser
           (all data flows through central server)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Cloud Device Farm Architecture:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Your Test Data → Cloud Provider Servers → Test Results
              (they promise not to look)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;P2P Architecture:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Your Device ↔ WebRTC P2P ↔ Your Browser
        (data flows directly, never touches third-party servers)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With P2P, your test data, app binaries, and device interactions flow &lt;strong&gt;directly between your devices and your browser&lt;/strong&gt; via WebRTC peer-to-peer connections.&lt;/p&gt;

&lt;p&gt;This isn't "we promise not to look at your data." This is "we &lt;strong&gt;architecturally cannot&lt;/strong&gt; access your data."&lt;/p&gt;

&lt;h2&gt;
  
  
  What Modern Solutions Should Solve
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Problem&lt;/th&gt;
&lt;th&gt;OpenSTF&lt;/th&gt;
&lt;th&gt;Modern Solution&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Android 14/15 support&lt;/td&gt;
&lt;td&gt;Broken&lt;/td&gt;
&lt;td&gt;Full support&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;iOS support&lt;/td&gt;
&lt;td&gt;Never worked well&lt;/td&gt;
&lt;td&gt;Native support&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Data privacy&lt;/td&gt;
&lt;td&gt;No encryption&lt;/td&gt;
&lt;td&gt;Zero-trust P2P&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Infrastructure complexity&lt;/td&gt;
&lt;td&gt;12+ containers&lt;/td&gt;
&lt;td&gt;Single agent install&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Maintenance burden&lt;/td&gt;
&lt;td&gt;Significant&lt;/td&gt;
&lt;td&gt;Managed updates&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Test automation&lt;/td&gt;
&lt;td&gt;Separate setup&lt;/td&gt;
&lt;td&gt;Built-in&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Full Test Framework Support
&lt;/h2&gt;

&lt;p&gt;Unlike OpenSTF which only provides remote access, modern solutions should support all major frameworks:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Framework&lt;/th&gt;
&lt;th&gt;OpenSTF&lt;/th&gt;
&lt;th&gt;Modern Solution&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Appium&lt;/td&gt;
&lt;td&gt;Requires separate setup&lt;/td&gt;
&lt;td&gt;Built-in&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Espresso&lt;/td&gt;
&lt;td&gt;Manual integration&lt;/td&gt;
&lt;td&gt;Built-in&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;XCUITest&lt;/td&gt;
&lt;td&gt;Complex setup&lt;/td&gt;
&lt;td&gt;Built-in&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Maestro&lt;/td&gt;
&lt;td&gt;Not supported&lt;/td&gt;
&lt;td&gt;Native support&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Migration from OpenSTF
&lt;/h2&gt;

&lt;p&gt;If you're currently running OpenSTF or DeviceFarmer, migration should be straightforward:&lt;/p&gt;

&lt;h3&gt;
  
  
  What You Keep
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Your physical devices&lt;/li&gt;
&lt;li&gt;Your Appium test suites&lt;/li&gt;
&lt;li&gt;Your Espresso and XCUITest projects&lt;/li&gt;
&lt;li&gt;Your CI/CD integrations&lt;/li&gt;
&lt;li&gt;Your USB hubs and host machines&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  What You Gain
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;iOS device support&lt;/li&gt;
&lt;li&gt;Android 14/15 support (and future versions)&lt;/li&gt;
&lt;li&gt;All test frameworks built-in&lt;/li&gt;
&lt;li&gt;Zero-trust data privacy&lt;/li&gt;
&lt;li&gt;No more RethinkDB maintenance&lt;/li&gt;
&lt;li&gt;No more minicap/minitouch debugging&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  What You Leave Behind
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;RethinkDB administration&lt;/li&gt;
&lt;li&gt;Node.js 8.x security vulnerabilities&lt;/li&gt;
&lt;li&gt;The anxiety of running abandoned software&lt;/li&gt;
&lt;li&gt;Android-only limitations&lt;/li&gt;
&lt;li&gt;12+ Docker containers&lt;/li&gt;
&lt;li&gt;Manual Appium server setup&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  When OpenSTF Still Makes Sense
&lt;/h2&gt;

&lt;p&gt;To be fair, OpenSTF/DeviceFarmer isn't wrong for everyone:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;OpenSTF might fit if you:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Have dedicated DevOps resources with STF expertise&lt;/li&gt;
&lt;li&gt;Only need Android devices (older versions)&lt;/li&gt;
&lt;li&gt;Don't need integrated test automation&lt;/li&gt;
&lt;li&gt;Have unlimited time for setup and maintenance&lt;/li&gt;
&lt;li&gt;Run in an isolated network with no security requirements&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Frequently Asked Questions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Is OpenSTF still maintained?
&lt;/h3&gt;

&lt;p&gt;No. The original OpenSTF project was abandoned in July 2020. The last official release (v3.4.1) only supports Android 9. Development moved to DeviceFarmer, which is volunteer-maintained with limited resources.&lt;/p&gt;

&lt;h3&gt;
  
  
  What testing frameworks should modern solutions support?
&lt;/h3&gt;

&lt;p&gt;Look for built-in support for Appium, Espresso, XCUITest, and Maestro. Your existing test suites should work without modification.&lt;/p&gt;

&lt;h3&gt;
  
  
  How should data privacy be handled?
&lt;/h3&gt;

&lt;p&gt;Look for WebRTC peer-to-peer connections. Your device screen, inputs, and test data should flow directly between your device and your browser—never through third-party servers.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can I migrate gradually from OpenSTF?
&lt;/h3&gt;

&lt;p&gt;Yes. You can run new solutions alongside your existing OpenSTF setup during evaluation. Many teams migrate device-by-device over a few weeks.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Bottom Line
&lt;/h2&gt;

&lt;p&gt;OpenSTF was revolutionary for its time. It proved that teams could build their own device labs instead of paying cloud testing premiums. But it's 2025, and OpenSTF is dead.&lt;/p&gt;

&lt;p&gt;The DeviceFarmer fork is on life support. Cloud alternatives require trusting third parties with your data.&lt;/p&gt;

&lt;p&gt;Modern solutions should offer:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;WebRTC P2P architecture (not 2015-era message queues)&lt;/li&gt;
&lt;li&gt;Zero-trust privacy (your data never touches third-party servers)&lt;/li&gt;
&lt;li&gt;iOS and Android support&lt;/li&gt;
&lt;li&gt;All frameworks built-in (Appium, Espresso, XCUITest, Maestro)&lt;/li&gt;
&lt;li&gt;Actual maintenance and support&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you're still running OpenSTF or DeviceFarmer, it's time to upgrade.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;We built &lt;a href="https://devicelab.dev" rel="noopener noreferrer"&gt;DeviceLab&lt;/a&gt; to solve these exact problems. First device free forever.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>openstf</category>
      <category>mobile</category>
      <category>testing</category>
      <category>android</category>
    </item>
    <item>
      <title>Went in to support Maestro for physical devices. Came out with even more.</title>
      <dc:creator>Om Narayan</dc:creator>
      <pubDate>Mon, 08 Dec 2025 08:55:49 +0000</pubDate>
      <link>https://forem.com/omnarayan/went-in-to-support-maestro-for-physical-devices-came-out-with-even-more-2o0g</link>
      <guid>https://forem.com/omnarayan/went-in-to-support-maestro-for-physical-devices-came-out-with-even-more-2o0g</guid>
      <description>&lt;h2&gt;
  
  
  The Backstory
&lt;/h2&gt;

&lt;p&gt;iOS real device support has been one of Maestro's most requested features since January 2023.&lt;/p&gt;

&lt;p&gt;Almost three years of developers asking for the same thing. Issue after issue. Comment after comment.&lt;/p&gt;

&lt;p&gt;We looked at the codebase. Surprisingly, a lot of groundwork was already there. Infrastructure PRs had been merged. The foundation existed.&lt;/p&gt;

&lt;p&gt;It just needed someone to finish the last mile.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why Us?
&lt;/h2&gt;

&lt;p&gt;At &lt;a href="https://devicelab.dev" rel="noopener noreferrer"&gt;DeviceLab&lt;/a&gt;, we run tests on real devices every day. It's literally what we do.&lt;/p&gt;

&lt;p&gt;When our customers started asking for Maestro support on physical iOS devices, we had the context—and the motivation—to build it.&lt;/p&gt;

&lt;p&gt;So we did.&lt;/p&gt;

&lt;p&gt;We submitted &lt;a href="https://github.com/mobile-dev-inc/Maestro/pull/2856" rel="noopener noreferrer"&gt;PR #2856&lt;/a&gt; to give it back to the community.&lt;/p&gt;

&lt;p&gt;But here's the thing: The Maestro team has indicated official iOS real device support won't land until next year. No committed timeline. Our PR won't help anyone until it's merged—or until they build their own solution.&lt;/p&gt;

&lt;p&gt;We didn't want teams to wait that long.&lt;/p&gt;

&lt;p&gt;That's why we packaged a ready-to-use tool.&lt;/p&gt;




&lt;h2&gt;
  
  
  What It Looks Like
&lt;/h2&gt;

&lt;p&gt;Your existing Maestro flows. Real iPhones. No changes.&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;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="nn"&gt;---&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;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;Login"&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;Submit"&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;Welcome"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same YAML. Same commands. Just real devices.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tested on:&lt;/strong&gt; iOS 26.x and iOS 18.x, including parallel execution on multiple devices.&lt;/p&gt;




&lt;h2&gt;
  
  
  Under the Hood
&lt;/h2&gt;

&lt;p&gt;Getting Maestro to talk to real iOS devices required four pieces:&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Device Detection
&lt;/h3&gt;

&lt;p&gt;Maestro's iOS driver was built for simulators. We taught it to recognize physical devices with Developer Mode enabled.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. XCTest Runner
&lt;/h3&gt;

&lt;p&gt;The XCTest driver needed to be built and deployed to actual hardware—not just the simulator. This requires code signing with a valid Apple Developer certificate.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Port Forwarding
&lt;/h3&gt;

&lt;p&gt;Simulators run on localhost. Real devices don't.&lt;/p&gt;

&lt;p&gt;We bridge that gap:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;localhost:6001 → device:22087
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  4. Session Management
&lt;/h3&gt;

&lt;p&gt;Real devices have constraints simulators don't. We handle them gracefully—like &lt;code&gt;clearState&lt;/code&gt; working via app reinstall instead of &lt;code&gt;simctl&lt;/code&gt; commands.&lt;/p&gt;




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



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;┌─────────────┐     ┌──────────────────┐     ┌─────────────┐
│   Maestro   │────▶│ maestro-ios-device│────▶│ iOS Device  │
│  (patched)  │     │  (port forward)  │     │ (XCTest)    │
└─────────────┘     └──────────────────┘     └─────────────┘
     :6001                                        :22087
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;maestro-ios-device&lt;/code&gt; builds and installs the XCTest runner on your device&lt;/li&gt;
&lt;li&gt;The runner starts an HTTP server on the device&lt;/li&gt;
&lt;li&gt;Port forwarding connects your local Maestro to the device&lt;/li&gt;
&lt;li&gt;Your tests run exactly as they would on a simulator&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Bonus: Parallel Testing
&lt;/h2&gt;

&lt;p&gt;While we were in there, we unlocked something else.&lt;/p&gt;

&lt;p&gt;Running tests on multiple devices simultaneously? Wasn't possible before. Hardcoded port limitation.&lt;/p&gt;

&lt;p&gt;Now it is:&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;# Terminal 1&lt;/span&gt;
maestro-ios-device &lt;span class="nt"&gt;--team-id&lt;/span&gt; ABC123 &lt;span class="nt"&gt;--device&lt;/span&gt; DEVICE_1 &lt;span class="nt"&gt;--driver-host-port&lt;/span&gt; 6001

&lt;span class="c"&gt;# Terminal 2&lt;/span&gt;
maestro-ios-device &lt;span class="nt"&gt;--team-id&lt;/span&gt; ABC123 &lt;span class="nt"&gt;--device&lt;/span&gt; DEVICE_2 &lt;span class="nt"&gt;--driver-host-port&lt;/span&gt; 6002
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Parallel testing on real iOS devices. Finally.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Honest Limitations
&lt;/h2&gt;

&lt;p&gt;We're not claiming this is perfect. Some iOS platform restrictions apply:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Command&lt;/th&gt;
&lt;th&gt;Status&lt;/th&gt;
&lt;th&gt;Notes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;clearState&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;✅ Works&lt;/td&gt;
&lt;td&gt;Via app reinstall (requires &lt;code&gt;--app-file&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;setLocation&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;⚠️ Limited&lt;/td&gt;
&lt;td&gt;Requires additional setup&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;addMedia&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;❌ Not supported&lt;/td&gt;
&lt;td&gt;iOS restriction&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;These are Apple's limitations, not ours.&lt;/p&gt;




&lt;h2&gt;
  
  
  Try It
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Install:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-fsSL&lt;/span&gt; https://raw.githubusercontent.com/devicelab-dev/maestro-ios-device/main/setup.sh | bash
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Start the device bridge:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;maestro-ios-device &lt;span class="nt"&gt;--team-id&lt;/span&gt; YOUR_TEAM_ID &lt;span class="nt"&gt;--device&lt;/span&gt; DEVICE_UDID
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Run your tests&lt;/strong&gt; (in another terminal):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;maestro &lt;span class="nt"&gt;--driver-host-port&lt;/span&gt; 6001 &lt;span class="nt"&gt;--device&lt;/span&gt; DEVICE_UDID &lt;span class="nt"&gt;--app-file&lt;/span&gt; /path/to/app.ipa &lt;span class="nb"&gt;test &lt;/span&gt;flow.yaml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Full docs: &lt;a href="https://github.com/devicelab-dev/maestro-ios-device" rel="noopener noreferrer"&gt;github.com/devicelab-dev/maestro-ios-device&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Compatibility
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Maestro Version&lt;/th&gt;
&lt;th&gt;Status&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;2.0.10&lt;/td&gt;
&lt;td&gt;✅ Supported&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2.0.9&lt;/td&gt;
&lt;td&gt;✅ Supported&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Other versions&lt;/td&gt;
&lt;td&gt;❌ Not tested&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  ⚠️ Disclaimer
&lt;/h2&gt;

&lt;p&gt;This is an unofficial community tool. Not supported by mobile.dev or the Maestro team. Use at your own risk.&lt;/p&gt;

&lt;p&gt;When Maestro releases official iOS device support, we recommend switching to the official version.&lt;/p&gt;




&lt;h2&gt;
  
  
  Links
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Tool:&lt;/strong&gt; &lt;a href="https://github.com/devicelab-dev/maestro-ios-device" rel="noopener noreferrer"&gt;github.com/devicelab-dev/maestro-ios-device&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;PR:&lt;/strong&gt; &lt;a href="https://github.com/mobile-dev-inc/Maestro/pull/2856" rel="noopener noreferrer"&gt;github.com/mobile-dev-inc/Maestro/pull/2856&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;Built by &lt;a href="https://devicelab.dev" rel="noopener noreferrer"&gt;DeviceLab&lt;/a&gt;—stop renting devices you already own.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>maestro</category>
      <category>ios</category>
      <category>testing</category>
    </item>
  </channel>
</rss>
