Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions scripts/perps/agentic/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,9 @@ Executable gate checks that must pass before a flow runs. Defined in `teams/<tea
| `select_account` | `address` | Switch Ethereum account |
| `toggle_testnet` | | Enable/disable testnet mode |
| `switch_provider` | `provider` | Switch perps provider |
| `app_background` | `duration_ms` | Send app to home screen (iOS: Cmd+Shift+H). Waits `duration_ms` (default 5000) |
| `app_foreground` | | Bring app back to foreground via `xcrun simctl launch` |
| `app_restart` | `boot_wait_ms` | Terminate + relaunch app. Waits `boot_wait_ms` (default 15000) for Metro reconnect |
| `switch` | `cases` | Branch based on assertions (workflow only) |
| `end` | | Terminal node with pass/fail (workflow only) |

Expand Down
22 changes: 22 additions & 0 deletions scripts/perps/agentic/cdp-bridge.js
Original file line number Diff line number Diff line change
Expand Up @@ -705,6 +705,28 @@ Environment:
const port = loadPort();
const timeout = Number.parseInt(process.env.CDP_TIMEOUT || '5000', 10);

// `status` probes ALL connected targets so both platforms are visible.
if (command === 'status') {
const { discoverAllTargets } = require('./lib/target-discovery');
const allTargets = await discoverAllTargets(port);
const results = [];
for (const target of allTargets) {
let client;
try {
client = await createWSClient(target.wsUrl, timeout);
const platform = await cdpEval(client, 'globalThis.__AGENTIC__?.platform') || '';
const result = await handler(client, args.slice(1), { deviceName: target.deviceName, platform });
results.push(result);
} catch {
// Target not responsive — skip
} finally {
if (client) client.close();
}
}
console.log(JSON.stringify(results.length === 1 ? results[0] : results, null, 2));
return;
}

const { wsUrl, deviceName } = await discoverTarget(port);
const client = await createWSClient(wsUrl, timeout);

Expand Down
186 changes: 186 additions & 0 deletions scripts/perps/agentic/lib/app-lifecycle.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
'use strict';

const { spawnSync } = require('node:child_process');

// ---------------------------------------------------------------------------
// Platform detection
// ---------------------------------------------------------------------------

function detectPlatform() {
if (process.env.ADB_SERIAL || process.env.ANDROID_SERIAL) {
return 'android';
}
if (process.platform === 'darwin') {
return 'ios';
}
throw new Error(
'Cannot detect simulator platform. Set ADB_SERIAL for Android or run on macOS for iOS.',
);
}

// ---------------------------------------------------------------------------
// iOS helpers
// ---------------------------------------------------------------------------

function resolveBootedSimulatorUdid() {
const result = spawnSync(
'xcrun',
['simctl', 'list', 'devices', 'booted', '-j'],
{ encoding: 'utf8' },
);
const parsed = JSON.parse(result.stdout || '{}');
for (const devices of Object.values(parsed.devices || {})) {
for (const d of devices) {
if (d.state === 'Booted') {
return d.udid;
}
}
}
throw new Error('No booted iOS simulator found');
}

function resolveIosBundleId(udid) {
const result = spawnSync('xcrun', ['simctl', 'listapps', udid], {
encoding: 'utf8',
});
const match = (result.stdout || '').match(
/CFBundleIdentifier\s*=\s*"(io\.metamask\.[^"]+)"/,
);
return match ? match[1] : 'io.metamask.MetaMask';
}

function iosBackground() {
spawnSync(
'osascript',
[
'-e', 'tell application "Simulator" to activate',
'-e', 'delay 0.3',
'-e', 'tell application "System Events" to keystroke "h" using {command down, shift down}',
],
{ encoding: 'utf8' },
);
}

function iosForeground(udid, bundleId) {
spawnSync('xcrun', ['simctl', 'launch', udid, bundleId], {
encoding: 'utf8',
});
}

function iosTerminate(udid, bundleId) {
spawnSync('xcrun', ['simctl', 'terminate', udid, bundleId], {
encoding: 'utf8',
});
}

// ---------------------------------------------------------------------------
// Android helpers
// ---------------------------------------------------------------------------

function resolveAdbSerial() {
return process.env.ADB_SERIAL || process.env.ANDROID_SERIAL || '';
}

function resolveAndroidPackage() {
// Check for running MetaMask activity
const serial = resolveAdbSerial();
const args = serial ? ['-s', serial] : [];
const result = spawnSync(
'adb',
[...args, 'shell', 'dumpsys', 'activity', 'activities'],
{ encoding: 'utf8' },
);
const match = (result.stdout || '').match(
/(io\.metamask[.\w]*)\/.+Activity/,
);
return match ? match[1] : 'io.metamask';
}

function adbShell(...shellArgs) {
const serial = resolveAdbSerial();
const args = serial ? ['-s', serial] : [];
return spawnSync('adb', [...args, 'shell', ...shellArgs], {
encoding: 'utf8',
});
}

function androidBackground() {
adbShell('input', 'keyevent', 'KEYCODE_HOME');
}

function androidForeground(packageName) {
adbShell(
'monkey',
'-p', packageName,
'-c', 'android.intent.category.LAUNCHER',
'1',
);
}

function androidTerminate(packageName) {
adbShell('am', 'force-stop', packageName);
}

// ---------------------------------------------------------------------------
// Public API — platform-agnostic
// ---------------------------------------------------------------------------

/**
* Send the app to the OS home screen.
* @returns {{ bundleId: string }} Resolved app identifier
*/
function backgroundApp() {
const platform = detectPlatform();
if (platform === 'ios') {
const udid = resolveBootedSimulatorUdid();
const bundleId = resolveIosBundleId(udid);
iosBackground();
return { bundleId };
}
const packageName = resolveAndroidPackage();
androidBackground();
return { bundleId: packageName };
}

/**
* Bring the app back to the foreground.
* @returns {{ bundleId: string }}
*/
function foregroundApp() {
const platform = detectPlatform();
if (platform === 'ios') {
const udid = resolveBootedSimulatorUdid();
const bundleId = resolveIosBundleId(udid);
iosForeground(udid, bundleId);
return { bundleId };
}
const packageName = resolveAndroidPackage();
androidForeground(packageName);
return { bundleId: packageName };
}

/**
* Terminate and relaunch the app.
* @returns {{ bundleId: string }}
*/
function restartApp() {
const platform = detectPlatform();
if (platform === 'ios') {
const udid = resolveBootedSimulatorUdid();
const bundleId = resolveIosBundleId(udid);
iosTerminate(udid, bundleId);
iosForeground(udid, bundleId);
return { bundleId };
}
const packageName = resolveAndroidPackage();
androidTerminate(packageName);
androidForeground(packageName);
return { bundleId: packageName };
}

module.exports = {
backgroundApp,
detectPlatform,
foregroundApp,
restartApp,
};
7 changes: 5 additions & 2 deletions scripts/perps/agentic/lib/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,12 +25,15 @@ function loadPort() {

/** Read IOS_SIMULATOR name from .js.env or env (default: none — accept any device) */
function loadSimulatorName() {
return process.env.IOS_SIMULATOR || loadEnvValue('IOS_SIMULATOR') || '';
// Explicit empty string in env means "no simulator" — don't fall through to .js.env
if ('IOS_SIMULATOR' in process.env) return process.env.IOS_SIMULATOR;
return loadEnvValue('IOS_SIMULATOR') || '';
}

/** Read ANDROID_DEVICE serial from .js.env or env (default: none — accept any device) */
function loadAndroidDevice() {
return process.env.ANDROID_DEVICE || loadEnvValue('ANDROID_DEVICE') || '';
if ('ANDROID_DEVICE' in process.env) return process.env.ANDROID_DEVICE;
return loadEnvValue('ANDROID_DEVICE') || '';
}

module.exports = { loadEnvValue, loadPort, loadSimulatorName, loadAndroidDevice };
46 changes: 45 additions & 1 deletion scripts/perps/agentic/lib/target-discovery.js
Original file line number Diff line number Diff line change
Expand Up @@ -132,4 +132,48 @@ async function discoverTarget(port) {
return { wsUrl: candidates[0].webSocketDebuggerUrl, deviceName: candidates[0].deviceName || '' };
}

module.exports = { discoverTarget };
/**
* Discover ALL Hermes targets with __AGENTIC__ installed (one per platform/device).
* Groups by deviceName so each device returns at most one target.
*/
async function discoverAllTargets(port) {
const listUrl = `http://localhost:${port}/json/list`;
let targets;
try {
targets = await fetchJSON(listUrl);
} catch (e) {
throw new Error(
`Cannot reach Metro at ${listUrl}. Is Metro running?\n ${e.message}`,
);
}

