Replies: 6 comments
-
Beta Was this translation helpful? Give feedback.
-
|
This isn't supported. Given the limited number of folks who require it, it's not something staff have planned. If I were automating this today, I wouldn't want 2 device scenarios. I'd want to test that 1 device responds appropriately to the API, in a bunch of situations. I'd be looking to JavaScript to build out an API client to mimic the second device whilst observing and running assertions on the first. |
Beta Was this translation helpful? Give feedback.
-
|
I am testing a chat with sender and receiver, is this supported? I see the maestro driver being installed only in one device no matter if I specify two devices |
Beta Was this translation helpful? Give feedback.
-
|
Okay thats intersting. In my head the are soo many use cases where you need multiple devices to test. Espically every app which works with multiple users and uses a network connection. Messaging Apps (WhatsApp, Telegram, Messenger) Ride-Sharing Apps (Uber, Lyft) Payment Apps (Venmo, Cash App, PayPal) Food Delivery Apps (DoorDash, Uber Eats) Multiplayer Games (Chess.com, Words with Friends, Among Us) Collaboration Apps (Google Docs, Notion, Figma, Slack) Dating Apps (Tinder, Bumble, Hinge) Marketplace Apps (eBay, Facebook Marketplace, Airbnb) The Pain Without Native Multi-Device Support But if you say nobody needs this I will search a workaround. But I am not a fan from mimic anyhting as then it is (in my eyes) not a real End to End test anymore, and against the purpose of it. |
Beta Was this translation helpful? Give feedback.
-
There are folks building workarounds, maybe intersting for you: #2556 (comment) |
Beta Was this translation helpful? Give feedback.
-
My Workaround: Maestro Multi-Device Test RunnerRun sequential Maestro flows across multiple devices with data passing between them. Quick Start1. Define your devices: const devices = {
DAD: 'R5CW23AHD1E', // Get ID from: adb devices
MOM: '30ef154',
};2. Define your flows: const flows = [
{
device: 'DAD',
file: './subflows/createInvitation.yaml',
env: {
NAME: 'Mom' // Static value
},
returnValues: ['INVITE_CODE']
},
{
device: 'MOM',
file: './subflows/joinWithInviteCode.yaml',
env: {
INVITE_CODE: '$INVITE_CODE' // $ = lookup from sharedData
}
}
];3. Run: node test-runner.jsThe
|
| Syntax | Meaning | Example |
|---|---|---|
'value' |
Static value | NAME: 'Mom' → -e NAME=Mom |
'$KEY' |
sharedData lookup | INVITE_CODE: '$INVITE_CODE' → -e INVITE_CODE=<value> |
Outputting Values from Flows
Use copyTextFrom to capture text, then evalScript to output it:
# 1. Add testID to your React Native component
<ThemedText testID="invite_code">{invitationCode}</ThemedText>
# 2. In your YAML flow - capture with copyTextFrom
- copyTextFrom:
id: "invite_code"
# 3. Output to console (test-runner parses this)
- evalScript: "${console.log('INVITE_CODE=' + maestro.copiedText)}"Naming Convention
Use UPPER_SNAKE_CASE consistently everywhere:
// In test-runner.js
returnValues: ['INVITE_CODE'] // Not 'inviteCode'
env: { INVITE_CODE: '$INVITE_CODE' }
// In YAML
- evalScript: "${console.log('INVITE_CODE=' + maestro.copiedText)}"Known Issue: Port 7001 Conflict
Problem: Maestro uses port 7001 for device communication. When switching between devices, the port doesn't release cleanly, causing Connection refused errors.
Solution: Kill the process on port 7001 before switching devices.
The test-runner includes killPort7001() that runs automatically on device switch:
function killPort7001() {
// Find PID using port 7001 via netstat
// Kill it with taskkill
}
// Called automatically when device changes
if (lastDevice && lastDevice !== flow.device) {
killPort7001();
}Future Fix: Maestro PR #2821 adds --driver-host-port flag to use different ports per device. Once released, you can use:
maestro --driver-host-port 7005 --device device1 test flow.yaml
maestro --driver-host-port 7006 --device device2 test flow.yamlTrack progress: #2556
How Data Flows
┌─────────────────────────────────────────────────────────────┐
│ test-runner.js │
│ │
│ sharedData = { INVITE_CODE: 'QZSJYF', ... } │
└─────────────────────────────────────────────────────────────┘
│ │
│ 1. maestro test │ 2. maestro test
│ -e NAME=Mom │ -e INVITE_CODE=QZSJYF
│ │
▼ ▼
┌───────────┐ ┌───────────┐
│ DAD │ killPort7001 │ MOM │
│ Device │ ──────────────▶ │ Device │
│ │ │ │
│ copyText │ │ uses │
│ From → │ │ INVITE_ │
│ console. │ │ CODE var │
│ log(...) │ │ │
└───────────┘ └───────────┘
│
▼
maestro.log
"JsConsole: INVITE_CODE=QZSJYF"
│
▼
test-runner extracts → sharedData
Full test-runner.js
const { spawnSync } = require('child_process');
const fs = require('fs');
const path = require('path');
// ============================================================
// CONFIGURE THESE
// ============================================================
const devices = {
DAD: 'R5CW23AHD1E',
MOM: '30ef154',
};
const flows = [
{
device: 'DAD',
file: './subflows/createInvitation.yaml',
env: {
NAME: 'Mom'
},
returnValues: ['INVITE_CODE']
},
{
device: 'MOM',
file: './subflows/joinWithInviteCode.yaml',
env: {
INVITE_CODE: '$INVITE_CODE'
}
}
];
// ============================================================
// TEST RUNNER
// ============================================================
const DEBUG_DIR = path.join(__dirname, '.temp-debug-logs');
const sharedData = {};
function setupCleanupHandlers() {
const cleanup = () => {
if (fs.existsSync(DEBUG_DIR)) {
try { fs.rmSync(DEBUG_DIR, { recursive: true, force: true }); } catch (e) {}
}
};
process.on('exit', cleanup);
process.on('SIGINT', () => { cleanup(); process.exit(1); });
process.on('SIGTERM', () => { cleanup(); process.exit(1); });
process.on('uncaughtException', (err) => {
console.error('\n❌ Unexpected error:', err.message);
cleanup();
process.exit(1);
});
}
setupCleanupHandlers();
/**
* Kill process using port 7001 (Maestro driver port)
* Needed when switching between devices
*/
function killPort7001() {
console.log('\n--- Killing process on port 7001 ---');
try {
const netstat = spawnSync('netstat', ['-ano'], { encoding: 'utf-8', shell: true });
const lines = (netstat.stdout || '').split('\n');
for (const line of lines) {
if (line.includes(':7001') && line.includes('LISTENING')) {
const parts = line.trim().split(/\s+/);
const pid = parts[parts.length - 1];
if (pid && pid !== '0') {
console.log(` Found PID ${pid} on port 7001, killing...`);
spawnSync('taskkill', ['//F', '//PID', pid], { encoding: 'utf-8', shell: true });
console.log(` Killed PID ${pid}`);
}
}
}
} catch (e) {
console.warn(' Warning: Could not kill port 7001 process:', e.message);
}
}
function findMaestroLog(debugDir) {
const testsDir = path.join(debugDir, '.maestro', 'tests');
if (!fs.existsSync(testsDir)) return null;
const folders = fs.readdirSync(testsDir)
.filter(f => fs.statSync(path.join(testsDir, f)).isDirectory())
.sort()
.reverse();
for (const folder of folders) {
const logPath = path.join(testsDir, folder, 'maestro.log');
if (fs.existsSync(logPath)) return logPath;
}
return null;
}
function extractFromLog(logPath, keys) {
const extracted = {};
if (!logPath || !fs.existsSync(logPath)) return extracted;
const content = fs.readFileSync(logPath, 'utf-8');
for (const key of keys) {
const pattern = new RegExp(`JsConsole:\\s*${key}=([A-Za-z0-9_-]+)`, 'i');
const match = content.match(pattern);
if (match) extracted[key] = match[1];
}
return extracted;
}
function cleanDebugDir() {
if (fs.existsSync(DEBUG_DIR)) {
fs.rmSync(DEBUG_DIR, { recursive: true, force: true });
}
}
function runFlow(flow) {
const deviceId = devices[flow.device];
if (!deviceId) {
console.error(`Unknown device: ${flow.device}`);
return false;
}
console.log(`\n${'='.repeat(50)}`);
console.log(`=== ${flow.device}: ${flow.file} ===`);
console.log('='.repeat(50));
cleanDebugDir();
// Build env flags
const envFlags = [];
if (flow.env) {
for (const [envKey, value] of Object.entries(flow.env)) {
if (value.startsWith('$')) {
const dataKey = value.slice(1);
const resolvedValue = sharedData[dataKey];
if (resolvedValue) {
envFlags.push('-e', `${envKey}=${resolvedValue}`);
console.log(` ${envKey} = "${resolvedValue}" (from $${dataKey})`);
} else {
console.warn(` Warning: ${dataKey} not found in sharedData`);
}
} else {
envFlags.push('-e', `${envKey}=${value}`);
console.log(` ${envKey} = "${value}"`);
}
}
}
const args = [
`--device=${deviceId}`,
'test',
`--debug-output=${DEBUG_DIR}`,
...envFlags,
flow.file
];
console.log(`\nRunning: maestro ${args.join(' ')}\n`);
const result = spawnSync('maestro', args, {
encoding: 'utf-8',
cwd: __dirname,
shell: true
});
if (result.stdout) console.log(result.stdout);
if (result.status !== 0) {
console.error(`\n✗ ${flow.device} FAILED (exit code: ${result.status})`);
if (result.stderr) console.error(result.stderr);
return false;
}
// Extract returnValues
if (flow.returnValues && flow.returnValues.length > 0) {
const logPath = findMaestroLog(DEBUG_DIR);
if (logPath) {
console.log(`\n--- Parsing maestro.log for values ---`);
const extracted = extractFromLog(logPath, flow.returnValues);
for (const key of flow.returnValues) {
if (extracted[key]) {
sharedData[key] = extracted[key];
console.log(`>>> Extracted ${key}: ${sharedData[key]}`);
} else {
console.warn(`>>> Warning: Could not extract ${key} (looked for JsConsole: ${key}=...)`);
}
}
} else {
console.warn(`\n>>> Warning: Could not find maestro.log`);
}
}
console.log(`\n✓ ${flow.device} completed`);
return true;
}
// Main execution
console.log('\n🚀 Multi-Device Test Runner\n');
console.log('Devices:');
Object.entries(devices).forEach(([name, id]) => {
console.log(` ${name}: ${id}`);
});
let lastDevice = null;
for (const flow of flows) {
// Kill port 7001 when switching devices
if (lastDevice && lastDevice !== flow.device) {
killPort7001();
}
lastDevice = flow.device;
if (!runFlow(flow)) {
console.error(`\n❌ Stopped at: ${flow.file}`);
cleanDebugDir();
process.exit(1);
}
}
cleanDebugDir();
console.log('\n' + '='.repeat(50));
console.log('✅ All flows completed!');
console.log('='.repeat(50) + '\n');Subflow Pattern
Create reusable subflows with parameters:
# subflows/createInvitation.yaml
appId: com.raccoonrewards.app.dev
---
- tapOn:
- tapOn: ${NAME}
- tapOn: Send Invitation
- copyTextFrom:
id: "invite_code"
- evalScript: "${console.log('INVITE_CODE=' + maestro.copiedText)}"
- tapOn: Done
- tapOn: ✕// Call it with:
{
device: 'DAD',
file: './subflows/createInvitation.yaml',
env: { NAME: 'Mom' },
returnValues: ['INVITE_CODE']
}Beta Was this translation helpful? Give feedback.
Uh oh!
There was an error while loading. Please reload this page.
-
Use case
Problem
I cannot find any documentation or solution for testing scenarios that require interaction between two devices.
My app is a simple messaging app (like WhatsApp for Parent and Children). Almost all my test flows require this pattern:
Every meaningful test in my app needs this: I do something on Device A, then verify the result on Device B.
What I've Found
Current Workaround (Painful)
The only solution I can think of is writing a huge orchestrator script in Python/Bash, splitting every single step into its own YAML file:
test_orchestrator.py
run_test("device1", "01_create_invitation.yaml")
code = read_file("/tmp/code.txt")
write_file("/tmp/code.txt", code)
run_test("device2", "02_enter_invitation.yaml")
run_test("device1", "03_send_message.yaml")
run_test("device2", "04_verify_message.yaml")
... and so on
Use Cases
This would enable testing for:
Questions
Thanks for building such an amazing tool! This feature would make it perfect for social/collaborative app testing.
Proposal
add a switch device command to yaml
do ...
switch device to: ...
check...
Anything else?
No response
Beta Was this translation helpful? Give feedback.
All reactions