const candidates = (targets || []).filter(
(t) =>
t.webSocketDebuggerUrl &&
t.title &&
(/react/i.test(t.title) || /hermes/i.test(t.title)),
);

// Sort by page number descending (JS runtime has higher page number)
candidates.sort((a, b) => {
const aPage = Number.parseInt((a.id || '').split('-').pop() || '0', 10);
const bPage = Number.parseInt((b.id || '').split('-').pop() || '0', 10);
return bPage - aPage;
});

// Group by deviceName, probe each to find the JS runtime target
const seen = new Set();
const results = [];
for (const candidate of candidates) {
const device = candidate.deviceName || candidate.id || candidate.webSocketDebuggerUrl;
if (seen.has(device)) continue;
const hasAgentic = await probeTarget(candidate.webSocketDebuggerUrl);
if (hasAgentic) {
seen.add(device);
results.push({ wsUrl: candidate.webSocketDebuggerUrl, deviceName: device });
}
}
return results;
}

module.exports = { discoverTarget, discoverAllTargets };
4 changes: 4 additions & 0 deletions scripts/perps/agentic/lib/workflow.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ const EXECUTABLE_ACTIONS = new Set([
'select_account',
'toggle_testnet',
'switch_provider',
// App lifecycle actions
'app_background',
'app_foreground',
'app_restart',
]);

const CONTROL_ACTIONS = new Set(['switch', 'end']);
Expand Down
18 changes: 14 additions & 4 deletions scripts/perps/agentic/preflight.sh
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,13 @@ sweep_port() {
holder_pid=$(lsof -iTCP:"$port" -sTCP:LISTEN -t 2>/dev/null | head -1 || true)
[ -z "$holder_pid" ] && return 0

# Probe /status first — if Metro responds, it's alive and reusable regardless of process name
if curl -sf "http://localhost:$port/status" >/dev/null 2>&1; then
ok "Port $port ($label) — Metro already running (PID $holder_pid), reusing"
# start-metro.sh will detect running Metro and only launch the app
return 0
fi

local holder_cmd
holder_cmd=$(ps -p "$holder_pid" -o command= 2>/dev/null || echo unknown)

Expand Down Expand Up @@ -531,6 +538,8 @@ fi

# ── Step: Metro ─────────────────────────────────────────────────────
step "Starting Metro" "Bundler on port $PORT → logs at $LOGFILE"
# start-metro.sh detects running Metro and skips start. With --launch it
# opens the app via expo deeplink for the target platform regardless.
bash "$SCRIPTS/start-metro.sh" --platform "$PLAT" $($DO_LAUNCH && echo "--launch" || echo "")
ok "Metro running on port $PORT"

Expand Down Expand Up @@ -589,10 +598,11 @@ for p in json.loads(sys.stdin.read() or "[]"):
fail "CDP did not become available after ${CDP_TIMEOUT}s"
fi

# Verify CDP is connected to the right platform
CDP_PLATFORM=$(node "$SCRIPTS/cdp-bridge.js" status 2>/dev/null | jq -r '.platform // empty' || true)
if [ -n "$CDP_PLATFORM" ] && [ "$CDP_PLATFORM" != "$PLAT" ]; then
fail "CDP connected to $CDP_PLATFORM app but expected $PLAT — launch the $PLAT app first"
# Verify CDP is connected to the right platform (status may return object or array)
CDP_STATUS=$(node "$SCRIPTS/cdp-bridge.js" status 2>/dev/null || true)
CDP_HAS_PLAT=$(echo "$CDP_STATUS" | jq -r 'if type == "array" then [.[].platform] else [.platform] end | map(select(. == "'"$PLAT"'")) | length' 2>/dev/null || echo 0)
if [ "$CDP_HAS_PLAT" = "0" ]; then
warn "CDP did not find $PLAT app — it may still be loading"
fi

# Brief stabilization
Expand Down
5 changes: 5 additions & 0 deletions scripts/perps/agentic/teams/perps/evals/core.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,5 +23,10 @@
"description": "Watchlist markets from controller state",
"expression": "JSON.stringify(Engine.context.PerpsController.state.watchlistMarkets)",
"async": false
},
"available-dexs": {
"description": "HIP-3 DEX list (validates DEX discovery cache)",
"expression": "Engine.context.PerpsController.getAvailableDexs().then(function(dexs){return JSON.stringify({count:dexs.length,dexs:dexs,hasMainDex:dexs.indexOf('')>=0})})",
"async": true
}
}
Loading
Loading