/recipes/` and prove that end-to-end scenarios work across flow boundaries.
-
-See `teams/perps/recipes/full-trade-lifecycle.json` for an example that chains: wallet home → mainnet → perps → testnet → clear position → open market → TP/SL (presets) → close — all via `flow_ref`.
-
-```bash
-# Run a recipe
-bash scripts/perps/agentic/validate-recipe.sh \
- scripts/perps/agentic/teams/perps/recipes/full-trade-lifecycle.json
-
-# Dry-run
-bash scripts/perps/agentic/validate-recipe.sh \
- scripts/perps/agentic/teams/perps/recipes/full-trade-lifecycle.json --dry-run
-```
-
----
-
-## Error Recovery
-
-| Symptom | Fix |
-| ------------------------ | --------------------------------------------------------------------------------- |
-| Metro crash / no output | `bash start-metro.sh --platform ` |
-| CDP "not connected" | Check Metro running + device booted. Poll for `__AGENTIC__` (5-120s after unlock) |
-| Hot reload resets app | `app-navigate.sh WalletTabHome` then target screen |
-| App crash / white screen | `bash preflight.sh --platform
` |
-| eval returns undefined | Use `eval-async` with `.then(function(r){ return JSON.stringify(r) })` |
-| "SyntaxError" in eval | ES5 violation — check for arrow functions, const/let, template literals |
-| Eval ref assertion fails | Check `eval-ref --list` for correct name; re-read the eval ref JSON |
-| adb reverse lost | `adb reverse tcp:PORT tcp:PORT` |
-| Route not found | Check route name in the table below; cdp-bridge handles nested routing |
-
----
-
-## Routes and State Paths
-
-### Perps Routes
-
-| Route | Description | Params |
-| ----------------------- | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ |
-| `PerpsMarketListView` | Perps home (positions, orders, watchlist) | |
-| `PerpsTrendingView` | Market list (all markets) | |
-| `PerpsMarketDetails` | Market detail view | `{"market":{"symbol":"BTC","name":"BTC","price":"0","change24h":"0","change24hPercent":"0","volume":"0","maxLeverage":"100"}}` |
-| `PerpsActivity` | Activity history | `{"redirectToPerpsTransactions":true}` |
-| `PerpsClosePosition` | Close a position | |
-| `PerpsTPSL` | Take-profit / stop-loss | |
-| `PerpsAdjustMargin` | Adjust position margin | |
-| `PerpsOrderDetailsView` | Order detail view | |
-| `PerpsOrderBook` | Order book depth | |
-| `PerpsWithdraw` | Withdraw funds | |
-| `PerpsTutorial` | Onboarding tutorial | |
-
-Other useful routes: `WalletTabHome`, `SettingsView`, `DeveloperOptions`, `BrowserTabHome`.
-
-### Engine Controller Paths
-
-```bash
-Engine.context.PerpsController.state # Positions, orders, balances, config
-Engine.context.NetworkController.state # Network selection
-Engine.context.AccountsController.state # Accounts, selected account
-Engine.context.RemoteFeatureFlagController.state # Feature flags
-Engine.context.PreferencesController.state # User preferences
-```
+The HUD is a human-facing proof aid. It should display one concise current
+intent, optionally one subflow/context line, and failure details when useful. It
+must not duplicate internal action names or task-specific debug text.
-### Common PerpsController Methods
+## Fixture workflow
-| Method | Returns | Description |
-| --------------------------- | ----------------------- | ------------------------ |
-| `getPositions()` | `Promise` | Open positions |
-| `getAccountState()` | `Promise` | Balances, margin |
-| `getMarketDataWithPrices()` | `Promise` | Markets with live prices |
-| `getOpenOrders()` | `Promise` | Active limit/stop orders |
-| `getTradeConfiguration()` | `Promise` | Leverage limits, fees |
-| `placeOrder(params)` | `Promise` | Submit an order |
-| `closePosition({symbol})` | `Promise` | Close by symbol |
+Local fixture files belong under `.agent/` and must not be committed. The bridge
+supports fixture setup so recipes can start from a deterministic wallet state,
+while still validating behavior through the real app runtime.
diff --git a/docs/perps/perps-agentic-scripts-quickref.md b/docs/perps/perps-agentic-scripts-quickref.md
deleted file mode 100644
index 2e59a61cbec2..000000000000
--- a/docs/perps/perps-agentic-scripts-quickref.md
+++ /dev/null
@@ -1,106 +0,0 @@
-# Agentic Scripts — Quick Reference
-
-## Yarn Shortcuts
-
-| Command | What it does | Time |
-| ---------------------- | ---------------------------------------------------------- | ------- |
-| `yarn a:setup:ios` | Clean install + build + Metro + launch + CDP + wallet seed | ~2.5min |
-| `yarn a:setup:android` | Same as above for Android | ~3min |
-| `yarn a:ios` | Metro + launch + CDP + unlock/seed (no clean, no rebuild) | ~30s |
-| `yarn a:android` | Same as above for Android | ~30s |
-| `yarn a:watch` | Interactive Metro with live reload | — |
-| `yarn a:stop` | Stop Metro | — |
-| `yarn a:reload` | Reload JS bundle on connected app | — |
-| `yarn a:status` | App state snapshot (route + account) | — |
-| `yarn a:navigate` | Navigate to a route | — |
-
-## When to use what
-
-- **First time / after `git clean`**: `yarn a:setup:ios` (full clean)
-- **Daily dev / branch switch**: `yarn a:ios` (reuses existing build, unlocks wallet)
-- **Just want Metro**: `yarn a:watch`
-
-## Prerequisites
-
-1. `.js.env` must have `WATCHER_PORT`, `IOS_SIMULATOR`, `SIM_UDID` (iOS) or `ANDROID_DEVICE` (Android)
-2. `.agent/wallet-fixture.json` must exist (copy from `scripts/perps/agentic/wallet-fixture.example.json`)
-
-## Flows
-
-Flows are parameterized JSON test sequences in `scripts/perps/agentic/teams//flows/`.
-
-```bash
-# List all flows
-ls scripts/perps/agentic/teams/perps/flows/*.json
-
-# Run a flow
-bash scripts/perps/agentic/validate-recipe.sh \
- scripts/perps/agentic/teams/perps/flows/market-discovery.json --skip-manual
-
-# Dry-run (prints steps, no execution)
-bash scripts/perps/agentic/validate-recipe.sh \
- scripts/perps/agentic/teams/perps/flows/trade-open-market.json --dry-run
-
-# Run all flows (dry-run)
-for f in scripts/perps/agentic/teams/perps/flows/*.json; do
- bash scripts/perps/agentic/validate-recipe.sh "$f" --dry-run --skip-manual
-done
-```
-
-### Parameter Passing
-
-Flows use `{{param}}` tokens. Defaults are declared in the flow's `inputs` block. Override via `flow_ref` params or by editing the JSON.
-
-### Pre-Conditions
-
-Flows can declare `pre_conditions` — named checks that must pass before steps run. If a check fails, the runner aborts with a hint. Available pre-conditions are registered in `teams/perps/pre-conditions.js`.
-
-## CDP Bridge Commands
-
-```bash
-CDP="node scripts/perps/agentic/cdp-bridge.js"
-
-$CDP status # Route + account snapshot
-$CDP navigate PerpsMarketListView # Navigate to a screen
-$CDP get-route # Current route
-$CDP get-state engine.backgroundState.NetworkController # Redux state
-$CDP eval "1+1" # Eval JS in app
-$CDP eval-async "fetch('...')" # Eval async JS
-$CDP unlock # Unlock wallet on login screen
-$CDP press-test-id # Press component by testID
-$CDP scroll-view --test-id # Scroll a ScrollView/FlatList
-$CDP list-accounts # All accounts
-$CDP switch-account # Switch active account
-$CDP eval-ref perps/positions # Run a named eval ref
-$CDP eval-ref --list # List all eval refs
-$CDP check-pre-conditions '' # Validate pre-conditions
-```
-
-## Other Scripts
-
-```bash
-scripts/perps/agentic/app-navigate.sh # Navigate + screenshot
-scripts/perps/agentic/app-navigate.sh --list # Discover all live routes
-scripts/perps/agentic/screenshot.sh # Capture simulator screenshot
-scripts/perps/agentic/setup-wallet.sh # Seed wallet via CDP
-scripts/perps/agentic/unlock-wallet.sh # Unlock via CDP
-scripts/perps/agentic/validate-recipe.sh # Run PR recipe folder
-scripts/perps/agentic/validate-flow-schema.js # Validate flow authoring rules
-scripts/perps/agentic/validate-pre-conditions.js # Validate pre-condition registry
-```
-
-## Architecture
-
-```
-NavigationService.ts (set navigation)
- --> AgenticService.install(navRef, deferredNav) [__DEV__ only]
- --> globalThis.__AGENTIC__ = { setupWallet, pressTestId, scrollView, ... }
-
-CDP Bridge (cdp-bridge.js)
- --> Metro /json/list --> WebSocket --> Runtime.evaluate
- --> reads globalThis.__AGENTIC__.*
-```
-
-## Worktree / Multi-Device Mapping
-
-Ports are set per-slot via `.js.env` `WATCHER_PORT`. When both iOS and Android devices are connected, set `PLATFORM=android` or `PLATFORM=ios` to disambiguate screenshot targets. CDP commands are platform-agnostic.
diff --git a/docs/perps/perps-agentic-system-design.md b/docs/perps/perps-agentic-system-design.md
deleted file mode 100644
index e1b56c531d60..000000000000
--- a/docs/perps/perps-agentic-system-design.md
+++ /dev/null
@@ -1,295 +0,0 @@
-# Agentic System Design
-
-The agentic toolkit is a system that lets AI agents write code, verify it against a running
-app, and iterate — all without human intervention. It provides a fast, local feedback loop:
-the agent gets signal in seconds from a live app instead of waiting for heavyweight test
-frameworks. It complements E2E tests (Detox) and CI — it doesn't replace them. It's built
-on three pillars.
-
-The toolkit was built by the perps team but designed for any team in MetaMask Mobile. The
-infrastructure (`scripts/perps/agentic/teams/`) auto-discovers team directories — any team
-can add flows, recipes, and pre-conditions without modifying shared code.
-
----
-
-## The Three Pillars
-
-1. **Wallet Fixtures & Preflight** — Get to a known state in seconds, not minutes
-2. **Recipe & Flow System** — Parameterized, composable, deterministic test sequences
-3. **CDP Instrumentation** — Direct app access via Chrome DevTools Protocol, no vision model needed
-
-These aren't independent tools. They form a flywheel:
-
-```
-Wallet Fixtures ──→ Known State ──→ Recipes execute deterministically
- ↑ │
- └──── Clean state for next iteration ←────┘
- ↑
- CDP: text-based assertions
- (no screenshots, no vision tokens)
-```
-
----
-
-## Pillar 1: Wallet Fixtures & Preflight
-
-### The problem
-
-A fresh MetaMask wallet requires ~15 manual steps to reach a usable state: create wallet,
-back up seed phrase, dismiss onboarding modals, import trading accounts, enable feature
-flags, suppress consent screens, navigate to the target feature. An E2E test takes 2-5
-minutes for this. An agent doing it via UI automation burns tokens on every step and hits
-flaky modal dismissals along the way.
-
-### The solution
-
-`wallet-fixture.json` defines the desired wallet state declaratively — password, accounts
-(mnemonic or private key), and settings that suppress friction:
-
-```json
-{
- "password": "...",
- "accounts": [
- { "type": "mnemonic", "value": "twelve word seed ..." },
- { "type": "privateKey", "value": "0xabc...", "name": "Trading" }
- ],
- "settings": {
- "metametrics": false,
- "skipGtmModals": true,
- "skipPerpsTutorial": true,
- "autoLockNever": true
- }
-}
-```
-
-`setup-wallet.sh` reads this fixture and calls `__AGENTIC__.setupWallet(fixture)` — a single
-CDP eval that restores the wallet, imports accounts, dispatches all onboarding flags,
-suppresses modals, and navigates to wallet home. Pure JS execution, no UI navigation, no
-modal handling, no screenshot verification.
-
-`preflight.sh` orchestrates the full environment pipeline:
-
-| Scenario | What runs | Time |
-| ---------------------------------- | ----------------------------------- | ------- |
-| Cold start (first time) | build + boot + Metro + CDP + wallet | ~150s |
-| Warm start (Metro running) | boot device + CDP + wallet | ~10-20s |
-| Hot iteration (everything running) | wallet restore if needed | ~2-5s |
-
-**Key insight: isolation.** Each agent run starts from a known wallet state. No leaking
-state between iterations. The fixture is the contract — deterministic input produces
-deterministic starting point.
-
----
-
-## Pillar 2: Recipe & Flow System
-
-### The problem
-
-E2E tests (Detox) take 90-300 seconds per test, run serially, and produce failures that
-require screenshots to diagnose. CI on GitHub can take up to 20 minutes per push. These
-tools remain essential for final validation, but an agent iterating on a fix needs faster
-signal during development.
-
-### The solution
-
-JSON-based recipes and flows executed via `validate-recipe.sh`, organized by team under
-`scripts/perps/agentic/teams/`. Each team directory follows the same structure:
-
-- `teams//flows/` — flow JSONs validated by `validate-flow-schema.js`
-- `teams//evals.json` — quick eval refs (e.g. `perps/positions`, `swap/quote-status`)
-- `teams//evals/` — named eval ref collections
-- `teams//pre-conditions.js` — namespaced checks (e.g. `perps.ready_to_trade`, `swap.has_valid_quote`)
-
-`lib/registry.js` auto-discovers all team directories and merges their pre-conditions at load
-time. Duplicate keys across teams cause a load-time error — namespace enforcement by convention.
-A new team adds a directory and immediately gets access to all shared infrastructure.
-
-**Recipes** are single CDP eval expressions — state snapshots that run in <1 second.
-The path `/` is the team boundary — `eval-ref perps/positions` is a perps team
-eval ref, `eval-ref swap/quote-status` would be a swap team eval ref.
-
-**Flows** are multi-step UI sequences — navigate, press, type, wait, assert. They run in
-10-30 seconds. Parameterized with `{{symbol}}`, composable via `flow_ref` and `eval_ref`.
-
-| Dimension | E2E (Detox) | Recipes/Flows |
-| ------------- | --------------------------- | ----------------------------------------- |
-| Speed | 90-300s/test | 1-30s/flow |
-| Flakiness | High (animations, timing) | Low (explicit waits, direct fiber access) |
-| Output | Screenshots (vision tokens) | JSON text (cheap) |
-| Composability | Copy entire test files | `flow_ref` + `eval_ref` + params |
-
-Flows declare their requirements via pre-conditions. If the wallet isn't unlocked or no
-position exists, the runner aborts with a clear error before wasting time on doomed steps.
-
-### Recipes are the agent's eyes
-
-Instead of "take a screenshot and look at it" (thousands of vision tokens), the agent runs
-`recipe perps/positions` and gets structured JSON back. The assertion system (`eq`, `gt`,
-`length_gt`, `contains`) lets the agent verify state without seeing the screen. One recipe
-call costs one tool invocation. One screenshot costs a vision model call plus the tokens
-to describe what's in the image.
-
-### Recipes are proof
-
-When an agent fixes a bug, it writes a recipe that reproduces the bug (assertion fails),
-applies the fix, re-runs the recipe (assertion passes). The recipe IS the proof. It goes
-into the PR as `recipe.json` — reviewers can re-run it to verify. The same recipe becomes
-a regression check for future changes.
-
----
-
-## Pillar 3: CDP Instrumentation
-
-### The problem
-
-Traditional mobile test automation requires either a native framework (Detox, Appium) with
-heavy setup, or coordinate-based tapping that breaks on layout changes.
-
-### The solution
-
-The `__AGENTIC__` bridge on `globalThis`, installed by `AgenticService.ts` in `__DEV__`
-mode when NavigationService sets its ref. It exposes:
-
-- **Navigation**: `navigate()`, `getRoute()`, `canGoBack()`, `goBack()`
-- **Accounts**: `listAccounts()`, `getSelectedAccount()`, `switchAccount()`
-- **UI interaction**: `pressTestId()`, `scrollView()`, `setInput()`
-- **Setup**: `setupWallet()` (the 11-step initialization from Pillar 1)
-
-`pressTestId` walks the React fiber tree via `__REACT_DEVTOOLS_GLOBAL_HOOK__` to find the
-component with a matching `testID` prop and calls its `onPress` handler directly. No
-coordinates, no image recognition, no screenshots. Same for `setInput` (calls
-`onChangeText`) and `scrollView` (calls `scrollTo` on the nearest scrollable ancestor).
-
-`cdp-bridge.js` connects via Metro's Hermes WebSocket — same protocol on iOS and Android.
-Everything returns structured JSON.
-
-**Key insight: the bridge turns the running app into an API.** Instead of "look at the
-screen, find the button, tap at coordinates", the agent says
-`press perps-market-details-long-button`. Instead of "take a screenshot to check what
-screen we're on", the agent evaluates `getRoute().name` and gets `"PerpsMarketDetails"`
-as a string.
-
----
-
-## The Flywheel: How It All Connects
-
-### Agent development cycle
-
-1. Agent gets a task (bug fix, new feature, PR review)
-2. Preflight restores wallet to known state (~2-5s warm)
-3. Agent reads code, understands the problem
-4. Agent writes a recipe that reproduces the bug (assertion fails)
-5. Agent fixes the code
-6. Metro hot-reloads (~2s)
-7. Agent re-runs the recipe (assertion passes) — **sub-minute verification**
-8. Agent commits fix + recipe as PR evidence
-
-**Without the toolkit:** the agent's fastest feedback is Detox (90-300s per test) or pushing
-to CI (up to 20 minutes). Screenshots require vision models (expensive, fragile).
-
-**With the toolkit:** the agent verifies locally against a running app. Metro auto-reloads
-on save (HMR for React changes is instantaneous), and feedback comes back as text.
-
-### Feedback channels — cheapest to most expensive
-
-The toolkit provides multiple feedback layers. In practice, ~95% of verification uses the
-cheapest one:
-
-1. **DevLogger + grep (primary)** — Drop a tagged log line in any render path or hook, save
- the file, Metro hot-reloads instantly, grep the Metro log for your tag. One log line +
- one grep = instant signal about what the UI is actually doing. Works for state bugs, race
- conditions, render order, data flow — anything where you need to know _what happened_, not
- _what it looks like_. Zero vision tokens, near-zero cost.
-2. **CDP eval / recipes** — Query app state directly via `__AGENTIC__` bridge. Returns
- structured JSON. Use when you need to assert on controller state, position data, or
- any value the UI consumes. Cheap but each call is a tool invocation.
-3. **Screenshots** — Capture the screen for visual feedback. Use when implementing from a
- design reference and comparing against designer mockups. Triggers a vision model call —
- reserve for cases where visual appearance is what you're verifying.
-4. **System logs (logcat / Console.app)** — For native module work (Objective-C, Java/Kotlin).
- Rare on MetaMask Mobile since most code is JS/TS in the React Native layer.
-
-**Rule of thumb:** if you can verify with a log line, don't take a screenshot. If you can
-verify with a recipe, don't write custom CDP eval. Always start at level 1.
-
-### HUD overlay — making videos reviewable
-
-Agents produce video recordings as PR evidence, but raw video of an app being tapped by
-an invisible hand is hard for human reviewers to follow. The **Agent Step HUD**
-(`AgentStepHud.tsx`) solves this by rendering a persistent on-screen overlay during recipe
-execution that shows the current step ID, description, and action type.
-
-The HUD is enabled by default. Use `--no-hud` to disable it. Before each step executes,
-the runner sends the step metadata to the app via CDP eval, and `AgentStepHud` renders it
-as an overlay banner. The HUD propagates through `flow_ref` sub-invocations
-automatically, so nested flow steps are annotated too.
-
-This turns an opaque screen recording into a narrated walkthrough: reviewers see exactly
-what the agent is testing at each moment, which assertion is running, and what the
-expected outcome is — without needing to cross-reference the recipe JSON. The result is a
-tighter feedback loop between autonomous agents and human reviewers: the video itself
-communicates intent.
-
-### The compounding effect
-
-- Wallet fixtures make recipes deterministic (known starting state)
-- Recipes make bug fixes provable (assertion = proof)
-- CDP instrumentation makes recipes cheap (text, not vision)
-- Pre-conditions catch stale state early (fail fast with hints)
-- `flow_ref` lets agents compose complex scenarios from simple building blocks
-- Each recipe written for one PR becomes reusable regression for future PRs
-
-### Beyond single agents
-
-The toolkit is designed to be consumed by autonomous orchestration systems. The orchestrator
-dispatches tasks using **workflow templates** (bug fix, PR review, feature dev) that are
-project-scoped, not team-scoped. An outer orchestrator can:
-
-1. **Dispatch tasks** — assign a Jira ticket to an agent with a worker template
-2. **Prepare the environment** — run `preflight.sh` to get the slot ready
-3. **Monitor progress** — poll the task file for status transitions
-4. **Validate results** — re-run the agent's recipe to confirm the fix independently
-5. **Scale horizontally** — run multiple agents in parallel worktrees, each with its own
- `WATCHER_PORT`, device, and wallet fixture
-
-The worker template injects team-specific context (which flows to run, which pre-conditions
-to check) via template variables — different teams have different flow libraries but share
-the same preflight, CDP bridge, recipe runner, and assertion engine.
-
-This works because the toolkit's contracts are stable: fixtures produce known state, recipes
-produce JSON assertions, CDP returns structured data. An orchestrator just prepares the
-environment and lets the agent use the toolkit's primitives.
-
----
-
-## Practical Example: Bug Fix Workflow
-
-Here's a concrete example from the perps team — the first adopter. The same pattern
-applies to any team's flows.
-
-An agent is assigned: "TP/SL values don't persist after edit."
-
-1. **Preflight** — wallet restored with funds on testnet (~5s)
-2. **`flow_ref: trade-open-market`** — opens a BTC long position ($10)
-3. **`flow_ref: tpsl-create`** — sets initial TP/SL using percentage presets (TP +25%, SL -10%)
-4. **Recipe: read TP/SL** — `recipe perps/core/tpsl-orders` → assert TP/SL orders exist (PASS)
-5. **`flow_ref: tpsl-edit`** — changes TP/SL presets (TP +50%, SL -25%)
-6. **Recipe: read TP/SL** — assert updated TP/SL values (FAIL — bug confirmed, still shows old values)
-7. **Agent reads code** — finds stale cache in the edit handler, fixes it
-8. **Hot-reload** — Metro picks up changes (~2s)
-9. **Re-run steps 5-6** — assert updated TP/SL values (PASS — fix verified)
-10. **Recipe goes into PR** as `recipe.json` — reviewer runs `validate-recipe.sh` to verify
-
-Total time from bug confirmation to verified fix: under 3 minutes of agent wall time.
-The recipe.json is the test, the reproduction, and the proof — all in one file.
-
----
-
-## Cross-Reference
-
-- `docs/perps/perps-agentic-feedback-loop.md` — full reference for all commands, actions,
- routes, and pre-conditions
-- `docs/perps/agentic-scripts-quickref.md` — cheat sheet for daily use
-- `scripts/perps/agentic/schemas/flow.schema.json` — formal flow specification
-- `scripts/perps/agentic/teams/README.md` — contribution guide for adding a new team
-- `app/core/AgenticService/AgenticService.ts` — bridge implementation
diff --git a/locales/languages/en.json b/locales/languages/en.json
index 5588603e8747..80dd67c4349d 100644
--- a/locales/languages/en.json
+++ b/locales/languages/en.json
@@ -2301,6 +2301,7 @@
},
"world_cup": {
"title": "World Cup",
+ "predictions_title": "World Cup predictions",
"banner_title": "World Cup 2026",
"banner_description": "Trade every match, every moment.",
"tabs": {
@@ -2380,9 +2381,15 @@
"new_prediction": "New prediction",
"resolved_markets": "Resolved markets"
},
+ "positions_empty": {
+ "description": "Your predictions will appear here, showing your stake and market movements",
+ "browse_markets": "Browse markets"
+ },
"tabs": {
"about": "About",
+ "active_positions": "Active positions",
"positions": "Positions",
+ "history": "History",
"outcomes": "Outcomes"
},
"outcome_groups": {
@@ -4068,13 +4075,18 @@
"risky": "Risky",
"malicious_token_title": "Malicious token",
"malicious_token_description": "{{symbol}} is a malicious token. Avoid interacting with it or trading it.",
+ "malicious_token_description_no_symbol": "This is a malicious token. Avoid interacting with it or trading it.",
"malicious_token_banner_description": "{{symbol}} is flagged as malicious. It's likely to steal funds from anyone who interacts with it.",
+ "malicious_token_banner_description_no_symbol": "This token is flagged as malicious. It's likely to steal funds from anyone who interacts with it.",
"suspicious_token_description": "{{symbol}} is a suspicious token.",
+ "suspicious_token_description_no_symbol": "This is a suspicious token.",
"verified_token_title": "Verified token",
"verified_token_description": "{{symbol}} is actively traded and is widely recognized. Verification is not an endorsement by MetaMask.",
"risky_token_title": "Suspicious token",
"risky_token_description": "{{symbol}} is flagged as suspicious. Take a look at the risks before you continue.",
+ "risky_token_description_no_symbol": "This token is flagged as suspicious. Take a look at the risks before you continue.",
"malicious_token_sheet_description": "Serious risk signals detected on {{symbol}}. We recommend not trading this token.",
+ "malicious_token_sheet_description_no_symbol": "Serious risk signals detected on this token. We recommend not trading this token.",
"got_it": "Got it",
"proceed": "Proceed",
"continue_anyway": "Continue anyway",
@@ -9252,6 +9264,8 @@
"title": "Explore",
"crypto_movers": "Crypto movers",
"perps_movers": "Perps movers",
+ "perps_movers_pill_gainers": "Gainers",
+ "perps_movers_pill_losers": "Losers",
"trending_tokens": "Trending",
"crypto": "Crypto",
"stocks": "Stocks",
diff --git a/package.json b/package.json
index 081792342ed9..2e938b775ef7 100644
--- a/package.json
+++ b/package.json
@@ -160,10 +160,10 @@
"a:status": "scripts/perps/agentic/app-state.sh status",
"a:reload": "scripts/perps/agentic/reload-metro.sh",
"a:navigate": "scripts/perps/agentic/app-navigate.sh",
- "a:ios": "scripts/perps/agentic/preflight.sh --platform ios --wallet-setup",
- "a:android": "scripts/perps/agentic/preflight.sh --platform android --wallet-setup",
- "a:setup:ios": "scripts/perps/agentic/preflight.sh --platform ios --clean --wallet-setup",
- "a:setup:android": "scripts/perps/agentic/preflight.sh --platform android --clean --wallet-setup"
+ "a:ios": "scripts/perps/agentic/preflight.sh --platform ios --mode fast --wallet-setup",
+ "a:android": "scripts/perps/agentic/preflight.sh --platform android --mode fast --wallet-setup",
+ "a:setup:ios": "scripts/perps/agentic/preflight.sh --platform ios --mode clean --wallet-setup",
+ "a:setup:android": "scripts/perps/agentic/preflight.sh --platform android --mode clean --wallet-setup"
},
"lint-staged": {
"*.{js,jsx,ts,tsx}": [
@@ -243,12 +243,12 @@
"@metamask/account-tree-controller": "^7.2.0",
"@metamask/accounts-controller": "^38.0.0",
"@metamask/address-book-controller": "^7.1.2",
- "@metamask/ai-controllers": "0.6.3",
+ "@metamask/ai-controllers": "^0.7.0",
"@metamask/analytics-controller": "^1.0.0",
"@metamask/app-metadata-controller": "^2.0.0",
"@metamask/approval-controller": "^9.0.0",
- "@metamask/assets-controller": "^8.0.1",
- "@metamask/assets-controllers": "^108.1.0",
+ "@metamask/assets-controller": "^8.2.0",
+ "@metamask/assets-controllers": "^108.3.0",
"@metamask/authenticated-user-storage": "^2.0.0",
"@metamask/base-controller": "^9.0.1",
"@metamask/bitcoin-wallet-snap": "^1.11.0",
diff --git a/scripts/perps/agentic/CDP-capabilities-mobile.md b/scripts/perps/agentic/CDP-capabilities-mobile.md
index 5d9473da8a8e..1c8b3e6ee4d7 100644
--- a/scripts/perps/agentic/CDP-capabilities-mobile.md
+++ b/scripts/perps/agentic/CDP-capabilities-mobile.md
@@ -1,127 +1,17 @@
-# MetaMask Mobile — CDP Capabilities
+# Mobile CDP bridge capabilities
-Mobile mirror of the extension's CDP capabilities study. Records which runner capabilities are exposed, how they're validated, and which families are structurally absent.
+Mobile keeps a small product-side bridge for local development builds. External
+Recipe v1 runners use this bridge to implement portable actions.
-Substrate:
+Supported bridge commands include:
-- **Hermes CDP** via Metro inspector-proxy (Runtime.evaluate, Profiler)
-- **Device layer** via `xcrun simctl` (iOS) / `adb` (Android)
-- **In-app bridges**: `globalThis.__AGENTIC__`, Redux `store`, `Engine.context.*`, React DevTools hook
+- route/status inspection;
+- navigation and back navigation;
+- selected-account reads and account switching;
+- testID press, input, and scroll helpers;
+- screenshot capture through the companion shell script;
+- Hermes profiler start/stop;
+- console/error issue capture for validation evidence.
-## Supported families
-
-| Family | Recipe verbs | Mobile path | Status |
-| --- | --- | --- | --- |
-| Runtime / eval | `eval_sync`, `eval_async`, `eval_ref` | `Runtime.evaluate` | validated |
-| UI interaction | `navigate`, `press`, `scroll`, `set_input`, `type_keypad`, `wait_for` | `__AGENTIC__` + fiber walk | validated |
-| Lifecycle | `app_background`, `app_foreground`, `app_restart` | simctl / adb | validated |
-| App surface | `select_account`, `toggle_testnet`, `switch_provider` | Redux + `Engine.context.*` | validated |
-| Evidence (manual) | `screenshot`, `log_watch`, `manual` | `screenshot.sh` / Metro log | validated |
-| Evidence (automatic) | built-in run-wide issue review | Metro log + in-app console hook | validated 2026-04-17 |
-| Performance | `eval_sync` on `HermesInternal.getInstrumentedStats()` | Hermes built-in | validated 2026-04-17 |
-| Tracing | `trace_start`, `trace_stop` | Hermes `Profiler` via CDP | validated 2026-04-17 |
-
-The runner exposes intent, not raw CDP plumbing. Canonical recipes live in `teams/perps/recipes/capabilities/`.
-
-## Structurally absent (document, don't force)
-
-| Family | Why | Workaround |
-| --- | --- | --- |
-| Network (offline/throttling) | no Hermes Network domain; iOS NLC is device-wide | XHR/fetch monkey-patch via `eval_sync` (narrow); simctl NLC (blunt) |
-| Emulation — CPU | no Hermes Emulation domain | synthetic JS burn loop (not equivalent) |
-| Emulation — media / timezone | no Hermes Emulation domain | `xcrun simctl status_bar` + appearance |
-| Storage (web) | no Hermes Storage domain | MMKV / Redux clear via `eval_ref` |
-| Service worker | no RN analog | `app_background` / `app_foreground` |
-| Target (multi-page) | one Hermes target per simulator | N/A |
-| Browser permissions | no Browser CDP domain | `xcrun simctl privacy` (deferred) |
-| Fetch (request failure) | no Hermes Fetch domain | `global.fetch` / XHR monkey-patch |
-
-## Capability details
-
-### Performance metrics snapshot
-
-`eval_sync` on `HermesInternal.getInstrumentedStats()` — GC counters, heap/allocation stats, RN bridge counters — plus `global.performance.now()` for timestamps. Direct Hermes analog to Chrome's `Metrics.Timestamp`. Canonical: `capabilities/performance-metrics-smoke.json`.
-
-### Hermes sampling profiler
-
-`trace_start` / `trace_stop` call the CDP `Profiler` domain. Output is a Chrome-compatible `.cpuprofile` under `.agent/recipe-runs//traces/trace-.cpuprofile`. Hermes quirk: `Profiler.enable` is unsupported (`-32601`); bridge calls `start` / `stop` directly. Canonical: `capabilities/profiler-trace-smoke.json`.
-
-Wiring: `cdp-bridge.js` `profiler-start` / `profiler-stop`; `validate-recipe.js` action cases; `lib/workflow.js` verb allowlist.
-
-### Automatic recipe issue review
-
-Every recipe run emits a uniform review contract without per-recipe wiring. Workers do **not** add `log_watch` for generic warnings/errors/exceptions.
-
-Dual-channel capture, merged at teardown:
-
-- **In-app console hook** — one-shot `Runtime.evaluate` at run start wraps `console.warn` / `console.error`, attaches `error` / `unhandledrejection` listeners, chains `ErrorUtils.setGlobalHandler`. Pushes to `globalThis.__AGENTIC_ISSUES__` (500-entry cap).
-- **Metro log tail** — records byte offset at run start; classifies the appended slice at teardown via regex (`WARN`, `ERROR`, `Uncaught` / `unhandledRejection` / `FATAL EXCEPTION`).
-
-Artifacts per run dir (`.agent/recipe-runs/_/`):
-
-| File | Content |
-| --- | --- |
-| `recipe-issues.json` | all issues, deduped by `(level, channel, source, textHash)` |
-| `console-warnings.json` | `level === "warning"` |
-| `console-errors.json` | `level === "error"` |
-| `runtime-exceptions.json` | `level === "exception"` |
-| `recipe-issues-review.json` | synthesized worker-facing review |
-| `recipe-issues-review.md` | human-readable equivalent |
-
-`summary.json.recipeIssues = { captured, unexpected, failOn, review }` where `review = { status, note, observed, gating, informational, topIssues, artifactFiles }`.
-
-`review.status`:
-
-- `clean` — nothing captured
-- `review` — passed but issues observed; worker should mention with artifact paths. **Not automatic proof the PR caused the signal** (may be ambient RN noise).
-- `gating` — `fail_on_unexpected` matched; recipe forced to `FAIL`
-
-Opt-in gating (recipe top-level, all sub-fields optional):
-
-```json
-{
- "fail_on_unexpected": {
- "levels": ["error", "exception"],
- "textMatches": ["^Fatal:", "SES_UNCAUGHT"],
- "allowlist": [
- { "textMatch": "ExpoModulesProxy", "level": "warning", "reason": "known benign" }
- ]
- }
-}
-```
-
-Allowlist is evaluated first: matches move `observed` → `informational` and never trigger gating.
-
-Canonical: `capabilities/recipe-issues-smoke.json`.
-
-Limitations:
-
-- No WebView / in-app browser console (no mobile WebView CDP target).
-- No native crash ingestion (iOS crash reports, Crashlytics, Sentry). JS layer only.
-- No Android logcat (scope = iOS simulator).
-- Metro regex classification is coarser than real CDP event types; in-app hook entries preferred when both channels report same text.
-- In-app hook installs at run start; boot-time exceptions fall through to metro-log only.
-
-## Recipe vocabulary
-
-**UI**: `navigate`, `wait`, `wait_for`, `press`, `scroll`, `set_input`, `type_keypad`, `clear_keypad`
-**Eval**: `eval_sync`, `eval_async`, `eval_ref`
-**App state**: `select_account`, `toggle_testnet`, `switch_provider`
-**Lifecycle**: `app_background`, `app_foreground`, `app_restart`
-**Evidence**: `screenshot`, `log_watch`, `manual`
-**Profiling**: `trace_start`, `trace_stop`
-**Control**: `switch`, `end`, `call`
-
-Converges with extension where semantics align; diverges where mobile has no primitive (no `network`, `fetch`, `storage`, `emulation`, `service_worker`, `target`, `browser`).
-
-## Next steps
-
-1. Wrap `xcrun simctl privacy` as `permission_grant` / `permission_reset` — smallest useful gap for controlled experiments.
-2. Expose `global.fetch` / XHR fault injection via `eval_ref`. Defer until a concrete recipe demands it.
-3. **Do not** add synthetic network throttling. Network conditioning belongs to Detox mock servers or whole-device NLC; a fake primitive weakens the parity claim.
-
-## References
-
-- [CDP-summary-mobile.md](./CDP-summary-mobile.md) — matrix only
-- [validate-recipe.js](./validate-recipe.js), [cdp-bridge.js](./cdp-bridge.js), [lib/workflow.js](./lib/workflow.js)
-- `teams/perps/recipes/capabilities/` — canonical smokes
+Recipe files, capability smokes, and action manifests live in the external
+MetaMask recipe runner, not in this Mobile repository.
diff --git a/scripts/perps/agentic/CDP-summary-mobile.md b/scripts/perps/agentic/CDP-summary-mobile.md
index ffccee148d3a..fe49230eec09 100644
--- a/scripts/perps/agentic/CDP-summary-mobile.md
+++ b/scripts/perps/agentic/CDP-summary-mobile.md
@@ -1,54 +1,17 @@
-# MetaMask Mobile — CDP Summary
+# Mobile CDP bridge summary
-Quick-reference parity matrix for the mobile agentic runner. Detailed rationale lives in [CDP-capabilities-mobile.md](./CDP-capabilities-mobile.md).
+The Mobile CDP bridge exposes development-only app control for external Recipe
+v1 runners and local diagnostics. It does not own recipe definitions or flow
+composition.
-## Validated capability matrix
+Core entry points:
-| Family | Slice | Substrate | Canonical recipe |
-| --- | --- | --- | --- |
-| `runtime` | sync / async / ref evaluation | Hermes `Runtime.evaluate` | any `benchmark/*.json` |
-| `page` | navigate / press / scroll / set_input | `__AGENTIC__` + React DevTools hook | `benchmark/perps-position-market-buy.json` |
-| `page` | `wait_for` route / testID / expression | bridge + fiber walk | same |
-| `lifecycle` | background / foreground / restart | `xcrun simctl` (iOS) / `adb` (Android) | `benchmark/perps-app-restart-preserves-state.json` |
-| `app_state` | testnet / provider / account | Redux + `Engine.context.*` | `benchmark/perps-position-market-buy.json` |
-| `evidence` | screenshot | `screenshot.sh` | any |
-| `evidence` | automatic issue review (warnings/errors/exceptions) | Metro log + in-app console hook | `capabilities/recipe-issues-smoke.json` |
-| `performance` | metrics snapshot | `HermesInternal.getInstrumentedStats()` | `capabilities/performance-metrics-smoke.json` |
-| `trace` | sampling CPU profile (.cpuprofile) | Hermes `Profiler` domain | `capabilities/profiler-trace-smoke.json` |
+- `cdp-bridge.js` — Hermes CDP command bridge.
+- `app-state.sh` — route/account status snapshot.
+- `app-navigate.sh` — local navigation diagnostic.
+- `setup-wallet.sh` — deterministic wallet fixture setup through
+ `AgenticService`.
+- `screenshot.sh` — visible proof capture.
-## Structurally absent (do not force)
-
-| Family | Why | Workaround if needed |
-| --- | --- | --- |
-| `network` | no Hermes Network domain; iOS NLC is device-wide | XHR/fetch monkey-patch via `eval_sync` |
-| `emulation` CPU | no Hermes Emulation | synthetic JS burn loop (not equivalent) |
-| `emulation` media/timezone | no Hermes Emulation | `xcrun simctl status_bar` + appearance |
-| `storage` web | no Hermes Storage | MMKV/Redux clear via `eval_ref` |
-| `service_worker` | no RN analog | `app_background` / `app_foreground` |
-| `target` multi-page | one Hermes target per sim | N/A |
-| `browser` permissions | no Browser CDP | `xcrun simctl privacy` (deferred) |
-| `fetch` request failure | no Hermes Fetch | `global.fetch` / XHR monkey-patch |
-
-## Directory convention
-
-- `teams//recipes/benchmark/` — migrated Detox specs
-- `teams//recipes/capabilities/` — generic capability proofs
-- `teams//recipes/` — other product recipes
-
-Product behavior stays separate from capability proofs.
-
-## Validation stance
-
-Smallest trustworthy proof per slice:
-
-- Hermes-layer (Profiler, getInstrumentedStats): CDP response shape + artifact size
-- Device-layer (simctl/adb): before/after probe of page-visible effect
-- App-layer (Redux, Engine.context, `__AGENTIC__`): direct `eval_ref`
-- Structurally absent families: documented as gaps, no synthetic equivalents
-
-## References
-
-- [CDP-capabilities-mobile.md](./CDP-capabilities-mobile.md)
-- [validate-recipe.js](./validate-recipe.js) — runner
-- [cdp-bridge.js](./cdp-bridge.js) — CDP dispatcher
-- [lib/workflow.js](./lib/workflow.js) — action allowlist
+Portable recipes and action manifests are maintained in the external MetaMask
+recipe runner.
diff --git a/scripts/perps/agentic/GETTING_STARTED.md b/scripts/perps/agentic/GETTING_STARTED.md
deleted file mode 100644
index ed3c33d24818..000000000000
--- a/scripts/perps/agentic/GETTING_STARTED.md
+++ /dev/null
@@ -1,101 +0,0 @@
-# Getting Started — Agentic Recipes
-
-Run your first recipe on a side simulator without touching the iPhone-16-Pro + Metro you already use to develop.
-
-## Prereqs
-
-You already have the repo cloned, Pods installed, an iOS simulator booted, and Metro on `8081` for daily work. Keep it running. This doc spins up a *second* simulator on a *different* Metro port. `node`, `jq`, `xcrun`, and your wallet seed phrase are the only extras.
-
-## Setup
-
-**1. Create a dedicated simulator + pick a port.** Recommended: a fresh simulator used only for recipes — keeps your dev sim's wallet, history, and notifications untouched and makes it obvious which window is the recipe runner.
-
-```bash
-xcrun simctl create "mm-agentic" "iPhone 15" # one-time; reuse the same name forever
-```
-
-(Or skip this and pick any non-dev simulator from `xcrun simctl list devices available | grep iPhone`.)
-
-Append to `.js.env` (preflight reads these; they override defaults):
-
-```bash
-IOS_SIMULATOR=mm-agentic # the dedicated sim, NOT your dev sim
-WATCHER_PORT=8062 # any free port other than 8081
-```
-
-**2. Create the wallet fixture.** The recipe needs an unlocked wallet at boot:
-
-```bash
-mkdir -p .agent
-cp scripts/perps/agentic/wallet-fixture.example.json .agent/wallet-fixture.json
-# Edit .agent/wallet-fixture.json — fill in `password` and at least one account
-# (type `mnemonic` with a 12-word seed, or `privateKey` with `0x...`).
-```
-
-`.agent/wallet-fixture.json` is gitignored. Use a throwaway seed for testnet work.
-
-**3. Run preflight.** Boots the chosen simulator, builds + installs the app on `$WATCHER_PORT`, starts Metro, connects CDP, imports the fixture wallet:
-
-```bash
-yarn a:setup:ios
-```
-
-Expected tail: `=== Preflight complete ===` with all steps green. Your dev simulator + Metro on `8081` keep running untouched.
-
-**4. Verify the toolkit is wired up.** Do not skip — recipes require all three (Metro, CDP, route) to be live:
-
-```bash
-yarn a:status
-```
-
-Expected: JSON with a `route` field (e.g. `"Wallet"`) and a selected account address. Empty/error output ⇒ stop and re-check step 3 before running a recipe.
-
-## Run a recipe
-
-```bash
-bash scripts/perps/agentic/validate-recipe.sh \
- scripts/perps/agentic/teams/perps/recipes/provider-smoke.json
-```
-
-What to look for: a Mermaid graph prints, then per-node `pass` lines (`check-testnet → decide-testnet → … → done`), then `summary: pass`. Artifacts (`trace.json`, `summary.json`, `workflow.mmd`) land in the recipe's output dir.
-
-## What to try next
-
-| Recipe | What it does | Notes |
-|---|---|---|
-| `teams/perps/recipes/app-lifecycle.json` | App launch + route smoke | No funded account needed |
-| `teams/perps/recipes/full-trade-lifecycle.json` | Open → TPSL → close on testnet | Needs a funded testnet account in fixture |
-| `teams/perps/recipes/reference-decimal-key-screens.json` | Visual decimal/format audit | Produces screenshots |
-
-## Troubleshooting
-
-Re-run `yarn a:status` first when anything looks off — it pinpoints which leg (Metro / CDP / route) is down.
-
-- **`Neither IOS_SIMULATOR nor SIM_UDID is set`** — `.js.env` change wasn't picked up. New shell, or check the var name.
-- **Preflight booted/built on your dev simulator** — `IOS_SIMULATOR` matches the dev sim's name. Pick a different one.
-- **CDP timeout, "targets found on 8081 but none on $PORT"** — `WATCHER_PORT` collided or wasn't exported. Confirm `.js.env` has a non-8081 port and re-run.
-- **`Wallet fixture not found`** — step 2 was skipped or `.agent/wallet-fixture.json` is in the wrong directory (must be repo root).
-
-After a successful run, `xcrun simctl list devices | grep Booted` shows both simulators (dev + recipe) booted.
-
-## The `a:*` script family
-
-`a` is for *agentic* — thin yarn aliases over the same scripts in `scripts/perps/agentic/` that validate-recipe.sh uses. Same plumbing the toolkit uses to drive the app, exposed for humans. Goal is for these to become the default way to launch the app for daily dev once they've fully replaced the legacy `yarn start` / `yarn ios` flow.
-
-| Script | What it does |
-|---|---|
-| `yarn a:setup:ios` / `:android` | Full clean: deps + build + install + Metro + CDP + wallet (this doc, step 3) |
-| `yarn a:ios` / `:android` | Same as `a:setup:*` but skips clean — fast re-launch when build artifacts are warm |
-| `yarn a:start` / `a:stop` | Metro lifecycle on `$WATCHER_PORT` |
-| `yarn a:reload` | Reload the JS bundle without restarting Metro |
-| `yarn a:status` | Health probe (Metro + CDP + route) |
-| `yarn a:navigate` | Drive the app to a route from the CLI |
-| `yarn a:watch` | Interactive Metro with live log filtering |
-
-## Further reading
-
-- [docs/perps/perps-agentic-system-design.md](../../../docs/perps/perps-agentic-system-design.md) — architecture: CDP bridge, recipe runner, eval refs/flows
-- [docs/perps/perps-agentic-feedback-loop.md](../../../docs/perps/perps-agentic-feedback-loop.md) — how recipes plug into the dev/PR loop
-- [docs/perps/perps-agentic-scripts-quickref.md](../../../docs/perps/perps-agentic-scripts-quickref.md) — full command reference for everything under `scripts/perps/agentic/`
-- [README.md](./README.md) — directory map for this folder
-- ADR — [decisions/core/0058-recipe-based-verification-system.md](https://github.com/MetaMask/decisions/blob/main/decisions/core/0058-recipe-based-verification-system.md) — why recipes exist
diff --git a/scripts/perps/agentic/README.md b/scripts/perps/agentic/README.md
index 4e6193260f6a..60c3e7015850 100644
--- a/scripts/perps/agentic/README.md
+++ b/scripts/perps/agentic/README.md
@@ -1,307 +1,46 @@
-# Agentic Workflow Toolkit
+# Mobile agentic bridge
-Automated validation toolkit for MetaMask Mobile. Drives the app on a simulator over CDP (Chrome DevTools Protocol via Hermes inspector-proxy) — no manual tapping, no Detox build cycle.
+This directory contains the MetaMask Mobile product-side bridge used by external
+Farmslot Recipe v1 runners. It is not the recipe authoring surface.
-## Scope
+## Ownership boundary
-Lives under `scripts/perps/` and is owned by `@MetaMask/perps`. The infrastructure (`lib/`, validators, `teams/` layout) is team-agnostic by design. The intent is to promote it to a shared `scripts/agentic/` location with each product team owning `teams//`.
+Mobile owns only the code required to make a development build controllable and
+observable:
-## Layout
+- `cdp-bridge.js` and `lib/cdp-eval.js` connect to the React Native Hermes CDP
+ target.
+- `app-state.sh`, `app-navigate.sh`, and `screenshot.sh` provide small local
+ diagnostics for humans and runners.
+- `setup-wallet.sh` applies deterministic wallet fixtures through the
+ `AgenticService` bridge.
+- Metro/preflight scripts start, stop, reload, and check a development runtime.
-```
-agentic/
- cdp-bridge.js CDP client: eval, navigate, press, scroll, eval-ref, profiler-start/stop
- validate-recipe.js Recipe runner (live app)
- validate-recipe.sh Wrapper for validate-recipe.js
- validate-flow-schema.js Offline: enforce flow/recipe authoring rules
- validate-pre-conditions.js Offline: verify pre-condition assertion fixtures
- lib/
- assert.js Assertion evaluator
- catalog.js Team/flow/eval discovery + template rendering + ref resolution
- workflow.js Graph normalization, cycle detection, Mermaid
- recipe-issues.js Automatic issue-review capture
- cdp-eval.js Low-level CDP eval helpers
- ws-client.js WebSocket client
- screenshot.js Screenshot filename helpers
- config.js Runtime config (ports, paths)
- schemas/flow.schema.json JSON Schema for flow docs
- teams/
- perps/ Perps team (flows, recipes, pre-conditions, evals)
- mobile-platform/ Placeholder
- app-state.sh Wrapper: status, eval, press, eval-ref, accounts
- app-navigate.sh Navigate to any registered screen
- screenshot.sh Capture simulator screenshot
- start-metro.sh Start Metro bundler
- preflight.sh Verify app + Metro + CDP connectivity
- setup-wallet.sh Seed wallet from fixture
-```
-
-## Concepts
-
-### Flows
-
-Parameterized, reusable UI sequences. Live in `teams//flows/.json`. Declare `inputs` (with defaults) and a `validate.workflow` graph. `{{param}}` tokens resolve at runtime.
-
-```json
-{
- "title": "Trade — market {{side}} {{symbol}} ${{usdAmount}}",
- "inputs": {
- "side": { "type": "string", "default": "long" },
- "symbol": { "type": "string", "default": "BTC" },
- "usdAmount": { "type": "string", "default": "10" }
- },
- "validate": {
- "workflow": {
- "pre_conditions": ["wallet.unlocked", "perps.ready_to_trade"],
- "entry": "nav",
- "nodes": {
- "nav": { "action": "navigate", "target": "PerpsMarketDetails", "params": { "market": { "symbol": "{{symbol}}" } }, "next": "press-side" },
- "press-side": { "action": "press", "test_id": "perps-market-details-{{side}}-button", "next": "place-order" },
- "place-order": { "action": "press", "test_id": "perps-order-view-place-order-button", "next": "done" },
- "done": { "action": "end", "status": "pass" }
- }
- }
- }
-}
-```
-
-### Recipes
-
-Directed graph that composes flows and inline steps. Live in `teams//recipes/`. Nodes keyed by ID; most have a `next` pointer. `switch` branches on assertions. `end` terminates.
-
-```json
-{
- "entry": "setup",
- "nodes": {
- "setup": { "action": "call", "ref": "setup-testnet", "next": "open" },
- "open": { "action": "call", "ref": "trade-open-market", "params": { "symbol": "BTC" }, "next": "verify" },
- "verify":{ "action": "eval_ref", "ref": "positions", "assert": { "operator": "length_gt", "value": 0 }, "next": "close" },
- "close": { "action": "call", "ref": "trade-close-position", "params": { "symbol": "BTC" }, "next": "done" },
- "done": { "action": "end", "status": "pass" }
- }
-}
-```
-
-### Branching — `switch`
-
-Cases evaluate against `env`, `inputs`, `vars`, `last`; `default` catches unmatched.
-
-```json
-{
- "decide": {
- "action": "switch",
- "cases": [
- { "when": { "operator": "length_gt", "field": "vars.positions.length", "value": 0 }, "next": "close-first" },
- { "when": { "operator": "length_eq", "field": "vars.positions.length", "value": 0 }, "next": "open-new" }
- ],
- "default": "open-new"
- }
-}
-```
-
-### Guards — `when` / `unless`
-
-Any executable node can guard on the same condition language; skipped nodes fall through to `next`.
-
-```json
-{
- "maybe-close": {
- "action": "call",
- "ref": "trade-close-position",
- "when": { "operator": "gt", "field": "vars.positionCount", "value": 0 },
- "next": "done"
- }
-}
-```
-
-Context available to guards and switch: `env` (appRoot, recipePath, team), `inputs` (templated params), `vars` (via `save_as`), `last` (most recent result), `nodes` (per-node records).
-
-### Setup / teardown
-
-Linear arrays before/after the workflow graph. Teardown runs on both pass and fail.
-
-```json
-{
- "setup": [{ "id": "enable-testnet", "action": "toggle_testnet", "enabled": true }],
- "teardown": [{ "id": "close-all", "action": "eval_async", "expression": "Engine.context.PerpsController.closeAllPositions().then(function(r){return JSON.stringify(r)})", "assert": { "operator": "not_null" } }]
-}
-```
-
-### Eval refs
-
-Named CDP eval expressions. Two homes:
-- `teams//evals.json` — quick refs (`perps/positions`)
-- `teams//evals/.json` — grouped refs (`perps/core/tpsl-orders`)
-
-```bash
-node cdp-bridge.js eval-ref --list
-node cdp-bridge.js eval-ref perps/positions
-```
-
-### Pre-conditions
-
-Gate checks that must pass before a flow runs. Defined in `teams//pre-conditions.js`.
-
-| Field | Description |
-| --- | --- |
-| `description` | human-readable label |
-| `async` | whether expression returns a Promise |
-| `expression` | CDP eval (string, or function for parameterized) |
-| `assert` | `{ operator, field, value }` |
-| `hint` | actionable failure message |
-| `fixtures` | `{ pass, fail }` JSON strings for offline validation |
+Portable recipes, flow composition, action manifests, and MetaMask domain
+actions live outside the Mobile repository in the external recipe runner. The
+Mobile repository should not grow task-specific recipes or reusable recipe
+actions.
-```js
-'wallet.unlocked': {
- description: 'Wallet is unlocked and app is navigable',
- async: false,
- expression: '(function(){ var r=globalThis.__AGENTIC__.getRoute().name; return JSON.stringify({route:r,unlocked:r!=="Login"}); })()',
- assert: { operator: 'eq', field: 'unlocked', value: true },
- hint: 'Unlock the wallet first.',
- fixtures: {
- pass: '{"route":"WalletView","unlocked":true}',
- fail: '{"route":"Login","unlocked":false}',
- },
-},
-```
-
-## Actions
-
-| Action | Required | Purpose |
-| --- | --- | --- |
-| `navigate` | `target` | go to a screen |
-| `press` | `test_id` | tap component by testID |
-| `set_input` | `test_id`, `value` | type into TextInput |
-| `type_keypad` | `value` | type digits via keypad buttons |
-| `clear_keypad` | — | press delete N times (default 8) |
-| `scroll` | — | scroll view (optional `test_id`, `offset`) |
-| `eval_sync` | `expression`, `assert` | sync CDP eval |
-| `eval_async` | `expression`, `assert` | promise-based CDP eval |
-| `eval_ref` | `ref`, `assert` | run a named eval ref |
-| `call` | `ref` | invoke another flow (workflow only) |
-| `wait` | — | pause N ms |
-| `wait_for` | condition | poll until route / test_id / expression matches. Timing fields: `timeout_ms`, `poll_ms` |
-| `log_watch` | `watch_for` / `must_not_appear` | scan Metro logs |
-| `screenshot` | — | capture screen |
-| `manual` | — | human intervention point |
-| `select_account` | `address` | switch Ethereum account |
-| `toggle_testnet` | — | enable/disable testnet |
-| `switch_provider` | `provider` | switch perps provider |
-| `app_background` | — | send app to home. `duration_ms` default 5000 |
-| `app_foreground` | — | relaunch via `xcrun simctl launch` |
-| `app_restart` | — | terminate + relaunch. `boot_wait_ms` default 15000 |
-| `trace_start` / `trace_stop` | `label` | Hermes sampling profiler → `.cpuprofile` |
-| `switch` | `cases` | branch on assertions (workflow only) |
-| `end` | — | terminal node (workflow only) |
-
-## Assertion operators
-
-Used in `assert` blocks on steps and pre-conditions:
-
-| Operator | Meaning |
-| --- | --- |
-| `not_null` | value not null/undefined |
-| `truthy` / `falsy` | boolean truthiness |
-| `eq` / `neq` | strict equality |
-| `gt` / `lt` / `gte` / `lte` | numeric comparison |
-| `deep_eq` | deep strict equality |
-| `length_eq` / `length_gt` / `length_gte` | array/string length |
-| `contains` / `not_contains` | array or string includes |
-| `matches` | regex match (`/pattern/flags` or string) |
-| `one_of` | value in `values` array |
-| `exists` | field not undefined |
-
-Compound: `{ all: [...] }`, `{ any: [...] }`, `{ none: [...] }`.
-
-## Preflight modes
-
-`preflight.sh` accepts `--mode ` to control how much of the native setup runs. Cuts cold-rebuild waste when the native dep graph hasn't changed.
-
-| Mode | yarn setup | pod install | xcodebuild | Reads shared cache |
-|---|---|---|---|---|
-| `auto` (recommended) | no | only on native rebuild, no `--repo-update` (one-shot `--repo-update` retry on failure) | only on fingerprint miss | yes |
-| `fast` | no | no | no — fail loud if missing | yes |
-| `rebuild-native` | no | yes (no `--repo-update`) | yes | no |
-| `clean` (legacy `--clean`) | yes | yes with `--repo-update` | yes | no (writes only) |
-
-Cache lives in `$MM_BUILD_CACHE_DIR` (default `~/Library/Caches/mm-mobile-builds` on macOS, `~/.cache/mm-mobile-builds` on Linux), keyed by an agentic `@expo/fingerprint` hash computed by `scripts/perps/agentic/lib/compute-cache-fp.js`. The agentic fingerprint *extends* the project-wide `fingerprint.config.js` (which EAS Build and OTA still consume unchanged) with additional `ignorePaths` for per-worktree build artifacts that don't influence binary semantics (`ios/build/`, `.gradle/`, Xcode `xcuserdata`, NDK `.cxx`, etc.). Binary-affecting inputs — env-populated `xcconfig`, `google-services.json`, and the bundled `InpageBridgeWeb3.js` — stay hashed, so the cache only converges across worktrees when those inputs match. Parallel worktrees at the same fingerprint share one artifact through a per-fingerprint mutex: Linux uses `flock(1)` (auto-released by the kernel on process death); macOS, where `flock` is not in base, uses an atomic `mkdir .lock.d` fallback that is released by the script's `EXIT` trap. If a script is killed with `kill -9` between `mkdir` and the trap, the mutex dir can be left behind — delete it manually under `$MM_BUILD_CACHE_DIR//`. Override retention with `BUILD_CACHE_RETAIN=N` (default 5 per platform).
-
-Invoke directly:
-
-```bash
-bash scripts/perps/agentic/preflight.sh --platform ios --mode auto --wallet-setup # fingerprint-gated reuse, build only on miss
-bash scripts/perps/agentic/preflight.sh --platform ios --mode fast --wallet-setup # fail loud if no cached/installed build
-bash scripts/perps/agentic/preflight.sh --platform ios --clean --wallet-setup # legacy clean rebuild (unchanged)
-```
-
-Farmslot dispatch: once this branch lands on `main`, switch `projects/metamask-mobile-farm/project.json` `preflight` hook from `--clean` to `--mode auto`. Keep `--mode clean` as the explicit burn-it-down escape.
+## Local bridge commands
-## CLI
+From the repository root:
```bash
-# Run recipe (live app)
-bash scripts/perps/agentic/validate-recipe.sh teams/perps/recipes/full-trade-lifecycle.json
-bash scripts/perps/agentic/validate-recipe.sh teams/perps/recipes/full-trade-lifecycle.json --dry-run
-
-# Offline validators
-node scripts/perps/agentic/validate-flow-schema.js # all
-node scripts/perps/agentic/validate-flow-schema.js teams/perps/flows/trade-open-market.json
-node scripts/perps/agentic/validate-pre-conditions.js
-
-# App interaction
-bash scripts/perps/agentic/app-state.sh status
-bash scripts/perps/agentic/app-state.sh eval "Engine.context.PerpsController.state"
-bash scripts/perps/agentic/app-state.sh eval-ref perps/positions
-bash scripts/perps/agentic/app-state.sh eval-ref --list
-bash scripts/perps/agentic/app-navigate.sh PerpsMarketDetails '{"market":{"symbol":"BTC"}}'
-bash scripts/perps/agentic/screenshot.sh my-label
-bash scripts/perps/agentic/start-metro.sh --platform ios
-```
-
-## Adding a new team
-
-1. `teams//pre-conditions.js` exporting `Record`.
-2. Key convention: `.` (e.g. `swap.has_quote`).
-3. Include `fixtures: { pass, fail }` on every entry.
-4. Optionally add `flows/`, `recipes/`, `evals/`, `evals.json`.
-5. Run both validators:
-
-```bash
-node scripts/perps/agentic/validate-pre-conditions.js
-node scripts/perps/agentic/validate-flow-schema.js
-```
-
-See `teams/README.md` for the full contribution guide.
-
-## CDP eval rules
-
-All expressions are **ES5** — no arrow functions, `const`/`let`, template literals, top-level `await`.
-
-```js
-// OK
-var x = Engine.context.PerpsController.state;
-JSON.stringify({ count: x.positions.length });
-
-// Not OK
-const x = Engine.context.PerpsController.state;
-`count: ${x.positions.length}`;
-```
-
-Async via `.then()`:
-
-```js
-Engine.context.PerpsController.getPositions().then(function(ps) {
- return JSON.stringify({ count: ps.length });
-});
+yarn a:status
+yarn a:navigate
+yarn a:reload
+yarn a:ios # start/reuse an iOS development runtime
+yarn a:android # start/reuse an Android development runtime
```
-## Run artifacts
+Use the recipe-harness skill or the external runner for Recipe v1 execution.
+Those tools consume this bridge but own recipe semantics and evidence output.
-Every recipe run writes to `.agent/recipe-runs/_/`:
+## Fixture setup
-- `summary.json`, `workflow.json`, `workflow.mmd`, `trace.json`
-- `screenshots/`, `traces/` (`.cpuprofile` when `trace_stop` ran)
-- `recipe-issues.json` + `console-warnings.json` + `console-errors.json` + `runtime-exceptions.json`
-- `recipe-issues-review.json` + `recipe-issues-review.md`
+Create a local fixture at `.agent/wallet-fixture.json` when deterministic wallet
+state is required. `setup-wallet.sh` validates the file and applies it through
+`globalThis.__AGENTIC__` in a development build.
-`summary.json.recipeIssues` is automatic and aligned with the extension contract. See [CDP-capabilities-mobile.md](./CDP-capabilities-mobile.md#automatic-recipe-issue-review) for the review vocabulary and opt-in `fail_on_unexpected` gating.
+Do not commit real fixture secrets or task-specific validation recipes to this
+repository.
diff --git a/scripts/perps/agentic/app-state.sh b/scripts/perps/agentic/app-state.sh
index 1497f00db815..86a7e08be0f7 100755
--- a/scripts/perps/agentic/app-state.sh
+++ b/scripts/perps/agentic/app-state.sh
@@ -17,8 +17,6 @@
# scripts/perps/agentic/app-state.sh press # Press component by testID
# scripts/perps/agentic/app-state.sh scroll [--test-id ] [--offset ] # Scroll
# scripts/perps/agentic/app-state.sh set-input # Set text input value
-# scripts/perps/agentic/app-state.sh eval-ref perps/positions # Run an eval ref
-# scripts/perps/agentic/app-state.sh eval-ref --list # List eval refs
set -euo pipefail
@@ -79,9 +77,6 @@ case "$COMMAND" in
unlock)
node scripts/perps/agentic/cdp-bridge.js unlock "$@"
;;
- eval-ref)
- node scripts/perps/agentic/cdp-bridge.js eval-ref "$@"
- ;;
*)
echo "Usage: app-state.sh [args...]"
echo ""
@@ -103,8 +98,6 @@ case "$COMMAND" in
echo " set-input Set text input value by testID"
echo " sentry-debug [enable|disable] Patch Sentry to log errors to console"
echo " unlock Unlock wallet via fiber tree"
- echo " eval-ref Run an eval ref (e.g. perps/positions)"
- echo " eval-ref --list List all available eval refs"
exit 1
;;
esac
diff --git a/scripts/perps/agentic/cdp-bridge.js b/scripts/perps/agentic/cdp-bridge.js
index 983a96f75371..be453aed9adb 100644
--- a/scripts/perps/agentic/cdp-bridge.js
+++ b/scripts/perps/agentic/cdp-bridge.js
@@ -18,23 +18,11 @@
const fs = require('node:fs');
const path = require('node:path');
-const PRE_CONDITIONS = require('./lib/registry');
const { loadPort } = require('./lib/config');
const { discoverTarget } = require('./lib/target-discovery');
const { createWSClient } = require('./lib/ws-client');
const { cdpEval, cdpEvalAsync } = require('./lib/cdp-eval');
-const { checkAssert } = require('./lib/assert');
-const { buildArmSnippet, buildCollectSnippet } = require('./lib/recipe-issues');
-
-async function evalSpec(client, entry, params) {
- const expr = typeof entry.expression === 'function' ? entry.expression(params) : entry.expression;
- let raw = entry.async
- ? await cdpEvalAsync(client, expr)
- : await cdpEval(client, expr);
- if (raw === undefined || raw === null) raw = 'null';
- if (typeof raw !== 'string') raw = JSON.stringify(raw);
- return raw;
-}
+const { buildArmSnippet, buildCollectSnippet } = require('./lib/issue-capture');
// ---------------------------------------------------------------------------
// Commands
@@ -504,48 +492,6 @@ const COMMANDS = {
};
},
- async 'check-pre-conditions'(client, args) {
- const specsJson = args[0];
- if (!specsJson) throw new Error('Usage: check-pre-conditions ');
-
- let specs;
- try {
- specs = JSON.parse(specsJson);
- } catch (e) {
- throw new Error(`Invalid specs JSON: ${e.message}`);
- }
- if (!Array.isArray(specs) || specs.length === 0) return { ok: true, checked: 0 };
-
- const failures = [];
-
- for (const spec of specs) {
- const name = typeof spec === 'string' ? spec : spec.name;
- const params = typeof spec === 'object' ? spec : {};
- const entry = PRE_CONDITIONS[name];
-
- if (!entry) {
- failures.push({ name, error: `Unknown pre-condition "${name}". Check pre-conditions.js for valid names.` });
- continue;
- }
-
- let raw;
- try {
- raw = await evalSpec(client, entry, params);
- } catch (e) {
- failures.push({ name, description: entry.description, error: `Eval failed: ${e.message}`, hint: entry.hint });
- continue;
- }
-
- const passed = checkAssert(raw, entry.assert);
- if (!passed) {
- failures.push({ name, description: entry.description, got: raw, hint: entry.hint });
- }
- }
-
- const ok = failures.length === 0;
- return { ok, checked: specs.length, failures: ok ? [] : failures };
- },
-
async 'show-step'(client, args) {
const stepId = args[0] || '';
const description = args.slice(1).join(' ');
@@ -585,7 +531,7 @@ const COMMANDS = {
}
const serialized = JSON.stringify(profile);
if (!outPath) {
- const tracesDir = path.resolve(process.env.APP_ROOT || process.cwd(), 'temp/agentic/recipes/test-artifacts/traces');
+ const tracesDir = path.resolve(process.env.APP_ROOT || process.cwd(), 'temp/agentic/traces');
fs.mkdirSync(tracesDir, { recursive: true });
outPath = path.join(tracesDir, `trace-${label}.cpuprofile`);
} else {
@@ -621,93 +567,8 @@ const COMMANDS = {
return result || { count: 0, entries: [] };
},
- async 'eval-ref'(client, args) {
- const arg = args[0];
- if (!arg || arg === '--help') {
- console.error('Usage: eval-ref | eval-ref --list');
- process.exit(1);
- }
-
- const teamsDir = path.resolve(__dirname, 'teams');
-
- if (arg === '--list') {
- return listEvalRefs(teamsDir);
- }
-
- // Parse "team/name" (2-part) or "team/subfile/name" (3-part)
- const parts = arg.split('/');
- if (parts.length < 2 || parts.length > 3) {
- throw new Error('Eval ref must be "team/name" or "team/subfile/name" (e.g. perps/positions or perps/core/pump-market)');
- }
- let evalFile, evalName;
- if (parts.length === 3) {
- const [team, subfile, name] = parts;
- evalFile = path.join(teamsDir, team, 'evals', `${subfile}.json`);
- evalName = name;
- } else {
- const [team, name] = parts;
- evalFile = path.join(teamsDir, team, 'evals.json');
- evalName = name;
- }
- if (!fs.existsSync(evalFile)) {
- throw new Error(`No eval file found: ${path.relative(path.dirname(teamsDir), evalFile)}`);
- }
- const evals = JSON.parse(fs.readFileSync(evalFile, 'utf8'));
- const entry = evals[evalName];
- if (!entry) {
- const available = Object.keys(evals).join(', ');
- throw new Error(`Eval ref "${evalName}" not found. Available: ${available}`);
- }
-
- const raw = entry.async
- ? await cdpEvalAsync(client, entry.expression)
- : await cdpEval(client, entry.expression);
- // Eval expressions typically JSON.stringify their result.
- // Parse it so main()'s JSON.stringify produces clean output
- // instead of double-encoded strings.
- if (typeof raw === 'string') {
- try { return JSON.parse(raw); } catch { /* not JSON — return as-is */ }
- }
- return raw;
- },
};
-// ---------------------------------------------------------------------------
-// Eval-ref helpers
-// ---------------------------------------------------------------------------
-
-/** List all eval refs from teams//evals.json and teams//evals/*.json */
-function listEvalRefs(teamsDir) {
- const all = {};
- const teamDirs = fs.readdirSync(teamsDir, { withFileTypes: true })
- .filter((d) => d.isDirectory())
- .map((d) => d.name);
- // Top-level evals: teams//evals.json → keyed as
- for (const team of teamDirs) {
- const f = path.join(teamsDir, team, 'evals.json');
- if (fs.existsSync(f)) {
- const data = JSON.parse(fs.readFileSync(f, 'utf8'));
- all[team] = Object.fromEntries(
- Object.entries(data).map(([name, r]) => [name, r.description || ''])
- );
- }
- }
- // Sub-collections: teams//evals/.json → keyed as /
- for (const team of teamDirs) {
- const evalsDir = path.join(teamsDir, team, 'evals');
- if (!fs.existsSync(evalsDir)) continue;
- const subFiles = fs.readdirSync(evalsDir).filter((f) => f.endsWith('.json'));
- for (const file of subFiles) {
- const key = `${team}/${path.basename(file, '.json')}`;
- const data = JSON.parse(fs.readFileSync(path.join(evalsDir, file), 'utf8'));
- all[key] = Object.fromEntries(
- Object.entries(data).map(([name, r]) => [name, r.description || ''])
- );
- }
- }
- return all;
-}
-
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
@@ -740,13 +601,11 @@ Commands:
set-input Set text input value by testID (calls onChangeText)
sentry-debug [enable|disable] Patch Sentry to log errors to console with [SENTRY-DEBUG] prefix
unlock Unlock wallet (inject password + press login button via fiber tree)
- eval-ref Run an eval ref (e.g. perps/positions)
- eval-ref --list List all available eval refs
profiler-start Start Hermes sampling profiler
profiler-stop [--out ] [--label ]
Stop profiler, dump Chrome-compatible
.cpuprofile to (default:
- temp/agentic/recipes/test-artifacts/traces/trace-.cpuprofile)
+ temp/agentic/traces/trace-.cpuprofile)
issues-arm Install console/exception hooks that
populate globalThis.__AGENTIC_ISSUES__
issues-collect Snapshot + clear the in-app issue buffer
@@ -766,12 +625,6 @@ Environment:
process.exit(1);
}
- // `eval-ref --list` only reads local JSON files — skip CDP connection entirely.
- if (command === 'eval-ref' && args[1] === '--list') {
- const result = await handler(null, args.slice(1), {});
- console.log(JSON.stringify(result, null, 2));
- return;
- }
const port = loadPort();
const timeout = Number.parseInt(process.env.CDP_TIMEOUT || '5000', 10);
diff --git a/scripts/perps/agentic/e2e-recipe-benchmark.md b/scripts/perps/agentic/e2e-recipe-benchmark.md
deleted file mode 100644
index 5c5c360c88ca..000000000000
--- a/scripts/perps/agentic/e2e-recipe-benchmark.md
+++ /dev/null
@@ -1,69 +0,0 @@
-# Perps E2E Recipe Benchmark
-
-Detox perps specs couple tightly to mock infra (commandQueueServer, deposit mocks, price manipulation) that only exists inside the Detox harness. Slow to iterate, fragile, often skipped in CI.
-
-The agentic recipe runner (`validate-recipe.js`) drives a live app via Hermes CDP — no mocks. This doc records the migration of 8 Detox/Playwright specs to recipe JSON.
-
-## Results
-
-8/8 recipes validated on `mm-2` testnet; full UI-navigation parity with their Detox counterparts.
-
-### Risk-free (no trades)
-
-| Recipe | Detox spec | Nodes | Coverage |
-| --- | --- | --- | --- |
-| perps-no-funds-tutorial | smoke/perps/perps-no-funds-tutorial.spec.ts | 7/7 | tutorial show/dismiss via controller state |
-| perps-add-funds | smoke/perps/perps-add-funds.spec.ts | 9/9 | UI navigation; Detox also mocks deposit server-side |
-| perf-add-funds | performance/login/perps-add-funds.spec.ts | 7/7 | simplified variant |
-
-### Trades (real testnet orders)
-
-| Recipe | Detox spec | Nodes | Coverage |
-| --- | --- | --- | --- |
-| perps-position | smoke/perps/perps-position.spec.ts | 11/11 | open long ETH, set TP/SL, close |
-| perps-position-stop-loss | smoke/perps/perps-position-stop-loss.spec.ts | 9/9 | open long ETH, set SL, verify, close |
-| perps-position-liquidation | smoke/perps/perps-position-liquidation.spec.ts | 9/9 | UI parity; Detox also mocks price manipulation |
-| perf-position-management | performance/login/perps-position-management.spec.ts | 9/9 | open BTC long, verify, close |
-| perps-limit-long-fill | smoke/perps/perps-limit-long-fill.spec.ts | 22/22 | limit long ETH at Mid, fill, cleanup |
-
-Detox mocks server-side behavior (deposits, liquidations) — neither approach tests real backend execution. Both validate the UI flow.
-
-## What each approach validates
-
-**Recipes** — no build cycle, idempotent setup/teardown hooks, composable flows (`trade-open-market`, `trade-close-position`, `tpsl-create`), real testnet orders.
-
-**Detox** — hermetic app-data wipe before each test, mocked deposits / price / liquidations, pixel-level visual assertions, multi-app coordination.
-
-## Shared flows
-
-| Flow | Purpose |
-| --- | --- |
-| `setup-testnet` | enable testnet, verify markets load |
-| `trade-open-market` | nav → keypad → place market order |
-| `trade-close-position` | nav → close → confirm |
-| `tpsl-create` | open auto-close, set TP/SL presets |
-
-## Layout
-
-- Recipes: `scripts/perps/agentic/teams/perps/recipes/benchmark/`
-- Flows: `scripts/perps/agentic/teams/perps/flows/`
-- Runner: `scripts/perps/agentic/validate-recipe.js`
-
-## Running
-
-```bash
-# single
-IOS_SIMULATOR=mm-2 node scripts/perps/agentic/validate-recipe.js \
- scripts/perps/agentic/teams/perps/recipes/benchmark/perps-position.json
-
-# all
-for f in scripts/perps/agentic/teams/perps/recipes/benchmark/*.json; do
- IOS_SIMULATOR=mm-2 node scripts/perps/agentic/validate-recipe.js "$f"
-done
-```
-
-Prereqs: `mm-2` iOS simulator, Metro running (`yarn start`), wallet unlocked, sufficient testnet balance for trades.
-
-## Related docs
-
-- [timing-benchmark.md](./timing-benchmark.md) — dual-simulator wall-clock comparison and harness setup
diff --git a/scripts/perps/agentic/lib/assert.js b/scripts/perps/agentic/lib/assert.js
deleted file mode 100644
index ef574039e4b2..000000000000
--- a/scripts/perps/agentic/lib/assert.js
+++ /dev/null
@@ -1,147 +0,0 @@
-'use strict';
-
-const { isDeepStrictEqual } = require('node:util');
-
-function parseRaw(raw) {
- let parsed = raw;
-
- if (typeof raw === 'string') {
- try {
- parsed = JSON.parse(raw);
- } catch {
- parsed = raw;
- }
- }
-
- if (typeof parsed === 'string') {
- try {
- parsed = JSON.parse(parsed);
- } catch {
- // Keep the plain string when it is not nested JSON.
- }
- }
-
- return parsed;
-}
-
-function getFieldValue(value, field) {
- if (field == null || field === '') {
- return value;
- }
-
- let current = value;
- for (const part of String(field).split('.')) {
- if (current == null) {
- return undefined;
- }
- current = current[part];
- }
- return current;
-}
-
-function checkAssert(raw, assertSpec) {
- if (!assertSpec) {
- return true;
- }
-
- const parsed = parseRaw(raw);
- return evaluateAssert(parsed, assertSpec);
-}
-
-function normalizeRegex(value) {
- if (value instanceof RegExp) {
- return value;
- }
-
- if (typeof value !== 'string') {
- return new RegExp(String(value));
- }
-
- const match = value.match(/^\/(.+)\/([dgimsuvy]*)$/);
- if (match) {
- return new RegExp(match[1], match[2]);
- }
-
- return new RegExp(value);
-}
-
-function evaluateAssert(parsed, assertSpec) {
- if (!assertSpec) {
- return true;
- }
-
- if (Array.isArray(assertSpec.all)) {
- return assertSpec.all.every((entry) => evaluateAssert(parsed, entry));
- }
-
- if (Array.isArray(assertSpec.any)) {
- return assertSpec.any.some((entry) => evaluateAssert(parsed, entry));
- }
-
- if (Array.isArray(assertSpec.none)) {
- return assertSpec.none.every((entry) => !evaluateAssert(parsed, entry));
- }
-
- const actual = getFieldValue(parsed, assertSpec.field);
- const expected = assertSpec.value;
-
- switch (assertSpec.operator) {
- case 'exists':
- return actual !== undefined;
- case 'not_null':
- return actual != null;
- case 'truthy':
- return Boolean(actual);
- case 'falsy':
- return !actual;
- case 'eq':
- return actual === expected;
- case 'deep_eq':
- return isDeepStrictEqual(actual, expected);
- case 'neq':
- return actual !== expected;
- case 'lt':
- return typeof actual === 'number' && actual < expected;
- case 'gt':
- return typeof actual === 'number' && actual > expected;
- case 'lte':
- return typeof actual === 'number' && actual <= expected;
- case 'gte':
- return typeof actual === 'number' && actual >= expected;
- case 'length_eq':
- return actual != null && actual.length === expected;
- case 'length_gt':
- return actual != null && actual.length > expected;
- case 'length_gte':
- return actual != null && actual.length >= expected;
- case 'contains':
- if (Array.isArray(actual)) {
- return actual.includes(expected);
- }
- return typeof actual === 'string' && actual.includes(expected);
- case 'not_contains':
- if (Array.isArray(actual)) {
- return !actual.includes(expected);
- }
- return typeof actual !== 'string' || !actual.includes(expected);
- case 'matches':
- return (
- (typeof actual === 'string' || typeof actual === 'number') &&
- normalizeRegex(assertSpec.pattern ?? expected).test(String(actual))
- );
- case 'one_of': {
- const values = Array.isArray(assertSpec.values) ? assertSpec.values : expected;
- return Array.isArray(values) && values.includes(actual);
- }
- default:
- throw new Error(`Unknown operator: ${assertSpec.operator}`);
- }
-}
-
-module.exports = {
- checkAssert,
- evaluateAssert,
- getFieldValue,
- normalizeRegex,
- parseRaw,
-};
diff --git a/scripts/perps/agentic/lib/catalog.js b/scripts/perps/agentic/lib/catalog.js
deleted file mode 100644
index fe18befba93b..000000000000
--- a/scripts/perps/agentic/lib/catalog.js
+++ /dev/null
@@ -1,301 +0,0 @@
-'use strict';
-
-const fs = require('node:fs');
-const path = require('node:path');
-
-function getAppRoot(explicitAppRoot) {
- return path.resolve(explicitAppRoot || process.env.APP_ROOT || process.cwd());
-}
-
-function getTeamsDir(explicitAppRoot) {
- return path.join(getAppRoot(explicitAppRoot), 'scripts', 'perps', 'agentic', 'teams');
-}
-
-function listTeamNames(explicitAppRoot) {
- const teamsDir = getTeamsDir(explicitAppRoot);
- if (!fs.existsSync(teamsDir)) {
- return [];
- }
-
- return fs
- .readdirSync(teamsDir, { withFileTypes: true })
- .filter((entry) => entry.isDirectory())
- .map((entry) => entry.name)
- .sort();
-}
-
-function readJsonFile(filePath) {
- return JSON.parse(fs.readFileSync(filePath, 'utf8'));
-}
-
-function renderTemplateString(value, params = {}) {
- if (typeof value !== 'string') {
- return value;
- }
-
- return value.replace(
- /\{\{([^|}]+)(?:\|([^}]+))?\}\}/g,
- (_match, key, fallback) => {
- if (Object.prototype.hasOwnProperty.call(params, key) && params[key] != null) {
- return String(params[key]);
- }
- return fallback != null ? String(fallback) : `{{${key}}}`;
- }
- );
-}
-
-function renderTemplate(value, params = {}) {
- if (typeof value === 'string') {
- return renderTemplateString(value, params);
- }
-
- if (Array.isArray(value)) {
- return value.map((item) => renderTemplate(item, params));
- }
-
- if (value && typeof value === 'object') {
- return Object.fromEntries(
- Object.entries(value).map(([key, item]) => [key, renderTemplate(item, params)])
- );
- }
-
- return value;
-}
-
-function parsePreConditionSpec(spec) {
- if (typeof spec !== 'string') {
- return spec;
- }
-
- const match = spec.match(/^([^(]+)\((.+)\)$/);
- if (!match) {
- return spec;
- }
-
- const result = { name: match[1] };
- match[2].split(',').forEach((pair) => {
- const eqIndex = pair.indexOf('=');
- if (eqIndex <= 0) {
- return;
- }
- const key = pair.slice(0, eqIndex).trim();
- const rawValue = pair.slice(eqIndex + 1).trim();
- result[key] = rawValue;
- });
- return result;
-}
-
-function inferTeamFromPath(filePath, explicitAppRoot) {
- const teamsDir = getTeamsDir(explicitAppRoot);
- const relative = path.relative(teamsDir, path.resolve(filePath));
- if (
- relative.startsWith(`..${path.sep}`) ||
- relative === '..' ||
- path.isAbsolute(relative)
- ) {
- return null;
- }
-
- const [team] = relative.split(path.sep);
- return team || null;
-}
-
-function splitRef(ref, defaultTeam) {
- const parts = String(ref || '')
- .split('/')
- .filter(Boolean);
-
- if (parts.length === 0) {
- throw new Error('Reference cannot be empty');
- }
-
- if (parts.length === 1) {
- if (!defaultTeam) {
- throw new Error(
- `Unqualified reference "${ref}" requires a default team. Use "team/name" instead.`
- );
- }
-
- return {
- team: defaultTeam,
- refParts: parts,
- };
- }
-
- return {
- team: parts[0],
- refParts: parts.slice(1),
- };
-}
-
-function resolveFlowRef(ref, options = {}) {
- const appRoot = getAppRoot(options.appRoot);
- const teamsDir = getTeamsDir(appRoot);
- const { team, refParts } = splitRef(ref, options.defaultTeam);
- const filePath = path.join(teamsDir, team, 'flows', `${path.join(...refParts)}.json`);
-
- if (!fs.existsSync(filePath)) {
- throw new Error(`Flow "${ref}" not found at ${filePath}`);
- }
-
- return {
- team,
- ref: `${team}/${refParts.join('/')}`,
- filePath,
- };
-}
-
-function resolveEvalRef(ref, options = {}) {
- const appRoot = getAppRoot(options.appRoot);
- const teamsDir = getTeamsDir(appRoot);
- const { team, refParts } = splitRef(ref, options.defaultTeam);
-
- let filePath;
- let key;
- if (refParts.length === 1) {
- filePath = path.join(teamsDir, team, 'evals.json');
- key = refParts[0];
- } else {
- filePath = path.join(
- teamsDir,
- team,
- 'evals',
- `${path.join(...refParts.slice(0, -1))}.json`
- );
- key = refParts[refParts.length - 1];
- }
-
- if (!fs.existsSync(filePath)) {
- throw new Error(`Eval ref "${ref}" not found at ${filePath}`);
- }
-
- const catalog = readJsonFile(filePath);
- const entry = catalog[key];
- if (!entry) {
- throw new Error(`Eval ref "${ref}" is missing key "${key}" in ${filePath}`);
- }
-
- return {
- team,
- ref: `${team}/${refParts.join('/')}`,
- filePath,
- key,
- entry,
- };
-}
-
-function walkJsonFiles(dirPath, files = []) {
- if (!fs.existsSync(dirPath)) {
- return files;
- }
-
- for (const entry of fs.readdirSync(dirPath, { withFileTypes: true })) {
- const fullPath = path.join(dirPath, entry.name);
- if (entry.isDirectory()) {
- walkJsonFiles(fullPath, files);
- } else if (entry.isFile() && entry.name.endsWith('.json')) {
- files.push(fullPath);
- }
- }
-
- return files;
-}
-
-function collectScenarioFiles(explicitAppRoot, directories = ['flows', 'recipes']) {
- const appRoot = getAppRoot(explicitAppRoot);
- const teamsDir = getTeamsDir(appRoot);
- const files = [];
-
- if (!fs.existsSync(teamsDir)) {
- return files;
- }
-
- for (const team of listTeamNames(appRoot)) {
- for (const directory of directories) {
- walkJsonFiles(path.join(teamsDir, team, directory), files);
- }
- }
-
- return files.sort();
-}
-
-function listEvalRefs(explicitAppRoot) {
- const appRoot = getAppRoot(explicitAppRoot);
- const teamsDir = getTeamsDir(appRoot);
- const refs = [];
-
- for (const team of listTeamNames(appRoot)) {
- const quickFile = path.join(teamsDir, team, 'evals.json');
- if (fs.existsSync(quickFile)) {
- const quickRefs = readJsonFile(quickFile);
- for (const [key, entry] of Object.entries(quickRefs)) {
- refs.push({
- ref: `${team}/${key}`,
- description: entry.description || '',
- async: entry.async === true,
- filePath: quickFile,
- });
- }
- }
-
- const evalDir = path.join(teamsDir, team, 'evals');
- for (const filePath of walkJsonFiles(evalDir, [])) {
- const relative = path
- .relative(evalDir, filePath)
- .replace(/\.json$/u, '')
- .split(path.sep)
- .join('/');
- const collection = readJsonFile(filePath);
- for (const [key, entry] of Object.entries(collection)) {
- refs.push({
- ref: `${team}/${relative}/${key}`,
- description: entry.description || '',
- async: entry.async === true,
- filePath,
- });
- }
- }
- }
-
- return refs.sort((a, b) => a.ref.localeCompare(b.ref));
-}
-
-function loadPreConditionRegistry(explicitAppRoot) {
- const appRoot = getAppRoot(explicitAppRoot);
- const teamsDir = getTeamsDir(appRoot);
- const merged = {};
-
- for (const team of listTeamNames(appRoot)) {
- const filePath = path.join(teamsDir, team, 'pre-conditions.js');
- if (!fs.existsSync(filePath)) {
- continue;
- }
-
- delete require.cache[require.resolve(filePath)];
- const entries = require(filePath);
- for (const [key, entry] of Object.entries(entries)) {
- if (merged[key]) {
- throw new Error(`Duplicate pre-condition key "${key}" from team "${team}"`);
- }
- merged[key] = entry;
- }
- }
-
- return merged;
-}
-
-module.exports = {
- collectScenarioFiles,
- getAppRoot,
- getTeamsDir,
- inferTeamFromPath,
- listEvalRefs,
- listTeamNames,
- loadPreConditionRegistry,
- parsePreConditionSpec,
- readJsonFile,
- renderTemplate,
- renderTemplateString,
- resolveEvalRef,
- resolveFlowRef,
-};
diff --git a/scripts/perps/agentic/lib/cdp-eval.js b/scripts/perps/agentic/lib/cdp-eval.js
index 480cc670040f..366614bbd549 100644
--- a/scripts/perps/agentic/lib/cdp-eval.js
+++ b/scripts/perps/agentic/lib/cdp-eval.js
@@ -30,7 +30,7 @@ async function cdpEval(client, expression) {
* Hermes CDP doesn't support awaitPromise, so we store the result on
* globalThis.__cdp_async__ and poll for it.
*/
-async function cdpEvalAsync(client, expression, timeoutMs = 30000) {
+async function cdpEvalAsync(client, expression, timeoutMs = cdpMessageTimeout()) {
// Unique key per call to avoid collisions
const key = `__cdp_async_${Date.now()}_${Math.random().toString(36).slice(2)}__`;
@@ -102,4 +102,9 @@ async function cdpEvalAsync(client, expression, timeoutMs = 30000) {
}
}
+function cdpMessageTimeout() {
+ const timeoutMs = Number.parseInt(process.env.CDP_TIMEOUT || '30000', 10);
+ return Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : 30000;
+}
+
module.exports = { cdpEval, cdpEvalAsync };
diff --git a/scripts/perps/agentic/lib/recipe-issues.js b/scripts/perps/agentic/lib/issue-capture.js
similarity index 98%
rename from scripts/perps/agentic/lib/recipe-issues.js
rename to scripts/perps/agentic/lib/issue-capture.js
index 3b2e9d367ace..f9140d88de29 100644
--- a/scripts/perps/agentic/lib/recipe-issues.js
+++ b/scripts/perps/agentic/lib/issue-capture.js
@@ -320,12 +320,12 @@ function writeArtifacts(runDir, { unexpected, informational, review }) {
const all = [...(unexpected || []), ...(informational || [])];
const paths = {
- allIssues: path.join(outDir, 'recipe-issues.json'),
+ allIssues: path.join(outDir, 'agentic-issues.json'),
consoleWarnings: path.join(outDir, 'console-warnings.json'),
consoleErrors: path.join(outDir, 'console-errors.json'),
runtimeExceptions: path.join(outDir, 'runtime-exceptions.json'),
- reviewJson: path.join(outDir, 'recipe-issues-review.json'),
- reviewMd: path.join(outDir, 'recipe-issues-review.md'),
+ reviewJson: path.join(outDir, 'agentic-issues-review.json'),
+ reviewMd: path.join(outDir, 'agentic-issues-review.md'),
};
writeJsonArtifact(paths.allIssues, all);
diff --git a/scripts/perps/agentic/lib/registry.js b/scripts/perps/agentic/lib/registry.js
deleted file mode 100644
index dab23021f0bb..000000000000
--- a/scripts/perps/agentic/lib/registry.js
+++ /dev/null
@@ -1,8 +0,0 @@
-'use strict';
-
-const path = require('node:path');
-const { loadPreConditionRegistry } = require('./catalog');
-
-// Load from the repo root (three levels up from lib/)
-const appRoot = path.resolve(__dirname, '..', '..', '..', '..');
-module.exports = loadPreConditionRegistry(appRoot);
diff --git a/scripts/perps/agentic/lib/safe-env-parser.sh b/scripts/perps/agentic/lib/safe-env-parser.sh
new file mode 100755
index 000000000000..64cf64e283d0
--- /dev/null
+++ b/scripts/perps/agentic/lib/safe-env-parser.sh
@@ -0,0 +1,27 @@
+#!/bin/bash
+# Shared safe .js.env parser. Source this file, then call load_js_env.
+# Values are treated as data — no eval/source. Only allowlisted keys are exported.
+# Caller env takes precedence (vars already set are not overwritten).
+
+_JS_ENV_ALLOWED="WATCHER_PORT SIM_UDID IOS_SIMULATOR ANDROID_DEVICE ADB_SERIAL PLATFORM METAMASK_ENVIRONMENT MM_PASSWORD MM_WALLET_PASSWORD CDP_TIMEOUT CDP_DISCOVERY_RETRIES DETOX_SIMULATOR AGENTIC_SIMULATOR MM_BUILD_CACHE_DIR WALLET_FIXTURE BUILD_TYPE METAMASK_DEBUG"
+
+load_js_env() {
+ local envfile="${1:-.js.env}"
+ [ -f "$envfile" ] || return 0
+ local _line _key _val
+ while IFS= read -r _line || [ -n "$_line" ]; do
+ [[ "$_line" =~ ^[[:space:]]*(#|$) ]] && continue
+ _line="${_line#export }"
+ _key="${_line%%=*}"
+ _key="${_key//[[:space:]]/}"
+ _val="${_line#*=}"
+ _val="${_val#\"}" ; _val="${_val%\"}"
+ _val="${_val#\'}" ; _val="${_val%\'}"
+ case " $_JS_ENV_ALLOWED " in
+ *" $_key "*) ;;
+ *) continue ;;
+ esac
+ [[ -z "${!_key+x}" ]] && export "$_key=$_val"
+ done < "$envfile"
+ return 0
+}
diff --git a/scripts/perps/agentic/lib/workflow.js b/scripts/perps/agentic/lib/workflow.js
deleted file mode 100644
index 7377e751e4e2..000000000000
--- a/scripts/perps/agentic/lib/workflow.js
+++ /dev/null
@@ -1,435 +0,0 @@
-'use strict';
-
-const path = require('node:path');
-
-const EXECUTABLE_ACTIONS = new Set([
- 'navigate',
- 'wait',
- 'wait_for',
- 'press',
- 'scroll',
- 'set_input',
- 'screenshot',
- 'call',
- 'eval_ref',
- 'eval_sync',
- 'eval_async',
- 'manual',
- 'log_watch',
- // MetaMask-specific actions
- 'type_keypad',
- 'clear_keypad',
- 'select_account',
- 'toggle_testnet',
- 'switch_provider',
- // App lifecycle actions
- 'app_background',
- 'app_foreground',
- 'app_restart',
- // Profiler / trace capture (Hermes sampling profiler via CDP)
- 'trace_start',
- 'trace_stop',
-]);
-
-const CONTROL_ACTIONS = new Set(['switch', 'end']);
-const ALL_WORKFLOW_ACTIONS = new Set([...EXECUTABLE_ACTIONS, ...CONTROL_ACTIONS]);
-
-function deepClone(value) {
- if (value == null) {
- return value;
- }
-
- return JSON.parse(JSON.stringify(value));
-}
-
-function normalizeNodeAction(node) {
- const action = node.action || node.type || '';
- return String(action || '').trim();
-}
-
-function normalizeTarget(value) {
- if (typeof value === 'string') {
- return value.trim();
- }
-
- if (value && typeof value === 'object') {
- return String(value.next || value.node || '').trim();
- }
-
- return '';
-}
-
-function normalizeSwitchCase(rawCase, index) {
- const entry =
- typeof rawCase === 'string'
- ? { next: rawCase }
- : deepClone(rawCase || {});
-
- return {
- ...entry,
- index,
- label: entry.label || '',
- next: normalizeTarget(entry.next),
- };
-}
-
-function normalizeNode(nodeId, rawNode) {
- const node = deepClone(rawNode || {});
- const action = normalizeNodeAction(node);
- const normalized = {
- ...node,
- action,
- id: String(nodeId),
- };
-
- delete normalized.type;
-
- if (Object.prototype.hasOwnProperty.call(normalized, 'next')) {
- normalized.next = normalizeTarget(normalized.next);
- }
-
- if (Object.prototype.hasOwnProperty.call(normalized, 'default')) {
- normalized.default = normalizeTarget(normalized.default);
- }
-
- if (Array.isArray(normalized.cases)) {
- normalized.cases = normalized.cases.map((entry, index) =>
- normalizeSwitchCase(entry, index)
- );
- }
-
- return normalized;
-}
-
-function normalizeGraphWorkflow(spec = {}) {
- const rawNodes =
- spec.nodes && typeof spec.nodes === 'object' && !Array.isArray(spec.nodes)
- ? spec.nodes
- : {};
-
- const nodes = Object.fromEntries(
- Object.entries(rawNodes).map(([nodeId, rawNode]) => [
- String(nodeId),
- normalizeNode(nodeId, rawNode),
- ])
- );
-
- if (Object.keys(nodes).length === 0) {
- nodes.__end__ = normalizeNode('__end__', {
- action: 'end',
- status: 'pass',
- message: '',
- });
- }
-
- return {
- entry: String(spec.entry || Object.keys(nodes)[0] || '__end__'),
- nodes,
- };
-}
-
-function normalizeWorkflowDocument(document, options = {}) {
- const validate = document.validate || {};
- const workflowSpec = validate.workflow || null;
-
- if (!workflowSpec || typeof workflowSpec !== 'object') {
- throw new Error('validate.workflow is required');
- }
-
- const workflow = normalizeGraphWorkflow(workflowSpec);
-
- return {
- description: document.description || '',
- hooks: {
- pre_conditions: deepClone(workflowSpec.pre_conditions || []),
- setup: deepClone(workflowSpec.setup || []),
- teardown: deepClone(workflowSpec.teardown || []),
- },
- inputs: deepClone(document.inputs || {}),
- sourcePath: options.sourcePath ? path.resolve(options.sourcePath) : '',
- title: document.title || '',
- workflow,
- };
-}
-
-function summarizeAssert(assertSpec) {
- if (!assertSpec || typeof assertSpec !== 'object') {
- return '';
- }
-
- if (Array.isArray(assertSpec.all)) {
- return assertSpec
- .all
- .map((entry) => summarizeAssert(entry))
- .filter(Boolean)
- .join(' & ');
- }
-
- if (Array.isArray(assertSpec.any)) {
- return assertSpec
- .any
- .map((entry) => summarizeAssert(entry))
- .filter(Boolean)
- .join(' | ');
- }
-
- if (Array.isArray(assertSpec.none)) {
- return `not(${assertSpec.none
- .map((entry) => summarizeAssert(entry))
- .filter(Boolean)
- .join(' | ')})`;
- }
-
- const operator = assertSpec.operator || '';
- const field = assertSpec.field || '$';
-
- if (Object.prototype.hasOwnProperty.call(assertSpec, 'value')) {
- return `${field} ${operator} ${JSON.stringify(assertSpec.value)}`;
- }
-
- if (Object.prototype.hasOwnProperty.call(assertSpec, 'values')) {
- return `${field} ${operator} ${JSON.stringify(assertSpec.values)}`;
- }
-
- if (Object.prototype.hasOwnProperty.call(assertSpec, 'pattern')) {
- return `${field} ${operator} ${JSON.stringify(assertSpec.pattern)}`;
- }
-
- return `${field} ${operator}`.trim();
-}
-
-function getNodeTargets(node) {
- if (!node) {
- return [];
- }
-
- if (node.action === 'switch') {
- const targets = [];
-
- (node.cases || []).forEach((entry) => {
- if (entry.next) {
- targets.push(entry.next);
- }
- });
-
- if (node.default) {
- targets.push(node.default);
- }
-
- return targets;
- }
-
- if (node.action === 'end') {
- return [];
- }
-
- return node.next ? [node.next] : [];
-}
-
-function listWorkflowEdges(workflow) {
- const edges = [];
-
- for (const [nodeId, node] of Object.entries(workflow.nodes || {})) {
- if (node.action === 'switch') {
- (node.cases || []).forEach((entry, index) => {
- if (!entry.next) {
- return;
- }
-
- edges.push({
- from: nodeId,
- label: entry.label || summarizeAssert(entry.when) || `case ${index + 1}`,
- to: entry.next,
- });
- });
-
- if (node.default) {
- edges.push({
- from: nodeId,
- label: 'default',
- to: node.default,
- });
- }
-
- continue;
- }
-
- if (node.next) {
- edges.push({
- from: nodeId,
- label: node.when
- ? `when ${summarizeAssert(node.when)}`
- : node.unless
- ? `unless ${summarizeAssert(node.unless)}`
- : '',
- to: node.next,
- });
- }
- }
-
- return edges;
-}
-
-function findReachableNodes(workflow) {
- const visited = new Set();
- const queue = [workflow.entry];
-
- while (queue.length > 0) {
- const nodeId = queue.shift();
- if (!nodeId || visited.has(nodeId)) {
- continue;
- }
-
- visited.add(nodeId);
- const node = workflow.nodes?.[nodeId];
- if (!node) {
- continue;
- }
-
- getNodeTargets(node).forEach((target) => {
- if (target && !visited.has(target)) {
- queue.push(target);
- }
- });
- }
-
- return visited;
-}
-
-function findUnreachableNodes(workflow) {
- const reachable = findReachableNodes(workflow);
- return Object.keys(workflow.nodes || {}).filter((nodeId) => !reachable.has(nodeId));
-}
-
-function findMissingTargets(workflow) {
- return listWorkflowEdges(workflow).filter((edge) => !workflow.nodes?.[edge.to]);
-}
-
-function detectWorkflowCycles(workflow) {
- const visited = new Set();
- const active = new Set();
- const stack = [];
- const dedupe = new Set();
- const cycles = [];
-
- function visit(nodeId) {
- if (!nodeId || !workflow.nodes?.[nodeId]) {
- return;
- }
-
- if (active.has(nodeId)) {
- const startIndex = stack.indexOf(nodeId);
- const cycle = stack.slice(startIndex).concat(nodeId);
- const key = cycle.join(' -> ');
- if (!dedupe.has(key)) {
- dedupe.add(key);
- cycles.push(cycle);
- }
- return;
- }
-
- if (visited.has(nodeId)) {
- return;
- }
-
- visited.add(nodeId);
- active.add(nodeId);
- stack.push(nodeId);
-
- getNodeTargets(workflow.nodes[nodeId]).forEach((target) => visit(target));
-
- stack.pop();
- active.delete(nodeId);
- }
-
- Object.keys(workflow.nodes || {}).forEach((nodeId) => visit(nodeId));
- return cycles;
-}
-
-function escapeMermaidLabel(value) {
- return String(value || '')
- .replaceAll('"', '\\"')
- .replaceAll('\n', ' ');
-}
-
-function buildMermaidIdMap(workflow) {
- const map = {};
- const used = new Set();
- let index = 1;
-
- for (const nodeId of Object.keys(workflow.nodes || {})) {
- let candidate = `node_${String(nodeId).replaceAll(/[^a-zA-Z0-9_]/g, '_') || index}`;
- while (used.has(candidate)) {
- index += 1;
- candidate = `${candidate}_${index}`;
- }
- used.add(candidate);
- map[nodeId] = candidate;
- }
-
- return map;
-}
-
-function mermaidNodeShape(nodeId, node, mermaidId) {
- const action = node.action || '';
- const title = node.description || node.ref || action || nodeId;
- const label = escapeMermaidLabel(`${nodeId} ${title}`);
-
- if (action === 'switch') {
- return `${mermaidId}{"${label}"}`;
- }
-
- if (action === 'end') {
- const status = String(node.status || 'pass').toUpperCase();
- return `${mermaidId}(["${escapeMermaidLabel(`${nodeId} ${status}`)}"])`;
- }
-
- if (action === 'call') {
- return `${mermaidId}[["${label}"]]`;
- }
-
- return `${mermaidId}["${label}"]`;
-}
-
-function renderWorkflowMermaid(normalizedDocument) {
- const workflow = normalizedDocument.workflow || normalizedDocument;
- const mermaidIds = buildMermaidIdMap(workflow);
- const lines = ['flowchart TD'];
-
- if (normalizedDocument.title) {
- lines.push(` %% ${escapeMermaidLabel(normalizedDocument.title)}`);
- }
-
- lines.push(` __entry__(["ENTRY"]) --> ${mermaidIds[workflow.entry] || workflow.entry}`);
-
- for (const [nodeId, node] of Object.entries(workflow.nodes || {})) {
- lines.push(` ${mermaidNodeShape(nodeId, node, mermaidIds[nodeId] || nodeId)}`);
- }
-
- for (const edge of listWorkflowEdges(workflow)) {
- const label = edge.label ? `|${escapeMermaidLabel(edge.label)}|` : '';
- lines.push(
- ` ${mermaidIds[edge.from] || edge.from} -->${label} ${mermaidIds[edge.to] || edge.to}`
- );
- }
-
- return `${lines.join('\n')}\n`;
-}
-
-module.exports = {
- ALL_WORKFLOW_ACTIONS,
- CONTROL_ACTIONS,
- EXECUTABLE_ACTIONS,
- deepClone,
- detectWorkflowCycles,
- findMissingTargets,
- findReachableNodes,
- findUnreachableNodes,
- getNodeTargets,
- listWorkflowEdges,
- normalizeGraphWorkflow,
- normalizeNode,
- normalizeWorkflowDocument,
- renderWorkflowMermaid,
- summarizeAssert,
-};
diff --git a/scripts/perps/agentic/run-timing-benchmark.sh b/scripts/perps/agentic/run-timing-benchmark.sh
deleted file mode 100755
index 295091c699dc..000000000000
--- a/scripts/perps/agentic/run-timing-benchmark.sh
+++ /dev/null
@@ -1,345 +0,0 @@
-#!/usr/bin/env bash
-# run-timing-benchmark.sh — Wall-clock timing comparison: Detox vs Agentic recipes
-#
-# Uses TWO separate simulators and builds to avoid conflicts:
-# - Detox: e2e debug build on dedicated "detox-benchmark" simulator (port 8081)
-# Detox wipes app data each test — isolated from dev environment.
-# - Agentic: dev build on IOS_SIMULATOR from .js.env (port from WATCHER_PORT)
-# Uses existing wallet with real testnet balance — no wipe.
-#
-# Prerequisites:
-# 1. Detox build: yarn test:e2e:ios:debug:build
-# 2. Dev build: yarn a:setup:ios (or preflight.sh)
-# 3. Wallet unlocked on IOS_SIMULATOR with perps enabled + testnet balance
-#
-# The two phases run sequentially. Each manages its own Metro instance.
-#
-# Usage:
-# bash scripts/perps/agentic/run-timing-benchmark.sh
-#
-# Override simulators:
-# DETOX_SIMULATOR="my-detox-sim" AGENTIC_SIMULATOR="my-dev-sim" \
-# bash scripts/perps/agentic/run-timing-benchmark.sh
-
-set -euo pipefail
-
-SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
-PROJECT_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)"
-
-# ---------------------------------------------------------------------------
-# Source .js.env (non-destructive: only sets vars not already in env)
-# Same approach as preflight.sh — caller env takes precedence.
-# ---------------------------------------------------------------------------
-if [[ -f "$PROJECT_ROOT/.js.env" ]]; then
- while IFS= read -r _line || [[ -n "$_line" ]]; do
- [[ "$_line" =~ ^[[:space:]]*(#|$) ]] && continue
- _line="${_line#export }"
- _key="${_line%%=*}"
- _key="${_key//[[:space:]]/}"
- [[ -n "$_key" && -z "${!_key+x}" ]] && eval "export $_line" 2>/dev/null || true
- done < "$PROJECT_ROOT/.js.env"
- unset _line _key
-fi
-
-# ---------------------------------------------------------------------------
-# Config — two simulators, shared port (Metro restarts between phases)
-# ---------------------------------------------------------------------------
-
-DETOX_SIMULATOR="${DETOX_SIMULATOR:-detox-benchmark}"
-SHARED_PORT="${WATCHER_PORT:-8062}"
-DETOX_PORT="$SHARED_PORT"
-
-AGENTIC_SIMULATOR="${AGENTIC_SIMULATOR:-${IOS_SIMULATOR:-mm-2}}"
-AGENTIC_PORT="$SHARED_PORT"
-
-# Matched pairs: Detox spec path -> agentic recipe path
-SPEC_NAMES=("perps-position" "perps-position-stop-loss" "perps-limit-long-fill")
-
-declare -A DETOX_SPECS
-DETOX_SPECS[perps-position]="tests/smoke/perps/perps-position.spec.ts"
-DETOX_SPECS[perps-position-stop-loss]="tests/smoke/perps/perps-position-stop-loss.spec.ts"
-DETOX_SPECS[perps-limit-long-fill]="tests/smoke/perps/perps-limit-long-fill.spec.ts"
-
-declare -A AGENTIC_RECIPES
-AGENTIC_RECIPES[perps-position]="scripts/perps/agentic/teams/perps/recipes/benchmark/perps-position.json"
-AGENTIC_RECIPES[perps-position-stop-loss]="scripts/perps/agentic/teams/perps/recipes/benchmark/perps-position-stop-loss.json"
-AGENTIC_RECIPES[perps-limit-long-fill]="scripts/perps/agentic/teams/perps/recipes/benchmark/perps-limit-long-fill.json"
-
-# Results arrays
-declare -A DETOX_TIMES
-declare -A AGENTIC_TIMES
-declare -A DETOX_STATUS
-declare -A AGENTIC_STATUS
-
-# ---------------------------------------------------------------------------
-# Helpers
-# ---------------------------------------------------------------------------
-
-timestamp() { date '+%Y-%m-%d %H:%M:%S'; }
-
-run_timed() {
- # run_timed
- # Prints elapsed seconds, returns exit code
- local label="$1"; shift
- local start_s=$SECONDS
- echo ""
- echo "================================================================"
- echo "[$label] started at $(timestamp)"
- echo "================================================================"
- set +e
- "$@"
- local exit_code=$?
- set -e
- local elapsed=$(( SECONDS - start_s ))
- echo "----------------------------------------------------------------"
- echo "[$label] finished in ${elapsed}s (exit=$exit_code)"
- echo "----------------------------------------------------------------"
- # Export via global
- _TIMED_ELAPSED=$elapsed
- _TIMED_EXIT=$exit_code
-}
-
-wait_for_cdp() {
- local port=$1
- local timeout=${2:-60}
- echo "Waiting for CDP targets on port $port..."
- for i in $(seq 1 "$timeout"); do
- TARGETS=$(curl -sf "http://localhost:$port/json/list" 2>/dev/null \
- | python3 -c "import sys,json; print(len(json.loads(sys.stdin.read() or '[]')))" 2>/dev/null || echo 0)
- if [[ "$TARGETS" -gt 0 ]]; then
- echo "CDP ready: $TARGETS target(s)"
- return 0
- fi
- sleep 2
- done
- echo "WARNING: CDP not ready after $((timeout * 2))s"
- return 1
-}
-
-# ---------------------------------------------------------------------------
-# Header
-# ---------------------------------------------------------------------------
-
-cd "$PROJECT_ROOT"
-
-echo ""
-echo "========================================"
-echo " Perps Timing Benchmark"
-echo " $(timestamp)"
-echo "========================================"
-echo ""
-echo " Detox: simulator=$DETOX_SIMULATOR port=$DETOX_PORT env=e2e"
-echo " Agentic: simulator=$AGENTIC_SIMULATOR port=$AGENTIC_PORT env=dev"
-echo ""
-
-# ---------------------------------------------------------------------------
-# Phase 1: Detox specs (e2e build, dedicated simulator)
-# ---------------------------------------------------------------------------
-
-echo ""
-echo "========================================"
-echo " PHASE 1: Detox Smoke Specs"
-echo " Simulator: $DETOX_SIMULATOR"
-echo " Metro port: $DETOX_PORT (METAMASK_ENVIRONMENT=e2e)"
-echo "========================================"
-
-# Ensure Metro on shared port with e2e env — kill any existing Metro first
-METRO_PID=$(lsof -iTCP:"$SHARED_PORT" -sTCP:LISTEN -t 2>/dev/null | head -1 || true)
-if [[ -n "$METRO_PID" ]]; then
- METRO_ENV=$(ps -p "$METRO_PID" -E 2>/dev/null | grep -o 'METAMASK_ENVIRONMENT=[^ ]*' || echo "")
- if [[ "$METRO_ENV" == "METAMASK_ENVIRONMENT=e2e" ]]; then
- echo "Metro already running on $SHARED_PORT with e2e"
- else
- echo "Metro on $SHARED_PORT has $METRO_ENV — restarting with e2e..."
- kill "$METRO_PID" 2>/dev/null; sleep 3
- METRO_PID=""
- fi
-fi
-if [[ -z "$METRO_PID" ]] || ! curl -sf "http://localhost:$SHARED_PORT/status" >/dev/null 2>&1; then
- echo "Starting Metro on port $SHARED_PORT with METAMASK_ENVIRONMENT=e2e IS_TEST=true..."
- METAMASK_ENVIRONMENT=e2e IS_TEST=true METAMASK_BUILD_TYPE=main WATCHER_PORT="$SHARED_PORT" \
- bash "$SCRIPT_DIR/start-metro.sh"
-fi
-
-# Source .e2e.env for Detox
-E2E_ENV="$PROJECT_ROOT/.e2e.env"
-[[ -f "$E2E_ENV" ]] && source "$E2E_ENV"
-
-# Boot detox simulator if needed
-if ! xcrun simctl list devices | grep "$DETOX_SIMULATOR" | grep -q "Booted"; then
- echo "Booting $DETOX_SIMULATOR..."
- xcrun simctl boot "$DETOX_SIMULATOR" 2>/dev/null || true
- sleep 3
-fi
-
-for name in "${SPEC_NAMES[@]}"; do
- spec="${DETOX_SPECS[$name]}"
-
- run_timed "detox:$name" \
- env IOS_SIMULATOR="$DETOX_SIMULATOR" WATCHER_PORT="$DETOX_PORT" \
- yarn test:e2e:ios:debug:run "$spec"
-
- DETOX_TIMES[$name]=$_TIMED_ELAPSED
- if [[ $_TIMED_EXIT -eq 0 ]]; then
- DETOX_STATUS[$name]="PASS"
- else
- DETOX_STATUS[$name]="FAIL"
- fi
-done
-
-# ---------------------------------------------------------------------------
-# Phase 2: Agentic recipes (dev build, existing simulator + wallet)
-# ---------------------------------------------------------------------------
-
-echo ""
-echo "========================================"
-echo " PHASE 2: Agentic Recipes"
-echo " Simulator: $AGENTIC_SIMULATOR"
-echo " Metro port: $AGENTIC_PORT (METAMASK_ENVIRONMENT=dev)"
-echo "========================================"
-
-# Restart Metro on shared port with dev env for agentic phase
-echo "Switching Metro on $SHARED_PORT to dev environment..."
-METRO_PID=$(lsof -iTCP:"$SHARED_PORT" -sTCP:LISTEN -t 2>/dev/null | head -1 || true)
-if [[ -n "$METRO_PID" ]]; then
- kill "$METRO_PID" 2>/dev/null; sleep 3
-fi
-METAMASK_ENVIRONMENT=dev METAMASK_BUILD_TYPE=main \
- bash "$SCRIPT_DIR/start-metro.sh" --platform ios --launch
-wait_for_cdp "$SHARED_PORT"
-
-# Post-restart setup: unlock wallet, dismiss onboarding, init perps
-echo ""
-echo "Post-restart: setting up agentic environment..."
-CDP_BRIDGE="$SCRIPT_DIR/cdp-bridge.js"
-export IOS_SIMULATOR="$AGENTIC_SIMULATOR"
-export WATCHER_PORT="$AGENTIC_PORT"
-
-# Wait for app to fully load (Login or Wallet screen)
-echo " Waiting for app to be ready..."
-for i in $(seq 1 15); do
- ROUTE=$(node "$CDP_BRIDGE" get-route 2>/dev/null | python3 -c "import sys,json; print(json.load(sys.stdin).get('name',''))" 2>/dev/null || echo "")
- if [[ "$ROUTE" == "Login" || "$ROUTE" == "Wallet" ]]; then
- echo " App ready on $ROUTE screen after $((i * 2))s"
- break
- fi
- sleep 2
-done
-
-# Unlock wallet if on Login screen
-WALLET_PW=$(python3 -c "import json; print(json.load(open('.agent/wallet-fixture.json'))['password'])" 2>/dev/null || echo "qwerasdf")
-if [[ "$ROUTE" == "Login" ]]; then
- echo " Unlocking wallet..."
- node "$CDP_BRIDGE" unlock "$WALLET_PW" 2>/dev/null; sleep 3
-else
- echo " Wallet already unlocked"
-fi
-
-# Init perps provider + testnet
-echo " Initializing perps provider..."
-node "$CDP_BRIDGE" eval-async 'Engine.context.PerpsController.init().then(function(){return Engine.context.PerpsController.toggleTestnet(true)}).then(function(){return JSON.stringify({ok:true})})' >/dev/null 2>&1
-sleep 2
-
-# Verify readiness
-echo " Checking perps readiness..."
-node "$CDP_BRIDGE" check-pre-conditions '["perps.ready_to_trade","perps.sufficient_balance"]' 2>/dev/null || echo "WARNING: perps pre-conditions may not be met"
-
-# Pre-flight: check agentic wallet balance via CDP
-echo ""
-echo "Pre-flight: checking perps balance on $AGENTIC_SIMULATOR..."
-BALANCE_CHECK=$(env IOS_SIMULATOR="$AGENTIC_SIMULATOR" WATCHER_PORT="$AGENTIC_PORT" \
- node "$SCRIPT_DIR/cdp-bridge.js" eval-ref perps/balances 2>/dev/null || echo '{"error":"balance check failed (non-fatal)"}')
-echo " $BALANCE_CHECK"
-echo ""
-
-for name in "${SPEC_NAMES[@]}"; do
- recipe="${AGENTIC_RECIPES[$name]}"
-
- run_timed "agentic:$name" \
- env IOS_SIMULATOR="$AGENTIC_SIMULATOR" WATCHER_PORT="$AGENTIC_PORT" \
- node scripts/perps/agentic/validate-recipe.js "$recipe"
-
- AGENTIC_TIMES[$name]=$_TIMED_ELAPSED
- if [[ $_TIMED_EXIT -eq 0 ]]; then
- AGENTIC_STATUS[$name]="PASS"
- else
- AGENTIC_STATUS[$name]="FAIL"
- fi
-done
-
-# ---------------------------------------------------------------------------
-# Phase 3: Results table
-# ---------------------------------------------------------------------------
-
-echo ""
-echo "========================================"
-echo " TIMING BENCHMARK RESULTS"
-echo " $(timestamp)"
-echo "========================================"
-echo ""
-
-# Print markdown table
-TABLE="| Spec | Detox (s) | Detox Status | Agentic (s) | Agentic Status | Delta (s) | Speedup |
-|------|-----------|--------------|-------------|----------------|-----------|---------|"
-
-TOTAL_DETOX=0
-TOTAL_AGENTIC=0
-
-for name in "${SPEC_NAMES[@]}"; do
- dt=${DETOX_TIMES[$name]}
- at=${AGENTIC_TIMES[$name]}
- ds=${DETOX_STATUS[$name]}
- as=${AGENTIC_STATUS[$name]}
- delta=$(( dt - at ))
- if [[ $at -gt 0 ]]; then
- speedup_100=$(( dt * 100 / at ))
- speedup="$(( speedup_100 / 100 )).$(printf '%02d' $(( speedup_100 % 100 )))x"
- else
- speedup="N/A"
- fi
- TABLE="$TABLE
-| $name | $dt | $ds | $at | $as | ${delta} | ${speedup} |"
- TOTAL_DETOX=$(( TOTAL_DETOX + dt ))
- TOTAL_AGENTIC=$(( TOTAL_AGENTIC + at ))
-done
-
-TOTAL_DELTA=$(( TOTAL_DETOX - TOTAL_AGENTIC ))
-if [[ $TOTAL_AGENTIC -gt 0 ]]; then
- total_speedup_100=$(( TOTAL_DETOX * 100 / TOTAL_AGENTIC ))
- total_speedup="$(( total_speedup_100 / 100 )).$(printf '%02d' $(( total_speedup_100 % 100 )))x"
-else
- total_speedup="N/A"
-fi
-TABLE="$TABLE
-| **TOTAL** | **$TOTAL_DETOX** | | **$TOTAL_AGENTIC** | | **${TOTAL_DELTA}** | **${total_speedup}** |"
-
-echo "$TABLE"
-
-# ---------------------------------------------------------------------------
-# Append to benchmark doc
-# ---------------------------------------------------------------------------
-
-BENCHMARK_DOC="$SCRIPT_DIR/timing-benchmark.md"
-RUN_DATE=$(date '+%Y-%m-%d %H:%M')
-
-SECTION="
-
-## Timing Benchmark ($RUN_DATE)
-
-**Detox:** simulator=$DETOX_SIMULATOR, port=$DETOX_PORT, env=e2e (debug build, mock infrastructure)
-**Agentic:** simulator=$AGENTIC_SIMULATOR, port=$AGENTIC_PORT, env=dev (dev build, real testnet)
-
-$TABLE
-
-### Notes
-- Detox and agentic use **different builds and simulators** — Detox needs e2e mocks, recipes need real API.
-- Detox times include app wipe+reinstall, fixture inject, mock server, test execution, teardown.
-- Agentic times include CDP connection, preflight checks, recipe execution, teardown.
-- Delta = Detox - Agentic (positive = agentic faster).
-- Speedup = Detox time / Agentic time.
-"
-
-echo "$SECTION" >> "$BENCHMARK_DOC"
-echo ""
-echo "Results appended to: $BENCHMARK_DOC"
-echo "Done."
diff --git a/scripts/perps/agentic/schemas/flow.schema.json b/scripts/perps/agentic/schemas/flow.schema.json
deleted file mode 100644
index 25da6e5e1590..000000000000
--- a/scripts/perps/agentic/schemas/flow.schema.json
+++ /dev/null
@@ -1,328 +0,0 @@
-{
- "$schema": "https://json-schema.org/draft/2020-12/schema",
- "$id": "flow.schema.json",
- "title": "Agentic Flow",
- "description": "A parameterized test sequence that agents execute against a running MetaMask Mobile app via CDP. Flows combine navigation, UI interaction, and state assertions into a reproducible validation sequence.",
- "type": "object",
- "required": ["title", "validate"],
- "additionalProperties": false,
- "properties": {
- "title": {
- "type": "string",
- "description": "Human-readable title. May contain {{param}} template tokens that resolve at runtime."
- },
- "schema_version": {
- "type": ["string", "number"],
- "description": "Opt-in strict-validation marker. Set to 1 to require fields like screenshot `note`. Recipes without this field (legacy/demo) only get console warnings instead of validation errors. Bump in future versions when adopting additional strict rules."
- },
- "pr": {
- "type": ["string", "number"],
- "description": "Associated PR number (informational)."
- },
- "inputs": {
- "type": "object",
- "description": "Declared parameters. Every {{param}} used in steps MUST have a matching key here. Params without a default are required.",
- "additionalProperties": {
- "type": "object",
- "required": ["type"],
- "properties": {
- "type": {
- "type": "string",
- "enum": ["string", "number", "boolean"],
- "description": "Informational type hint (no runtime type checking)."
- },
- "default": {
- "description": "Default value. If present, the param is optional. Single source of truth — prefer this over inline {{param|default}} syntax."
- },
- "description": {
- "type": "string",
- "description": "What this parameter controls."
- }
- },
- "additionalProperties": false
- }
- },
- "initial_conditions": {
- "type": "object",
- "description": "App state to establish before running steps.",
- "properties": {
- "account": { "type": "string", "description": "Ethereum address to switch to." },
- "testnet": { "type": "boolean", "description": "Enable/disable testnet mode." },
- "provider": { "type": "string", "description": "Active provider (hyperliquid, myx, aggregated)." }
- },
- "additionalProperties": false
- },
- "validate": {
- "type": "object",
- "required": ["runtime"],
- "additionalProperties": false,
- "properties": {
- "runtime": {
- "type": "object",
- "required": ["steps"],
- "additionalProperties": false,
- "properties": {
- "pre_conditions": {
- "type": "array",
- "description": "Checks that must pass before steps run. String refs or parameterized objects.",
- "items": {
- "oneOf": [
- {
- "type": "string",
- "description": "Named pre-condition (e.g. 'wallet.unlocked'). Shorthand 'name(k=v)' is also supported."
- },
- {
- "type": "object",
- "required": ["name"],
- "properties": {
- "name": { "type": "string", "description": "Pre-condition registry key." }
- },
- "additionalProperties": { "type": "string" },
- "description": "Parameterized pre-condition (e.g. { name: 'perps.open_position', symbol: 'BTC' })."
- }
- ]
- }
- },
- "initial_conditions": {
- "type": "object",
- "description": "Same as top-level initial_conditions (nested form for backward compat).",
- "properties": {
- "account": { "type": "string" },
- "testnet": { "type": "boolean" },
- "provider": { "type": "string" }
- },
- "additionalProperties": false
- },
- "steps": {
- "type": "array",
- "minItems": 1,
- "description": "Ordered sequence of actions. Terminal step MUST assert.",
- "items": { "$ref": "#/$defs/step" }
- }
- }
- }
- }
- }
- },
- "$defs": {
- "assert": {
- "type": "object",
- "required": ["operator"],
- "additionalProperties": false,
- "description": "Assertion applied to step result.",
- "properties": {
- "operator": {
- "type": "string",
- "enum": ["not_null", "eq", "neq", "gt", "length_eq", "length_gt", "contains", "not_contains"],
- "description": "Comparison operator."
- },
- "field": {
- "type": ["string", "null"],
- "description": "Dot-path into the result JSON to extract the actual value (e.g. 'route', 'positions.0.symbol'). Null or omitted = use entire result."
- },
- "value": {
- "description": "Expected value for eq/gt/length_eq/length_gt/contains/not_contains. Not used for not_null."
- }
- }
- },
- "step": {
- "type": "object",
- "required": ["id", "action"],
- "properties": {
- "id": {
- "type": "string",
- "description": "Unique step identifier. Use descriptive kebab-case (e.g. 'assert-route', 'press-close')."
- },
- "description": {
- "type": "string",
- "description": "Optional human note. Only add when the id + action aren't self-explanatory."
- },
- "action": {
- "type": "string",
- "enum": [
- "navigate", "eval_sync", "eval_async", "eval_ref",
- "press", "scroll", "set_input", "type_keypad", "clear_keypad",
- "call", "log_watch", "wait", "wait_for", "screenshot", "manual",
- "select_account", "toggle_testnet", "switch_provider"
- ]
- },
- "assert": { "$ref": "#/$defs/assert" }
- },
- "allOf": [
- {
- "if": { "properties": { "action": { "const": "navigate" } } },
- "then": {
- "properties": {
- "target": { "type": "string", "description": "Route name (e.g. 'PerpsMarketDetails')." },
- "params": { "type": "object", "description": "Navigation params (route-specific)." }
- },
- "required": ["target"]
- }
- },
- {
- "if": { "properties": { "action": { "const": "eval_sync" } } },
- "then": {
- "properties": {
- "expression": { "type": "string", "description": "ES5 JS expression evaluated synchronously via CDP." }
- },
- "required": ["expression", "assert"]
- }
- },
- {
- "if": { "properties": { "action": { "const": "eval_async" } } },
- "then": {
- "properties": {
- "expression": { "type": "string", "description": "ES5 JS expression returning a Promise. Use .then() chains, NOT await." }
- },
- "required": ["expression", "assert"]
- }
- },
- {
- "if": { "properties": { "action": { "const": "eval_ref" } } },
- "then": {
- "properties": {
- "ref": { "type": "string", "description": "Eval ref name (e.g. 'positions', 'core/tpsl-orders')." }
- },
- "required": ["ref", "assert"]
- }
- },
- {
- "if": { "properties": { "action": { "const": "press" } } },
- "then": {
- "properties": {
- "test_id": { "type": "string", "description": "testID prop of the React component to press." }
- },
- "required": ["test_id"]
- }
- },
- {
- "if": { "properties": { "action": { "const": "scroll" } } },
- "then": {
- "properties": {
- "test_id": { "type": "string", "description": "testID of the scrollable container." },
- "offset": { "type": "number", "default": 300, "description": "Scroll offset in pixels." },
- "animated": { "type": "boolean", "default": false }
- }
- }
- },
- {
- "if": { "properties": { "action": { "const": "set_input" } } },
- "then": {
- "properties": {
- "test_id": { "type": "string", "description": "testID of the TextInput." },
- "value": { "type": "string", "description": "Text to type into the input." }
- },
- "required": ["test_id", "value"]
- }
- },
- {
- "if": { "properties": { "action": { "const": "type_keypad" } } },
- "then": {
- "properties": {
- "value": { "type": "string", "description": "Digits/dot to type via keypad buttons (e.g. '10.5')." }
- },
- "required": ["value"]
- }
- },
- {
- "if": { "properties": { "action": { "const": "clear_keypad" } } },
- "then": {
- "properties": {
- "count": { "type": "number", "default": 8, "description": "Number of delete presses." }
- }
- }
- },
- {
- "if": { "properties": { "action": { "const": "call" } } },
- "then": {
- "properties": {
- "ref": { "type": "string", "description": "Flow path: 'team/name' or just 'name' (defaults to perps team)." },
- "params": { "type": "object", "description": "Values for the referenced flow's {{param}} tokens." }
- },
- "required": ["ref"]
- }
- },
- {
- "if": { "properties": { "action": { "const": "log_watch" } } },
- "then": {
- "properties": {
- "window_seconds": { "type": "number", "default": 10 },
- "must_not_appear": { "type": "array", "items": { "type": "string" }, "description": "Strings that must NOT appear in recent logs (case-insensitive)." },
- "watch_for": { "type": "array", "items": { "type": "string" }, "description": "Strings to count in recent logs (informational)." }
- }
- }
- },
- {
- "if": { "properties": { "action": { "const": "wait" } } },
- "then": {
- "properties": {
- "ms": { "type": "number", "default": 1000, "description": "Milliseconds to pause." }
- }
- }
- },
- {
- "if": { "properties": { "action": { "const": "screenshot" } } },
- "then": {
- "required": ["filename"],
- "properties": {
- "filename": { "type": "string", "minLength": 1, "description": "Screenshot label." },
- "note": {
- "type": "string",
- "minLength": 3,
- "description": "State-specific caption describing what the screenshot proves (e.g. 'AC1: Recent Activity skeleton with 3 shimmer rows'). Required when top-level schema_version is set (>= 1). Recipes without schema_version (legacy) only get a console warning."
- }
- }
- }
- },
- {
- "if": { "properties": { "action": { "const": "manual" } } },
- "then": {
- "properties": {
- "note": { "type": "string", "description": "Instructions for human intervention." }
- }
- }
- },
- {
- "if": { "properties": { "action": { "const": "select_account" } } },
- "then": {
- "properties": {
- "address": { "type": "string", "description": "Ethereum address to switch to." }
- },
- "required": ["address"]
- }
- },
- {
- "if": { "properties": { "action": { "const": "toggle_testnet" } } },
- "then": {
- "properties": {
- "enabled": { "type": "boolean", "default": true, "description": "Desired testnet state." }
- }
- }
- },
- {
- "if": { "properties": { "action": { "const": "switch_provider" } } },
- "then": {
- "properties": {
- "provider": { "type": "string", "description": "Provider ID (hyperliquid, myx, aggregated)." }
- },
- "required": ["provider"]
- }
- },
- {
- "if": { "properties": { "action": { "const": "wait_for" } } },
- "then": {
- "properties": {
- "expression": { "type": "string", "description": "ES5 JS expression to poll (sync or async with .then()). Used when route/test_id sugar is not sufficient." },
- "route": { "type": "string", "description": "Shorthand: poll until current route name equals this value. Expands to a getRoute() expression + eq assert." },
- "not_route": { "type": "string", "description": "Shorthand: poll until current route name does NOT equal this value (e.g. wait to leave a screen)." },
- "test_id": { "type": "string", "description": "Shorthand: poll until a component with this testID exists (or disappears if visible=false) in the React fiber tree." },
- "visible": { "type": "boolean", "default": true, "description": "Used with test_id: true = wait for element to appear, false = wait for element to disappear." },
- "timeout_ms": { "type": "number", "default": 10000, "description": "Max wait in milliseconds before failing." },
- "poll_ms": { "type": "number", "default": 500, "description": "Polling interval in milliseconds." }
- }
- }
- }
- ]
- }
- }
-}
diff --git a/scripts/perps/agentic/setup-wallet.sh b/scripts/perps/agentic/setup-wallet.sh
index 0f12efea97d2..3c52aca5fd62 100755
--- a/scripts/perps/agentic/setup-wallet.sh
+++ b/scripts/perps/agentic/setup-wallet.sh
@@ -11,29 +11,27 @@
# {
# "password": "yourpassword",
# "accounts": [
-# { "type": "mnemonic", "value": "word1 word2 ..." },
-# { "type": "privateKey", "value": "0xabc...", "name": "Trading" }
+# { "type": "mnemonic", "value": "word1 word2 ...", "name": "Primary" },
+# { "type": "privateKey", "value": "0xabc...", "name": "Trading" },
+# { "type": "privateKey", "value": "0xdef...", "name": "MYXTrading" }
# ],
-# "settings": { "metametrics": false, "skipGtmModals": true }
+# "settings": { "metametrics": true, "skipGtmModals": true, "skipPerpsTutorial": true, "autoLockNever": true, "deviceAuthEnabled": true }
# }
set -euo pipefail
-cd "$(dirname "$0")/../../.."
+# Resolve the script directory to an absolute path BEFORE cd, so sourcing and
+# helper paths work regardless of the caller's CWD (e.g. ./setup-wallet.sh).
+SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
+cd "$SCRIPT_DIR/../../.."
# Source .js.env but only for vars not already set, so caller env takes precedence.
-if [ -f .js.env ]; then
- while IFS= read -r _line || [ -n "$_line" ]; do
- [[ "$_line" =~ ^[[:space:]]*(#|$) ]] && continue
- _line="${_line#export }"
- _key="${_line%%=*}"
- _key="${_key//[[:space:]]/}"
- [[ -n "$_key" && -z "${!_key+x}" ]] && eval "export $_line" 2>/dev/null || true
- done < .js.env
- unset _line _key
-fi
+# shellcheck source=lib/safe-env-parser.sh
+. "$SCRIPT_DIR/lib/safe-env-parser.sh"
+load_js_env
PORT="${WATCHER_PORT:-8081}"
-SCRIPTS="$(dirname "$0")"
+[[ "$PORT" =~ ^[0-9]+$ ]] || { echo "ERROR: WATCHER_PORT must be numeric (got: $PORT)" >&2; exit 1; }
+SCRIPTS="$SCRIPT_DIR"
export CDP_TIMEOUT="${CDP_TIMEOUT:-30000}"
export CDP_DISCOVERY_RETRIES="${CDP_DISCOVERY_RETRIES:-3}"
CDP="node $SCRIPTS/cdp-bridge.js"
@@ -45,7 +43,9 @@ cdp_eval_async() { $CDP eval-async "$1" 2>/dev/null | jq -r '.'; }
# -- Parse args --
while [[ $# -gt 0 ]]; do
case "$1" in
- --fixture) FIXTURE_PATH="$2"; shift 2 ;;
+ --fixture)
+ [ $# -ge 2 ] || { echo "ERROR: --fixture requires a path argument"; exit 2; }
+ FIXTURE_PATH="$2"; shift 2 ;;
-h|--help) echo "Usage: setup-wallet.sh [--fixture path.json]"; exit 0 ;;
*) echo "Unknown arg: $1"; exit 2 ;;
esac
@@ -82,7 +82,12 @@ for i in $(seq 0 $((ACCOUNT_COUNT - 1))); do
fi
[ -z "$ACC_VALUE" ] && { echo "ERROR: accounts[$i].value is empty"; exit 1; }
done
-echo "Fixture OK: password + ${ACCOUNT_COUNT} account(s)"
+# Expected EVM account total = sum of mnemonic counts + one per private key.
+# A mnemonic entry with count=N materializes N HD accounts, not one, so the
+# entry count alone (ACCOUNT_COUNT) under-counts and would let setup pass while
+# silently missing HD accounts.
+EXPECTED_ETH_TOTAL=$(jq -r '[.accounts[] | if .type == "mnemonic" then (.count // .numberOfAccounts // 1) else 1 end] | add // 0' "$FIXTURE_PATH")
+echo "Fixture OK: password + ${ACCOUNT_COUNT} entry(ies), ${EXPECTED_ETH_TOTAL} expected EVM account(s)"
# -- Check CDP --
$CDP eval "JSON.stringify({ok:true})" >/dev/null 2>&1 || { echo "ERROR: CDP not reachable"; exit 1; }
@@ -103,50 +108,156 @@ if [ "$ENGINE_OK" != "ready" ]; then
exit 1
fi
+# The backup subscriber reads the Engine *class* static
+# `disableAutomaticVaultBackup`. AgenticService.setupWallet/applyWalletFixture
+# set that static authoritatively before any account import/rename. The
+# CDP-exposed `Engine` is the facade object, not the class, so we cannot set the
+# static from here — only record harness intent for observability.
+if ! DISABLE_BACKUP=$(cdp_eval "(function(){ globalThis.__AGENTIC_DISABLE_VAULT_BACKUP = true; return JSON.stringify({agenticDisableVaultBackup: globalThis.__AGENTIC_DISABLE_VAULT_BACKUP === true}); })()"); then
+ echo "WARN: could not record vault backup guard intent before wallet setup"
+ DISABLE_BACKUP='{}'
+fi
+echo "Vault backup guard intent: $DISABLE_BACKUP"
+
+# Read fixture JSON and escape it for safe embedding in a JS string literal.
+FIXTURE_JSON=$(jq -c '.' "$FIXTURE_PATH")
+ESCAPED_FIXTURE=$(node -p "JSON.stringify(JSON.stringify(JSON.parse(process.argv[1])))" "$FIXTURE_JSON")
+
# -- Check vault state --
-VAULT_STATE=$(cdp_eval "(function(){ var v = Engine.context.KeyringController.state; return JSON.stringify({hasVault: v.vault !== undefined && v.vault !== null, isUnlocked: v.isUnlocked}); })()")
+VAULT_STATE=$(cdp_eval "(function(){ var v = Engine.context.KeyringController.state; return JSON.stringify({hasVault: v.vault !== undefined && v.vault !== null, isUnlocked: Engine.context.KeyringController.isUnlocked()}); })()")
HAS_VAULT=$(echo "$VAULT_STATE" | jq -r '.hasVault')
IS_UNLOCKED=$(echo "$VAULT_STATE" | jq -r '.isUnlocked')
echo "Vault state: hasVault=$HAS_VAULT, isUnlocked=$IS_UNLOCKED"
-# -- Existing vault: just unlock and skip to summary --
if [ "$HAS_VAULT" = "true" ]; then
- if [ "$IS_UNLOCKED" != "true" ]; then
- echo "Unlocking existing vault..."
- bash "$SCRIPTS/unlock-wallet.sh" "$PASSWORD"
- else
+ # Do NOT unlock here with a bare KeyringController.submitPassword(): that
+ # bypasses the real auth flow (multichain init + dispatchLogin/password state)
+ # and can leave Redux/auth state stale. applyWalletFixture unlocks via
+ # Authentication.unlockWallet() when the vault is locked.
+ if [ "$IS_UNLOCKED" = "true" ]; then
echo "Vault already unlocked."
+ else
+ echo "Vault locked — applyWalletFixture will unlock via Authentication.unlockWallet() (real post-login flow)."
fi
+
+ echo "Applying fixture accounts/names to existing vault..."
+ APPLY_RESULT=$(cdp_eval_async "(function(){ var fixture = JSON.parse($ESCAPED_FIXTURE); if (!globalThis.__AGENTIC__ || typeof globalThis.__AGENTIC__.applyWalletFixture !== 'function') { return JSON.stringify({ok:false, error:'__AGENTIC__.applyWalletFixture is not installed; reload the app from Metro'}); } return globalThis.__AGENTIC__.applyWalletFixture(fixture).then(function(r){ return JSON.stringify(r); }).catch(function(e){ return JSON.stringify({ok:false, error: e.message || String(e)}); }); })()")
+ APPLY_OK=$(echo "$APPLY_RESULT" | jq -r '.ok')
+ if [ "$APPLY_OK" != "true" ]; then
+ APPLY_ERR=$(echo "$APPLY_RESULT" | jq -r '.error // "unknown error"')
+ echo "ERROR: applyWalletFixture failed — $APPLY_ERR"
+ exit 1
+ fi
+ echo "Wallet fixture apply result:"
+ echo "$APPLY_RESULT" | jq -r '.accounts[]? | " \(.name): \(.address)"'
else
- # -- Call AgenticService.setupWallet() --
+ # -- Call AgenticService.setupWallet() on fresh app only --
echo "Calling __AGENTIC__.setupWallet()..."
- # Read fixture JSON and escape it for safe embedding in a JS string literal
- FIXTURE_JSON=$(jq -c '.' "$FIXTURE_PATH")
- ESCAPED_FIXTURE=$(node -p "JSON.stringify(JSON.stringify(JSON.parse(process.argv[1])))" "$FIXTURE_JSON")
-
- SETUP_RESULT=$(cdp_eval_async "(function(){ var fixture = JSON.parse($ESCAPED_FIXTURE); return globalThis.__AGENTIC__.setupWallet(fixture).then(function(r){ return JSON.stringify(r); }).catch(function(e){ return JSON.stringify({ok:false, error: e.message || String(e)}); }); })()")
+ SETUP_RESULT=$(cdp_eval_async "(function(){ var fixture = JSON.parse($ESCAPED_FIXTURE); if (!globalThis.__AGENTIC__ || typeof globalThis.__AGENTIC__.setupWallet !== 'function') { return JSON.stringify({ok:false, error:'__AGENTIC__.setupWallet is not installed'}); } return globalThis.__AGENTIC__.setupWallet(fixture).then(function(r){ return JSON.stringify(r); }).catch(function(e){ return JSON.stringify({ok:false, error: e.message || String(e)}); }); })()")
SETUP_OK=$(echo "$SETUP_RESULT" | jq -r '.ok')
if [ "$SETUP_OK" != "true" ]; then
SETUP_ERR=$(echo "$SETUP_RESULT" | jq -r '.error // "unknown error"')
- echo "ERROR: setupWallet failed — $SETUP_ERR"
+ SETUP_STEP=$(echo "$SETUP_RESULT" | jq -r '.step // "unknown-step"')
+ echo "ERROR: setupWallet failed at ${SETUP_STEP} — $SETUP_ERR"
exit 1
fi
- echo "Wallet created. Accounts:"
+ echo "Wallet setup result:"
echo "$SETUP_RESULT" | jq -r '.accounts[]? | " \(.name): \(.address)"'
sleep 2
fi
-# -- Summary --
-ACCOUNTS=$(cdp_eval "(function(){ var accs = Object.values(Engine.context.AccountsController.state.internalAccounts.accounts); var eth = accs.filter(function(a){ return a.address.indexOf('0x') === 0; }); return JSON.stringify({total: accs.length, ethAccounts: eth.length, first3: eth.slice(0,3).map(function(a){ return {name: a.metadata.name, address: a.address}; })}); })()")
+# -- Ask the app to leave auth/onboarding after unlock.
+# HomeNav matches the product auth reset path, but some warm/onboarding states
+# land on intermediate post-onboarding screens. Follow with WalletView so the
+# harness proves the user-visible unlocked wallet, not just a populated vault.
+$CDP navigate HomeNav >/dev/null 2>&1 || true
+sleep 1
+$CDP navigate WalletView >/dev/null 2>&1 || true
+sleep 1
+
+# -- Summary + hard validation --
+ACCOUNTS=$(cdp_eval "(function(){ try { var ctx = Engine && Engine.context ? Engine.context : {}; var accountsController = ctx.AccountsController || {}; var keyringController = ctx.KeyringController || {}; var state = accountsController.state || {}; var internalAccounts = state.internalAccounts || {}; var accountsById = internalAccounts.accounts || {}; var accs = Object.values(accountsById); var eth = accs.filter(function(a){ return String(a && a.address || '').indexOf('0x') === 0; }); var route = globalThis.__AGENTIC__ && globalThis.__AGENTIC__.getRoute ? globalThis.__AGENTIC__.getRoute() : null; var selected = null; if (typeof accountsController.getSelectedAccount === 'function') { selected = accountsController.getSelectedAccount(); } else if (internalAccounts.selectedAccount && accountsById[internalAccounts.selectedAccount]) { selected = accountsById[internalAccounts.selectedAccount]; } return JSON.stringify({ok:true, unlocked: typeof keyringController.isUnlocked === 'function' ? keyringController.isUnlocked() : null, routeName: route && route.name, selected: selected ? {name: selected.metadata && selected.metadata.name, address: selected.address} : null, total: accs.length, ethAccounts: eth.length, accounts: eth.map(function(a){ return {name: a && a.metadata && a.metadata.name, address: a && a.address}; }), first3: eth.slice(0,3).map(function(a){ return {name: a && a.metadata && a.metadata.name, address: a && a.address}; })}); } catch (e) { return JSON.stringify({ok:false, error: e && (e.stack || e.message) || String(e)}); } })()")
+ACCOUNTS_OK=$(echo "$ACCOUNTS" | jq -r '.ok // false')
+if [ "$ACCOUNTS_OK" != "true" ]; then
+ echo "ERROR: Unable to read wallet state after setupWallet"
+ echo "$ACCOUNTS" | jq .
+ exit 1
+fi
TOTAL=$(echo "$ACCOUNTS" | jq -r '.total')
ETH_COUNT=$(echo "$ACCOUNTS" | jq -r '.ethAccounts')
+UNLOCKED=$(echo "$ACCOUNTS" | jq -r '.unlocked')
+ROUTE_NAME=$(echo "$ACCOUNTS" | jq -r '.routeName // empty')
+EXPECTED_ADDRESSES=$(node - "$FIXTURE_PATH" <<'NODE'
+const fs = require('fs');
+const { Wallet } = require('ethers');
+const fixture = JSON.parse(fs.readFileSync(process.argv[2], 'utf8'));
+const addresses = [];
+for (const account of fixture.accounts || []) {
+ if (account.type === 'mnemonic') {
+ const rawCount =
+ account.count != null
+ ? account.count
+ : account.numberOfAccounts != null
+ ? account.numberOfAccounts
+ : 1;
+ const count = Number(rawCount);
+ for (let i = 0; i < count; i += 1) {
+ addresses.push(
+ Wallet.fromMnemonic(account.value, `m/44'/60'/0'/0/${i}`).address.toLowerCase(),
+ );
+ }
+ } else if (account.type === 'privateKey') {
+ const key = account.value.startsWith('0x') ? account.value : `0x${account.value}`;
+ addresses.push(new Wallet(key).address.toLowerCase());
+ }
+}
+console.log(JSON.stringify(addresses));
+NODE
+)
+MISSING_ADDRESSES=$(ACCOUNTS_JSON="$ACCOUNTS" EXPECTED_JSON="$EXPECTED_ADDRESSES" node <<'NODE'
+const accounts = JSON.parse(process.env.ACCOUNTS_JSON);
+const expected = JSON.parse(process.env.EXPECTED_JSON);
+const actual = new Set((accounts.accounts || accounts.first3 || []).map((a) => String(a.address || '').toLowerCase()));
+const missing = expected.filter((address) => !actual.has(address));
+console.log(JSON.stringify(missing));
+NODE
+)
+if [ "$UNLOCKED" != "true" ]; then
+ echo "ERROR: Wallet setup did not unlock the vault"
+ echo "$ACCOUNTS" | jq .
+ exit 1
+fi
+if [ "$ETH_COUNT" -lt "$EXPECTED_ETH_TOTAL" ]; then
+ echo "ERROR: Wallet setup produced $ETH_COUNT ETH account(s), expected at least $EXPECTED_ETH_TOTAL from fixture"
+ echo "$ACCOUNTS" | jq .
+ exit 1
+fi
+if [ "$(echo "$MISSING_ADDRESSES" | jq 'length')" != "0" ]; then
+ echo "ERROR: Wallet setup did not import/unlock the expected fixture account(s)"
+ echo "Missing addresses:"
+ echo "$MISSING_ADDRESSES" | jq -r '.[] | " " + .'
+ echo "Actual wallet state:"
+ echo "$ACCOUNTS" | jq .
+ exit 1
+fi
+case "$ROUTE_NAME" in
+ Login|Onboarding|ExperienceEnhancer|FoxLoader|"")
+ echo "ERROR: Wallet setup did not reach the unlocked wallet UI (route=${ROUTE_NAME:-empty})"
+ echo "$ACCOUNTS" | jq .
+ exit 1
+ ;;
+esac
echo ""
echo "=== Wallet Ready ==="
+echo "Route: ${ROUTE_NAME:-unknown}"
+echo "Unlocked: $UNLOCKED"
echo "Accounts: $ETH_COUNT ETH (${TOTAL} total)"
echo "$ACCOUNTS" | jq -r '.first3[] | " \(.name): \(.address)"'
+echo "Selected:"
+echo "$ACCOUNTS" | jq -r '.selected | " \(.name): \(.address)"'
echo ""
echo "Done."
exit 0
diff --git a/scripts/perps/agentic/teams/README.md b/scripts/perps/agentic/teams/README.md
deleted file mode 100644
index 5b3dc6506131..000000000000
--- a/scripts/perps/agentic/teams/README.md
+++ /dev/null
@@ -1,105 +0,0 @@
-# Agentic Teams — Contribution Guide
-
-Each team owns its own directory under `teams//`.
-The registry auto-discovers and merges all team pre-conditions at load time.
-
-## Directory structure
-
-```
-teams/
- perps/
- flows/ ← flow JSON files (validated by validate-flow-schema.js)
- recipes/ ← integration-level recipes that compose flows via call action
- evals/ ← named eval collections (core.json, setup.json, ...)
- evals.json ← quick CDP eval refs (positions, auth, balances, ...)
- pre-conditions.js ← perps.* checks
- mobile-platform/
- pre-conditions.js ← mobile-platform.* checks
- /
- flows/ ← optional: flow JSON files
- recipes/ ← optional: integration-level recipes
- evals/ ← optional: named eval collections
- evals.json ← optional: quick CDP eval refs
- pre-conditions.js ← .* checks
-```
-
-## Adding a new team
-
-1. Create `teams//pre-conditions.js` exporting a `Record`.
-2. Key naming convention: `.` — e.g. `swap.has_quote`, `nft.owns_token`.
-3. Duplicate keys across teams cause a load-time error, so namespacing is enforced by convention.
-4. Optionally add `flows/`, `evals/`, and `evals.json` for team-specific automation.
-
-## Pre-condition shape
-
-```js
-'use strict';
-const REGISTRY = {
- 'myteam.some_check': {
- description: 'Human-readable description shown on failure.',
- async: false,
- // Plain string for fixed checks; function(params) => string for parameterised ones.
- expression: 'JSON.stringify({ ok: true })',
- assert: { operator: 'eq', field: 'ok', value: true },
- hint: 'What the user should do when this check fails.',
- },
-};
-module.exports = REGISTRY;
-```
-
-## Flows
-
-Flow JSON files live in `teams//flows/`. They are automatically discovered by `validate-flow-schema.js`.
-
-```bash
-# Validate all flows
-node scripts/perps/agentic/validate-flow-schema.js
-
-# Validate a single flow
-node scripts/perps/agentic/validate-flow-schema.js scripts/perps/agentic/teams/perps/flows/trade-open-market.json
-```
-
-## Evals
-
-Named eval collections live in `teams//evals/.json`.
-Run them via: `node cdp-bridge.js eval-ref //`
-
-Example: `node cdp-bridge.js eval-ref perps/core/pump-market`
-
-Quick CDP eval refs live in `teams//evals.json`.
-Run them via: `node cdp-bridge.js eval-ref /`
-
-Example: `node cdp-bridge.js eval-ref perps/positions`
-
-List all available eval refs:
-
-```bash
-node scripts/perps/agentic/cdp-bridge.js eval-ref --list
-```
-
-## Recipes
-
-Recipes live in `teams//recipes/`. They compose multiple flows via the `call` action for integration-level validation — proving that end-to-end scenarios work across flow boundaries.
-
-```bash
-# Run a recipe against the live app
-bash scripts/perps/agentic/validate-recipe.sh scripts/perps/agentic/teams/perps/recipes/full-trade-lifecycle.json
-
-# Dry-run (prints steps without executing)
-bash scripts/perps/agentic/validate-recipe.sh scripts/perps/agentic/teams/perps/recipes/full-trade-lifecycle.json --dry-run
-```
-
-See `teams/perps/recipes/full-trade-lifecycle.json` for an example that chains wallet home → mainnet → perps → testnet → open position → TP/SL → close.
-
-## Validators
-
-```bash
-# Check assertion correctness for all pre-conditions (no live app needed)
-node scripts/perps/agentic/validate-pre-conditions.js
-
-# Validate all flow JSON files against schema rules
-node scripts/perps/agentic/validate-flow-schema.js
-
-# Run a recipe against the live app
-bash scripts/perps/agentic/validate-recipe.sh
-```
diff --git a/scripts/perps/agentic/teams/mobile-platform/pre-conditions.js b/scripts/perps/agentic/teams/mobile-platform/pre-conditions.js
deleted file mode 100644
index 051618a3e5e6..000000000000
--- a/scripts/perps/agentic/teams/mobile-platform/pre-conditions.js
+++ /dev/null
@@ -1,7 +0,0 @@
-'use strict';
-/** @type {Record} */
-const REGISTRY = {
- // mobile-platform.* pre-conditions go here.
- // Key naming convention: mobile-platform.
-};
-module.exports = REGISTRY;
diff --git a/scripts/perps/agentic/teams/perps/evals.json b/scripts/perps/agentic/teams/perps/evals.json
deleted file mode 100644
index 31e60411ee8f..000000000000
--- a/scripts/perps/agentic/teams/perps/evals.json
+++ /dev/null
@@ -1,57 +0,0 @@
-{
- "positions": {
- "description": "Open perps positions",
- "expression": "Engine.context.PerpsController.getPositions().then(function(r){return JSON.stringify(r)})",
- "async": true
- },
- "auth": {
- "description": "Active provider auth state (isReadyToTrade)",
- "expression": "(function(){var c=Engine.context.PerpsController;var id=c.state.activeProvider;var p=c.providers.get(id);if(!p)return JSON.stringify({error:'no provider for '+id});return p.isReadyToTrade().then(function(r){return JSON.stringify({provider:id,isAuthenticated:r.ready,authenticatedAddress:r.authenticatedAddress||null})});})()",
- "async": true
- },
- "balances": {
- "description": "Account state (balances, margin) from cached state",
- "expression": "JSON.stringify(Engine.context.PerpsController.state.accountState)",
- "async": false
- },
- "markets": {
- "description": "Markets with live prices",
- "expression": "Engine.context.PerpsController.getMarketDataWithPrices().then(function(r){return JSON.stringify(r.map(function(m){return{symbol:m.symbol,price:m.price,change24h:m.change24hPercent}}))})",
- "async": true
- },
- "orders": {
- "description": "Open limit/stop orders",
- "expression": "Engine.context.PerpsController.getOpenOrders().then(function(r){return JSON.stringify(r)})",
- "async": true
- },
- "state": {
- "description": "Full PerpsController state snapshot",
- "expression": "JSON.stringify(Engine.context.PerpsController.state)",
- "async": false
- },
- "providers": {
- "description": "Registered provider IDs",
- "expression": "JSON.stringify(Array.from(Engine.context.PerpsController.providers.keys()))",
- "async": false
- },
- "pre-trade": {
- "description": "Position count + balance snapshot before a trade (use for pre/post comparison)",
- "expression": "Promise.all([Engine.context.PerpsController.getPositions(),Engine.context.PerpsController.getAccountState()]).then(function(r){return JSON.stringify({positionCount:r[0].length,positions:r[0].map(function(p){return{symbol:p.symbol,side:p.side,size:p.size,entryPrice:p.entryPrice,unrealizedPnl:p.unrealizedPnl}}),accountState:r[1]})})",
- "async": true
- },
- "place-order": {
- "description": "TEMPLATE: market buy BTC $10 at 2x leverage — substitute params via eval-async for custom scenarios",
- "expression": "Engine.context.PerpsController.placeOrder({symbol:'BTC',isBuy:true,orderType:'market',size:'0.0001',leverage:2,usdAmount:'10',maxSlippageBps:500}).then(function(r){return JSON.stringify(r)})",
- "async": true
- },
- "post-trade": {
- "description": "Position count + balance snapshot after a trade (compare with pre-trade output)",
- "expression": "Promise.all([Engine.context.PerpsController.getPositions(),Engine.context.PerpsController.getAccountState()]).then(function(r){return JSON.stringify({positionCount:r[0].length,positions:r[0].map(function(p){return{symbol:p.symbol,side:p.side,size:p.size,entryPrice:p.entryPrice,unrealizedPnl:p.unrealizedPnl}}),accountState:r[1]})})",
- "async": true
- },
- "hl-fixture-state": {
- "description": "HL fixture provisioning snapshot: balances and derived shape flags used by hl-provision-fixture flow and post-op assertions",
- "expression": "(function(){var s=(Engine.context.PerpsController.state&&Engine.context.PerpsController.state.accountState)||{};var avail=parseFloat(s.withdrawableBalance||'0');var trade=parseFloat(s.spendableBalance||s.withdrawableBalance||'0');var total=parseFloat(s.totalBalance||'0');var spotFold=+(trade-avail).toFixed(6);return JSON.stringify({withdrawableBalance:avail,spendableBalance:trade,totalBalance:total,spotFold:spotFold,hasPerpsBalance:avail>0.01,hasSpotBalance:spotFold>0.01,isEmpty:total<0.01})})()",
- "async": false
- }
-}
\ No newline at end of file
diff --git a/scripts/perps/agentic/teams/perps/evals/core.json b/scripts/perps/agentic/teams/perps/evals/core.json
deleted file mode 100644
index 194c67b5b118..000000000000
--- a/scripts/perps/agentic/teams/perps/evals/core.json
+++ /dev/null
@@ -1,32 +0,0 @@
-{
- "pump-market": {
- "description": "PUMP market data with live price",
- "expression": "Engine.context.PerpsController.getMarketDataWithPrices().then(function(ms){return JSON.stringify(ms.find(function(m){return m.symbol==='PUMP'}))})",
- "async": true
- },
- "tpsl-orders": {
- "description": "Open TP/SL trigger orders",
- "expression": "Engine.context.PerpsController.getOpenOrders().then(function(os){return JSON.stringify(os.filter(function(o){return o.isTrigger}))})",
- "async": true
- },
- "positions-by-symbol": {
- "description": "Find open position by symbol (TEMPLATE — substitute symbol via eval-async for custom scenarios)",
- "expression": "Engine.context.PerpsController.getPositions().then(function(ps){return JSON.stringify(ps.find(function(p){return p.symbol==='BTC'}))})",
- "async": true
- },
- "leverage-config": {
- "description": "Trade configurations (leverage, fee tiers) from controller state",
- "expression": "JSON.stringify(Engine.context.PerpsController.state.tradeConfigurations)",
- "async": false
- },
- "watchlist": {
- "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
- }
-}
diff --git a/scripts/perps/agentic/teams/perps/evals/setup.json b/scripts/perps/agentic/teams/perps/evals/setup.json
deleted file mode 100644
index 2f9e276111f7..000000000000
--- a/scripts/perps/agentic/teams/perps/evals/setup.json
+++ /dev/null
@@ -1,17 +0,0 @@
-{
- "testnet-mode": {
- "description": "Current testnet mode (isTestnet boolean)",
- "expression": "Engine.context.PerpsController.state.isTestnet",
- "async": false
- },
- "current-provider": {
- "description": "Currently active provider",
- "expression": "Engine.context.PerpsController.state.activeProvider",
- "async": false
- },
- "account-balance": {
- "description": "Perps account balance",
- "expression": "Engine.context.PerpsController.getBalance().then(function(r){return JSON.stringify(r)})",
- "async": true
- }
-}
diff --git a/scripts/perps/agentic/teams/perps/flows/activity-view.json b/scripts/perps/agentic/teams/perps/flows/activity-view.json
deleted file mode 100644
index 24484ab4a838..000000000000
--- a/scripts/perps/agentic/teams/perps/flows/activity-view.json
+++ /dev/null
@@ -1,50 +0,0 @@
-{
- "title": "Activity — view recent trades and funding history",
- "inputs": {
- "tab": {
- "type": "string",
- "default": "trades",
- "description": "Activity tab to select (trades/orders/funding/deposits)"
- }
- },
- "validate": {
- "workflow": {
- "pre_conditions": [
- "wallet.unlocked",
- "perps.feature_enabled"
- ],
- "entry": "nav-activity",
- "nodes": {
- "nav-activity": {
- "description": "Navigate to perps activity screen (perps transactions tab)",
- "action": "navigate",
- "target": "PerpsActivity",
- "params": {
- "redirectToPerpsTransactions": true
- },
- "next": "wait-render"
- },
- "wait-render": {
- "action": "wait_for",
- "route": "PerpsActivity",
- "next": "press-tab"
- },
- "press-tab": {
- "description": "Select the requested tab",
- "action": "press",
- "test_id": "perps-transactions-tab-{{tab}}",
- "next": "wait-filter"
- },
- "wait-filter": {
- "action": "wait_for",
- "route": "PerpsActivity",
- "next": "done"
- },
- "done": {
- "action": "end",
- "status": "pass"
- }
- }
- }
- }
-}
diff --git a/scripts/perps/agentic/teams/perps/flows/candle-rapid-switch.json b/scripts/perps/agentic/teams/perps/flows/candle-rapid-switch.json
deleted file mode 100644
index 1998321912cd..000000000000
--- a/scripts/perps/agentic/teams/perps/flows/candle-rapid-switch.json
+++ /dev/null
@@ -1,191 +0,0 @@
-{
- "title": "Rapid market switching — validates candles load and no rate-limit/abort errors across BTC→ETH→SOL→HYPE→BTC→ETH",
- "schema_version": 1,
- "inputs": {
- "screenshot_filename": {
- "type": "string",
- "default": "",
- "description": "If non-empty, capture a screenshot of the final market detail screen with this filename. Pass e.g. 'evidence-ac3-rapid-switch.png' from a recipe that needs visual proof."
- }
- },
- "validate": {
- "workflow": {
- "pre_conditions": ["wallet.unlocked", "perps.feature_enabled"],
- "entry": "nav-list",
- "nodes": {
- "nav-list": {
- "action": "navigate",
- "target": "PerpsTrendingView",
- "next": "wait-list"
- },
- "wait-list": {
- "action": "wait_for",
- "test_id": "perps-market-row-item-BTC",
- "timeout_ms": 10000,
- "next": "open-btc-1"
- },
- "open-btc-1": {
- "action": "press",
- "test_id": "perps-market-row-item-BTC",
- "next": "wait-detail-btc-1"
- },
- "wait-detail-btc-1": {
- "action": "wait_for",
- "test_id": "perps-market-details-view",
- "timeout_ms": 8000,
- "next": "back-1"
- },
- "back-1": {
- "action": "press",
- "test_id": "perps-market-header-back-button",
- "next": "wait-eth-1"
- },
- "wait-eth-1": {
- "action": "wait_for",
- "test_id": "perps-market-row-item-ETH",
- "timeout_ms": 5000,
- "next": "open-eth-1"
- },
- "open-eth-1": {
- "action": "press",
- "test_id": "perps-market-row-item-ETH",
- "next": "wait-detail-eth-1"
- },
- "wait-detail-eth-1": {
- "action": "wait_for",
- "test_id": "perps-market-details-view",
- "timeout_ms": 8000,
- "next": "back-2"
- },
- "back-2": {
- "action": "press",
- "test_id": "perps-market-header-back-button",
- "next": "wait-sol-1"
- },
- "wait-sol-1": {
- "action": "wait_for",
- "test_id": "perps-market-row-item-SOL",
- "timeout_ms": 5000,
- "next": "open-sol-1"
- },
- "open-sol-1": {
- "action": "press",
- "test_id": "perps-market-row-item-SOL",
- "next": "wait-detail-sol-1"
- },
- "wait-detail-sol-1": {
- "action": "wait_for",
- "test_id": "perps-market-details-view",
- "timeout_ms": 8000,
- "next": "back-3"
- },
- "back-3": {
- "action": "press",
- "test_id": "perps-market-header-back-button",
- "next": "wait-hype-1"
- },
- "wait-hype-1": {
- "action": "wait_for",
- "test_id": "perps-market-row-item-HYPE",
- "timeout_ms": 5000,
- "next": "open-hype-1"
- },
- "open-hype-1": {
- "action": "press",
- "test_id": "perps-market-row-item-HYPE",
- "next": "wait-detail-hype-1"
- },
- "wait-detail-hype-1": {
- "action": "wait_for",
- "test_id": "perps-market-details-view",
- "timeout_ms": 8000,
- "next": "back-4"
- },
- "back-4": {
- "action": "press",
- "test_id": "perps-market-header-back-button",
- "next": "wait-btc-2"
- },
- "wait-btc-2": {
- "action": "wait_for",
- "test_id": "perps-market-row-item-BTC",
- "timeout_ms": 5000,
- "next": "open-btc-2"
- },
- "open-btc-2": {
- "action": "press",
- "test_id": "perps-market-row-item-BTC",
- "next": "wait-detail-btc-2"
- },
- "wait-detail-btc-2": {
- "action": "wait_for",
- "test_id": "perps-market-details-view",
- "timeout_ms": 8000,
- "next": "back-5"
- },
- "back-5": {
- "action": "press",
- "test_id": "perps-market-header-back-button",
- "next": "wait-eth-2"
- },
- "wait-eth-2": {
- "action": "wait_for",
- "test_id": "perps-market-row-item-ETH",
- "timeout_ms": 5000,
- "next": "open-eth-2"
- },
- "open-eth-2": {
- "action": "press",
- "test_id": "perps-market-row-item-ETH",
- "next": "wait-detail-eth-2"
- },
- "wait-detail-eth-2": {
- "action": "wait_for",
- "test_id": "perps-market-details-view",
- "timeout_ms": 8000,
- "next": "gate-screenshot-check"
- },
- "gate-screenshot-check": {
- "action": "eval_sync",
- "expression": "JSON.stringify({take: '{{screenshot_filename}}' !== ''})",
- "assert": { "operator": "not_null" },
- "next": "gate-screenshot-route"
- },
- "gate-screenshot-route": {
- "action": "switch",
- "cases": [
- { "when": { "operator": "eq", "field": "take", "value": true }, "next": "screenshot-chart" }
- ],
- "default": "back-6"
- },
- "screenshot-chart": {
- "action": "screenshot",
- "filename": "{{screenshot_filename}}",
- "note": "Final ETH market detail after rapid BTC→ETH→SOL→HYPE→BTC→ETH switch — proves chart loads on the last symbol without rate-limit/abort errors",
- "next": "back-6"
- },
- "back-6": {
- "action": "press",
- "test_id": "perps-market-header-back-button",
- "next": "check-no-errors"
- },
- "check-no-errors": {
- "action": "log_watch",
- "window_seconds": 45,
- "must_not_appear": [
- "candleSnapshot error",
- "historical_candles_api",
- "candle_subscription_async",
- "initial_candles_fetch"
- ],
- "watch_for": ["candleSnapshot", "fetchHistoricalCandles", "historical_candles"],
- "next": "done"
- },
- "done": {
- "action": "end",
- "status": "pass"
- }
- }
- }
- }
-}
diff --git a/scripts/perps/agentic/teams/perps/flows/hl-balance-contract-check.json b/scripts/perps/agentic/teams/perps/flows/hl-balance-contract-check.json
deleted file mode 100644
index c02813aafd42..000000000000
--- a/scripts/perps/agentic/teams/perps/flows/hl-balance-contract-check.json
+++ /dev/null
@@ -1,57 +0,0 @@
-{
- "title": "HL — assert AccountState three-field contract (TAT-3047)",
- "description": "Switches to {{address}}, waits for PerpsController.accountState to populate, then asserts the new three-field balance contract: spendableBalance + withdrawableBalance + totalBalance present, and no legacy availableBalance / availableToTradeBalance keys. Provider-agnostic shape check — passes on any HL or MYX account.",
- "inputs": {
- "address": {
- "type": "string",
- "description": "EVM address to select before asserting"
- },
- "phaseLabel": {
- "type": "string",
- "default": "contract-check",
- "description": "Short label surfaced in assertions and screenshots"
- }
- },
- "validate": {
- "workflow": {
- "pre_conditions": ["wallet.unlocked"],
- "entry": "select",
- "nodes": {
- "select": {
- "action": "select_account",
- "address": "{{address}}",
- "next": "wait-account"
- },
- "wait-account": {
- "action": "wait_for",
- "expression": "Engine.context.PerpsController.getAccountState().then(function(r){return JSON.stringify({ready:!!r,hasSpendable:typeof r.spendableBalance==='string',hasWithdrawable:typeof r.withdrawableBalance==='string',phase:'{{phaseLabel}}'})}).catch(function(e){return JSON.stringify({ready:false,error:String(e),phase:'{{phaseLabel}}'})})",
- "assert": {
- "all": [
- { "operator": "eq", "field": "ready", "value": true },
- { "operator": "eq", "field": "hasSpendable", "value": true },
- { "operator": "eq", "field": "hasWithdrawable", "value": true }
- ]
- },
- "timeout_ms": 30000,
- "next": "assert-shape"
- },
- "assert-shape": {
- "description": "Contract: spendable + withdrawable + total present, no legacy fields",
- "action": "eval_sync",
- "expression": "(function(){var s=Engine.context.PerpsController.state.accountState||{};return JSON.stringify({hasSpendable:typeof s.spendableBalance==='string',hasWithdrawable:typeof s.withdrawableBalance==='string',hasTotal:typeof s.totalBalance==='string',hasLegacyAvailable:'availableBalance' in s,hasLegacyTradeable:'availableToTradeBalance' in s,spendableBalance:s.spendableBalance,withdrawableBalance:s.withdrawableBalance,totalBalance:s.totalBalance,phase:'{{phaseLabel}}'})})()",
- "assert": {
- "all": [
- { "operator": "eq", "field": "hasSpendable", "value": true },
- { "operator": "eq", "field": "hasWithdrawable", "value": true },
- { "operator": "eq", "field": "hasTotal", "value": true },
- { "operator": "eq", "field": "hasLegacyAvailable", "value": false },
- { "operator": "eq", "field": "hasLegacyTradeable", "value": false }
- ]
- },
- "next": "done"
- },
- "done": { "action": "end", "status": "pass" }
- }
- }
- }
-}
diff --git a/scripts/perps/agentic/teams/perps/flows/hl-balance-math-check.json b/scripts/perps/agentic/teams/perps/flows/hl-balance-math-check.json
deleted file mode 100644
index aa9f877859ab..000000000000
--- a/scripts/perps/agentic/teams/perps/flows/hl-balance-math-check.json
+++ /dev/null
@@ -1,61 +0,0 @@
-{
- "title": "HL — assert controller balance math matches raw HL REST (TAT-3047)",
- "description": "Asserts PerpsController.accountState is consistent with raw HL state. Uses the controller's own subAccountBreakdown (pre-spot-fold per-DEX values) as perps-side truth plus spotClearinghouseState via HL REST for spot-side truth. When `foldIntoCollateral` is true (Unified / Portfolio), expects spendable/withdrawable = Σ(breakdown) + freeSpot. When false (Standard / DEX-abstraction), expects spendable/withdrawable = Σ(breakdown) — spot is NOT folded into collateral fields. `totalBalance` always equals Σ(breakdown.total) + spot.total − spot.hold (display reflects combined wealth regardless of mode).",
- "inputs": {
- "address": {
- "type": "string",
- "description": "EVM address to query via HL REST and select in the app"
- },
- "phaseLabel": {
- "type": "string",
- "default": "math-check",
- "description": "Short label surfaced in assertions"
- },
- "epsilon": {
- "type": "number",
- "default": 0.01,
- "description": "Rounding tolerance when comparing controller fields vs REST-derived expected values"
- },
- "foldIntoCollateral": {
- "type": "boolean",
- "default": true,
- "description": "Expected mode-aware behavior: true when the account is in a unifying mode (Unified / Portfolio), false for Standard / DEX-abstraction. Governs how expected spendable/withdrawable are derived."
- }
- },
- "validate": {
- "workflow": {
- "pre_conditions": ["wallet.unlocked"],
- "entry": "select",
- "nodes": {
- "select": {
- "action": "select_account",
- "address": "{{address}}",
- "next": "wait-account"
- },
- "wait-account": {
- "action": "wait_for",
- "expression": "Engine.context.PerpsController.getAccountState().then(function(r){return JSON.stringify({ready:!!r,phase:'{{phaseLabel}}'})}).catch(function(e){return JSON.stringify({ready:false,error:String(e)})})",
- "assert": { "operator": "eq", "field": "ready", "value": true },
- "timeout_ms": 30000,
- "next": "assert-rest-vs-state"
- },
- "assert-rest-vs-state": {
- "description": "Derive expected values from breakdown (perps-side truth, covers HIP-3) + HL REST spot; compare against controller fields; gate fold on foldIntoCollateral input.",
- "action": "eval_async",
- "expression": "(function(){var addr='{{address}}'.toLowerCase();var eps={{epsilon}};var fold={{foldIntoCollateral}};var base='https://api.hyperliquid.xyz/info';return fetch(base,{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({type:'spotClearinghouseState',user:addr})}).then(function(r){return r.json()}).then(function(spot){var usdc=((spot&&spot.balances)||[]).find(function(b){return b.coin==='USDC'})||{};var t=parseFloat(usdc.total||'0');var h=parseFloat(usdc.hold||'0');var free=Math.max(0,t-h);var s=Engine.context.PerpsController.state.accountState||{};var breakdown=s.subAccountBreakdown||{};var sumSpendable=0;var sumWithdrawable=0;var sumTotal=0;Object.keys(breakdown).forEach(function(k){var e=breakdown[k]||{};sumSpendable+=parseFloat(e.spendableBalance||'0');sumWithdrawable+=parseFloat(e.withdrawableBalance||'0');sumTotal+=parseFloat(e.totalBalance||'0')});var expectedSpendable=fold?sumSpendable+free:sumSpendable;var expectedWithdrawable=fold?sumWithdrawable+free:sumWithdrawable;var expectedTotal=sumTotal+t-h;var actualSpendable=parseFloat(s.spendableBalance||'0');var actualWithdrawable=parseFloat(s.withdrawableBalance||'0');var actualTotal=parseFloat(s.totalBalance||'0');return JSON.stringify({phase:'{{phaseLabel}}',foldIntoCollateral:fold,spot:{total:t,hold:h,free:free},breakdownPerpsSums:{spendable:sumSpendable,withdrawable:sumWithdrawable,total:sumTotal},expected:{spendable:expectedSpendable,withdrawable:expectedWithdrawable,total:expectedTotal},actual:{spendable:actualSpendable,withdrawable:actualWithdrawable,total:actualTotal},spendableMatches:Math.abs(actualSpendable-expectedSpendable)<=eps,withdrawableMatches:Math.abs(actualWithdrawable-expectedWithdrawable)<=eps,totalMatches:Math.abs(actualTotal-expectedTotal)<=eps,spendableEqualsWithdrawable:Math.abs(actualSpendable-actualWithdrawable)<=eps})})})()",
- "assert": {
- "all": [
- { "operator": "eq", "field": "spendableMatches", "value": true },
- { "operator": "eq", "field": "withdrawableMatches", "value": true },
- { "operator": "eq", "field": "totalMatches", "value": true },
- { "operator": "eq", "field": "spendableEqualsWithdrawable", "value": true }
- ]
- },
- "timeout_ms": 20000,
- "next": "done"
- },
- "done": { "action": "end", "status": "pass" }
- }
- }
- }
-}
diff --git a/scripts/perps/agentic/teams/perps/flows/hl-balance-validation.json b/scripts/perps/agentic/teams/perps/flows/hl-balance-validation.json
deleted file mode 100644
index a51552c6eb2b..000000000000
--- a/scripts/perps/agentic/teams/perps/flows/hl-balance-validation.json
+++ /dev/null
@@ -1,237 +0,0 @@
-{
- "title": "HL balance validation — phase={{phaseLabel}} expectedMode={{expectedMode}}",
- "schema_version": 1,
- "description": "Captures and asserts the three balance readouts the TAT-3016 hotfix touches: (1) PerpsController.state.accountState (withdrawableBalance vs spendableBalance), (2) PerpsMarketDetails balance text (perps-market-available-balance-text — gates order-entry Long/Add-Funds CTA), (3) PerpsWithdraw balance text (perps-withdraw-available-balance-text — must show withdrawableBalance only, never the spot fold). Screenshots per screen tagged with phaseLabel for reviewer before/after comparison. Fold invariant (trade >= avail) is asserted unconditionally. expectedMode='unified' additionally asserts the fold amount is non-trivial when spot USDC is present; expectedMode='standard' asserts withdrawable==withdrawableBalance (no leak from fold into the withdrawable path).",
- "inputs": {
- "expectedMode": {
- "type": "string",
- "default": "unified",
- "description": "HL abstraction mode expected in this phase: 'unified' or 'standard'"
- },
- "phaseLabel": {
- "type": "string",
- "default": "phase",
- "description": "Short label used in screenshot filenames and trace annotations (e.g. 'initial-unified', 'after-flip-standard', 'restored-unified')"
- }
- },
- "validate": {
- "workflow": {
- "pre_conditions": [
- "wallet.unlocked",
- "perps.feature_enabled"
- ],
- "entry": "refresh-state",
- "nodes": {
- "refresh-state": {
- "action": "eval_async",
- "expression": "Engine.context.PerpsController.getAccountState().then(function(r){return JSON.stringify({ok:true,withdrawableBalance:r.withdrawableBalance,spendableBalance:r.spendableBalance,totalBalance:r.totalBalance,phase:'{{phaseLabel}}'})}).catch(function(e){return JSON.stringify({ok:false,error:String(e),phase:'{{phaseLabel}}'})})",
- "assert": { "operator": "eq", "field": "ok", "value": true },
- "timeout_ms": 20000,
- "next": "capture-controller-state"
- },
- "capture-controller-state": {
- "action": "eval_ref",
- "ref": "perps/hl-fixture-state",
- "assert": {
- "operator": "not_null",
- "field": "spendableBalance"
- },
- "next": "assert-fold-invariant"
- },
- "assert-fold-invariant": {
- "action": "eval_sync",
- "expression": "(function(){var s=(Engine.context.PerpsController.state&&Engine.context.PerpsController.state.accountState)||{};var avail=parseFloat(s.withdrawableBalance||'0');var trade=parseFloat(s.spendableBalance||s.withdrawableBalance||'0');var spotFold=+(trade-avail).toFixed(6);return JSON.stringify({withdrawableBalance:avail,spendableBalance:trade,spotFold:spotFold,foldInvariant:trade>=avail,phase:'{{phaseLabel}}'})})()",
- "assert": {
- "operator": "eq",
- "field": "foldInvariant",
- "value": true
- },
- "next": "nav-market-list"
- },
- "nav-market-list": {
- "action": "navigate",
- "target": "PerpsMarketListView",
- "next": "wait-market-list"
- },
- "wait-market-list": {
- "action": "wait_for",
- "expression": "(function(){try{var r=globalThis.__AGENTIC__&&globalThis.__AGENTIC__.getRoute();return JSON.stringify({route:r?r.name:'unknown'})}catch(e){return JSON.stringify({route:'unknown'})}})()",
- "assert": {
- "operator": "eq",
- "field": "route",
- "value": "PerpsMarketListView"
- },
- "timeout_ms": 15000,
- "next": "settle-market-list"
- },
- "settle-market-list": {
- "action": "wait_for",
- "expression": "(function(){var a=globalThis.__AGENTIC__;var mounted=!!(a&&a.findFiberByTestId&&a.findFiberByTestId('perps-market-available-balance-text'));return JSON.stringify({mounted:mounted})})()",
- "assert": { "operator": "eq", "field": "mounted", "value": true },
- "timeout_ms": 15000,
- "next": "capture-market-list-balance"
- },
- "capture-market-list-balance": {
- "action": "eval_sync",
- "expression": "(function(){try{var a=globalThis.__AGENTIC__;var display=a&&a.getTextByTestId('perps-market-available-balance-text')||'';var s=(Engine.context.PerpsController.state&&Engine.context.PerpsController.state.accountState)||{};var trade=parseFloat(s.spendableBalance||s.withdrawableBalance||'0');var tradeFloor=Math.floor(trade);var tradeStr=tradeFloor.toString();var mentionsTrade=display.length>0&&(display.indexOf(tradeStr)>=0||display.indexOf('$0')>=0&&trade<1);return JSON.stringify({display:display,spendableBalance:trade,displayMatchesTradeBalance:mentionsTrade,phase:'{{phaseLabel}}'})}catch(e){return JSON.stringify({error:String(e)})}})()",
- "assert": {
- "operator": "not_null",
- "field": "display"
- },
- "next": "screenshot-market-list"
- },
- "screenshot-market-list": {
- "action": "screenshot",
- "filename": "tat3016-{{phaseLabel}}-market-list.png",
- "note": "PerpsMarketListView available-balance text in {{phaseLabel}} phase — must reflect availableToTradeBalance (gates Long/Add-Funds CTA)",
- "next": "nav-withdraw"
- },
- "nav-withdraw": {
- "action": "navigate",
- "target": "PerpsWithdraw",
- "next": "wait-withdraw"
- },
- "wait-withdraw": {
- "action": "wait_for",
- "expression": "(function(){try{var r=globalThis.__AGENTIC__&&globalThis.__AGENTIC__.getRoute();return JSON.stringify({route:r?r.name:'unknown'})}catch(e){return JSON.stringify({route:'unknown'})}})()",
- "assert": {
- "operator": "eq",
- "field": "route",
- "value": "PerpsWithdraw"
- },
- "timeout_ms": 15000,
- "next": "settle-withdraw"
- },
- "settle-withdraw": {
- "action": "wait_for",
- "expression": "(function(){var a=globalThis.__AGENTIC__;var mounted=!!(a&&a.findFiberByTestId&&a.findFiberByTestId('perps-withdraw-available-balance-text'));return JSON.stringify({mounted:mounted})})()",
- "assert": { "operator": "eq", "field": "mounted", "value": true },
- "timeout_ms": 15000,
- "next": "capture-withdraw-balance"
- },
- "capture-withdraw-balance": {
- "action": "eval_sync",
- "expression": "(function(){try{var a=globalThis.__AGENTIC__;var display=a&&a.getTextByTestId('perps-withdraw-available-balance-text')||'';var s=(Engine.context.PerpsController.state&&Engine.context.PerpsController.state.accountState)||{};var avail=parseFloat(s.withdrawableBalance||'0');var trade=parseFloat(s.spendableBalance||s.withdrawableBalance||'0');var availFloor=Math.floor(avail);var availStr=availFloor.toString();var mentionsAvail=display.length>0&&(display.indexOf(availStr)>=0||(avail<1&&display.indexOf('$0')>=0));var leaksFold=trade>avail+0.5&&display.indexOf(Math.floor(trade).toString())>=0;return JSON.stringify({display:display,withdrawableBalance:avail,spendableBalance:trade,displayMatchesWithdrawable:mentionsAvail,displayLeaksFold:leaksFold,phase:'{{phaseLabel}}'})}catch(e){return JSON.stringify({error:String(e)})}})()",
- "assert": {
- "all": [
- { "operator": "eq", "field": "displayMatchesWithdrawable", "value": true },
- { "operator": "eq", "field": "displayLeaksFold", "value": false }
- ]
- },
- "next": "screenshot-withdraw"
- },
- "screenshot-withdraw": {
- "action": "screenshot",
- "filename": "tat3016-{{phaseLabel}}-withdraw.png",
- "note": "PerpsWithdraw available-balance text in {{phaseLabel}} phase — must show availableBalance only, never the spot-fold amount (TAT-3016 hotfix invariant)",
- "next": "nav-market-details"
- },
- "nav-market-details": {
- "action": "navigate",
- "target": "PerpsMarketDetails",
- "params": {
- "market": {
- "symbol": "BTC",
- "name": "BTC",
- "price": "0",
- "change24h": "0",
- "change24hPercent": "0",
- "volume": "0",
- "maxLeverage": "100"
- }
- },
- "next": "wait-long-button"
- },
- "wait-long-button": {
- "action": "wait_for",
- "expression": "(function(){var a=globalThis.__AGENTIC__;var mounted=!!(a&&a.findFiberByTestId&&a.findFiberByTestId('perps-market-details-long-button'));return JSON.stringify({mounted:mounted})})()",
- "assert": { "operator": "eq", "field": "mounted", "value": true },
- "timeout_ms": 15000,
- "next": "press-long"
- },
- "press-long": {
- "action": "press",
- "test_id": "perps-market-details-long-button",
- "next": "wait-order-form"
- },
- "wait-order-form": {
- "action": "wait_for",
- "expression": "(function(){var a=globalThis.__AGENTIC__;var btn=!!(a&&a.findFiberByTestId&&a.findFiberByTestId('perps-order-view-place-order-button'));var payWith=!!(a&&a.findFiberByTestId&&a.findFiberByTestId('pay-with-symbol'));return JSON.stringify({placeOrderMounted:btn,payWithMounted:payWith,ready:btn&&payWith})})()",
- "assert": { "operator": "eq", "field": "ready", "value": true },
- "timeout_ms": 15000,
- "next": "capture-order-form"
- },
- "capture-order-form": {
- "action": "eval_sync",
- "expression": "(function(){try{var a=globalThis.__AGENTIC__;var paySymbol=a&&a.getTextByTestId('pay-with-symbol')||'';var placeOrderMounted=!!(a&&a.findFiberByTestId('perps-order-view-place-order-button'));var s=(Engine.context.PerpsController.state&&Engine.context.PerpsController.state.accountState)||{};var trade=parseFloat(s.spendableBalance||s.withdrawableBalance||'0');var expectsPerpsBalance=trade>0;var paysWithPerps=paySymbol.toLowerCase().indexOf('perps')>=0;return JSON.stringify({paySymbol:paySymbol,paysWithPerpsBalance:paysWithPerps,placeOrderButtonMounted:placeOrderMounted,defaultsCorrectly:expectsPerpsBalance?paysWithPerps:true,phase:'{{phaseLabel}}'})}catch(e){return JSON.stringify({error:String(e)})}})()",
- "assert": {
- "all": [
- { "operator": "eq", "field": "defaultsCorrectly", "value": true },
- { "operator": "eq", "field": "placeOrderButtonMounted", "value": true }
- ]
- },
- "next": "screenshot-order-form"
- },
- "screenshot-order-form": {
- "action": "screenshot",
- "filename": "tat3016-{{phaseLabel}}-order-form.png",
- "note": "BTC order form in {{phaseLabel}} phase — pay-with should default to Perps balance when availableToTradeBalance > 0; place-order button mounted",
- "next": "back-to-wallet"
- },
- "back-to-wallet": {
- "action": "navigate",
- "target": "Wallet",
- "next": "mode-switch"
- },
- "mode-switch": {
- "action": "switch",
- "cases": [
- {
- "label": "expect-unified",
- "when": {
- "operator": "eq",
- "field": "inputs.expectedMode",
- "value": "unified"
- },
- "next": "assert-unified-shape"
- },
- {
- "label": "expect-standard",
- "when": {
- "operator": "eq",
- "field": "inputs.expectedMode",
- "value": "standard"
- },
- "next": "assert-standard-shape"
- }
- ],
- "default": "done"
- },
- "assert-unified-shape": {
- "action": "eval_sync",
- "expression": "(function(){var s=(Engine.context.PerpsController.state&&Engine.context.PerpsController.state.accountState)||{};var avail=parseFloat(s.withdrawableBalance||'0');var trade=parseFloat(s.spendableBalance||s.withdrawableBalance||'0');var spotFold=+(trade-avail).toFixed(6);return JSON.stringify({withdrawableBalance:avail,spendableBalance:trade,spotFold:spotFold,tradeExceedsAvail:trade>=avail,expectedMode:'unified',phase:'{{phaseLabel}}'})})()",
- "assert": {
- "operator": "eq",
- "field": "tradeExceedsAvail",
- "value": true
- },
- "next": "done"
- },
- "assert-standard-shape": {
- "action": "eval_sync",
- "expression": "(function(){var s=(Engine.context.PerpsController.state&&Engine.context.PerpsController.state.accountState)||{};var avail=parseFloat(s.withdrawableBalance||'0');var trade=parseFloat(s.spendableBalance||s.withdrawableBalance||'0');var spotFold=+(trade-avail).toFixed(6);return JSON.stringify({withdrawableBalance:avail,spendableBalance:trade,spotFold:spotFold,foldInvariant:trade>=avail,standardContract:true,expectedMode:'standard',phase:'{{phaseLabel}}'})})()",
- "assert": {
- "operator": "eq",
- "field": "foldInvariant",
- "value": true
- },
- "next": "done"
- },
- "done": {
- "action": "end",
- "status": "pass"
- }
- }
- }
- }
- }
\ No newline at end of file
diff --git a/scripts/perps/agentic/teams/perps/flows/hl-provision-fixture.json b/scripts/perps/agentic/teams/perps/flows/hl-provision-fixture.json
deleted file mode 100644
index 3d12a418e7b9..000000000000
--- a/scripts/perps/agentic/teams/perps/flows/hl-provision-fixture.json
+++ /dev/null
@@ -1,128 +0,0 @@
-{
- "title": "HL fixture provisioning — abstraction={{abstraction}} transfer={{transferDirection}} amount={{transferAmount}}",
- "description": "Admin/test flow. Drives the HyperLiquid SDK directly via HyperLiquidProvider.getExchangeClient() escape hatch. Guards each op with when/unless so callers can skip either phase. Amounts: transferAmount='max' (default when direction!='none') transfers the full source-side balance; explicit amount strings forward as-is. Requires mainnet HL (abstraction modes do not exist on testnet).",
- "inputs": {
- "abstraction": {
- "type": "string",
- "default": "none",
- "description": "Target abstraction mode: 'unifiedAccount' | 'dexAbstraction' | 'portfolioMargin' | 'disabled' | 'none' (skip)"
- },
- "transferDirection": {
- "type": "string",
- "default": "none",
- "description": "USDC move direction: 'to-spot' (perps -> spot), 'to-perp' (spot -> perps), 'none' (skip)"
- },
- "transferAmount": {
- "type": "string",
- "default": "max",
- "description": "USDC amount as string. 'max' = full source-side balance read at execution time."
- }
- },
- "validate": {
- "workflow": {
- "pre_conditions": [
- "wallet.unlocked",
- "perps.feature_enabled"
- ],
- "entry": "mode-switch",
- "nodes": {
- "mode-switch": {
- "action": "switch",
- "cases": [
- {
- "when": {
- "operator": "neq",
- "field": "inputs.abstraction",
- "value": "none"
- },
- "next": "set-abstraction"
- }
- ],
- "default": "transfer-switch"
- },
- "set-abstraction": {
- "action": "eval_async",
- "expression": "(function(){try{var ctrl=Engine.context.PerpsController;var provider=ctrl.getActiveProvider();var accs=Engine.context.AccountsController.state.internalAccounts;var user=accs.accounts[accs.selectedAccount].address;return provider.getExchangeClient().then(function(client){return client.userSetAbstraction({user:user,abstraction:'{{abstraction}}'})}).then(function(r){return JSON.stringify({ok:true,status:(r&&r.status)||'ok'})}).catch(function(e){return JSON.stringify({ok:false,error:String(e&&e.message||e)})})}catch(e){return JSON.stringify({ok:false,error:String(e)})}})()",
- "assert": {
- "operator": "eq",
- "field": "ok",
- "value": true
- },
- "timeout_ms": 30000,
- "next": "wait-mode-settle"
- },
- "wait-mode-settle": {
- "action": "wait_for",
- "expression": "Engine.context.PerpsController.getAccountState().then(function(r){return JSON.stringify({refreshed:!!r,totalBalance:r&&r.totalBalance})}).catch(function(e){return JSON.stringify({refreshed:false,error:String(e)})})",
- "assert": { "operator": "eq", "field": "refreshed", "value": true },
- "timeout_ms": 30000,
- "next": "transfer-switch"
- },
- "transfer-switch": {
- "action": "switch",
- "cases": [
- {
- "when": {
- "operator": "eq",
- "field": "inputs.transferDirection",
- "value": "to-spot"
- },
- "next": "transfer-to-spot"
- },
- {
- "when": {
- "operator": "eq",
- "field": "inputs.transferDirection",
- "value": "to-perp"
- },
- "next": "transfer-to-perp"
- }
- ],
- "default": "done"
- },
- "transfer-to-spot": {
- "action": "eval_async",
- "expression": "(function(){try{var ctrl=Engine.context.PerpsController;var provider=ctrl.getActiveProvider();var isTestnet=!!(ctrl.state&&ctrl.state.isTestnet);var base=isTestnet?'https://api.hyperliquid-testnet.xyz/info':'https://api.hyperliquid.xyz/info';var accs=Engine.context.AccountsController.state.internalAccounts;var user=accs.accounts[accs.selectedAccount].address;var amountInput='{{transferAmount}}';var resolveMax=amountInput==='max'?fetch(base,{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({type:'clearinghouseState',user:user})}).then(function(r){return r.json()}).then(function(j){return String(parseFloat((j&&j.withdrawable)||'0'))}):Promise.resolve(amountInput);return resolveMax.then(function(amount){if(!(parseFloat(amount)>0))return JSON.stringify({ok:false,error:'nothing to transfer',source:'perps',amount:amount});return provider.getExchangeClient().then(function(client){return client.usdClassTransfer({amount:amount,toPerp:false})}).then(function(r){return JSON.stringify({ok:true,direction:'to-spot',amount:amount,status:(r&&r.status)||'ok'})})}).catch(function(e){return JSON.stringify({ok:false,error:String(e&&e.message||e)})})}catch(e){return JSON.stringify({ok:false,error:String(e)})}})()",
- "assert": {
- "operator": "eq",
- "field": "ok",
- "value": true
- },
- "timeout_ms": 60000,
- "next": "wait-transfer-settle"
- },
- "transfer-to-perp": {
- "action": "eval_async",
- "expression": "(function(){try{var ctrl=Engine.context.PerpsController;var provider=ctrl.getActiveProvider();var isTestnet=!!(ctrl.state&&ctrl.state.isTestnet);var base=isTestnet?'https://api.hyperliquid-testnet.xyz/info':'https://api.hyperliquid.xyz/info';var accs=Engine.context.AccountsController.state.internalAccounts;var user=accs.accounts[accs.selectedAccount].address;var amountInput='{{transferAmount}}';var resolveMax=amountInput==='max'?fetch(base,{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({type:'spotClearinghouseState',user:user})}).then(function(r){return r.json()}).then(function(j){var bals=(j&&j.balances)||[];var free=0;for(var i=0;i0))return JSON.stringify({ok:false,error:'nothing to transfer',source:'spot',amount:amount});return provider.getExchangeClient().then(function(client){return client.usdClassTransfer({amount:amount,toPerp:true})}).then(function(r){return JSON.stringify({ok:true,direction:'to-perp',amount:amount,status:(r&&r.status)||'ok'})})}).catch(function(e){return JSON.stringify({ok:false,error:String(e&&e.message||e)})})}catch(e){return JSON.stringify({ok:false,error:String(e)})}})()",
- "assert": {
- "operator": "eq",
- "field": "ok",
- "value": true
- },
- "timeout_ms": 60000,
- "next": "wait-transfer-settle"
- },
- "wait-transfer-settle": {
- "action": "wait_for",
- "expression": "Engine.context.PerpsController.getAccountState().then(function(r){return JSON.stringify({refreshed:!!r,withdrawableBalance:r&&r.withdrawableBalance,spendableBalance:r&&r.spendableBalance})}).catch(function(e){return JSON.stringify({refreshed:false,error:String(e)})})",
- "assert": { "operator": "eq", "field": "refreshed", "value": true },
- "timeout_ms": 60000,
- "next": "verify-state"
- },
- "verify-state": {
- "action": "eval_ref",
- "ref": "hl-fixture-state",
- "assert": {
- "operator": "not_null",
- "field": "totalBalance"
- },
- "next": "done"
- },
- "done": {
- "action": "end",
- "status": "pass"
- }
- }
- }
- }
- }
\ No newline at end of file
diff --git a/scripts/perps/agentic/teams/perps/flows/market-discovery.json b/scripts/perps/agentic/teams/perps/flows/market-discovery.json
deleted file mode 100644
index 5036f886f949..000000000000
--- a/scripts/perps/agentic/teams/perps/flows/market-discovery.json
+++ /dev/null
@@ -1,106 +0,0 @@
-{
- "title": "Market Discovery — find {{symbol}} in list and verify price loads",
- "inputs": {
- "symbol": {
- "type": "string",
- "default": "BTC",
- "description": "Market symbol to look up"
- },
- "category": {
- "type": "string",
- "default": "",
- "description": "Optional category filter: crypto, stocks, commodities, forex. If empty, no chip is pressed (shows all)."
- },
- "search": {
- "type": "string",
- "default": "",
- "description": "Optional search query to type into the search bar. If empty, search is skipped."
- }
- },
- "validate": {
- "workflow": {
- "pre_conditions": [
- "wallet.unlocked",
- "perps.feature_enabled"
- ],
- "entry": "nav-market-list",
- "nodes": {
- "nav-market-list": {
- "action": "navigate",
- "target": "PerpsTrendingView",
- "next": "wait-market-data"
- },
- "wait-market-data": {
- "action": "wait_for",
- "route": "PerpsTrendingView",
- "next": "select-category"
- },
- "select-category": {
- "action": "eval_sync",
- "expression": "(function(){var cat='{{category}}';if(!cat)return JSON.stringify({skipped:true,category:'all'});var r=globalThis.__AGENTIC__.pressTestId('perps-market-list-sort-filters-categories-'+cat);return JSON.stringify({selected:true,category:cat})})()",
- "assert": {
- "operator": "not_null"
- },
- "next": "search-symbol"
- },
- "search-symbol": {
- "action": "eval_sync",
- "expression": "(function(){var q='{{search}}';if(!q)return JSON.stringify({skipped:true});globalThis.__AGENTIC__.setInput('perps-market-list-search-bar',q);return JSON.stringify({searched:true,query:q})})()",
- "assert": {
- "operator": "not_null"
- },
- "next": "wait-filter"
- },
- "wait-filter": {
- "action": "wait_for",
- "expression": "Engine.context.PerpsController.getMarketDataWithPrices().then(function(ms){return JSON.stringify({count:ms.length})})",
- "assert": {
- "operator": "gt",
- "field": "count",
- "value": 0
- },
- "next": "assert-symbol-in-list"
- },
- "assert-symbol-in-list": {
- "action": "eval_async",
- "expression": "Engine.context.PerpsController.getMarketDataWithPrices().then(function(ms){var m=ms.find(function(x){return x.symbol==='{{symbol}}'});return JSON.stringify({found:!!m,symbol:m?m.symbol:null})})",
- "assert": {
- "operator": "eq",
- "field": "found",
- "value": true
- },
- "next": "nav-market-detail"
- },
- "nav-market-detail": {
- "action": "navigate",
- "target": "PerpsMarketDetails",
- "params": {
- "market": {
- "symbol": "{{symbol}}",
- "name": "{{symbol}}",
- "price": "0",
- "change24h": "0",
- "change24hPercent": "0",
- "volume": "0",
- "maxLeverage": "100"
- }
- },
- "next": "wait-price-load"
- },
- "wait-price-load": {
- "action": "wait_for",
- "expression": "Engine.context.PerpsController.getMarketDataWithPrices().then(function(ms){var m=ms.find(function(x){return x.symbol==='{{symbol}}'});return JSON.stringify({price:m?m.price:'0'})})",
- "assert": {
- "operator": "not_null",
- "field": "price"
- },
- "next": "done"
- },
- "done": {
- "action": "end",
- "status": "pass"
- }
- }
- }
- }
-}
diff --git a/scripts/perps/agentic/teams/perps/flows/market-visit.json b/scripts/perps/agentic/teams/perps/flows/market-visit.json
deleted file mode 100644
index d63074551eec..000000000000
--- a/scripts/perps/agentic/teams/perps/flows/market-visit.json
+++ /dev/null
@@ -1,44 +0,0 @@
-{
- "title": "Market Visit — open {{symbol}} detail, wait for candles to load, go back",
- "inputs": {
- "symbol": {
- "type": "string",
- "default": "BTC",
- "description": "Market symbol to visit (must be visible in the current market list)"
- }
- },
- "validate": {
- "workflow": {
- "pre_conditions": ["wallet.unlocked", "perps.feature_enabled"],
- "entry": "wait-row",
- "nodes": {
- "wait-row": {
- "action": "wait_for",
- "test_id": "perps-market-row-item-{{symbol}}",
- "timeout_ms": 8000,
- "next": "open-market"
- },
- "open-market": {
- "action": "press",
- "test_id": "perps-market-row-item-{{symbol}}",
- "next": "wait-detail"
- },
- "wait-detail": {
- "action": "wait_for",
- "test_id": "perps-market-details-view",
- "timeout_ms": 8000,
- "next": "back"
- },
- "back": {
- "action": "press",
- "test_id": "perps-market-header-back-button",
- "next": "done"
- },
- "done": {
- "action": "end",
- "status": "pass"
- }
- }
- }
- }
-}
diff --git a/scripts/perps/agentic/teams/perps/flows/market-watchlist.json b/scripts/perps/agentic/teams/perps/flows/market-watchlist.json
deleted file mode 100644
index 3825e865bb65..000000000000
--- a/scripts/perps/agentic/teams/perps/flows/market-watchlist.json
+++ /dev/null
@@ -1,92 +0,0 @@
-{
- "title": "Market Watchlist — toggle {{symbol}} on/off watchlist",
- "inputs": {
- "symbol": {
- "type": "string",
- "default": "BTC",
- "description": "Market symbol to toggle on/off watchlist"
- }
- },
- "validate": {
- "workflow": {
- "pre_conditions": [
- "wallet.unlocked",
- "perps.feature_enabled",
- {
- "name": "perps.not_in_watchlist",
- "symbol": "{{symbol}}"
- }
- ],
- "entry": "ensure-not-in-watchlist",
- "nodes": {
- "ensure-not-in-watchlist": {
- "action": "eval_sync",
- "expression": "JSON.stringify({inWatchlist:(Engine.context.PerpsController.state.watchlistMarkets.mainnet||[]).indexOf('{{symbol}}')>=0||(Engine.context.PerpsController.state.watchlistMarkets.testnet||[]).indexOf('{{symbol}}')>=0})",
- "assert": {
- "operator": "eq",
- "field": "inWatchlist",
- "value": false
- },
- "next": "nav-market-detail"
- },
- "nav-market-detail": {
- "action": "navigate",
- "target": "PerpsMarketDetails",
- "params": {
- "market": {
- "symbol": "{{symbol}}",
- "name": "{{symbol}}",
- "price": "0",
- "change24h": "0",
- "change24hPercent": "0",
- "volume": "0",
- "maxLeverage": "100"
- }
- },
- "next": "wait-render"
- },
- "wait-render": {
- "action": "wait_for",
- "test_id": "perps-market-header-favorite-button",
- "next": "press-favorite-add"
- },
- "press-favorite-add": {
- "description": "Press star button to add to watchlist",
- "action": "press",
- "test_id": "perps-market-header-favorite-button",
- "next": "wait-state-update"
- },
- "wait-state-update": {
- "action": "wait_for",
- "expression": "JSON.stringify({inWatchlist:(Engine.context.PerpsController.state.watchlistMarkets.mainnet||[]).indexOf('{{symbol}}')>=0||(Engine.context.PerpsController.state.watchlistMarkets.testnet||[]).indexOf('{{symbol}}')>=0})",
- "assert": {
- "operator": "eq",
- "field": "inWatchlist",
- "value": true
- },
- "next": "press-favorite-remove"
- },
- "press-favorite-remove": {
- "description": "Press star button again to remove from watchlist",
- "action": "press",
- "test_id": "perps-market-header-favorite-button",
- "next": "wait-state-update-2"
- },
- "wait-state-update-2": {
- "action": "wait_for",
- "expression": "JSON.stringify({inWatchlist:(Engine.context.PerpsController.state.watchlistMarkets.mainnet||[]).indexOf('{{symbol}}')>=0||(Engine.context.PerpsController.state.watchlistMarkets.testnet||[]).indexOf('{{symbol}}')>=0})",
- "assert": {
- "operator": "eq",
- "field": "inWatchlist",
- "value": false
- },
- "next": "done"
- },
- "done": {
- "action": "end",
- "status": "pass"
- }
- }
- }
- }
-}
diff --git a/scripts/perps/agentic/teams/perps/flows/order-limit-cancel.json b/scripts/perps/agentic/teams/perps/flows/order-limit-cancel.json
deleted file mode 100644
index 3c34a0c7c1ce..000000000000
--- a/scripts/perps/agentic/teams/perps/flows/order-limit-cancel.json
+++ /dev/null
@@ -1,84 +0,0 @@
-{
- "title": "Order — cancel first open limit order for {{symbol}}",
- "inputs": {
- "symbol": {
- "type": "string",
- "description": "Market symbol of limit order to cancel"
- }
- },
- "validate": {
- "workflow": {
- "pre_conditions": [
- "wallet.unlocked",
- {
- "name": "perps.open_limit_order",
- "symbol": "{{symbol}}"
- }
- ],
- "entry": "assert-order-exists",
- "nodes": {
- "assert-order-exists": {
- "action": "eval_async",
- "expression": "Engine.context.PerpsController.getOpenOrders().then(function(os){var filtered=os.filter(function(o){return o.symbol==='{{symbol}}'&&!o.isTrigger});return JSON.stringify({count:filtered.length})})",
- "assert": {
- "operator": "gt",
- "field": "count",
- "value": 0
- },
- "next": "nav-market-detail"
- },
- "nav-market-detail": {
- "action": "navigate",
- "target": "PerpsMarketDetails",
- "params": {
- "market": {
- "symbol": "{{symbol}}",
- "name": "{{symbol}}",
- "price": "0",
- "change24h": "0",
- "change24hPercent": "0",
- "volume": "0",
- "maxLeverage": "100"
- }
- },
- "next": "wait-render"
- },
- "wait-render": {
- "action": "wait_for",
- "test_id": "perps-compact-order-row-first",
- "next": "press-first-order"
- },
- "press-first-order": {
- "description": "Tap first compact order row to navigate to order details",
- "action": "press",
- "test_id": "perps-compact-order-row-first",
- "next": "wait-order-details"
- },
- "wait-order-details": {
- "action": "wait_for",
- "test_id": "perps-order-details-cancel-button",
- "next": "press-cancel-button"
- },
- "press-cancel-button": {
- "action": "press",
- "test_id": "perps-order-details-cancel-button",
- "next": "wait-cancel"
- },
- "wait-cancel": {
- "action": "wait_for",
- "expression": "Engine.context.PerpsController.getOpenOrders().then(function(os){var filtered=os.filter(function(o){return o.symbol==='{{symbol}}'&&!o.isTrigger});return JSON.stringify({count:filtered.length})})",
- "assert": {
- "operator": "eq",
- "field": "count",
- "value": 0
- },
- "next": "done"
- },
- "done": {
- "action": "end",
- "status": "pass"
- }
- }
- }
- }
-}
diff --git a/scripts/perps/agentic/teams/perps/flows/order-limit-place-controller.json b/scripts/perps/agentic/teams/perps/flows/order-limit-place-controller.json
deleted file mode 100644
index 0d14b5790b8f..000000000000
--- a/scripts/perps/agentic/teams/perps/flows/order-limit-place-controller.json
+++ /dev/null
@@ -1,64 +0,0 @@
-{
- "title": "Order (controller) — limit {{side}} {{symbol}} ${{usdAmount}} at mid price",
- "description": "Controller-level limit order via PerpsController.placeOrder(). Gets current mid price from markets, then places limit order at that price. Bypasses UI order form which hits depositWithOrder → RedesignedConfirmations spinner in dev mode.",
- "inputs": {
- "side": {
- "type": "string",
- "default": "long",
- "description": "Trade side (long/short)"
- },
- "symbol": {
- "type": "string",
- "default": "ETH",
- "description": "Market symbol"
- },
- "usdAmount": {
- "type": "string",
- "default": "10",
- "description": "USD notional amount"
- }
- },
- "validate": {
- "workflow": {
- "pre_conditions": [
- "wallet.unlocked",
- "perps.ready_to_trade"
- ],
- "entry": "nav",
- "nodes": {
- "nav": {
- "action": "navigate",
- "target": "PerpsMarketDetails",
- "params": {
- "market": {
- "symbol": "{{symbol}}",
- "name": "{{symbol}}",
- "price": "0",
- "change24h": "0",
- "change24hPercent": "0",
- "volume": "0",
- "maxLeverage": "100"
- }
- },
- "next": "wait-render"
- },
- "wait-render": {
- "action": "wait_for",
- "test_id": "perps-market-details-{{side}}-button",
- "next": "place-limit-controller"
- },
- "place-limit-controller": {
- "action": "eval_async",
- "expression": "Engine.context.PerpsController.getMarketDataWithPrices().then(function(markets){var m=markets.find(function(x){return x.symbol==='{{symbol}}'});var raw=m?String(m.price):'2000';var midPrice=raw.replace(/[$,]/g,'');return Engine.context.PerpsController.placeOrder({symbol:'{{symbol}}',isBuy:'{{side}}'==='long',orderType:'limit',price:midPrice,size:'0.001',leverage:2,usdAmount:'{{usdAmount}}',maxSlippageBps:500}).then(function(r){return JSON.stringify(r)})})",
- "assert": { "operator": "eq", "field": "success", "value": true },
- "timeout_ms": 30000,
- "next": "done"
- },
- "done": {
- "action": "end",
- "status": "pass"
- }
- }
- }
- }
-}
diff --git a/scripts/perps/agentic/teams/perps/flows/order-limit-place.json b/scripts/perps/agentic/teams/perps/flows/order-limit-place.json
deleted file mode 100644
index b313ca9a5587..000000000000
--- a/scripts/perps/agentic/teams/perps/flows/order-limit-place.json
+++ /dev/null
@@ -1,171 +0,0 @@
-{
- "title": "Order — limit {{side}} {{symbol}} ${{usdAmount}} at ${{limitPrice}}",
- "inputs": {
- "side": {
- "type": "string",
- "default": "long",
- "description": "Trade side (long/short)"
- },
- "symbol": {
- "type": "string",
- "default": "BTC",
- "description": "Market symbol"
- },
- "usdAmount": {
- "type": "string",
- "default": "10",
- "description": "USD notional amount"
- },
- "limitPrice": {
- "type": "string",
- "default": "60000",
- "description": "Limit price in USD"
- }
- },
- "validate": {
- "workflow": {
- "pre_conditions": [
- "wallet.unlocked",
- "perps.ready_to_trade"
- ],
- "entry": "nav",
- "nodes": {
- "nav": {
- "action": "navigate",
- "target": "PerpsMarketDetails",
- "params": {
- "market": {
- "symbol": "{{symbol}}",
- "name": "{{symbol}}",
- "price": "0",
- "change24h": "0",
- "change24hPercent": "0",
- "volume": "0",
- "maxLeverage": "100"
- }
- },
- "next": "wait-render"
- },
- "wait-render": {
- "action": "wait_for",
- "test_id": "perps-market-details-{{side}}-button",
- "next": "press-side"
- },
- "press-side": {
- "action": "press",
- "test_id": "perps-market-details-{{side}}-button",
- "next": "wait-form"
- },
- "wait-form": {
- "action": "wait_for",
- "test_id": "perps-order-header-order-type-button",
- "next": "press-order-type"
- },
- "press-order-type": {
- "action": "press",
- "test_id": "perps-order-header-order-type-button",
- "next": "wait-type-sheet"
- },
- "wait-type-sheet": {
- "action": "wait_for",
- "test_id": "perps-order-type-limit",
- "next": "press-limit"
- },
- "press-limit": {
- "action": "press",
- "test_id": "perps-order-type-limit",
- "next": "wait-limit-form"
- },
- "wait-limit-form": {
- "action": "wait_for",
- "test_id": "perps-order-view-limit-price-row",
- "next": "press-limit-price-row"
- },
- "press-limit-price-row": {
- "action": "press",
- "test_id": "perps-order-view-limit-price-row",
- "next": "wait-price-sheet"
- },
- "wait-price-sheet": {
- "action": "wait_for",
- "test_id": "keypad-delete-button",
- "next": "clear-limit-keypad"
- },
- "clear-limit-keypad": {
- "action": "clear_keypad",
- "count": 12,
- "next": "type-limit-price"
- },
- "type-limit-price": {
- "action": "type_keypad",
- "value": "{{limitPrice}}",
- "next": "press-set"
- },
- "press-set": {
- "action": "press",
- "test_id": "perps-limit-price-confirm-button",
- "next": "wait-price-set"
- },
- "wait-price-set": {
- "action": "wait_for",
- "test_id": "perps-amount-display-touchable",
- "next": "press-amount"
- },
- "press-amount": {
- "action": "press",
- "test_id": "perps-amount-display-touchable",
- "next": "wait-keypad"
- },
- "wait-keypad": {
- "action": "wait_for",
- "test_id": "keypad-delete-button",
- "next": "clear-keypad"
- },
- "clear-keypad": {
- "action": "clear_keypad",
- "count": 8,
- "next": "type-amount"
- },
- "type-amount": {
- "action": "type_keypad",
- "value": "{{usdAmount}}",
- "next": "assert-amount"
- },
- "assert-amount": {
- "action": "eval_sync",
- "expression": "JSON.stringify((function(){ var hook=globalThis.__REACT_DEVTOOLS_GLOBAL_HOOK__; if(!hook)return{v:null}; var found=null; function walk(f){if(!f)return; if(f.memoizedProps&&f.memoizedProps.testID==='perps-amount-display-amount'){found=f;return;} walk(f.child);if(!found)walk(f.sibling);} for(var [id] of hook.renderers){var roots=hook.getFiberRoots?hook.getFiberRoots(id):null;if(roots)roots.forEach(function(r){if(!found)walk(r.current);});} return {v: found&&found.memoizedProps?String(found.memoizedProps.children||''):null}; })())",
- "assert": {
- "operator": "contains",
- "field": "v",
- "value": "{{usdAmount}}"
- },
- "next": "press-done"
- },
- "press-done": {
- "action": "press",
- "test_id": "perps-order-view-keypad-done",
- "next": "wait-summary"
- },
- "wait-summary": {
- "action": "wait_for",
- "test_id": "perps-order-view-place-order-button",
- "next": "place-order"
- },
- "place-order": {
- "action": "press",
- "test_id": "perps-order-view-place-order-button",
- "next": "wait-confirm"
- },
- "wait-confirm": {
- "action": "wait_for",
- "not_route": "RedesignedConfirmations",
- "next": "done"
- },
- "done": {
- "action": "end",
- "status": "pass"
- }
- }
- }
- }
-}
diff --git a/scripts/perps/agentic/teams/perps/flows/position-add-margin.json b/scripts/perps/agentic/teams/perps/flows/position-add-margin.json
deleted file mode 100644
index adc7057b4dbd..000000000000
--- a/scripts/perps/agentic/teams/perps/flows/position-add-margin.json
+++ /dev/null
@@ -1,125 +0,0 @@
-{
- "title": "Position — add ${{marginAmount}} margin to {{symbol}}",
- "inputs": {
- "symbol": {
- "type": "string",
- "description": "Market symbol of position to add margin to"
- },
- "marginAmount": {
- "type": "string",
- "description": "USD margin amount to add"
- }
- },
- "validate": {
- "workflow": {
- "pre_conditions": [
- "wallet.unlocked",
- {
- "name": "perps.open_position",
- "symbol": "{{symbol}}"
- }
- ],
- "entry": "assert-position-exists",
- "nodes": {
- "assert-position-exists": {
- "action": "eval_async",
- "expression": "Engine.context.PerpsController.getPositions().then(function(ps){var p=ps.find(function(x){return x.symbol==='{{symbol}}'});return JSON.stringify({margin:p?p.marginUsed:null})})",
- "assert": {
- "operator": "not_null",
- "field": "margin"
- },
- "next": "nav-market-detail"
- },
- "nav-market-detail": {
- "action": "navigate",
- "target": "PerpsMarketDetails",
- "params": {
- "market": {
- "symbol": "{{symbol}}",
- "name": "{{symbol}}",
- "price": "0",
- "change24h": "0",
- "change24hPercent": "0",
- "volume": "0",
- "maxLeverage": "100"
- }
- },
- "next": "wait-render"
- },
- "wait-render": {
- "action": "wait_for",
- "test_id": "position-card-margin-chevron",
- "next": "press-margin-chevron"
- },
- "press-margin-chevron": {
- "action": "press",
- "test_id": "position-card-margin-chevron",
- "next": "wait-action-sheet"
- },
- "wait-action-sheet": {
- "action": "wait_for",
- "test_id": "perps-adjust-margin-add-btn",
- "next": "press-add-margin-option"
- },
- "press-add-margin-option": {
- "action": "press",
- "test_id": "perps-adjust-margin-add-btn",
- "next": "wait-margin-screen"
- },
- "wait-margin-screen": {
- "action": "wait_for",
- "route": "PerpsAdjustMargin",
- "next": "press-amount-display"
- },
- "press-amount-display": {
- "action": "press",
- "test_id": "perps-amount-display-touchable",
- "next": "wait-keypad"
- },
- "wait-keypad": {
- "action": "wait_for",
- "test_id": "keypad-delete-button",
- "next": "clear-keypad"
- },
- "clear-keypad": {
- "action": "clear_keypad",
- "count": 8,
- "next": "type-amount"
- },
- "type-amount": {
- "action": "type_keypad",
- "value": "{{marginAmount}}",
- "next": "press-done"
- },
- "press-done": {
- "action": "press",
- "test_id": "perps-adjust-margin-done-button",
- "next": "wait-done"
- },
- "wait-done": {
- "action": "wait_for",
- "test_id": "perps-adjust-margin-confirm-button",
- "next": "press-confirm"
- },
- "press-confirm": {
- "action": "press",
- "test_id": "perps-adjust-margin-confirm-button",
- "next": "wait-confirm"
- },
- "wait-confirm": {
- "action": "wait_for",
- "expression": "Engine.context.PerpsController.getPositions().then(function(ps){var p=ps.find(function(x){return x.symbol==='{{symbol}}'});return JSON.stringify({margin:p?p.marginUsed:null})})",
- "assert": {
- "operator": "not_null",
- "field": "margin"
- },
- "next": "done"
- },
- "done": {
- "action": "end",
- "status": "pass"
- }
- }
- }
- }
-}
diff --git a/scripts/perps/agentic/teams/perps/flows/select-account.json b/scripts/perps/agentic/teams/perps/flows/select-account.json
deleted file mode 100644
index a99b1c4ac777..000000000000
--- a/scripts/perps/agentic/teams/perps/flows/select-account.json
+++ /dev/null
@@ -1,38 +0,0 @@
-{
- "title": "Select — switch to account {{address}} and verify",
- "inputs": {
- "address": {
- "type": "string",
- "description": "Ethereum address to switch to"
- }
- },
- "validate": {
- "workflow": {
- "pre_conditions": [
- "wallet.unlocked"
- ],
- "entry": "switch-account",
- "nodes": {
- "switch-account": {
- "action": "select_account",
- "address": "{{address}}",
- "next": "wait-switch"
- },
- "wait-switch": {
- "action": "wait_for",
- "expression": "JSON.stringify({address:Engine.context.AccountsController.state.internalAccounts.accounts[Engine.context.AccountsController.state.internalAccounts.selectedAccount].address})",
- "assert": {
- "operator": "eq",
- "field": "address",
- "value": "{{address}}"
- },
- "next": "done"
- },
- "done": {
- "action": "end",
- "status": "pass"
- }
- }
- }
- }
-}
diff --git a/scripts/perps/agentic/teams/perps/flows/setup-testnet.json b/scripts/perps/agentic/teams/perps/flows/setup-testnet.json
deleted file mode 100644
index e087c16ce5a5..000000000000
--- a/scripts/perps/agentic/teams/perps/flows/setup-testnet.json
+++ /dev/null
@@ -1,43 +0,0 @@
-{
- "title": "Setup — enable testnet and verify market data loads",
- "validate": {
- "workflow": {
- "pre_conditions": [
- "wallet.unlocked",
- "perps.feature_enabled"
- ],
- "entry": "enable-testnet",
- "nodes": {
- "enable-testnet": {
- "action": "toggle_testnet",
- "enabled": true,
- "next": "wait-reload"
- },
- "wait-reload": {
- "action": "wait_for",
- "expression": "JSON.stringify({isTestnet:Engine.context.PerpsController.state.isTestnet})",
- "assert": {
- "operator": "eq",
- "field": "isTestnet",
- "value": true
- },
- "next": "verify-markets"
- },
- "verify-markets": {
- "action": "eval_ref",
- "ref": "markets",
- "assert": {
- "operator": "not_null",
- "field": null,
- "value": null
- },
- "next": "done"
- },
- "done": {
- "action": "end",
- "status": "pass"
- }
- }
- }
- }
-}
diff --git a/scripts/perps/agentic/teams/perps/flows/tpsl-create.json b/scripts/perps/agentic/teams/perps/flows/tpsl-create.json
deleted file mode 100644
index c46634402604..000000000000
--- a/scripts/perps/agentic/teams/perps/flows/tpsl-create.json
+++ /dev/null
@@ -1,113 +0,0 @@
-{
- "title": "TP/SL — create +{{tpPreset}}% TP / {{slPreset}}% SL for {{symbol}}",
- "inputs": {
- "symbol": {
- "type": "string",
- "description": "Market symbol with open position"
- },
- "tpPreset": {
- "type": "string",
- "default": "25",
- "description": "Take-profit RoE preset to press (10, 25, 50, 100)"
- },
- "slPreset": {
- "type": "string",
- "default": "-10",
- "description": "Stop-loss RoE preset to press (-5, -10, -25, -50)"
- }
- },
- "validate": {
- "workflow": {
- "pre_conditions": [
- "wallet.unlocked",
- {
- "name": "perps.open_position",
- "symbol": "{{symbol}}"
- }
- ],
- "entry": "nav-market-detail",
- "nodes": {
- "nav-market-detail": {
- "action": "navigate",
- "target": "PerpsMarketDetails",
- "params": {
- "market": {
- "symbol": "{{symbol}}",
- "name": "{{symbol}}",
- "price": "0",
- "change24h": "0",
- "change24hPercent": "0",
- "volume": "0",
- "maxLeverage": "100"
- }
- },
- "next": "wait-market-render"
- },
- "wait-market-render": {
- "action": "wait_for",
- "test_id": "position-card-auto-close-toggle",
- "next": "press-auto-close"
- },
- "press-auto-close": {
- "action": "press",
- "test_id": "position-card-auto-close-toggle",
- "next": "wait-tpsl-route"
- },
- "wait-tpsl-route": {
- "action": "wait_for",
- "route": "PerpsTPSL",
- "next": "wait-tp-button"
- },
- "wait-tp-button": {
- "action": "wait_for",
- "test_id": "perps-tpsl-take-profit-percentage-button-{{tpPreset}}",
- "next": "press-tp-preset"
- },
- "press-tp-preset": {
- "action": "press",
- "test_id": "perps-tpsl-take-profit-percentage-button-{{tpPreset}}",
- "next": "wait-sl-button"
- },
- "wait-sl-button": {
- "action": "wait_for",
- "test_id": "perps-tpsl-stop-loss-percentage-button-{{slPreset}}",
- "next": "press-sl-preset"
- },
- "press-sl-preset": {
- "action": "press",
- "test_id": "perps-tpsl-stop-loss-percentage-button-{{slPreset}}",
- "next": "assert-tpsl-screen-intact"
- },
- "assert-tpsl-screen-intact": {
- "action": "eval_sync",
- "expression": "JSON.stringify({route:globalThis.__AGENTIC__.getRoute().name})",
- "assert": {
- "operator": "eq",
- "field": "route",
- "value": "PerpsTPSL"
- },
- "next": "press-set-tpsl"
- },
- "press-set-tpsl": {
- "action": "press",
- "test_id": "perps-tpsl-set-button",
- "next": "wait-tpsl-created"
- },
- "wait-tpsl-created": {
- "action": "wait_for",
- "expression": "Engine.context.PerpsController.getPositions().then(function(ps){var p=ps.find(function(x){return x.symbol==='{{symbol}}'});return JSON.stringify({hasTp:!!(p&&p.takeProfitPrice),hasSl:!!(p&&p.stopLossPrice)})})",
- "assert": {
- "operator": "eq",
- "field": "hasTp",
- "value": true
- },
- "next": "done"
- },
- "done": {
- "action": "end",
- "status": "pass"
- }
- }
- }
- }
-}
diff --git a/scripts/perps/agentic/teams/perps/flows/tpsl-edit.json b/scripts/perps/agentic/teams/perps/flows/tpsl-edit.json
deleted file mode 100644
index 59ce2b474714..000000000000
--- a/scripts/perps/agentic/teams/perps/flows/tpsl-edit.json
+++ /dev/null
@@ -1,113 +0,0 @@
-{
- "title": "TP/SL — edit to +{{tpPreset}}% TP / {{slPreset}}% SL for {{symbol}}",
- "inputs": {
- "symbol": {
- "type": "string",
- "description": "Market symbol with open position and existing TP/SL"
- },
- "tpPreset": {
- "type": "string",
- "default": "50",
- "description": "Take-profit RoE preset to press (10, 25, 50, 100)"
- },
- "slPreset": {
- "type": "string",
- "default": "-25",
- "description": "Stop-loss RoE preset to press (-5, -10, -25, -50)"
- }
- },
- "validate": {
- "workflow": {
- "pre_conditions": [
- "wallet.unlocked",
- {
- "name": "perps.open_position_tpsl",
- "symbol": "{{symbol}}"
- }
- ],
- "entry": "nav-market-detail",
- "nodes": {
- "nav-market-detail": {
- "action": "navigate",
- "target": "PerpsMarketDetails",
- "params": {
- "market": {
- "symbol": "{{symbol}}",
- "name": "{{symbol}}",
- "price": "0",
- "change24h": "0",
- "change24hPercent": "0",
- "volume": "0",
- "maxLeverage": "100"
- }
- },
- "next": "wait-render"
- },
- "wait-render": {
- "action": "wait_for",
- "test_id": "position-card-auto-close-toggle",
- "next": "press-modify-tpsl"
- },
- "press-modify-tpsl": {
- "action": "press",
- "test_id": "position-card-auto-close-toggle",
- "next": "wait-tpsl-route"
- },
- "wait-tpsl-route": {
- "action": "wait_for",
- "route": "PerpsTPSL",
- "next": "wait-tp-button"
- },
- "wait-tp-button": {
- "action": "wait_for",
- "test_id": "perps-tpsl-take-profit-percentage-button-{{tpPreset}}",
- "next": "press-tp-preset"
- },
- "press-tp-preset": {
- "action": "press",
- "test_id": "perps-tpsl-take-profit-percentage-button-{{tpPreset}}",
- "next": "wait-sl-button"
- },
- "wait-sl-button": {
- "action": "wait_for",
- "test_id": "perps-tpsl-stop-loss-percentage-button-{{slPreset}}",
- "next": "press-sl-preset"
- },
- "press-sl-preset": {
- "action": "press",
- "test_id": "perps-tpsl-stop-loss-percentage-button-{{slPreset}}",
- "next": "assert-tpsl-screen-intact"
- },
- "assert-tpsl-screen-intact": {
- "action": "eval_sync",
- "expression": "JSON.stringify({route:globalThis.__AGENTIC__.getRoute().name})",
- "assert": {
- "operator": "eq",
- "field": "route",
- "value": "PerpsTPSL"
- },
- "next": "press-set-tpsl"
- },
- "press-set-tpsl": {
- "action": "press",
- "test_id": "perps-tpsl-set-button",
- "next": "wait-tpsl-updated"
- },
- "wait-tpsl-updated": {
- "action": "wait_for",
- "expression": "Engine.context.PerpsController.getPositions().then(function(ps){var p=ps.find(function(x){return x.symbol==='{{symbol}}'});return JSON.stringify({hasTp:!!(p&&p.takeProfitPrice),hasSl:!!(p&&p.stopLossPrice)})})",
- "assert": {
- "operator": "eq",
- "field": "hasTp",
- "value": true
- },
- "next": "done"
- },
- "done": {
- "action": "end",
- "status": "pass"
- }
- }
- }
- }
-}
diff --git a/scripts/perps/agentic/teams/perps/flows/trade-close-position.json b/scripts/perps/agentic/teams/perps/flows/trade-close-position.json
deleted file mode 100644
index c8b95859dfac..000000000000
--- a/scripts/perps/agentic/teams/perps/flows/trade-close-position.json
+++ /dev/null
@@ -1,88 +0,0 @@
-{
- "title": "Trade — close position for {{symbol}}",
- "inputs": {
- "symbol": {
- "type": "string",
- "description": "Market symbol of position to close"
- },
- "closePercent": {
- "type": "string",
- "default": "100",
- "description": "Percentage of position to close (reserved for future partial close)"
- }
- },
- "validate": {
- "workflow": {
- "pre_conditions": [
- "wallet.unlocked",
- {
- "name": "perps.open_position",
- "symbol": "{{symbol}}"
- }
- ],
- "entry": "assert-position-exists",
- "nodes": {
- "assert-position-exists": {
- "action": "eval_async",
- "expression": "Engine.context.PerpsController.getPositions().then(function(ps){var p=ps.find(function(x){return x.symbol==='{{symbol}}'});return JSON.stringify({found:!!p})})",
- "assert": {
- "operator": "eq",
- "field": "found",
- "value": true
- },
- "next": "nav-market-detail"
- },
- "nav-market-detail": {
- "action": "navigate",
- "target": "PerpsMarketDetails",
- "params": {
- "market": {
- "symbol": "{{symbol}}",
- "name": "{{symbol}}",
- "price": "0",
- "change24h": "0",
- "change24hPercent": "0",
- "volume": "0",
- "maxLeverage": "100"
- }
- },
- "next": "wait-render"
- },
- "wait-render": {
- "action": "wait_for",
- "test_id": "perps-market-details-close-button",
- "next": "press-close"
- },
- "press-close": {
- "action": "press",
- "test_id": "perps-market-details-close-button",
- "next": "wait-close-screen"
- },
- "wait-close-screen": {
- "action": "wait_for",
- "route": "PerpsClosePosition",
- "next": "press-confirm-close"
- },
- "press-confirm-close": {
- "action": "press",
- "test_id": "close-position-confirm-button",
- "next": "wait-position-closed"
- },
- "wait-position-closed": {
- "action": "wait_for",
- "expression": "Engine.context.PerpsController.getPositions().then(function(ps){var p=ps.find(function(x){return x.symbol==='{{symbol}}'});return JSON.stringify({found:!!p})})",
- "assert": {
- "operator": "eq",
- "field": "found",
- "value": false
- },
- "next": "done"
- },
- "done": {
- "action": "end",
- "status": "pass"
- }
- }
- }
- }
-}
diff --git a/scripts/perps/agentic/teams/perps/flows/trade-open-market-controller.json b/scripts/perps/agentic/teams/perps/flows/trade-open-market-controller.json
deleted file mode 100644
index 60b1206e57e5..000000000000
--- a/scripts/perps/agentic/teams/perps/flows/trade-open-market-controller.json
+++ /dev/null
@@ -1,65 +0,0 @@
-{
- "title": "Trade (controller) — market {{side}} {{symbol}} ${{usdAmount}}",
- "description": "Controller-level market order via PerpsController.placeOrder(). Bypasses UI deposit flow (depositWithOrder → RedesignedConfirmations) which breaks in dev mode. Navigates to market screen for context, then places order directly via controller API.",
- "inputs": {
- "side": {
- "type": "string",
- "default": "long",
- "description": "Trade side (long/short)"
- },
- "symbol": {
- "type": "string",
- "default": "BTC",
- "description": "Market symbol"
- },
- "usdAmount": {
- "type": "string",
- "default": "10",
- "description": "USD notional amount"
- }
- },
- "validate": {
- "workflow": {
- "pre_conditions": [
- "wallet.unlocked",
- "perps.ready_to_trade",
- "perps.sufficient_balance"
- ],
- "entry": "nav",
- "nodes": {
- "nav": {
- "action": "navigate",
- "target": "PerpsMarketDetails",
- "params": {
- "market": {
- "symbol": "{{symbol}}",
- "name": "{{symbol}}",
- "price": "0",
- "change24h": "0",
- "change24hPercent": "0",
- "volume": "0",
- "maxLeverage": "100"
- }
- },
- "next": "wait-render"
- },
- "wait-render": {
- "action": "wait_for",
- "test_id": "perps-market-details-{{side}}-button",
- "next": "place-order-controller"
- },
- "place-order-controller": {
- "action": "eval_async",
- "expression": "Engine.context.PerpsController.placeOrder({symbol:'{{symbol}}',isBuy:'{{side}}'==='long',orderType:'market',size:'0.0001',leverage:2,usdAmount:'{{usdAmount}}',maxSlippageBps:500}).then(function(r){return JSON.stringify(r)})",
- "assert": { "operator": "eq", "field": "success", "value": true },
- "timeout_ms": 30000,
- "next": "done"
- },
- "done": {
- "action": "end",
- "status": "pass"
- }
- }
- }
- }
-}
diff --git a/scripts/perps/agentic/teams/perps/flows/trade-open-market.json b/scripts/perps/agentic/teams/perps/flows/trade-open-market.json
deleted file mode 100644
index ba379c2e7deb..000000000000
--- a/scripts/perps/agentic/teams/perps/flows/trade-open-market.json
+++ /dev/null
@@ -1,116 +0,0 @@
-{
- "title": "Trade — market {{side}} {{symbol}} ${{usdAmount}}",
- "inputs": {
- "side": {
- "type": "string",
- "default": "long",
- "description": "Trade side (long/short)"
- },
- "symbol": {
- "type": "string",
- "default": "BTC",
- "description": "Market symbol"
- },
- "usdAmount": {
- "type": "string",
- "default": "10",
- "description": "USD notional amount"
- },
- "leverage": {
- "type": "string",
- "default": "2",
- "description": "Leverage multiplier (reserved, not wired yet)"
- }
- },
- "validate": {
- "workflow": {
- "pre_conditions": [
- "wallet.unlocked",
- "perps.ready_to_trade",
- "perps.sufficient_balance"
- ],
- "entry": "nav",
- "nodes": {
- "nav": {
- "action": "navigate",
- "target": "PerpsMarketDetails",
- "params": {
- "market": {
- "symbol": "{{symbol}}",
- "name": "{{symbol}}",
- "price": "0",
- "change24h": "0",
- "change24hPercent": "0",
- "volume": "0",
- "maxLeverage": "100"
- }
- },
- "next": "wait-render"
- },
- "wait-render": {
- "action": "wait_for",
- "test_id": "perps-market-details-{{side}}-button",
- "next": "press-side"
- },
- "press-side": {
- "action": "press",
- "test_id": "perps-market-details-{{side}}-button",
- "next": "wait-form"
- },
- "wait-form": {
- "action": "wait_for",
- "test_id": "perps-amount-display-touchable",
- "timeout_ms": 10000,
- "next": "press-amount"
- },
- "press-amount": {
- "action": "press",
- "test_id": "perps-amount-display-touchable",
- "next": "wait-keypad"
- },
- "wait-keypad": {
- "action": "wait_for",
- "test_id": "perps-order-view-keypad",
- "timeout_ms": 5000,
- "next": "clear-keypad"
- },
- "clear-keypad": {
- "action": "clear_keypad",
- "count": 8,
- "next": "type-amount"
- },
- "type-amount": {
- "action": "type_keypad",
- "value": "{{usdAmount}}",
- "next": "press-done"
- },
- "press-done": {
- "action": "press",
- "test_id": "perps-order-view-keypad-done",
- "next": "wait-place-order"
- },
- "wait-place-order": {
- "action": "wait_for",
- "test_id": "perps-order-view-place-order-button",
- "timeout_ms": 15000,
- "next": "place-order"
- },
- "place-order": {
- "action": "press",
- "test_id": "perps-order-view-place-order-button",
- "next": "wait-order-placed"
- },
- "wait-order-placed": {
- "action": "wait_for",
- "not_route": "RedesignedConfirmations",
- "timeout_ms": 30000,
- "next": "done"
- },
- "done": {
- "action": "end",
- "status": "pass"
- }
- }
- }
- }
-}
diff --git a/scripts/perps/agentic/teams/perps/pre-conditions.js b/scripts/perps/agentic/teams/perps/pre-conditions.js
deleted file mode 100644
index 8a146b87d16f..000000000000
--- a/scripts/perps/agentic/teams/perps/pre-conditions.js
+++ /dev/null
@@ -1,178 +0,0 @@
-#!/usr/bin/env node
-/**
- * pre-conditions.js — Registry of named, executable pre-condition checks.
- *
- * Each entry defines a CDP eval expression (sync or async) and an assertion
- * that must pass before a flow is allowed to run. If a check fails the runner
- * aborts immediately with a clear message and actionable hint instead of
- * letting the flow limp forward and die on step 4 with a cryptic error.
- *
- * Usage in flow JSON:
- * "pre_conditions": [
- * "wallet.unlocked",
- * "perps.ready_to_trade",
- * { "name": "perps.open_position", "symbol": "BTC" }
- * ]
- *
- * String entries are looked up by name with no params.
- * Object entries pass remaining fields as params to the expression builder.
- *
- * Keys use dot-notation namespaces (wallet.*, perps.*, ui.*) to signal
- * ownership and avoid collisions across teams.
- *
- * Adding a new check:
- * 1. Add an entry to REGISTRY below.
- * 2. If the expression needs params (e.g. symbol), make `expression` a
- * function(params) => string instead of a plain string.
- * 3. Set `async: true` if the expression returns a Promise.
- * 4. Update your flow's pre_conditions array.
- * 5. Run `node validate-flow-schema.js` — it validates names against this file.
- */
-
-'use strict';
-
-/**
- * @typedef {Object} PreCondition
- * @property {string} description Human-readable description shown on failure.
- * @property {boolean} async true if expression returns a Promise.
- * @property {string | ((params: object) => string)} expression
- * CDP JS expression (ES5 only). String for fixed checks, function for
- * parameterised checks (receives the spec object minus "name").
- * @property {{ operator: string, field?: string, value?: unknown }} assert
- * Same operators as recipe step assertions.
- * @property {string} hint What to do when the check fails.
- * @property {{ pass: string, fail: string }} fixtures
- * Inline test fixtures for offline assertion correctness checks.
- */
-
-/** @type {Record} */
-const REGISTRY = {
- 'wallet.unlocked': {
- description: 'Wallet is unlocked and app is navigable',
- async: false,
- expression: '(function(){ var r=globalThis.__AGENTIC__.getRoute().name; return JSON.stringify({route:r,unlocked:r!=="Login"&&r!=="LoginView"&&r!=="Onboarding"}); })()',
- assert: { operator: 'eq', field: 'unlocked', value: true },
- hint: 'Unlock the wallet first:\n bash scripts/perps/agentic/app-state.sh unlock ',
- fixtures: {
- pass: '{"route":"WalletView","unlocked":true}',
- fail: '{"route":"Login","unlocked":false}',
- },
- },
-
- 'perps.feature_enabled': {
- description: 'PerpsController is available on Engine.context',
- async: false,
- expression: 'JSON.stringify({enabled: !!(Engine.context && Engine.context.PerpsController)})',
- assert: { operator: 'eq', field: 'enabled', value: true },
- hint: 'Enable the Perps feature flag for this account/environment.',
- fixtures: {
- pass: '{"enabled":true}',
- fail: '{"enabled":false}',
- },
- },
-
- 'perps.ready_to_trade': {
- description: 'Perps provider is ready to trade',
- async: true,
- expression: '(function(){ var c=Engine.context.PerpsController; var id=c.state.activeProvider; var p=c.providers.get(id); if(!p) return Promise.resolve(JSON.stringify({isAuthenticated:false,error:"no active provider"})); return p.isReadyToTrade().then(function(r){ return JSON.stringify({isAuthenticated:r.ready}); }); })()',
- assert: { operator: 'eq', field: 'isAuthenticated', value: true },
- hint: 'Complete Perps authentication/onboarding before running this flow.',
- fixtures: {
- pass: '{"isAuthenticated":true}',
- fail: '{"isAuthenticated":false}',
- },
- },
-
- 'perps.sufficient_balance': {
- description: 'Perps account has a non-zero available balance',
- async: true,
- expression: 'Engine.context.PerpsController.getAccountState().then(function(r){ return JSON.stringify({balance: parseFloat(r.withdrawableBalance||"0")}); })',
- assert: { operator: 'gt', field: 'balance', value: 0 },
- hint: 'Deposit funds into your Perps account before placing orders.',
- fixtures: {
- pass: '{"balance":100}',
- fail: '{"balance":0}',
- },
- },
-
- 'perps.open_position': {
- description: 'At least one open position exists (optionally filtered by symbol)',
- async: true,
- expression: (params) => {
- const filter = params && params.symbol
- ? `function(x){ return x.symbol === '${params.symbol}'; }`
- : `function(){ return true; }`;
- return `Engine.context.PerpsController.getPositions().then(function(ps){ var filtered=ps.filter(${filter}); return JSON.stringify({count:filtered.length}); })`;
- },
- assert: { operator: 'gt', field: 'count', value: 0 },
- hint: 'Open a position first using the trade-open-market flow.',
- fixtures: {
- pass: '{"count":2}',
- fail: '{"count":0}',
- },
- },
-
- 'perps.open_position_tpsl': {
- description: 'At least one open position with TP or SL set (optionally filtered by symbol)',
- async: true,
- expression: (params) => {
- const symbolClause = params && params.symbol
- ? `x.symbol === '${params.symbol}' && `
- : '';
- return `Engine.context.PerpsController.getPositions().then(function(ps){ var filtered=ps.filter(function(x){ return ${symbolClause}!!(x.takeProfitPrice||x.stopLossPrice); }); return JSON.stringify({count:filtered.length}); })`;
- },
- assert: { operator: 'gt', field: 'count', value: 0 },
- hint: 'Create a TP/SL first using the tpsl-create flow.',
- fixtures: {
- pass: '{"count":1}',
- fail: '{"count":0}',
- },
- },
-
- 'perps.open_limit_order': {
- description: 'At least one open limit order exists (optionally filtered by symbol)',
- async: true,
- expression: (params) => {
- const filter = params && params.symbol
- ? `function(x){ return x.symbol === '${params.symbol}'; }`
- : `function(){ return true; }`;
- return `Engine.context.PerpsController.getOpenOrders().then(function(orders){ var filtered=orders.filter(${filter}); return JSON.stringify({count:filtered.length}); })`;
- },
- assert: { operator: 'gt', field: 'count', value: 0 },
- hint: 'Place a limit order first using the order-limit-place flow.',
- fixtures: {
- pass: '{"count":1}',
- fail: '{"count":0}',
- },
- },
-
- 'perps.not_in_watchlist': {
- description: 'Symbol is not already in the watchlist',
- async: false,
- expression: (params) => {
- const symbol = (params && params.symbol) || '';
- return `(function(){ var s=Engine.context.PerpsController.state; var markets=s.isTestnet?s.watchlistMarkets.testnet:s.watchlistMarkets.mainnet; var inList=(markets||[]).some(function(m){ return (m.symbol||m)==='${symbol}'; }); return JSON.stringify({inWatchlist:inList}); })()`;
- },
- assert: { operator: 'eq', field: 'inWatchlist', value: false },
- hint: 'Remove the symbol from the watchlist first, or use a symbol not already in it.',
- fixtures: {
- pass: '{"inWatchlist":false}',
- fail: '{"inWatchlist":true}',
- },
- },
-
- 'perps.trading_flag': {
- description: 'Perps trading remote feature flag is enabled',
- async: false,
- expression: '(function(){ var f=Engine.context.RemoteFeatureFlagController.state.remoteFeatureFlags.perpsPerpTradingEnabled; var enabled=f===true||(f&&f.enabled===true); return JSON.stringify({enabled:!!enabled}); })()',
- assert: { operator: 'eq', field: 'enabled', value: true },
- hint: 'Enable the perps trading flag: Settings → Experimental → Feature Flags → perpsPerpTradingEnabled.',
- fixtures: {
- pass: '{"enabled":true}',
- fail: '{"enabled":false}',
- },
- },
-
-};
-
-module.exports = REGISTRY;
diff --git a/scripts/perps/agentic/teams/perps/recipes/app-lifecycle.json b/scripts/perps/agentic/teams/perps/recipes/app-lifecycle.json
deleted file mode 100644
index 0942619934fe..000000000000
--- a/scripts/perps/agentic/teams/perps/recipes/app-lifecycle.json
+++ /dev/null
@@ -1,181 +0,0 @@
-{
- "title": "App Lifecycle — validate data survives background, long background, and restart",
- "description": "Reference recipe demonstrating three lifecycle stress levels: short background (within WS grace period), long background (exceeds grace period, forces full WS reconnection), and full app restart. Use as a template for PR recipes that need lifecycle validation.",
- "inputs": {
- "symbol": {
- "type": "string",
- "default": "BTC",
- "description": "Market symbol to verify price recovery"
- },
- "short_background_ms": {
- "type": "number",
- "default": 5000,
- "description": "Short background duration — within WS grace period (cache preserved)"
- },
- "long_background_ms": {
- "type": "number",
- "default": 30000,
- "description": "Long background duration — exceeds WS grace period (forces full reconnection)"
- },
- "test_short_background": {
- "type": "boolean",
- "default": true,
- "description": "Test short background/foreground cycle"
- },
- "test_long_background": {
- "type": "boolean",
- "default": true,
- "description": "Test long background that triggers WS reconnection"
- },
- "test_restart": {
- "type": "boolean",
- "default": false,
- "description": "Test full app restart (slower, needs ~20s boot + possible unlock)"
- }
- },
- "validate": {
- "workflow": {
- "pre_conditions": ["wallet.unlocked", "perps.feature_enabled"],
- "entry": "nav-market-list",
- "nodes": {
- "nav-market-list": {
- "action": "navigate",
- "target": "PerpsTrendingView",
- "next": "wait-markets"
- },
- "wait-markets": {
- "action": "wait_for",
- "expression": "Engine.context.PerpsController.getMarketDataWithPrices().then(function(ms){return JSON.stringify({count:ms.length})})",
- "assert": { "operator": "gt", "field": "count", "value": 0 },
- "next": "baseline-price"
- },
- "baseline-price": {
- "action": "eval_async",
- "description": "Baseline — confirm markets and price data loaded",
- "expression": "Engine.context.PerpsController.getMarketDataWithPrices().then(function(ms){var m=ms.find(function(x){return x.symbol==='{{symbol}}'});return JSON.stringify({found:!!m,price:m?m.price:'0'})})",
- "assert": { "operator": "neq", "field": "price", "value": "0" },
- "next": "short-background"
- },
-
- "short-background": {
- "action": "app_background",
- "description": "Short background — within WS grace period, cache should be preserved",
- "when": { "operator": "eq", "field": "inputs.test_short_background", "value": true },
- "duration_ms": "{{short_background_ms}}",
- "next": "short-foreground"
- },
- "short-foreground": {
- "action": "app_foreground",
- "when": { "operator": "eq", "field": "inputs.test_short_background", "value": true },
- "next": "wait-post-short"
- },
- "wait-post-short": {
- "action": "wait_for",
- "when": { "operator": "eq", "field": "inputs.test_short_background", "value": true },
- "expression": "Engine.context.PerpsController.getMarketDataWithPrices().then(function(ms){return JSON.stringify({count:ms.length})})",
- "assert": { "operator": "gt", "field": "count", "value": 0 },
- "timeout_ms": 15000,
- "next": "verify-post-short"
- },
- "verify-post-short": {
- "action": "eval_async",
- "description": "Confirm markets + price survive short background",
- "when": { "operator": "eq", "field": "inputs.test_short_background", "value": true },
- "expression": "Engine.context.PerpsController.getMarketDataWithPrices().then(function(ms){var m=ms.find(function(x){return x.symbol==='{{symbol}}'});return JSON.stringify({found:!!m,price:m?m.price:'0'})})",
- "assert": { "operator": "neq", "field": "price", "value": "0" },
- "next": "long-background"
- },
-
- "long-background": {
- "action": "app_background",
- "description": "Long background — exceeds WS grace period, forces full reconnection",
- "when": { "operator": "eq", "field": "inputs.test_long_background", "value": true },
- "duration_ms": "{{long_background_ms}}",
- "next": "long-foreground"
- },
- "long-foreground": {
- "action": "app_foreground",
- "when": { "operator": "eq", "field": "inputs.test_long_background", "value": true },
- "next": "wait-post-long"
- },
- "wait-post-long": {
- "action": "wait_for",
- "when": { "operator": "eq", "field": "inputs.test_long_background", "value": true },
- "expression": "Engine.context.PerpsController.getMarketDataWithPrices().then(function(ms){return JSON.stringify({count:ms.length})})",
- "assert": { "operator": "gt", "field": "count", "value": 0 },
- "timeout_ms": 30000,
- "next": "verify-post-long"
- },
- "verify-post-long": {
- "action": "eval_async",
- "description": "Confirm markets + DEXs recover after full WS reconnection",
- "when": { "operator": "eq", "field": "inputs.test_long_background", "value": true },
- "expression": "(function(){return Promise.all([Engine.context.PerpsController.getMarketDataWithPrices(),Engine.context.PerpsController.getAvailableDexs()]).then(function(r){var m=r[0].find(function(x){return x.symbol==='{{symbol}}'});return JSON.stringify({markets:r[0].length,dexs:r[1].length,price:m?m.price:'0'})})})()",
- "assert": { "operator": "neq", "field": "price", "value": "0" },
- "next": "restart-app"
- },
-
- "restart-app": {
- "action": "app_restart",
- "when": { "operator": "eq", "field": "inputs.test_restart", "value": true },
- "boot_wait_ms": 20000,
- "next": "detect-post-restart"
- },
- "detect-post-restart": {
- "action": "eval_sync",
- "description": "Detect login vs wallet after restart",
- "when": { "operator": "eq", "field": "inputs.test_restart", "value": true },
- "expression": "(function(){try{var r=globalThis.__AGENTIC__.getRoute();return JSON.stringify({route:r.name})}catch(e){return JSON.stringify({route:'unknown'})}})()",
- "next": "check-needs-unlock"
- },
- "check-needs-unlock": {
- "action": "switch",
- "when": { "operator": "eq", "field": "inputs.test_restart", "value": true },
- "cases": [
- {
- "label": "needs-login",
- "when": { "operator": "eq", "field": "nodes.detect-post-restart.result.route", "value": "Login" },
- "next": "enter-password"
- }
- ],
- "default": "nav-after-restart"
- },
- "enter-password": {
- "action": "set_input",
- "test_id": "login-password-input",
- "value": "qwerasdf",
- "next": "press-login"
- },
- "press-login": {
- "action": "press",
- "test_id": "log-in-button",
- "next": "nav-after-restart"
- },
- "nav-after-restart": {
- "action": "navigate",
- "when": { "operator": "eq", "field": "inputs.test_restart", "value": true },
- "target": "PerpsTrendingView",
- "next": "wait-post-restart"
- },
- "wait-post-restart": {
- "action": "wait_for",
- "when": { "operator": "eq", "field": "inputs.test_restart", "value": true },
- "expression": "Engine.context.PerpsController.getMarketDataWithPrices().then(function(ms){return JSON.stringify({count:ms.length})})",
- "assert": { "operator": "gt", "field": "count", "value": 0 },
- "timeout_ms": 30000,
- "next": "verify-post-restart"
- },
- "verify-post-restart": {
- "action": "eval_async",
- "description": "Confirm markets reload cleanly after full restart",
- "when": { "operator": "eq", "field": "inputs.test_restart", "value": true },
- "expression": "Engine.context.PerpsController.getMarketDataWithPrices().then(function(ms){var m=ms.find(function(x){return x.symbol==='{{symbol}}'});return JSON.stringify({found:!!m,price:m?m.price:'0'})})",
- "assert": { "operator": "neq", "field": "price", "value": "0" },
- "next": "done"
- },
-
- "done": { "action": "end", "status": "pass" }
- }
- }
- }
-}
diff --git a/scripts/perps/agentic/teams/perps/recipes/benchmark/perf-add-funds.json b/scripts/perps/agentic/teams/perps/recipes/benchmark/perf-add-funds.json
deleted file mode 100644
index 97b4587c09f7..000000000000
--- a/scripts/perps/agentic/teams/perps/recipes/benchmark/perf-add-funds.json
+++ /dev/null
@@ -1,75 +0,0 @@
-{
- "title": "Benchmark: Perf — Perps add funds flow timing",
- "schema_version": 1,
- "description": "Mirrors performance/login/perps-add-funds.spec.ts. Ensures tutorial is completed via setup, then navigates to Perps, opens Add Funds, and types amount. Teardown navigates back to PerpsHome.",
- "validate": {
- "workflow": {
- "pre_conditions": ["wallet.unlocked", "perps.feature_enabled"],
- "setup": [
- {
- "id": "setup-complete-tutorial",
- "action": "eval_sync",
- "expression": "(function(){Engine.context.PerpsController.markTutorialCompleted();return JSON.stringify({done:true})})()",
- "assert": { "operator": "eq", "field": "done", "value": true }
- }
- ],
- "entry": "ensure-testnet",
- "nodes": {
- "ensure-testnet": {
- "action": "call",
- "ref": "setup-testnet",
- "next": "nav-perps"
- },
- "nav-perps": {
- "action": "navigate",
- "target": "PerpsHomeView",
- "next": "wait-add-funds"
- },
- "wait-add-funds": {
- "action": "wait_for",
- "test_id": "perps-market-add-funds-button",
- "timeout_ms": 20000,
- "next": "press-add-funds"
- },
- "press-add-funds": {
- "action": "press",
- "test_id": "perps-market-add-funds-button",
- "next": "wait-deposit"
- },
- "wait-deposit": {
- "action": "wait_for",
- "test_id": "deposit-keyboard",
- "timeout_ms": 15000,
- "next": "clear-keypad"
- },
- "clear-keypad": {
- "action": "clear_keypad",
- "count": 8,
- "next": "type-amount"
- },
- "type-amount": {
- "action": "type_keypad",
- "value": "2",
- "next": "screenshot-amount"
- },
- "screenshot-amount": {
- "action": "screenshot",
- "filename": "evidence-perf-add-funds.png",
- "note": "Perf benchmark: deposit screen reached with $2 entered — captures end of timed nav-to-amount path",
- "next": "done"
- },
- "done": {
- "action": "end",
- "status": "pass"
- }
- },
- "teardown": [
- {
- "id": "teardown-nav-home",
- "action": "navigate",
- "target": "PerpsHomeView"
- }
- ]
- }
- }
-}
diff --git a/scripts/perps/agentic/teams/perps/recipes/benchmark/perf-position-management.json b/scripts/perps/agentic/teams/perps/recipes/benchmark/perf-position-management.json
deleted file mode 100644
index e2487c9d00e4..000000000000
--- a/scripts/perps/agentic/teams/perps/recipes/benchmark/perf-position-management.json
+++ /dev/null
@@ -1,78 +0,0 @@
-{
- "title": "Benchmark: Perf — Perps position open and close BTC",
- "schema_version": 1,
- "description": "Mirrors performance/login/perps-position-management.spec.ts. Opens a BTC long position and closes it. Performance spec measures timing with 40x leverage; recipe uses default leverage via trade-open-market flow.",
- "validate": {
- "workflow": {
- "pre_conditions": ["wallet.unlocked", "perps.ready_to_trade", "perps.sufficient_balance"],
- "setup": [
- { "id": "setup-nav-home", "action": "navigate", "target": "PerpsHomeView" }
- ],
- "entry": "ensure-testnet",
- "nodes": {
- "ensure-testnet": {
- "action": "call",
- "ref": "setup-testnet",
- "next": "clear-btc-position"
- },
- "clear-btc-position": {
- "action": "eval_async",
- "expression": "Engine.context.PerpsController.getPositions().then(function(ps){var p=ps.find(function(x){return x.symbol==='BTC'});if(!p)return JSON.stringify({cleared:true});return Engine.context.PerpsController.closePosition({symbol:'BTC'}).then(function(){return JSON.stringify({cleared:true})})})",
- "assert": { "operator": "eq", "field": "cleared", "value": true },
- "next": "open-long-btc"
- },
- "open-long-btc": {
- "action": "call",
- "ref": "trade-open-market",
- "params": { "symbol": "BTC", "side": "long", "usdAmount": "10" },
- "next": "wait-position"
- },
- "wait-position": {
- "action": "wait_for",
- "expression": "Engine.context.PerpsController.getPositions().then(function(ps){var p=ps.find(function(x){return x.symbol==='BTC'});return JSON.stringify({found:!!p})})",
- "assert": { "operator": "eq", "field": "found", "value": true },
- "timeout_ms": 15000,
- "next": "screenshot-open"
- },
- "screenshot-open": {
- "action": "screenshot",
- "filename": "evidence-perf-btc-open.png",
- "note": "Perf benchmark: BTC long opened — captured before close timer starts",
- "next": "close-position"
- },
- "close-position": {
- "action": "call",
- "ref": "trade-close-position",
- "params": { "symbol": "BTC" },
- "next": "verify-closed"
- },
- "verify-closed": {
- "action": "wait_for",
- "expression": "Engine.context.PerpsController.getPositions().then(function(ps){var p=ps.find(function(x){return x.symbol==='BTC'});return JSON.stringify({found:!!p})})",
- "assert": { "operator": "eq", "field": "found", "value": false },
- "timeout_ms": 10000,
- "next": "screenshot-closed"
- },
- "screenshot-closed": {
- "action": "screenshot",
- "filename": "evidence-perf-btc-closed.png",
- "note": "Perf benchmark: BTC position closed — getPositions empty, end of timed open-then-close path",
- "next": "done"
- },
- "done": {
- "action": "end",
- "status": "pass"
- }
- },
- "teardown": [
- {
- "id": "teardown-close-btc",
- "action": "eval_async",
- "expression": "Engine.context.PerpsController.getPositions().then(function(ps){var p=ps.find(function(x){return x.symbol==='BTC'});if(!p)return JSON.stringify({clean:true});return Engine.context.PerpsController.closePosition({symbol:'BTC'}).then(function(){return JSON.stringify({clean:true})})})",
- "assert": { "operator": "not_null" }
- },
- { "id": "teardown-nav-home", "action": "navigate", "target": "PerpsHomeView" }
- ]
- }
- }
-}
diff --git a/scripts/perps/agentic/teams/perps/recipes/benchmark/perps-add-funds.json b/scripts/perps/agentic/teams/perps/recipes/benchmark/perps-add-funds.json
deleted file mode 100644
index 08704e1f38b4..000000000000
--- a/scripts/perps/agentic/teams/perps/recipes/benchmark/perps-add-funds.json
+++ /dev/null
@@ -1,81 +0,0 @@
-{
- "title": "Benchmark: Perps Add Funds — navigate to deposit screen and enter amount",
- "schema_version": 1,
- "description": "Mirrors smoke/perps/perps-add-funds.spec.ts. Navigates to Perps, taps Add Funds, and enters amount via keypad. Detox spec also confirms deposit via mock server; recipe validates the UI flow up to amount entry (deposit execution requires E2E mock infrastructure).",
- "validate": {
- "workflow": {
- "pre_conditions": ["wallet.unlocked", "perps.ready_to_trade"],
- "setup": [
- {
- "id": "setup-nav-home",
- "action": "navigate",
- "target": "PerpsHomeView"
- }
- ],
- "entry": "ensure-testnet",
- "nodes": {
- "ensure-testnet": {
- "action": "call",
- "ref": "setup-testnet",
- "next": "nav-perps-home"
- },
- "nav-perps-home": {
- "action": "navigate",
- "target": "PerpsHomeView",
- "next": "wait-perps-loaded"
- },
- "wait-perps-loaded": {
- "action": "wait_for",
- "expression": "(function(){var el=globalThis.__AGENTIC__.findFiberByTestId('perps-market-add-funds-button');return JSON.stringify({visible:!!el})})()",
- "assert": { "operator": "eq", "field": "visible", "value": true },
- "timeout_ms": 20000,
- "next": "screenshot-perps-home"
- },
- "screenshot-perps-home": {
- "action": "screenshot",
- "filename": "evidence-perps-home.png",
- "note": "PerpsHomeView loaded with Add Funds CTA visible — entry point for deposit flow",
- "next": "press-add-funds"
- },
- "press-add-funds": {
- "action": "press",
- "test_id": "perps-market-add-funds-button",
- "next": "wait-deposit-screen"
- },
- "wait-deposit-screen": {
- "action": "wait_for",
- "test_id": "deposit-keyboard",
- "timeout_ms": 15000,
- "next": "clear-keypad"
- },
- "clear-keypad": {
- "action": "clear_keypad",
- "count": 8,
- "next": "type-amount"
- },
- "type-amount": {
- "action": "type_keypad",
- "value": "80",
- "next": "screenshot-amount-entered"
- },
- "screenshot-amount-entered": {
- "action": "screenshot",
- "filename": "evidence-deposit-amount.png",
- "note": "Deposit screen with $80 entered via keypad — UI flow up to amount confirmation (deposit execution requires E2E mock)",
- "next": "done"
- },
- "done": {
- "action": "end",
- "status": "pass"
- }
- },
- "teardown": [
- {
- "id": "teardown-nav-home",
- "action": "navigate",
- "target": "PerpsHomeView"
- }
- ]
- }
- }
-}
diff --git a/scripts/perps/agentic/teams/perps/recipes/benchmark/perps-limit-long-fill.json b/scripts/perps/agentic/teams/perps/recipes/benchmark/perps-limit-long-fill.json
deleted file mode 100644
index a9f9ccd29701..000000000000
--- a/scripts/perps/agentic/teams/perps/recipes/benchmark/perps-limit-long-fill.json
+++ /dev/null
@@ -1,94 +0,0 @@
-{
- "title": "Benchmark: Perps Limit Long Fill — limit long ETH at Mid, verify order and fill",
- "schema_version": 1,
- "description": "Mirrors smoke/perps/perps-limit-long-fill.spec.ts. Creates an ETH limit long at mid price via controller API, verifies the order appears, then waits for fill. Uses order-limit-place-controller flow (bypasses UI deposit path broken in dev mode).",
- "validate": {
- "workflow": {
- "pre_conditions": ["wallet.unlocked", "perps.ready_to_trade", "perps.sufficient_balance"],
- "setup": [
- { "id": "setup-nav-home", "action": "navigate", "target": "PerpsHomeView" }
- ],
- "entry": "ensure-testnet",
- "nodes": {
- "ensure-testnet": {
- "action": "call",
- "ref": "setup-testnet",
- "next": "clear-eth-position"
- },
- "clear-eth-position": {
- "action": "eval_async",
- "expression": "Engine.context.PerpsController.getPositions().then(function(ps){var p=ps.find(function(x){return x.symbol==='ETH'});if(!p)return JSON.stringify({cleared:true});return Engine.context.PerpsController.closePosition({symbol:'ETH'}).then(function(){return JSON.stringify({cleared:true})})})",
- "assert": { "operator": "eq", "field": "cleared", "value": true },
- "next": "cancel-eth-orders"
- },
- "cancel-eth-orders": {
- "action": "eval_async",
- "expression": "Engine.context.PerpsController.getOpenOrders().then(function(os){var eth=os.filter(function(o){return o.symbol==='ETH'&&!o.isTrigger});if(!eth.length)return JSON.stringify({cancelled:0});return Promise.all(eth.map(function(o){return Engine.context.PerpsController.cancelOrder({orderId:o.oid})})).then(function(){return JSON.stringify({cancelled:eth.length})})})",
- "assert": { "operator": "not_null" },
- "next": "place-limit-order"
- },
- "place-limit-order": {
- "action": "call",
- "ref": "order-limit-place-controller",
- "params": { "symbol": "ETH", "side": "long", "usdAmount": "10" },
- "next": "screenshot-order"
- },
- "screenshot-order": {
- "action": "screenshot",
- "filename": "evidence-limit-order-placed.png",
- "note": "ETH limit long order placed at mid price — order present in PerpsController.getOpenOrders pre-fill",
- "next": "verify-order-or-fill"
- },
- "verify-order-or-fill": {
- "action": "eval_async",
- "expression": "Promise.all([Engine.context.PerpsController.getOpenOrders(),Engine.context.PerpsController.getPositions()]).then(function(r){var orders=r[0].filter(function(o){return o.symbol==='ETH'&&!o.isTrigger});var pos=r[1].find(function(p){return p.symbol==='ETH'});return JSON.stringify({hasOrder:orders.length>0,hasPosition:!!pos})})",
- "assert": {
- "any": [
- { "operator": "eq", "field": "hasOrder", "value": true },
- { "operator": "eq", "field": "hasPosition", "value": true }
- ]
- },
- "next": "wait-fill"
- },
- "wait-fill": {
- "action": "wait_for",
- "expression": "Engine.context.PerpsController.getPositions().then(function(ps){var p=ps.find(function(x){return x.symbol==='ETH'});return JSON.stringify({found:!!p})})",
- "assert": { "operator": "eq", "field": "found", "value": true },
- "timeout_ms": 120000,
- "next": "screenshot-filled"
- },
- "screenshot-filled": {
- "action": "screenshot",
- "filename": "evidence-limit-order-filled.png",
- "note": "ETH limit order filled — position now in PerpsController.getPositions, order removed from open orders",
- "next": "cleanup"
- },
- "cleanup": {
- "action": "eval_async",
- "expression": "Engine.context.PerpsController.closePosition({symbol:'ETH'}).then(function(){return JSON.stringify({closed:true})}).catch(function(){return JSON.stringify({closed:true})})",
- "assert": { "operator": "not_null" },
- "next": "done"
- },
- "done": {
- "action": "end",
- "status": "pass"
- }
- },
- "teardown": [
- {
- "id": "teardown-close-eth",
- "action": "eval_async",
- "expression": "Engine.context.PerpsController.getPositions().then(function(ps){var p=ps.find(function(x){return x.symbol==='ETH'});if(!p)return JSON.stringify({clean:true});return Engine.context.PerpsController.closePosition({symbol:'ETH'}).then(function(){return JSON.stringify({clean:true})}).catch(function(){return JSON.stringify({clean:true})})})",
- "assert": { "operator": "not_null" }
- },
- {
- "id": "teardown-cancel-eth-orders",
- "action": "eval_async",
- "expression": "Engine.context.PerpsController.getOpenOrders().then(function(os){var eth=os.filter(function(o){return o.symbol==='ETH'&&!o.isTrigger});if(!eth.length)return JSON.stringify({cancelled:0});return Promise.all(eth.map(function(o){return Engine.context.PerpsController.cancelOrder({orderId:o.oid})})).then(function(){return JSON.stringify({cancelled:eth.length})})})",
- "assert": { "operator": "not_null" }
- },
- { "id": "teardown-nav-home", "action": "navigate", "target": "PerpsHomeView" }
- ]
- }
- }
-}
diff --git a/scripts/perps/agentic/teams/perps/recipes/benchmark/perps-no-funds-tutorial.json b/scripts/perps/agentic/teams/perps/recipes/benchmark/perps-no-funds-tutorial.json
deleted file mode 100644
index c585f5ba41d1..000000000000
--- a/scripts/perps/agentic/teams/perps/recipes/benchmark/perps-no-funds-tutorial.json
+++ /dev/null
@@ -1,131 +0,0 @@
-{
- "title": "Benchmark: Perps No Funds Tutorial — walk through onboarding tutorial",
- "schema_version": 1,
- "description": "Mirrors smoke/perps/perps-no-funds-tutorial.spec.ts. Resets first-time-user state and navigates to Perps. If tutorial shows, walks through 6 continue steps. If user already onboarded (funded account), verifies market list is visible. Teardown marks tutorial completed and navigates to PerpsHome for clean state.",
- "validate": {
- "workflow": {
- "pre_conditions": ["wallet.unlocked", "perps.feature_enabled"],
- "setup": [
- {
- "id": "setup-reset-tutorial",
- "action": "eval_sync",
- "expression": "(function(){Engine.context.PerpsController.resetFirstTimeUserState();return JSON.stringify({reset:true})})()",
- "assert": { "operator": "eq", "field": "reset", "value": true }
- },
- {
- "id": "setup-nav-home",
- "action": "navigate",
- "target": "PerpsHomeView"
- }
- ],
- "entry": "ensure-testnet",
- "nodes": {
- "ensure-testnet": {
- "action": "call",
- "ref": "setup-testnet",
- "next": "nav-perps"
- },
- "nav-perps": {
- "action": "navigate",
- "target": "PerpsHomeView",
- "next": "wait-perps-screen"
- },
- "wait-perps-screen": {
- "action": "wait_for",
- "expression": "(function(){var route=globalThis.__AGENTIC__.getRoute().name;return JSON.stringify({route:route})})()",
- "assert": { "operator": "not_null", "field": "route" },
- "timeout_ms": 15000,
- "next": "check-tutorial"
- },
- "check-tutorial": {
- "action": "eval_sync",
- "expression": "(function(){var el=globalThis.__AGENTIC__.findFiberByTestId('perps-tutorial-continue-button');return JSON.stringify({hasTutorial:!!el})})()",
- "save_as": "tutorialCheck",
- "assert": { "operator": "not_null" },
- "next": "decide-path"
- },
- "decide-path": {
- "action": "switch",
- "cases": [
- {
- "label": "tutorial visible",
- "when": { "operator": "eq", "field": "vars.tutorialCheck.hasTutorial", "value": true },
- "next": "press-continue-1"
- }
- ],
- "default": "screenshot-already-onboarded"
- },
- "press-continue-1": {
- "action": "press",
- "test_id": "perps-tutorial-continue-button",
- "next": "wait-1"
- },
- "wait-1": { "action": "wait", "duration_ms": 500, "next": "press-continue-2" },
- "press-continue-2": {
- "action": "press",
- "test_id": "perps-tutorial-continue-button",
- "next": "wait-2"
- },
- "wait-2": { "action": "wait", "duration_ms": 500, "next": "press-continue-3" },
- "press-continue-3": {
- "action": "press",
- "test_id": "perps-tutorial-continue-button",
- "next": "wait-3"
- },
- "wait-3": { "action": "wait", "duration_ms": 500, "next": "press-continue-4" },
- "press-continue-4": {
- "action": "press",
- "test_id": "perps-tutorial-continue-button",
- "next": "wait-4"
- },
- "wait-4": { "action": "wait", "duration_ms": 500, "next": "press-continue-5" },
- "press-continue-5": {
- "action": "press",
- "test_id": "perps-tutorial-continue-button",
- "next": "wait-5"
- },
- "wait-5": { "action": "wait", "duration_ms": 500, "next": "press-continue-6" },
- "press-continue-6": {
- "action": "press",
- "test_id": "perps-tutorial-continue-button",
- "next": "screenshot-tutorial-complete"
- },
- "screenshot-tutorial-complete": {
- "action": "screenshot",
- "filename": "evidence-tutorial-complete.png",
- "note": "Onboarding tutorial completed — final continue button dismissed and market list ready",
- "next": "verify-market-view"
- },
- "screenshot-already-onboarded": {
- "action": "screenshot",
- "filename": "evidence-already-onboarded.png",
- "note": "Already-onboarded path — tutorial skipped, market list visible directly",
- "next": "verify-market-view"
- },
- "verify-market-view": {
- "action": "eval_sync",
- "expression": "(function(){var route=globalThis.__AGENTIC__.getRoute().name;return JSON.stringify({route:route,isPerps:route.indexOf('Perps')!==-1||route.indexOf('perps')!==-1})})()",
- "assert": { "operator": "eq", "field": "isPerps", "value": true },
- "next": "done"
- },
- "done": {
- "action": "end",
- "status": "pass"
- }
- },
- "teardown": [
- {
- "id": "teardown-mark-tutorial-done",
- "action": "eval_sync",
- "expression": "(function(){Engine.context.PerpsController.markTutorialCompleted();return JSON.stringify({completed:true})})()",
- "assert": { "operator": "eq", "field": "completed", "value": true }
- },
- {
- "id": "teardown-nav-home",
- "action": "navigate",
- "target": "PerpsHomeView"
- }
- ]
- }
- }
-}
diff --git a/scripts/perps/agentic/teams/perps/recipes/benchmark/perps-position-liquidation.json b/scripts/perps/agentic/teams/perps/recipes/benchmark/perps-position-liquidation.json
deleted file mode 100644
index 58bb4f1bdf75..000000000000
--- a/scripts/perps/agentic/teams/perps/recipes/benchmark/perps-position-liquidation.json
+++ /dev/null
@@ -1,79 +0,0 @@
-{
- "title": "Benchmark: Perps Position Liquidation — open long ETH, verify position exists",
- "schema_version": 1,
- "description": "Mirrors smoke/perps/perps-position-liquidation.spec.ts. Opens a long ETH position and verifies it exists. Detox spec pushes price to trigger liquidation via commandQueueServer; recipe validates position open (liquidation trigger requires E2E mock infrastructure for price manipulation).",
- "validate": {
- "workflow": {
- "pre_conditions": ["wallet.unlocked", "perps.ready_to_trade", "perps.sufficient_balance"],
- "setup": [
- { "id": "setup-nav-home", "action": "navigate", "target": "PerpsHomeView" }
- ],
- "entry": "ensure-testnet",
- "nodes": {
- "ensure-testnet": {
- "action": "call",
- "ref": "setup-testnet",
- "next": "clear-eth-position"
- },
- "clear-eth-position": {
- "action": "eval_async",
- "expression": "Engine.context.PerpsController.getPositions().then(function(ps){var p=ps.find(function(x){return x.symbol==='ETH'});if(!p)return JSON.stringify({cleared:true});return Engine.context.PerpsController.closePosition({symbol:'ETH'}).then(function(){return JSON.stringify({cleared:true})})})",
- "assert": { "operator": "eq", "field": "cleared", "value": true },
- "next": "open-long-eth"
- },
- "open-long-eth": {
- "action": "call",
- "ref": "trade-open-market",
- "params": { "symbol": "ETH", "side": "long", "usdAmount": "10" },
- "next": "wait-position"
- },
- "wait-position": {
- "action": "wait_for",
- "expression": "Engine.context.PerpsController.getPositions().then(function(ps){var p=ps.find(function(x){return x.symbol==='ETH'});return JSON.stringify({found:!!p,side:p?p.side:null,entryPrice:p?p.entryPrice:null})})",
- "assert": { "operator": "eq", "field": "found", "value": true },
- "timeout_ms": 15000,
- "next": "verify-on-market-details"
- },
- "verify-on-market-details": {
- "action": "navigate",
- "target": "PerpsMarketDetails",
- "params": {
- "market": { "symbol": "ETH", "name": "ETH", "price": "0", "change24h": "0", "change24hPercent": "0", "volume": "0", "maxLeverage": "100" }
- },
- "next": "wait-close-button"
- },
- "wait-close-button": {
- "action": "wait_for",
- "test_id": "perps-market-details-close-button",
- "timeout_ms": 15000,
- "next": "screenshot-position"
- },
- "screenshot-position": {
- "action": "screenshot",
- "filename": "evidence-position-open-for-liquidation.png",
- "note": "ETH long position open on PerpsMarketDetails with close button visible — liquidation precondition (price-trigger requires E2E mock)",
- "next": "cleanup"
- },
- "cleanup": {
- "action": "call",
- "ref": "trade-close-position",
- "params": { "symbol": "ETH" },
- "next": "done"
- },
- "done": {
- "action": "end",
- "status": "pass"
- }
- },
- "teardown": [
- {
- "id": "teardown-close-eth",
- "action": "eval_async",
- "expression": "Engine.context.PerpsController.getPositions().then(function(ps){var p=ps.find(function(x){return x.symbol==='ETH'});if(!p)return JSON.stringify({clean:true});return Engine.context.PerpsController.closePosition({symbol:'ETH'}).then(function(){return JSON.stringify({clean:true})})})",
- "assert": { "operator": "not_null" }
- },
- { "id": "teardown-nav-home", "action": "navigate", "target": "PerpsHomeView" }
- ]
- }
- }
-}
diff --git a/scripts/perps/agentic/teams/perps/recipes/benchmark/perps-position-stop-loss.json b/scripts/perps/agentic/teams/perps/recipes/benchmark/perps-position-stop-loss.json
deleted file mode 100644
index 289c470e9362..000000000000
--- a/scripts/perps/agentic/teams/perps/recipes/benchmark/perps-position-stop-loss.json
+++ /dev/null
@@ -1,78 +0,0 @@
-{
- "title": "Benchmark: Perps Position Stop Loss — open long ETH with SL, verify SL set",
- "schema_version": 1,
- "description": "Mirrors smoke/perps/perps-position-stop-loss.spec.ts. Opens a long ETH position, sets a stop loss via tpsl-create flow. Detox spec uses custom SL price (2300) and pushes price to trigger; recipe uses SL preset and verifies SL is set (price trigger requires E2E mock).",
- "validate": {
- "workflow": {
- "pre_conditions": ["wallet.unlocked", "perps.ready_to_trade", "perps.sufficient_balance"],
- "setup": [
- { "id": "setup-nav-home", "action": "navigate", "target": "PerpsHomeView" }
- ],
- "entry": "ensure-testnet",
- "nodes": {
- "ensure-testnet": {
- "action": "call",
- "ref": "setup-testnet",
- "next": "clear-eth-position"
- },
- "clear-eth-position": {
- "action": "eval_async",
- "expression": "Engine.context.PerpsController.getPositions().then(function(ps){var p=ps.find(function(x){return x.symbol==='ETH'});if(!p)return JSON.stringify({cleared:true});return Engine.context.PerpsController.closePosition({symbol:'ETH'}).then(function(){return JSON.stringify({cleared:true})})})",
- "assert": { "operator": "eq", "field": "cleared", "value": true },
- "next": "open-long-eth"
- },
- "open-long-eth": {
- "action": "call",
- "ref": "trade-open-market-controller",
- "params": { "symbol": "ETH", "side": "long", "usdAmount": "10" },
- "next": "wait-position"
- },
- "wait-position": {
- "action": "wait_for",
- "expression": "Engine.context.PerpsController.getPositions().then(function(ps){var p=ps.find(function(x){return x.symbol==='ETH'});return JSON.stringify({found:!!p})})",
- "assert": { "operator": "eq", "field": "found", "value": true },
- "timeout_ms": 15000,
- "next": "create-sl"
- },
- "create-sl": {
- "action": "call",
- "ref": "tpsl-create",
- "params": { "symbol": "ETH", "tpPreset": "50", "slPreset": "-25" },
- "next": "verify-sl"
- },
- "verify-sl": {
- "action": "wait_for",
- "expression": "Engine.context.PerpsController.getPositions().then(function(ps){var p=ps.find(function(x){return x.symbol==='ETH'});return JSON.stringify({hasSl:!!(p&&p.stopLossPrice),slPrice:p?p.stopLossPrice:null})})",
- "assert": { "operator": "eq", "field": "hasSl", "value": true },
- "timeout_ms": 10000,
- "next": "screenshot-sl-set"
- },
- "screenshot-sl-set": {
- "action": "screenshot",
- "filename": "evidence-stop-loss-set.png",
- "note": "ETH long position with stopLossPrice attached via tpsl-create — SL trigger live (price-based liquidation requires E2E mock)",
- "next": "cleanup"
- },
- "cleanup": {
- "action": "call",
- "ref": "trade-close-position",
- "params": { "symbol": "ETH" },
- "next": "done"
- },
- "done": {
- "action": "end",
- "status": "pass"
- }
- },
- "teardown": [
- {
- "id": "teardown-close-eth",
- "action": "eval_async",
- "expression": "Engine.context.PerpsController.getPositions().then(function(ps){var p=ps.find(function(x){return x.symbol==='ETH'});if(!p)return JSON.stringify({clean:true});return Engine.context.PerpsController.closePosition({symbol:'ETH'}).then(function(){return JSON.stringify({clean:true})})})",
- "assert": { "operator": "not_null" }
- },
- { "id": "teardown-nav-home", "action": "navigate", "target": "PerpsHomeView" }
- ]
- }
- }
-}
diff --git a/scripts/perps/agentic/teams/perps/recipes/benchmark/perps-position.json b/scripts/perps/agentic/teams/perps/recipes/benchmark/perps-position.json
deleted file mode 100644
index 39e71ed25f7a..000000000000
--- a/scripts/perps/agentic/teams/perps/recipes/benchmark/perps-position.json
+++ /dev/null
@@ -1,91 +0,0 @@
-{
- "title": "Benchmark: Perps Position — open long ETH with TP, close position",
- "schema_version": 1,
- "description": "Mirrors smoke/perps/perps-position.spec.ts. Opens a long ETH position, sets TP/SL, then closes it. Detox spec sets custom TP during order; recipe uses tpsl-create flow after position open (equivalent coverage, reuses existing flow).",
- "validate": {
- "workflow": {
- "pre_conditions": ["wallet.unlocked", "perps.ready_to_trade", "perps.sufficient_balance"],
- "setup": [
- { "id": "setup-nav-home", "action": "navigate", "target": "PerpsHomeView" }
- ],
- "entry": "ensure-testnet",
- "nodes": {
- "ensure-testnet": {
- "action": "call",
- "ref": "setup-testnet",
- "next": "clear-eth-position"
- },
- "clear-eth-position": {
- "action": "eval_async",
- "expression": "Engine.context.PerpsController.getPositions().then(function(ps){var p=ps.find(function(x){return x.symbol==='ETH'});if(!p)return JSON.stringify({cleared:true});return Engine.context.PerpsController.closePosition({symbol:'ETH'}).then(function(){return JSON.stringify({cleared:true})})})",
- "assert": { "operator": "eq", "field": "cleared", "value": true },
- "next": "open-long-eth"
- },
- "open-long-eth": {
- "action": "call",
- "ref": "trade-open-market-controller",
- "params": { "symbol": "ETH", "side": "long", "usdAmount": "10" },
- "next": "wait-position"
- },
- "wait-position": {
- "action": "wait_for",
- "expression": "Engine.context.PerpsController.getPositions().then(function(ps){var p=ps.find(function(x){return x.symbol==='ETH'});return JSON.stringify({found:!!p,side:p?p.side:null})})",
- "assert": { "operator": "eq", "field": "found", "value": true },
- "timeout_ms": 30000,
- "next": "screenshot-position-open"
- },
- "screenshot-position-open": {
- "action": "screenshot",
- "filename": "evidence-position-open.png",
- "note": "ETH long market position opened — appears in PerpsController.getPositions before TP/SL is attached",
- "next": "create-tpsl"
- },
- "create-tpsl": {
- "action": "call",
- "ref": "tpsl-create",
- "params": { "symbol": "ETH", "tpPreset": "25", "slPreset": "-10" },
- "next": "verify-tpsl"
- },
- "verify-tpsl": {
- "action": "wait_for",
- "expression": "Engine.context.PerpsController.getPositions().then(function(ps){var p=ps.find(function(x){return x.symbol==='ETH'});return JSON.stringify({hasTp:!!(p&&p.takeProfitPrice),hasSl:!!(p&&p.stopLossPrice)})})",
- "assert": { "operator": "eq", "field": "hasTp", "value": true },
- "timeout_ms": 10000,
- "next": "close-position"
- },
- "close-position": {
- "action": "call",
- "ref": "trade-close-position",
- "params": { "symbol": "ETH" },
- "next": "verify-closed"
- },
- "verify-closed": {
- "action": "wait_for",
- "expression": "Engine.context.PerpsController.getPositions().then(function(ps){var p=ps.find(function(x){return x.symbol==='ETH'});return JSON.stringify({found:!!p})})",
- "assert": { "operator": "eq", "field": "found", "value": false },
- "timeout_ms": 10000,
- "next": "screenshot-closed"
- },
- "screenshot-closed": {
- "action": "screenshot",
- "filename": "evidence-position-closed.png",
- "note": "ETH position closed — getPositions no longer returns ETH after trade-close-position completes",
- "next": "done"
- },
- "done": {
- "action": "end",
- "status": "pass"
- }
- },
- "teardown": [
- {
- "id": "teardown-close-eth",
- "action": "eval_async",
- "expression": "Engine.context.PerpsController.getPositions().then(function(ps){var p=ps.find(function(x){return x.symbol==='ETH'});if(!p)return JSON.stringify({clean:true});return Engine.context.PerpsController.closePosition({symbol:'ETH'}).then(function(){return JSON.stringify({clean:true})})})",
- "assert": { "operator": "not_null" }
- },
- { "id": "teardown-nav-home", "action": "navigate", "target": "PerpsHomeView" }
- ]
- }
- }
-}
diff --git a/scripts/perps/agentic/teams/perps/recipes/capabilities/performance-metrics-smoke.json b/scripts/perps/agentic/teams/perps/recipes/capabilities/performance-metrics-smoke.json
deleted file mode 100644
index bed42dd6c1fc..000000000000
--- a/scripts/perps/agentic/teams/perps/recipes/capabilities/performance-metrics-smoke.json
+++ /dev/null
@@ -1,83 +0,0 @@
-{
- "title": "Capability: Hermes performance metrics snapshot",
- "description": "Proof recipe for mobile performance metrics capture. Captures global.performance.now() + HermesInternal.getInstrumentedStats() before and after a bounded navigation workload via eval_sync. No new runner primitive required — this validates the existing eval_sync can serve as the mobile analog to extension's 'performance' capability.",
- "validate": {
- "workflow": {
- "pre_conditions": ["wallet.unlocked", "perps.feature_enabled"],
- "setup": [
- {
- "id": "setup-complete-tutorial",
- "action": "eval_sync",
- "expression": "(function(){try{Engine.context.PerpsController.markTutorialCompleted()}catch(e){}return JSON.stringify({done:true})})()",
- "assert": { "operator": "eq", "field": "done", "value": true }
- }
- ],
- "entry": "ensure-testnet",
- "nodes": {
- "ensure-testnet": {
- "action": "call",
- "ref": "setup-testnet",
- "next": "nav-perps"
- },
- "nav-perps": {
- "action": "navigate",
- "target": "PerpsHomeView",
- "next": "wait-markets"
- },
- "wait-markets": {
- "action": "wait_for",
- "test_id": "perps-market-row-item-ETH",
- "timeout_ms": 20000,
- "next": "snapshot-before"
- },
- "snapshot-before": {
- "action": "eval_sync",
- "expression": "(function(){var hasHermes=typeof HermesInternal==='object'&&HermesInternal!==null;var stats=hasHermes&&typeof HermesInternal.getInstrumentedStats==='function'?HermesInternal.getInstrumentedStats():null;var statKeys=stats?Object.keys(stats):[];return JSON.stringify({ts:typeof performance!=='undefined'&&performance.now?performance.now():Date.now(),hasHermes:hasHermes,statCount:statKeys.length,sampleStatKeys:statKeys.slice(0,5),jsNumGCs:stats?stats.js_numGCs||null:null,jsHeapSize:stats?stats.js_allocatedBytes||stats.js_heapSize||null:null})})()",
- "assert": {
- "all": [
- { "operator": "eq", "field": "hasHermes", "value": true },
- { "operator": "gt", "field": "statCount", "value": 0 },
- { "operator": "gt", "field": "ts", "value": 0 }
- ]
- },
- "next": "nav-market-detail"
- },
- "nav-market-detail": {
- "action": "press",
- "test_id": "perps-market-row-item-ETH",
- "next": "wait-detail"
- },
- "wait-detail": {
- "action": "wait_for",
- "route": "PerpsMarketDetails",
- "timeout_ms": 15000,
- "next": "snapshot-after"
- },
- "snapshot-after": {
- "action": "eval_sync",
- "expression": "(function(){var hasHermes=typeof HermesInternal==='object'&&HermesInternal!==null;var stats=hasHermes&&typeof HermesInternal.getInstrumentedStats==='function'?HermesInternal.getInstrumentedStats():null;var statKeys=stats?Object.keys(stats):[];return JSON.stringify({ts:typeof performance!=='undefined'&&performance.now?performance.now():Date.now(),hasHermes:hasHermes,statCount:statKeys.length,sampleStatKeys:statKeys.slice(0,5),jsNumGCs:stats?stats.js_numGCs||null:null,jsHeapSize:stats?stats.js_allocatedBytes||stats.js_heapSize||null:null})})()",
- "assert": {
- "all": [
- { "operator": "eq", "field": "hasHermes", "value": true },
- { "operator": "gt", "field": "statCount", "value": 0 },
- { "operator": "gt", "field": "ts", "value": 0 }
- ]
- },
- "next": "done"
- },
- "done": {
- "action": "end",
- "status": "pass",
- "message": "Mobile performance metrics snapshot proven via Hermes getInstrumentedStats + performance.now"
- }
- },
- "teardown": [
- {
- "id": "teardown-nav-home",
- "action": "navigate",
- "target": "PerpsHomeView"
- }
- ]
- }
- }
-}
diff --git a/scripts/perps/agentic/teams/perps/recipes/capabilities/profiler-trace-smoke.json b/scripts/perps/agentic/teams/perps/recipes/capabilities/profiler-trace-smoke.json
deleted file mode 100644
index 7761ed741132..000000000000
--- a/scripts/perps/agentic/teams/perps/recipes/capabilities/profiler-trace-smoke.json
+++ /dev/null
@@ -1,77 +0,0 @@
-{
- "title": "Capability: Hermes sampling profiler trace",
- "description": "Proof recipe for mobile trace capture. Exercises trace_start -> bounded navigation workload -> trace_stop, asserting the Chrome-compatible .cpuprofile artifact is written and non-empty. This is the mobile mirror of extension's trace_start/trace_stop capability, backed by the Hermes Profiler domain over CDP. Artifact path is under artifacts.tracesDir (see ensureRunArtifacts in validate-recipe.js).",
- "validate": {
- "workflow": {
- "pre_conditions": ["wallet.unlocked", "perps.feature_enabled"],
- "setup": [
- {
- "id": "setup-complete-tutorial",
- "action": "eval_sync",
- "expression": "(function(){try{Engine.context.PerpsController.markTutorialCompleted()}catch(e){}return JSON.stringify({done:true})})()",
- "assert": { "operator": "eq", "field": "done", "value": true }
- }
- ],
- "entry": "ensure-testnet",
- "nodes": {
- "ensure-testnet": {
- "action": "call",
- "ref": "setup-testnet",
- "next": "nav-perps"
- },
- "nav-perps": {
- "action": "navigate",
- "target": "PerpsHomeView",
- "next": "wait-markets"
- },
- "wait-markets": {
- "action": "wait_for",
- "test_id": "perps-market-row-item-ETH",
- "timeout_ms": 20000,
- "next": "start-trace"
- },
- "start-trace": {
- "action": "trace_start",
- "label": "perps-nav-smoke",
- "assert": { "operator": "eq", "field": "started", "value": true },
- "next": "press-market"
- },
- "press-market": {
- "action": "press",
- "test_id": "perps-market-row-item-ETH",
- "next": "wait-detail"
- },
- "wait-detail": {
- "action": "wait_for",
- "route": "PerpsMarketDetails",
- "timeout_ms": 15000,
- "next": "stop-trace"
- },
- "stop-trace": {
- "action": "trace_stop",
- "label": "perps-nav-smoke",
- "assert": {
- "all": [
- { "operator": "eq", "field": "ok", "value": true },
- { "operator": "gt", "field": "sizeBytes", "value": 0 },
- { "operator": "not_null", "field": "path" }
- ]
- },
- "next": "done"
- },
- "done": {
- "action": "end",
- "status": "pass",
- "message": "Mobile Hermes sampling profiler trace captured as Chrome-compatible .cpuprofile"
- }
- },
- "teardown": [
- {
- "id": "teardown-nav-home",
- "action": "navigate",
- "target": "PerpsHomeView"
- }
- ]
- }
- }
-}
diff --git a/scripts/perps/agentic/teams/perps/recipes/capabilities/recipe-issues-smoke.json b/scripts/perps/agentic/teams/perps/recipes/capabilities/recipe-issues-smoke.json
deleted file mode 100644
index f3f26f256e5c..000000000000
--- a/scripts/perps/agentic/teams/perps/recipes/capabilities/recipe-issues-smoke.json
+++ /dev/null
@@ -1,40 +0,0 @@
-{
- "title": "Capability: Recipe issue-review automatic capture",
- "description": "Proof recipe for the automatic recipe-issues feature. Emits exactly one warning, one error, and one uncaught exception via the in-app console hook; the runner must capture all three into the synthesized recipe-issues-review artifacts without any log_watch node wired by the recipe. Marker strings are deliberately unique so the validation wrapper can grep them back out.",
- "validate": {
- "workflow": {
- "pre_conditions": ["wallet.unlocked"],
- "entry": "emit-warning",
- "nodes": {
- "emit-warning": {
- "action": "eval_sync",
- "expression": "(function(){console.warn('recipe-watcher-mobile-warning MARKER_WARN_42');return JSON.stringify({emitted:'warn'})})()",
- "assert": { "operator": "eq", "field": "emitted", "value": "warn" },
- "next": "emit-error"
- },
- "emit-error": {
- "action": "eval_sync",
- "expression": "(function(){console.error('recipe-watcher-mobile-error MARKER_ERR_42');return JSON.stringify({emitted:'error'})})()",
- "assert": { "operator": "eq", "field": "emitted", "value": "error" },
- "next": "emit-exception"
- },
- "emit-exception": {
- "action": "eval_async",
- "expression": "new Promise(function(resolve){setTimeout(function(){try{throw new Error('recipe-watcher-mobile-exception MARKER_EXN_42')}catch(e){if(typeof ErrorUtils!=='undefined'&&ErrorUtils.reportFatalError){try{ErrorUtils.reportFatalError(e)}catch(_){}}else if(typeof globalThis!=='undefined'&&typeof globalThis.dispatchEvent==='function'){try{var ev=new Event('error');ev.error=e;ev.message=e.message;globalThis.dispatchEvent(ev)}catch(_){}}if(typeof globalThis!=='undefined'&&Array.isArray(globalThis.__AGENTIC_ISSUES__)){globalThis.__AGENTIC_ISSUES__.push({t:Date.now(),level:'exception',text:String(e.stack||e.message||e)})}}setTimeout(function(){resolve(JSON.stringify({emitted:'exception'}))},200)},0)})",
- "assert": { "operator": "eq", "field": "emitted", "value": "exception" },
- "next": "settle"
- },
- "settle": {
- "action": "wait",
- "duration_ms": 500,
- "next": "done"
- },
- "done": {
- "action": "end",
- "status": "pass",
- "message": "Emitted 3 marker signals; runner must synthesize recipe-issues-review artifacts"
- }
- }
- }
- }
-}
diff --git a/scripts/perps/agentic/teams/perps/recipes/full-trade-lifecycle.json b/scripts/perps/agentic/teams/perps/recipes/full-trade-lifecycle.json
deleted file mode 100644
index 1af13f081457..000000000000
--- a/scripts/perps/agentic/teams/perps/recipes/full-trade-lifecycle.json
+++ /dev/null
@@ -1,129 +0,0 @@
-{
- "title": "Full BTC trade lifecycle — mainnet start, testnet switch, open, TP/SL, close",
- "description": "Starts from wallet home on mainnet, navigates to Perps, switches to testnet, then chains 4 flows: market open, TP/SL creation, position close. Proves full end-to-end composability.",
- "validate": {
- "workflow": {
- "entry": "nav-wallet-home",
- "nodes": {
- "nav-wallet-home": {
- "action": "navigate",
- "target": "WalletView",
- "next": "switch-mainnet"
- },
- "switch-mainnet": {
- "action": "toggle_testnet",
- "enabled": false,
- "next": "wait-mainnet"
- },
- "wait-mainnet": {
- "action": "wait_for",
- "expression": "JSON.stringify({isTestnet:Engine.context.PerpsController.state.isTestnet})",
- "assert": {
- "operator": "eq",
- "field": "isTestnet",
- "value": false
- },
- "next": "nav-perps-home"
- },
- "nav-perps-home": {
- "action": "navigate",
- "target": "PerpsHomeView",
- "next": "wait-perps-home"
- },
- "wait-perps-home": {
- "action": "wait_for",
- "route": "PerpsMarketListView",
- "next": "ensure-testnet"
- },
- "ensure-testnet": {
- "action": "call",
- "ref": "setup-testnet",
- "next": "verify-provider"
- },
- "verify-provider": {
- "action": "eval_ref",
- "ref": "providers",
- "assert": {
- "operator": "contains",
- "value": "hyperliquid"
- },
- "next": "clear-btc-position"
- },
- "clear-btc-position": {
- "action": "eval_async",
- "expression": "Engine.context.PerpsController.getPositions().then(function(ps){var p=ps.find(function(x){return x.symbol==='BTC'});if(!p)return JSON.stringify({cleared:true});return Engine.context.PerpsController.closePosition({symbol:'BTC'}).then(function(){return JSON.stringify({cleared:true})})})",
- "assert": {
- "operator": "eq",
- "field": "cleared",
- "value": true
- },
- "next": "wait-no-btc"
- },
- "wait-no-btc": {
- "action": "wait_for",
- "expression": "Engine.context.PerpsController.getPositions().then(function(ps){var p=ps.find(function(x){return x.symbol==='BTC'});return JSON.stringify({found:!!p})})",
- "assert": {
- "operator": "eq",
- "field": "found",
- "value": false
- },
- "timeout_ms": 10000,
- "next": "open-long-btc"
- },
- "open-long-btc": {
- "action": "call",
- "ref": "trade-open-market",
- "params": {
- "symbol": "BTC",
- "side": "long",
- "usdAmount": "10",
- "leverage": "2"
- },
- "next": "wait-position"
- },
- "wait-position": {
- "action": "wait_for",
- "expression": "Engine.context.PerpsController.getPositions().then(function(ps){var p=ps.find(function(x){return x.symbol==='BTC'});return JSON.stringify({found:!!p,side:p?p.side:null})})",
- "assert": {
- "operator": "eq",
- "field": "found",
- "value": true
- },
- "timeout_ms": 10000,
- "next": "create-tpsl"
- },
- "create-tpsl": {
- "action": "call",
- "ref": "tpsl-create",
- "params": {
- "symbol": "BTC"
- },
- "next": "close-position"
- },
- "close-position": {
- "action": "call",
- "ref": "trade-close-position",
- "params": {
- "symbol": "BTC"
- },
- "next": "wait-closed"
- },
- "wait-closed": {
- "action": "wait_for",
- "expression": "Engine.context.PerpsController.getPositions().then(function(ps){var p=ps.find(function(x){return x.symbol==='BTC'});return JSON.stringify({found:!!p})})",
- "assert": {
- "operator": "eq",
- "field": "found",
- "value": false
- },
- "timeout_ms": 10000,
- "next": "done"
- },
- "done": {
- "action": "end",
- "status": "pass"
- }
- }
- }
- }
-}
diff --git a/scripts/perps/agentic/teams/perps/recipes/hl-balance-contract.json b/scripts/perps/agentic/teams/perps/recipes/hl-balance-contract.json
deleted file mode 100644
index dd0d67325320..000000000000
--- a/scripts/perps/agentic/teams/perps/recipes/hl-balance-contract.json
+++ /dev/null
@@ -1,375 +0,0 @@
-{
- "pr": "29303",
- "schema_version": 1,
- "title": "TAT-3047 validation — single-account state machine (parametric)",
- "jira": "TAT-3047",
- "description": "Single-account validation. Reviewer picks the fixture via `--input address=0x...` (default Trading). Recipe entry probes for open positions/orders and routes to one of two paths: Path A (clean account) runs the full Unified ↔ Standard mode-flip state machine to prove the mode-aware fold gate; Path B (positions present, e.g. Trading with HIP-3 hold) skips mode flips (HL rejects them while positions exist) and exercises the spot-fold math under non-trivial spot.hold. Manual reproduction steps map each state to an HL-web action: docs/perps/perps-account-abstraction-and-balance-contract.md#manual-reproduction. Recovery: if Path A crashes mid-mode-flip, the account is left in Standard — restore via `hl-provision-fixture abstraction=unifiedAccount transferDirection=none` standalone or by toggling 'Disable Unified Account Mode' off in HL web settings.",
- "acceptance_criteria": [
- "AC1: Three-field contract present, no legacy keys, on the runtime-selected account.",
- "AC2: Controller balances reconcile against raw HL REST under both Unified and (Path A only) Standard modes.",
- "AC3: PerpsWithdrawView renders folded withdrawableBalance (not \\$0) when account is Unified spot-funded.",
- "AC4: PerpsMarketListView header renders spendableBalance.",
- "AC5: useWithdrawValidation enables Continue when typed amount ≤ withdrawable.",
- "AC6: Over-amount strictly exceeds withdrawableBalance (field contract supplies real data to validation hook).",
- "AC7 (Path A): hyperLiquidModeFoldsSpot gate keeps spendable/withdrawable perps-only on Standard mode."
- ],
- "inputs": {
- "address": {
- "type": "string",
- "default": "0x316BDE155acd07609872a56Bc32CcfB0B13201fA",
- "description": "EVM address to validate against. Default: Trading. Pass via `--input address=0x...` to switch fixtures."
- }
- },
- "initial_conditions": {
- "testnet": false
- },
- "validate": {
- "workflow": {
- "pre_conditions": ["wallet.unlocked", "perps.feature_enabled"],
- "entry": "gate-check-route",
- "nodes": {
- "gate-check-route": {
- "description": "Capture entry route; if inside Perps views, navigate home first so the recipe runs from a clean baseline.",
- "action": "eval_sync",
- "expression": "JSON.stringify({ currentRoute: (function(){ try { var r = __AGENTIC__.getRoute(); return r ? r.name : 'unknown'; } catch(e) { return 'unknown'; } })() })",
- "assert": { "operator": "neq", "field": "currentRoute", "value": "unknown" },
- "next": "gate-route-switch"
- },
- "gate-route-switch": {
- "action": "switch",
- "cases": [
- { "when": { "operator": "eq", "field": "last.currentRoute", "value": "PerpsWithdraw" }, "next": "go-home" },
- { "when": { "operator": "eq", "field": "last.currentRoute", "value": "PerpsMarketListView" }, "next": "go-home" },
- { "when": { "operator": "eq", "field": "last.currentRoute", "value": "PerpsHomeView" }, "next": "go-home" }
- ],
- "default": "select-account"
- },
- "go-home": {
- "action": "navigate",
- "target": "WalletView",
- "next": "select-account"
- },
-
- "select-account": {
- "description": "Switch to the parametric address (defaults to Trading). Subsequent flow calls take the same address so a manual --input override drives the entire recipe.",
- "action": "select_account",
- "address": "{{address}}",
- "next": "wait-account"
- },
- "wait-account": {
- "action": "wait_for",
- "expression": "Engine.context.PerpsController.getAccountState().then(function(r){return JSON.stringify({ready:!!r,hasSpendable:typeof r.spendableBalance==='string'})}).catch(function(e){return JSON.stringify({ready:false,error:String(e)})})",
- "assert": {
- "all": [
- { "operator": "eq", "field": "ready", "value": true },
- { "operator": "eq", "field": "hasSpendable", "value": true }
- ]
- },
- "timeout_ms": 30000,
- "next": "preflight-probe"
- },
- "preflight-probe": {
- "description": "Classify account into Path A (clean — full mode-flip matrix) or Path B (positions/orders present — limited matrix). HL rejects userSetAbstraction when positions/orders/TWAPs exist, so the path choice is forced by venue state, not preference.",
- "action": "eval_async",
- "expression": "(function(){var addr='{{address}}'.toLowerCase();var base='https://api.hyperliquid.xyz/info';return Promise.all([fetch(base,{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({type:'clearinghouseState',user:addr})}).then(function(r){return r.json()}),fetch(base,{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({type:'openOrders',user:addr})}).then(function(r){return r.json()}),fetch(base,{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({type:'spotClearinghouseState',user:addr})}).then(function(r){return r.json()}),fetch(base,{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({type:'userAbstraction',user:addr})}).then(function(r){return r.json()})]).then(function(res){var perps=res[0]||{};var orders=res[1]||[];var spot=res[2]||{};var mode=res[3];var positions=(perps.assetPositions||[]).length;var pendingOrders=Array.isArray(orders)?orders.length:0;var usdc=((spot.balances)||[]).find(function(b){return b.coin==='USDC'})||{};var perpsWithdrawable=parseFloat(perps.withdrawable||'0');var spotTotal=parseFloat(usdc.total||'0');var spotHold=parseFloat(usdc.hold||'0');var freeSpot=Math.max(0,spotTotal-spotHold);var totalUsdc=perpsWithdrawable+freeSpot;return JSON.stringify({address:addr,pathA:positions===0&&pendingOrders===0,positions:positions,pendingOrders:pendingOrders,mode:mode,perpsWithdrawable:perpsWithdrawable,spotTotal:spotTotal,spotHold:spotHold,freeSpot:freeSpot,totalUsdc:totalUsdc,hasFunds:totalUsdc>=1})})})()",
- "assert": {
- "all": [
- { "operator": "not_null", "field": "address" },
- { "operator": "eq", "field": "hasFunds", "value": true }
- ]
- },
- "save_as": "preflight",
- "timeout_ms": 30000,
- "next": "shared-contract"
- },
-
- "shared-contract": {
- "description": "Mode-agnostic shape check — runs on both paths.",
- "action": "call",
- "ref": "perps/hl-balance-contract-check",
- "params": {
- "address": "{{address}}",
- "phaseLabel": "shared-entry"
- },
- "next": "shared-math"
- },
- "shared-math": {
- "description": "Mode-agnostic math check — runs on both paths. Uses controller subAccountBreakdown as perps truth + HL REST spot. Fold expectation = whatever the runtime mode dictates (Unified default).",
- "action": "call",
- "ref": "perps/hl-balance-math-check",
- "params": {
- "address": "{{address}}",
- "phaseLabel": "shared-entry"
- },
- "next": "shared-ui-checks"
- },
- "shared-ui-checks": {
- "description": "PerpsMarketListView + PerpsWithdrawView assertions + keypad valid/over checks. These run on both paths; on a spot-only Unified account they prove TAT-3047's primary fix (withdraw shows real number, not $0).",
- "action": "navigate",
- "target": "PerpsMarketListView",
- "next": "shared-wait-market-list"
- },
- "shared-wait-market-list": {
- "action": "wait_for",
- "expression": "JSON.stringify({mounted:!!(globalThis.__AGENTIC__&&__AGENTIC__.findFiberByTestId&&__AGENTIC__.findFiberByTestId('perps-market-available-balance-text'))})",
- "assert": { "operator": "eq", "field": "mounted", "value": true },
- "timeout_ms": 15000,
- "next": "shared-assert-market-list"
- },
- "shared-assert-market-list": {
- "description": "AC4: PerpsMarketBalanceActions header text matches accountState.spendableBalance.",
- "action": "eval_sync",
- "expression": "(function(){try{var display=__AGENTIC__.getTextByTestId('perps-market-available-balance-text')||'';var s=Engine.context.PerpsController.state.accountState||{};var spendable=parseFloat(s.spendableBalance||'0');var spendableFloor=Math.floor(spendable);var matches=display.length>0&&display.indexOf(String(spendableFloor))>=0;return JSON.stringify({display:display,spendable:spendable,displayMatchesSpendable:matches})}catch(e){return JSON.stringify({error:String(e)})}})()",
- "assert": { "operator": "eq", "field": "displayMatchesSpendable", "value": true },
- "next": "shared-screenshot-market-list"
- },
- "shared-screenshot-market-list": {
- "action": "screenshot",
- "filename": "shared-market-list.png",
- "note": "AC4: Market list header renders spendableBalance",
- "next": "shared-nav-withdraw"
- },
- "shared-nav-withdraw": {
- "action": "navigate",
- "target": "PerpsWithdraw",
- "next": "shared-wait-withdraw"
- },
- "shared-wait-withdraw": {
- "action": "wait_for",
- "expression": "JSON.stringify({mounted:!!(globalThis.__AGENTIC__&&__AGENTIC__.findFiberByTestId&&__AGENTIC__.findFiberByTestId('perps-withdraw-available-balance-text'))})",
- "assert": { "operator": "eq", "field": "mounted", "value": true },
- "timeout_ms": 15000,
- "next": "shared-assert-withdraw"
- },
- "shared-assert-withdraw": {
- "description": "AC3: PerpsWithdrawView renders accountState.withdrawableBalance, not $0.",
- "action": "eval_sync",
- "expression": "(function(){try{var display=__AGENTIC__.getTextByTestId('perps-withdraw-available-balance-text')||'';var s=Engine.context.PerpsController.state.accountState||{};var withdrawable=parseFloat(s.withdrawableBalance||'0');var withdrawableFloor=Math.floor(withdrawable);var matches=display.length>0&&display.indexOf(String(withdrawableFloor))>=0;var showsZero=display.indexOf('$0.00')>=0||display.indexOf('$0 ')>=0;return JSON.stringify({display:display,withdrawable:withdrawable,displayMatchesWithdrawable:matches,displayShowsZero:showsZero})}catch(e){return JSON.stringify({error:String(e)})}})()",
- "assert": {
- "all": [
- { "operator": "eq", "field": "displayMatchesWithdrawable", "value": true },
- { "operator": "eq", "field": "displayShowsZero", "value": false }
- ]
- },
- "next": "shared-screenshot-withdraw"
- },
- "shared-screenshot-withdraw": {
- "action": "screenshot",
- "filename": "shared-withdraw-folded.png",
- "note": "AC3: Withdraw view shows withdrawableBalance, never $0 (TAT-3047 fix)",
- "next": "shared-valid-clear"
- },
- "shared-valid-clear": {
- "description": "AC5: typing $1 leaves Continue button enabled (validation hook says input ≤ withdrawable).",
- "action": "clear_keypad",
- "count": 12,
- "next": "shared-valid-digits"
- },
- "shared-valid-digits": {
- "action": "type_keypad",
- "value": "1",
- "next": "shared-assert-valid"
- },
- "shared-assert-valid": {
- "action": "wait_for",
- "expression": "(function(){try{var fiber=__AGENTIC__.findFiberByTestId('continue-button');var props=fiber&&fiber.memoizedProps;return JSON.stringify({buttonMounted:!!fiber,disabled:!!(props&&props.disabled)})}catch(e){return JSON.stringify({error:String(e)})}})()",
- "assert": {
- "all": [
- { "operator": "eq", "field": "buttonMounted", "value": true },
- { "operator": "eq", "field": "disabled", "value": false }
- ]
- },
- "timeout_ms": 3000,
- "next": "shared-over-clear"
- },
- "shared-over-clear": {
- "description": "AC6: $99999 strictly exceeds withdrawable on any reasonable fixture; recipe asserts contract supplies the real data.",
- "action": "clear_keypad",
- "count": 12,
- "next": "shared-over-digits"
- },
- "shared-over-digits": {
- "action": "type_keypad",
- "value": "99999",
- "next": "shared-assert-over"
- },
- "shared-assert-over": {
- "action": "eval_sync",
- "expression": "(function(){try{var s=Engine.context.PerpsController.state.accountState||{};var w=parseFloat(s.withdrawableBalance||'0');return JSON.stringify({withdrawable:w,overAmount:99999,overExceedsWithdrawable:99999>w})}catch(e){return JSON.stringify({error:String(e)})}})()",
- "assert": {
- "all": [
- { "operator": "gt", "field": "withdrawable", "value": 0 },
- { "operator": "eq", "field": "overExceedsWithdrawable", "value": true }
- ]
- },
- "next": "go-home-before-paths"
- },
-
- "go-home-before-paths": {
- "action": "navigate",
- "target": "WalletView",
- "next": "path-switch"
- },
-
- "path-switch": {
- "description": "Route to Path A (full mode-flip) or Path B (positions present — perps↔spot transfers only).",
- "action": "switch",
- "cases": [
- { "when": { "operator": "eq", "field": "vars.preflight.pathA", "value": true }, "next": "pathA-flip-standard", "label": "PATH A — clean account, full mode-flip matrix" },
- { "when": { "operator": "eq", "field": "vars.preflight.pathA", "value": false }, "next": "pathB-shift-spot", "label": "PATH B — positions present, transfer-only matrix" }
- ],
- "default": "done"
- },
-
- "pathA-flip-standard": {
- "description": "Path A — Unified → Standard. Equivalent to checking 'Disable Unified Account Mode' in HL web settings.",
- "action": "call",
- "ref": "perps/hl-provision-fixture",
- "params": {
- "abstraction": "disabled",
- "transferDirection": "none"
- },
- "next": "pathA-wait-standard"
- },
- "pathA-wait-standard": {
- "description": "Bite on HL-side propagation: poll userAbstraction REST until 'disabled'. Catches the userSetAbstraction-returned-ok-before-validators-propagate race.",
- "action": "wait_for",
- "expression": "(function(){var addr='{{address}}'.toLowerCase();return fetch('https://api.hyperliquid.xyz/info',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({type:'userAbstraction',user:addr})}).then(function(r){return r.json()}).then(function(mode){return JSON.stringify({mode:mode,isStandard:mode==='disabled'})}).catch(function(e){return JSON.stringify({mode:null,isStandard:false,error:String(e)})})})()",
- "assert": { "operator": "eq", "field": "isStandard", "value": true },
- "timeout_ms": 30000,
- "next": "pathA-contract-standard"
- },
- "pathA-contract-standard": {
- "description": "Contract shape stays mode-agnostic on Standard.",
- "action": "call",
- "ref": "perps/hl-balance-contract-check",
- "params": {
- "address": "{{address}}",
- "phaseLabel": "pathA-standard"
- },
- "next": "pathA-math-standard"
- },
- "pathA-math-standard": {
- "description": "Math in Standard with foldIntoCollateral=false: spendable/withdrawable must match Σ(breakdown) without spot fold.",
- "action": "call",
- "ref": "perps/hl-balance-math-check",
- "params": {
- "address": "{{address}}",
- "phaseLabel": "pathA-standard-no-fold",
- "foldIntoCollateral": false
- },
- "next": "pathA-fold-correctness"
- },
- "pathA-fold-correctness": {
- "description": "AC7: Standard-mode regression guard — quantify inflation vs Σ(breakdown) and assert ≤ ε. Inlined here because it's the only Standard-mode call site; math-check{foldIntoCollateral=false} above already proves the gate, this node turns it into a delta a reviewer can read in the trace.",
- "action": "eval_async",
- "expression": "(function(){var addr='{{address}}'.toLowerCase();var minSignal=0.5;var eps=0.05;return fetch('https://api.hyperliquid.xyz/info',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({type:'spotClearinghouseState',user:addr})}).then(function(r){return r.json()}).then(function(spot){var usdc=((spot&&spot.balances)||[]).find(function(b){return b.coin==='USDC'})||{};var t=parseFloat(usdc.total||'0');var h=parseFloat(usdc.hold||'0');var free=Math.max(0,t-h);var s=Engine.context.PerpsController.state.accountState||{};var breakdown=s.subAccountBreakdown||{};var perpsOnlySpendable=0;var perpsOnlyWithdrawable=0;Object.keys(breakdown).forEach(function(k){var e=breakdown[k]||{};perpsOnlySpendable+=parseFloat(e.spendableBalance||'0');perpsOnlyWithdrawable+=parseFloat(e.withdrawableBalance||'0')});var actualSpendable=parseFloat(s.spendableBalance||'0');var actualWithdrawable=parseFloat(s.withdrawableBalance||'0');var spendableInflation=actualSpendable-perpsOnlySpendable;var withdrawableInflation=actualWithdrawable-perpsOnlyWithdrawable;return JSON.stringify({phase:'pathA-standard-fold-quantified',spot:{total:t,hold:h,free:free},standardSemanticExpected:{spendable:perpsOnlySpendable,withdrawable:perpsOnlyWithdrawable},adapterActual:{spendable:actualSpendable,withdrawable:actualWithdrawable},observedInflation:{spendable:spendableInflation,withdrawable:withdrawableInflation},freeSpotMeaningful:free>=minSignal,spendableMatchesPerpsOnly:Math.abs(spendableInflation)<=eps,withdrawableMatchesPerpsOnly:Math.abs(withdrawableInflation)<=eps,standardModeCorrect:Math.abs(spendableInflation)<=eps&&Math.abs(withdrawableInflation)<=eps})})})()",
- "assert": {
- "all": [
- { "operator": "eq", "field": "freeSpotMeaningful", "value": true },
- { "operator": "eq", "field": "standardModeCorrect", "value": true }
- ]
- },
- "timeout_ms": 20000,
- "next": "pathA-restore-unified"
- },
- "pathA-restore-unified": {
- "description": "Path A teardown — flip back to Unified. If anything below this fails, account stays in Standard; recover per the recipe-level recovery note.",
- "action": "call",
- "ref": "perps/hl-provision-fixture",
- "params": {
- "abstraction": "unifiedAccount",
- "transferDirection": "none"
- },
- "next": "pathA-wait-restored"
- },
- "pathA-wait-restored": {
- "description": "Bite on HL-side propagation back to Unified before the post-restore contract check runs.",
- "action": "wait_for",
- "expression": "(function(){var addr='{{address}}'.toLowerCase();return fetch('https://api.hyperliquid.xyz/info',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({type:'userAbstraction',user:addr})}).then(function(r){return r.json()}).then(function(mode){return JSON.stringify({mode:mode,isUnified:mode==='unifiedAccount'})}).catch(function(e){return JSON.stringify({mode:null,isUnified:false,error:String(e)})})})()",
- "assert": { "operator": "eq", "field": "isUnified", "value": true },
- "timeout_ms": 30000,
- "next": "pathA-verify-restored"
- },
- "pathA-verify-restored": {
- "description": "Shape survives Unified → Standard → Unified round trip.",
- "action": "call",
- "ref": "perps/hl-balance-contract-check",
- "params": {
- "address": "{{address}}",
- "phaseLabel": "pathA-restored"
- },
- "next": "done"
- },
-
- "pathB-shift-spot": {
- "description": "Path B — exercise the spot-fold math under non-trivial spot.hold (e.g. Trading's HIP-3 margin lock). usdClassTransfer is allowed even with positions, so we shift any free perps margin to spot to grow the fold delta, then restore.",
- "action": "call",
- "ref": "perps/hl-provision-fixture",
- "params": {
- "abstraction": "none",
- "transferDirection": "to-spot",
- "transferAmount": "max"
- },
- "next": "pathB-contract-shifted"
- },
- "pathB-contract-shifted": {
- "description": "Shape after spot shift — should be unchanged (mode hasn't flipped).",
- "action": "call",
- "ref": "perps/hl-balance-contract-check",
- "params": {
- "address": "{{address}}",
- "phaseLabel": "pathB-spot-shifted"
- },
- "next": "pathB-math-shifted"
- },
- "pathB-math-shifted": {
- "description": "Math under shifted spot. spot.hold from positions remains intact; controller total must not double-count it (regression vector).",
- "action": "call",
- "ref": "perps/hl-balance-math-check",
- "params": {
- "address": "{{address}}",
- "phaseLabel": "pathB-spot-shifted"
- },
- "next": "pathB-restore"
- },
- "pathB-restore": {
- "description": "Restore — pull free spot back to perps. Positions stay untouched.",
- "action": "call",
- "ref": "perps/hl-provision-fixture",
- "params": {
- "abstraction": "none",
- "transferDirection": "to-perp",
- "transferAmount": "max"
- },
- "next": "done"
- },
-
- "done": { "action": "end", "status": "pass" }
- },
- "setup": [
- {
- "id": "setup-ensure-mainnet",
- "description": "Recipe targets HyperLiquid mainnet (real fixtures, real userAbstraction). Force isTestnet=false so a stale testnet toggle from a prior run can't poison the validation.",
- "action": "toggle_testnet",
- "enabled": false
- },
- {
- "id": "setup-wait-mainnet",
- "description": "Bite on the controller flipping back to mainnet before the graph runs.",
- "action": "wait_for",
- "expression": "JSON.stringify({ isTestnet: Engine.context.PerpsController.state.isTestnet })",
- "assert": { "operator": "eq", "field": "isTestnet", "value": false },
- "timeout_ms": 15000
- }
- ],
- "teardown": [
- {
- "id": "teardown-nav-home",
- "action": "navigate",
- "target": "WalletView"
- }
- ]
- }
- }
-}
diff --git a/scripts/perps/agentic/teams/perps/recipes/provider-smoke.json b/scripts/perps/agentic/teams/perps/recipes/provider-smoke.json
deleted file mode 100644
index ae939c4ce5da..000000000000
--- a/scripts/perps/agentic/teams/perps/recipes/provider-smoke.json
+++ /dev/null
@@ -1,59 +0,0 @@
-{
- "title": "Provider smoke — verify active provider and branch on testnet mode",
- "description": "Demonstrates switch branching: checks testnet state, branches to enable it if needed, then verifies providers are registered.",
- "validate": {
- "workflow": {
- "pre_conditions": ["wallet.unlocked", "perps.feature_enabled"],
- "entry": "check-testnet",
- "nodes": {
- "check-testnet": {
- "action": "eval_sync",
- "expression": "JSON.stringify({isTestnet: Engine.context.PerpsController.state.isTestnet})",
- "assert": { "operator": "not_null" },
- "save_as": "testnetState",
- "next": "decide-testnet"
- },
- "decide-testnet": {
- "action": "switch",
- "description": "Branch based on current testnet mode",
- "cases": [
- {
- "label": "already testnet",
- "when": { "operator": "eq", "field": "vars.testnetState.isTestnet", "value": true },
- "next": "verify-providers"
- }
- ],
- "default": "enable-testnet"
- },
- "enable-testnet": {
- "action": "toggle_testnet",
- "enabled": true,
- "next": "wait-testnet"
- },
- "wait-testnet": {
- "action": "wait_for",
- "expression": "JSON.stringify({isTestnet: Engine.context.PerpsController.state.isTestnet})",
- "assert": { "operator": "eq", "field": "isTestnet", "value": true },
- "timeout_ms": 5000,
- "next": "verify-providers"
- },
- "verify-providers": {
- "action": "eval_ref",
- "ref": "providers",
- "assert": { "operator": "not_null" },
- "next": "verify-markets"
- },
- "verify-markets": {
- "action": "eval_ref",
- "ref": "markets",
- "assert": { "operator": "length_gt", "value": 0 },
- "next": "done"
- },
- "done": {
- "action": "end",
- "status": "pass"
- }
- }
- }
- }
-}
diff --git a/scripts/perps/agentic/teams/perps/recipes/reference-decimal-key-screens.json b/scripts/perps/agentic/teams/perps/recipes/reference-decimal-key-screens.json
deleted file mode 100644
index 25a28739c3b0..000000000000
--- a/scripts/perps/agentic/teams/perps/recipes/reference-decimal-key-screens.json
+++ /dev/null
@@ -1,638 +0,0 @@
-{
- "title": "Reference decimal key screens",
- "description": "Canonical reusable value-extraction recipe for perps decimal formatting across the main mobile key screens. Uses tracked generic flows for setup/trade state and keeps screen-specific extraction inline here instead of adding more one-off committed flows.",
- "validate": {
- "workflow": {
- "pre_conditions": ["wallet.unlocked", "perps.feature_enabled"],
- "setup": [
- {
- "id": "setup-account",
- "action": "call",
- "ref": "perps/select-account",
- "params": {
- "address": "0x8dc623e964475d4d669da601fd15ea9125469003"
- }
- },
- {
- "id": "setup-testnet",
- "action": "call",
- "ref": "perps/setup-testnet"
- },
- {
- "id": "setup-clear-eth",
- "action": "eval_async",
- "expression": "Engine.context.PerpsController.getPositions().then(function(ps){var p=ps.find(function(x){return x.symbol==='ETH'});if(!p){return JSON.stringify({cleared:true});}return Engine.context.PerpsController.closePosition({symbol:'ETH'}).then(function(){return JSON.stringify({cleared:true});});})",
- "assert": {
- "operator": "eq",
- "field": "cleared",
- "value": true
- }
- },
- {
- "id": "setup-wait-no-eth",
- "action": "wait_for",
- "expression": "Engine.context.PerpsController.getPositions().then(function(ps){var p=ps.find(function(x){return x.symbol==='ETH'});return JSON.stringify({found:!!p});})",
- "assert": {
- "operator": "eq",
- "field": "found",
- "value": false
- },
- "timeout_ms": 10000
- }
- ],
- "entry": "btc-market-nav",
- "nodes": {
- "btc-market-nav": {
- "action": "navigate",
- "target": "PerpsMarketDetails",
- "params": {
- "market": {
- "symbol": "BTC",
- "name": "BTC",
- "price": "0",
- "change24h": "0",
- "change24hPercent": "0",
- "volume": "0",
- "maxLeverage": "100"
- }
- },
- "next": "btc-market-wait"
- },
- "btc-market-wait": {
- "action": "wait_for",
- "route": "PerpsMarketDetails",
- "next": "btc-market-extract"
- },
- "btc-market-extract": {
- "action": "eval_sync",
- "expression": "(function(){ function flat(v,out){ if(v===null||v===undefined)return; if(typeof v==='string'||typeof v==='number'){ var s=String(v).trim(); if(s) out.push(s); return; } if(Array.isArray(v)){ for(var i=0;i= 0 ? num : 0;
-}
-
-function requireFields(node, fields, issues) {
- fields.forEach((field) => {
- if (!Object.prototype.hasOwnProperty.call(node, field) || node[field] === '') {
- issues.push(` [${node.id || '?'}] action="${node.action}" requires "${field}"`);
- }
- });
-}
-
-function validateActionShape(node, issues, context = {}) {
- const { schemaVersion = 1, fileLabel = '?' } = context;
- switch (node.action) {
- case 'navigate':
- requireFields(node, ['target'], issues);
- break;
- case 'press':
- requireFields(node, ['test_id'], issues);
- break;
- case 'set_input':
- requireFields(node, ['test_id', 'value'], issues);
- break;
- case 'call':
- case 'eval_ref':
- requireFields(node, ['ref'], issues);
- break;
- case 'eval_sync':
- case 'eval_async':
- requireFields(node, ['expression'], issues);
- break;
- case 'type_keypad':
- requireFields(node, ['value'], issues);
- break;
- case 'select_account':
- requireFields(node, ['address'], issues);
- break;
- case 'switch_provider':
- requireFields(node, ['provider'], issues);
- break;
- case 'screenshot': {
- requireFields(node, ['filename'], issues);
- const note = typeof node.note === 'string' ? node.note.trim() : '';
- const noteOk = note.length >= 3;
- if (!noteOk) {
- if (schemaVersion >= 1) {
- issues.push(
- ` [${node.id || '?'}] action="screenshot" requires "note" (>= 3 chars) at schema_version ${schemaVersion}`
- );
- } else {
- console.warn(
- ` warning [${fileLabel}] [${node.id || '?'}] screenshot missing "note" — caption will fall back to filename. Add a note, then declare top-level "schema_version": ${CURRENT_SCHEMA_VERSION} to opt into strict validation.`
- );
- }
- }
- break;
- }
- case 'log_watch':
- if (!(node.watch_for?.length || node.must_not_appear?.length)) {
- issues.push(
- ` [${node.id || '?'}] action="log_watch" requires watch_for or must_not_appear`
- );
- }
- break;
- case 'wait_for':
- if (
- !('assert' in node) &&
- !('route' in node) &&
- !('not_route' in node) &&
- !('test_id' in node)
- ) {
- issues.push(
- ` [${node.id || '?'}] wait_for requires an assert block or route/test_id sugar`
- );
- }
- if (!node.route && !node.not_route && !node.test_id && !node.expression) {
- issues.push(` [${node.id || '?'}] action="wait_for" requires a condition`);
- }
- if ('timeout' in node && !('timeout_ms' in node)) {
- issues.push(
- ` [${node.id || '?'}] action="wait_for" uses unsupported field "timeout"; use "timeout_ms" instead`
- );
- }
- break;
- case 'switch':
- if (!Array.isArray(node.cases) || node.cases.length === 0) {
- issues.push(` [${node.id || '?'}] action="switch" requires at least one case`);
- }
- (node.cases || []).forEach((entry, index) => {
- if (!entry.when) {
- issues.push(` [${node.id || '?'}] switch case ${index + 1} requires "when"`);
- }
- if (!entry.next) {
- issues.push(` [${node.id || '?'}] switch case ${index + 1} requires "next"`);
- }
- });
- break;
- case 'end':
- if (node.status && !['pass', 'fail'].includes(String(node.status))) {
- issues.push(` [${node.id || '?'}] end status must be "pass" or "fail"`);
- }
- break;
- default:
- break;
- }
-}
-
-function collectUsedParams(value, usedParams) {
- if (typeof value === 'string') {
- const pattern = /\{\{([^|}]+)(?:\|[^}]*)?\}\}/g;
- let match;
- while ((match = pattern.exec(value)) !== null) {
- usedParams.add(match[1]);
- }
- return;
- }
-
- if (Array.isArray(value)) {
- value.forEach((item) => collectUsedParams(item, usedParams));
- return;
- }
-
- if (value && typeof value === 'object') {
- Object.values(value).forEach((item) => collectUsedParams(item, usedParams));
- }
-}
-
-function validatePreConditions(preConditions, registry, issues) {
- preConditions.forEach((spec) => {
- const parsed = parsePreConditionSpec(spec);
- const name = typeof parsed === 'string' ? parsed : parsed?.name;
- if (!name) {
- issues.push(` pre_condition entry has no name: ${JSON.stringify(spec)}`);
- return;
- }
-
- if (!registry[name]) {
- issues.push(` pre_condition "${name}" is not registered for this app`);
- }
- });
-}
-
-function validateReference(node, appRoot, defaultTeam, issues) {
- if (node.action === 'call' && node.ref) {
- try {
- resolveFlowRef(node.ref, { appRoot, defaultTeam });
- } catch (error) {
- issues.push(` [${node.id || '?'}] ${String(error.message || error)}`);
- }
- }
-
- if (node.action === 'eval_ref' && node.ref) {
- try {
- resolveEvalRef(node.ref, { appRoot, defaultTeam });
- } catch (error) {
- issues.push(` [${node.id || '?'}] ${String(error.message || error)}`);
- }
- }
-}
-
-function validateNodeCommon(node, issues, context = {}) {
- const action = node.action || '';
- const id = node.id || '?';
-
- if (!ALL_WORKFLOW_ACTIONS.has(action)) {
- issues.push(` [${id}] unknown action "${action}"`);
- return;
- }
-
- if (MUST_ASSERT.has(action) && !('assert' in node)) {
- issues.push(` [${id}] action="${action}" requires an assert block`);
- }
-
- if (node.save_as != null && String(node.save_as).trim() === '') {
- issues.push(` [${id}] save_as must be a non-empty string`);
- }
-
- validateActionShape(node, issues, context);
-}
-
-function validateHookSection(sectionName, steps, appRoot, defaultTeam, issues, seenIds, context = {}) {
- steps.forEach((step, index) => {
- const node = {
- ...step,
- action: String(step.action || step.type || ''),
- id: String(step.id || `${sectionName}-${index + 1}`),
- };
-
- if (!step.id) {
- issues.push(` [${node.id}] every step must define an id`);
- } else if (seenIds.has(node.id)) {
- issues.push(` [${node.id}] duplicate step id`);
- } else {
- seenIds.add(node.id);
- }
-
- if (!HOOK_ONLY_ACTIONS.has(node.action)) {
- issues.push(
- ` [${node.id}] ${sectionName} hooks only support executable actions, got "${node.action}"`
- );
- return;
- }
-
- validateNodeCommon(node, issues, context);
- validateReference(node, appRoot, defaultTeam, issues);
- });
-}
-
-function validateWorkflowNodes(normalizedDocument, appRoot, defaultTeam, issues, seenIds, context = {}) {
- const workflow = normalizedDocument.workflow;
-
- if (!workflow.entry) {
- issues.push(' validate.workflow.entry is required');
- return;
- }
-
- if (!workflow.nodes || Object.keys(workflow.nodes).length === 0) {
- issues.push(' validate.workflow.nodes must define at least one node');
- return;
- }
-
- if (!workflow.nodes[workflow.entry]) {
- issues.push(` validate.workflow.entry "${workflow.entry}" does not exist`);
- }
-
- let endNodeCount = 0;
-
- for (const [nodeId, node] of Object.entries(workflow.nodes)) {
- if (seenIds.has(nodeId)) {
- issues.push(` [${nodeId}] duplicate node id`);
- } else {
- seenIds.add(nodeId);
- }
-
- if (node.id !== nodeId) {
- issues.push(` [${nodeId}] node id must match its object key`);
- }
-
- if (CONTROL_ACTIONS.has(node.action) && (node.when || node.unless)) {
- issues.push(` [${nodeId}] control nodes cannot use when/unless guards`);
- }
-
- validateNodeCommon(node, issues, context);
- validateReference(node, appRoot, defaultTeam, issues);
-
- if (node.action === 'end') {
- endNodeCount += 1;
- continue;
- }
-
- if (node.action === 'switch') {
- continue;
- }
-
- if (!node.next) {
- issues.push(` [${nodeId}] action="${node.action}" requires "next"`);
- }
- }
-
- if (endNodeCount === 0) {
- issues.push(' validate.workflow must define at least one end node');
- }
-
- findMissingTargets(workflow).forEach((edge) => {
- issues.push(` [${edge.from}] transition targets missing node "${edge.to}"`);
- });
-
- findUnreachableNodes(workflow).forEach((nodeId) => {
- issues.push(` [${nodeId}] unreachable node`);
- });
-
- detectWorkflowCycles(workflow).forEach((cycle) => {
- issues.push(` cycle detected: ${cycle.join(' -> ')}`);
- });
-}
-
-function validateScenario(filePath, registry) {
- const appRoot = getAppRoot();
- const issues = [];
- let document;
-
- try {
- document = readJsonFile(filePath);
- } catch (error) {
- return [` parse error: ${error.message}`];
- }
-
- const defaultTeam = inferTeamFromPath(filePath, appRoot);
- let normalizedDocument;
-
- try {
- normalizedDocument = normalizeWorkflowDocument(document, {
- sourcePath: filePath,
- });
- } catch (error) {
- return [` ${String(error.message || error)}`];
- }
-
- const seenIds = new Set();
- const schemaVersion = parseSchemaVersion(document.schema_version);
- if (schemaVersion > CURRENT_SCHEMA_VERSION) {
- issues.push(
- ` [schema_version] declared "${document.schema_version}" exceeds validator's known max (${CURRENT_SCHEMA_VERSION}). Update validator or fix the recipe.`
- );
- }
- const context = { schemaVersion, fileLabel: path.basename(filePath) };
-
- validatePreConditions(normalizedDocument.hooks.pre_conditions || [], registry, issues);
- validateHookSection(
- 'setup',
- normalizedDocument.hooks.setup || [],
- appRoot,
- defaultTeam,
- issues,
- seenIds,
- context
- );
- validateHookSection(
- 'teardown',
- normalizedDocument.hooks.teardown || [],
- appRoot,
- defaultTeam,
- issues,
- seenIds,
- context
- );
- validateWorkflowNodes(normalizedDocument, appRoot, defaultTeam, issues, seenIds, context);
-
- const inputs = document.inputs || {};
- const inputKeys = new Set(Object.keys(inputs));
- const usedParams = new Set();
-
- collectUsedParams(document.title || '', usedParams);
- collectUsedParams(document.description || '', usedParams);
- collectUsedParams(document.validate || {}, usedParams);
-
- for (const param of usedParams) {
- if (!inputKeys.has(param)) {
- issues.push(` [inputs] param "{{${param}}}" is used but not declared in inputs`);
- }
- }
-
- for (const key of inputKeys) {
- if (!usedParams.has(key)) {
- console.warn(
- ` warning [${path.basename(filePath)}] input "${key}" is declared but unused`
- );
- }
- }
-
- return issues;
-}
-
-function main() {
- const appRoot = getAppRoot();
- const registry = loadPreConditionRegistry(appRoot);
- const inputFiles = process.argv.slice(2);
- const files =
- inputFiles.length > 0
- ? inputFiles.map((filePath) => path.resolve(filePath))
- : collectScenarioFiles(appRoot);
-
- let totalViolations = 0;
-
- files.forEach((filePath) => {
- const issues = validateScenario(filePath, registry);
- const relative = path.relative(appRoot, filePath);
- if (issues.length === 0) {
- console.log(`PASS ${relative}`);
- return;
- }
-
- console.log(`FAIL ${relative}`);
- issues.forEach((issue) => console.log(issue));
- totalViolations += issues.length;
- });
-
- console.log('');
- if (totalViolations === 0) {
- console.log(`All ${files.length} scenario file(s) pass schema validation.`);
- process.exit(0);
- }
-
- console.log(`${totalViolations} violation(s) across ${files.length} scenario file(s).`);
- process.exit(1);
-}
-
-main();
diff --git a/scripts/perps/agentic/validate-myx.sh b/scripts/perps/agentic/validate-myx.sh
deleted file mode 100755
index 91a648c95a08..000000000000
--- a/scripts/perps/agentic/validate-myx.sh
+++ /dev/null
@@ -1,576 +0,0 @@
-#!/bin/bash
-set -euo pipefail
-# scripts/perps/agentic/validate-myx.sh
-# Comprehensive MYX provider validation — tests each method category on testnet/mainnet.
-# Shows raw data previews for every call so a human can assess what's working.
-#
-# Prerequisites:
-# - Metro running, device connected, app open
-# - Engine exposed on globalThis (dev builds via __AGENTIC__ bridge)
-# - MYX provider enabled (MM_PERPS_MYX_PROVIDER_ENABLED=true)
-#
-# Usage:
-# scripts/perps/agentic/validate-myx.sh # Both networks
-# scripts/perps/agentic/validate-myx.sh --network testnet # Testnet only
-# scripts/perps/agentic/validate-myx.sh --network mainnet # Mainnet only
-
-SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
-cd "$SCRIPT_DIR/../../.."
-
-# ── Args ────────────────────────────────────────────────────────────
-NETWORK="both"
-while [[ $# -gt 0 ]]; do
- case $1 in
- --network) NETWORK="$2"; shift 2 ;;
- *) echo "Unknown option: $1"; exit 1 ;;
- esac
-done
-
-# ── Helpers ─────────────────────────────────────────────────────────
-eval_sync() {
- "$SCRIPT_DIR/app-state.sh" eval "$@" 2>&1
-}
-
-eval_async() {
- "$SCRIPT_DIR/app-state.sh" eval-async "$@" 2>&1
-}
-
-# Counters
-PASS_COUNT=0
-FAIL_COUNT=0
-SKIP_COUNT=0
-UNVERIFIED_COUNT=0
-
-RESULTS=""
-
-record() {
- local category="$1" test_name="$2" result="$3" details="${4:-}"
- local icon
- case "$result" in
- PASS) icon="✓"; PASS_COUNT=$((PASS_COUNT + 1)) ;;
- FAIL) icon="✗"; FAIL_COUNT=$((FAIL_COUNT + 1)) ;;
- SKIP) icon="⊘"; SKIP_COUNT=$((SKIP_COUNT + 1)) ;;
- UNVERIFIED) icon="?"; UNVERIFIED_COUNT=$((UNVERIFIED_COUNT + 1)) ;;
- esac
- RESULTS="${RESULTS}$(printf '%-16s %-28s %s %-10s %s\n' "$category" "$test_name" "$icon" "$result" "$details")\n"
-}
-
-# Print indented data preview (truncated to keep output scannable)
-preview() {
- local label="$1" raw="$2" max_len="${3:-200}"
- local trimmed
- if [ ${#raw} -gt "$max_len" ]; then
- trimmed="${raw:0:$max_len}..."
- else
- trimmed="$raw"
- fi
- echo " ↳ $label: $trimmed"
-}
-
-print_report() {
- local net_label="$1" chain_id="$2"
- echo ""
- echo "═══════════════════════════════════════════════════════════════════════"
- echo " MYX Provider Validation Report"
- echo " Network: $net_label (chainId: $chain_id)"
- echo " Timestamp: $(date -u +%Y-%m-%dT%H:%M:%SZ)"
- echo "═══════════════════════════════════════════════════════════════════════"
- echo ""
- printf '%-16s %-28s %-11s %s\n' "Category" "Test" "Result" "Details"
- echo "───────────────────────────────────────────────────────────────────────"
- echo -e "$RESULTS"
- echo ""
- local total=$((PASS_COUNT + FAIL_COUNT + SKIP_COUNT + UNVERIFIED_COUNT))
- echo "Summary: ${PASS_COUNT} passed, ${FAIL_COUNT} failed, ${SKIP_COUNT} skipped, ${UNVERIFIED_COUNT} unverified (${total} total)"
- if [ "$UNVERIFIED_COUNT" -gt 0 ]; then
- echo ""
- echo "⚠ UNVERIFIED = API returned data but auth was never validated."
- echo " myxClient.auth() is sync (stores callbacks, sets #authenticatedAddress=)."
- echo " MYX API may not auth-gate read endpoints — empty results prove nothing."
- fi
- echo "═══════════════════════════════════════════════════════════════════════"
-}
-
-# Parse JSON field with python3 (handles double-encoded strings)
-json_field() {
- local json="$1" field="$2"
- echo "$json" | python3 -c "
-import sys, json
-raw = json.load(sys.stdin)
-if isinstance(raw, str):
- raw = json.loads(raw)
-print(raw.get('$field', ''))
-" 2>/dev/null || echo ""
-}
-
-# Pretty-print decoded JSON (handles double-encoding)
-json_decode() {
- echo "$1" | python3 -c "
-import sys, json
-raw = json.load(sys.stdin)
-if isinstance(raw, str):
- try: raw = json.loads(raw)
- except: pass
-print(json.dumps(raw, indent=2, ensure_ascii=False))
-" 2>/dev/null || echo "$1"
-}
-
-# ── Pre-flight ──────────────────────────────────────────────────────
-echo "[pre-flight] Checking Engine availability..."
-ENGINE_CHECK=$(eval_sync 'Boolean(Engine && Engine.context)') || ENGINE_CHECK="false"
-if [ "$ENGINE_CHECK" != "true" ]; then
- echo "ERROR: Engine not available. Make sure app is running a dev build with agentic bridge."
- exit 1
-fi
-echo " Engine available"
-
-# Save initial testnet state so we can restore later
-INITIAL_TESTNET=$(eval_sync "JSON.stringify(Engine.context.PerpsController.state.isTestnet)") || INITIAL_TESTNET="unknown"
-echo " Current isTestnet: $INITIAL_TESTNET"
-
-# ── Run validation for a given network ──────────────────────────────
-run_validation() {
- local target_testnet="$1" # "true" or "false"
- local net_label
- local chain_id
-
- if [ "$target_testnet" = "true" ]; then
- net_label="testnet"
- chain_id="97"
- else
- net_label="mainnet"
- chain_id="56"
- fi
-
- RESULTS=""
- PASS_COUNT=0
- FAIL_COUNT=0
- SKIP_COUNT=0
- UNVERIFIED_COUNT=0
-
- echo ""
- echo "━━━ Validating $net_label (chainId: $chain_id) ━━━"
-
- # Switch network if needed
- local current_testnet
- current_testnet=$(eval_sync "JSON.stringify(Engine.context.PerpsController.state.isTestnet)") || current_testnet="unknown"
- if [ "$current_testnet" != "\"$target_testnet\"" ]; then
- echo "[setup] Switching to $net_label..."
- eval_async "Engine.context.PerpsController.toggleTestnet().then(function(r) { return JSON.stringify(r); })" >/dev/null 2>&1 || true
- sleep 2
- fi
-
- # ── SDK config probe ──
- echo "[probe] Checking SDK internal config..."
- local sdk_probe
- sdk_probe=$(eval_async '
- (function() {
- var provider = Engine.context.PerpsController.providers.get("myx");
- if (provider === null || provider === undefined) return JSON.stringify({error: "no provider"});
- var cs = provider.__private_621_clientService;
- if (cs === null || cs === undefined) return JSON.stringify({error: "no clientService"});
- var myxClient = cs.__private_635_myxClient;
- var config = myxClient.configManager.config;
- var authConfig = cs.__private_639_authConfig;
- return JSON.stringify({
- sdkChainId: config.chainId,
- sdkIsTestnet: config.isTestnet,
- sdkIsBetaMode: config.isBetaMode,
- authenticatedAddress: cs.__private_640_authenticatedAddress,
- appId: authConfig ? authConfig.appId : null,
- hasApiSecret: Boolean(authConfig && authConfig.apiSecret),
- brokerAddress: authConfig && authConfig.brokerAddress ? authConfig.brokerAddress : ""
- });
- })()
- ') || sdk_probe='{"error":"probe failed"}'
- preview "SDK config" "$(json_decode "$sdk_probe")" 500
-
- # ── 1. Init: Provider registered ──
- echo "[test] Init: Provider registered..."
- local provider_check
- provider_check=$(eval_async '
- Promise.resolve().then(function() {
- var p = Engine.context.PerpsController.providers.get("myx");
- return JSON.stringify({registered: Boolean(p)});
- })
- ') || provider_check='{"registered":false}'
- local registered
- registered=$(json_field "$provider_check" "registered")
- if [ "$registered" = "True" ] || [ "$registered" = "true" ]; then
- record "Init" "Provider registered" "PASS"
- else
- record "Init" "Provider registered" "FAIL" "not found in providers map"
- print_report "$net_label" "$chain_id"
- return
- fi
-
- # ── 2. Init: Markets load ──
- echo "[test] Init: Markets load..."
- local markets_result
- markets_result=$(eval_async '
- Engine.context.PerpsController.providers.get("myx").getMarkets().then(function(m) {
- return JSON.stringify({
- count: m.length,
- names: m.map(function(x) { return x.name; }),
- sampleKeys: m[0] ? Object.keys(m[0]) : []
- });
- })
- ') || markets_result='{"error":"getMarkets failed"}'
- local market_count
- market_count=$(json_field "$markets_result" "count")
- preview "markets" "$(json_decode "$markets_result")" 400
- local first_symbol=""
- if [ -n "$market_count" ] && [ "$market_count" != "0" ] && [ "$market_count" != "" ]; then
- record "Init" "Markets loaded" "PASS" "${market_count} markets"
- first_symbol=$(echo "$markets_result" | python3 -c "
-import sys, json
-raw = json.load(sys.stdin)
-if isinstance(raw, str): raw = json.loads(raw)
-names = raw.get('names', [])
-print(names[0] if names else '')
-" 2>/dev/null || echo "")
- else
- record "Init" "Markets loaded" "FAIL" "0 markets"
- fi
-
- # ── 3. Init: Market shape ──
- echo "[test] Init: Market shape..."
- local shape_result
- shape_result=$(eval_async '
- Engine.context.PerpsController.providers.get("myx").getMarkets().then(function(m) {
- if (m.length === 0) return JSON.stringify({ok: false, reason: "empty"});
- var s = m[0];
- return JSON.stringify({
- name: s.name, maxLeverage: s.maxLeverage, szDecimals: s.szDecimals,
- providerId: s.providerId, keys: Object.keys(s)
- });
- })
- ') || shape_result='{"ok":false}'
- preview "first market" "$(json_decode "$shape_result")" 400
- local has_name
- has_name=$(echo "$shape_result" | python3 -c "
-import sys, json
-raw = json.load(sys.stdin)
-if isinstance(raw, str): raw = json.loads(raw)
-n = raw.get('name', '')
-print('true' if n and n != 'None' else 'false')
-" 2>/dev/null || echo "false")
- if [ "$has_name" = "true" ]; then
- record "Init" "Markets have name" "PASS"
- else
- record "Init" "Markets have name" "FAIL" "name is empty/missing"
- fi
-
- # ── 4a. Prices: Raw ticker probe (API values before adapter) ──
- echo "[test] Prices: Raw ticker probe..."
- local raw_ticker_result
- raw_ticker_result=$(eval_async '
- (function() {
- var provider = Engine.context.PerpsController.providers.get("myx");
- var cs = provider.__private_621_clientService;
- var myxClient = cs.__private_635_myxClient;
- return myxClient.getTickers().then(function(resp) {
- var tickers = resp.data || [];
- return JSON.stringify({
- apiCount: tickers.length,
- samples: tickers.slice(0, 5).map(function(t) {
- return {symbol: t.baseSymbol || t.symbol, price: t.price, change: t.change, volume: t.volume};
- })
- });
- });
- })()
- ') || raw_ticker_result='{"error":"probe failed"}'
- preview "raw API tickers" "$(json_decode "$raw_ticker_result")" 600
-
- # ── 4b. Prices: Formatted tickers (after adapter) ──
- echo "[test] Prices: Formatted tickers..."
- local tickers_result
- tickers_result=$(eval_async '
- Engine.context.PerpsController.providers.get("myx").getMarketDataWithPrices().then(function(d) {
- return JSON.stringify({
- count: d.length,
- samples: d.slice(0, 3).map(function(x) {
- return {name: x.name, price: x.price, markPrice: x.markPrice, oraclePrice: x.oraclePrice, volume: x.volume, openInterest: x.openInterest, fundingRate: x.fundingRate};
- })
- });
- })
- ') || tickers_result='{"count":0}'
- local ticker_count
- ticker_count=$(json_field "$tickers_result" "count")
- preview "formatted tickers" "$(json_decode "$tickers_result")" 500
- if [ -n "$ticker_count" ] && [ "$ticker_count" != "0" ]; then
- # Check if prices are actually non-zero
- local has_real_price
- has_real_price=$(echo "$tickers_result" | python3 -c "
-import sys, json
-raw = json.load(sys.stdin)
-if isinstance(raw, str): raw = json.loads(raw)
-samples = raw.get('samples', [])
-real = [s for s in samples if s.get('price') and s['price'] not in ('0', '\$0', '<\$0.01', '\$0.00', None)]
-print('true' if real else 'false')
-" 2>/dev/null || echo "false")
- if [ "$has_real_price" = "true" ]; then
- record "Prices" "Tickers with real prices" "PASS" "${ticker_count} tickers"
- else
- record "Prices" "Tickers returned" "FAIL" "${ticker_count} tickers but ALL prices are \$0"
- fi
- else
- record "Prices" "Tickers returned" "FAIL" "0 tickers"
- fi
-
- # ── 5-7. Candles REST ──
- local intervals="1h 1D 5m"
- for interval in $intervals; do
- echo "[test] Candles REST: ${interval} historical..."
- if [ -n "$first_symbol" ] && [ "$first_symbol" != "" ]; then
- local candle_result
- candle_result=$(eval_async '
- (function() {
- var provider = Engine.context.PerpsController.providers.get("myx");
- return provider.getMarkets().then(function(markets) {
- var sym = markets[0] ? markets[0].name : null;
- if (sym === null) return JSON.stringify({error: "no markets"});
- var result = null;
- var err = null;
- provider.subscribeToCandles({symbol: sym, interval: "'"${interval}"'", callback: function(d) { result = d; }, onError: function(e) { err = String(e); }});
- return new Promise(function(resolve) {
- setTimeout(function() {
- var candles = result && result.candles ? result.candles : [];
- var first = candles[0];
- var last = candles[candles.length - 1];
- resolve(JSON.stringify({
- symbol: sym, interval: "'"${interval}"'", count: candles.length, error: err,
- first: first ? {time: first.time, open: first.open, close: first.close, vol: first.volume} : null,
- last: last ? {time: last.time, open: last.open, close: last.close, vol: last.volume} : null
- }));
- }, 8000);
- });
- });
- })()
- ') || candle_result='{"count":0,"error":"eval failed"}'
- local candle_count candle_error
- candle_count=$(json_field "$candle_result" "count")
- candle_error=$(json_field "$candle_result" "error")
- preview "candles ${interval}" "$(json_decode "$candle_result")" 400
- if [ -n "$candle_count" ] && [ "$candle_count" != "0" ] && [ "$candle_count" != "" ]; then
- record "Candles REST" "${interval} historical" "PASS" "${candle_count} candles"
- else
- record "Candles REST" "${interval} historical" "FAIL" "count=${candle_count:-0} err=${candle_error:-none}"
- fi
- else
- record "Candles REST" "${interval} historical" "SKIP" "no markets available"
- fi
- done
-
- # ── 5b. Candles WS: Live updates (sustained) ──
- # Subscribe and wait for multiple WS callbacks over time to prove the socket
- # stays open and delivers data at intervals (not just one burst).
- # Callback 1 = REST snapshot, callbacks 2+ = WS live updates.
- echo "[test] Candles WS: Sustained kline updates..."
- if [ -n "$first_symbol" ] && [ "$first_symbol" != "" ]; then
- local ws_candle_result
- ws_candle_result=$(eval_async '
- new Promise(function(resolve) {
- var callCount = 0;
- var firstCount = 0;
- var timestamps = [];
- var targetWsCallbacks = 3;
- var timer = setTimeout(function() {
- if (typeof unsub === "function") unsub();
- resolve(JSON.stringify({wsReceived: callCount > 1, callbackCount: callCount, restCount: firstCount, wsCallbacks: callCount - 1, timestamps: timestamps}));
- }, 65000);
- var unsub;
- var provider = Engine.context.PerpsController.providers.get("myx");
- unsub = provider.subscribeToCandles({symbol: "'"${first_symbol}"'", interval: "1m", limit: 5, callback: function(d) {
- callCount++;
- if (callCount === 1) { firstCount = d.candles.length; return; }
- timestamps.push(Date.now());
- if (callCount - 1 >= targetWsCallbacks) {
- clearTimeout(timer);
- if (typeof unsub === "function") unsub();
- resolve(JSON.stringify({wsReceived: true, callbackCount: callCount, restCount: firstCount, wsCallbacks: callCount - 1, timestamps: timestamps}));
- }
- }, onError: function(e) {
- clearTimeout(timer);
- if (typeof unsub === "function") unsub();
- resolve(JSON.stringify({wsReceived: false, error: String(e), callbackCount: callCount}));
- }});
- })
- ') || ws_candle_result='{"wsReceived":false,"error":"eval failed"}'
- preview "WS kline" "$(json_decode "$ws_candle_result")" 500
- local ws_received ws_callbacks
- ws_received=$(json_field "$ws_candle_result" "wsReceived")
- ws_callbacks=$(json_field "$ws_candle_result" "wsCallbacks")
- if [ "$ws_received" = "True" ] || [ "$ws_received" = "true" ]; then
- record "Candles WS" "Sustained kline updates" "PASS" "${ws_callbacks} WS callbacks received"
- else
- local ws_error
- ws_error=$(json_field "$ws_candle_result" "error")
- record "Candles WS" "Sustained kline updates" "FAIL" "got ${ws_callbacks:-0} WS callbacks in 65s ${ws_error:+err=$ws_error}"
- fi
- else
- record "Candles WS" "Sustained kline updates" "SKIP" "no markets available"
- fi
-
- # ── 5c. Prices WS: Live ticker updates ──
- # Check if subscribeToPrices delivers a second (WS-driven) callback
- echo "[test] Prices WS: Live ticker update..."
- if [ -n "$first_symbol" ] && [ "$first_symbol" != "" ]; then
- local ws_price_result
- ws_price_result=$(eval_async '
- new Promise(function(resolve) {
- var callCount = 0;
- var timer = setTimeout(function() { resolve(JSON.stringify({wsReceived: false, callbackCount: callCount})); }, 15000);
- var unsub;
- var provider = Engine.context.PerpsController.providers.get("myx");
- unsub = provider.subscribeToPrices({symbols: ["'"${first_symbol}"'"], callback: function(d) {
- callCount++;
- if (callCount >= 2) {
- clearTimeout(timer);
- if (typeof unsub === "function") unsub();
- var sample = d[0] ? {symbol: d[0].symbol, price: d[0].price} : null;
- resolve(JSON.stringify({wsReceived: true, callbackCount: callCount, sample: sample}));
- }
- }});
- })
- ') || ws_price_result='{"wsReceived":false,"error":"eval failed"}'
- preview "WS prices" "$(json_decode "$ws_price_result")" 400
- local ws_price_received
- ws_price_received=$(json_field "$ws_price_result" "wsReceived")
- if [ "$ws_price_received" = "True" ] || [ "$ws_price_received" = "true" ]; then
- record "Prices WS" "Live ticker update" "PASS" "received multiple callbacks"
- else
- record "Prices WS" "Live ticker update" "FAIL" "no 2nd callback in 15s (REST poll only?)"
- fi
- else
- record "Prices WS" "Live ticker update" "SKIP" "no markets available"
- fi
-
- # ── 8. Auth: isReadyToTrade ──
- # NOTE: myxClient.auth() is sync — it just stores signer + getAccessToken callback.
- # It sets #authenticatedAddress= immediately, no API call. So "ready:true" proves nothing.
- echo "[test] Auth: isReadyToTrade..."
- echo " ⚠ WARNING: myxClient.auth() is sync — ready:true does NOT mean credentials are valid"
- local ready_result
- ready_result=$(eval_async '
- Engine.context.PerpsController.providers.get("myx").isReadyToTrade().then(function(r) {
- return JSON.stringify(r);
- }).catch(function(e) { return JSON.stringify({ready: false, error: e.message}); })
- ') || ready_result='{"ready":false,"error":"eval failed"}'
- preview "isReadyToTrade" "$(json_decode "$ready_result")" 300
- local is_ready
- is_ready=$(json_field "$ready_result" "ready")
- if [ "$is_ready" = "True" ] || [ "$is_ready" = "true" ]; then
- record "Auth" "isReadyToTrade" "UNVERIFIED" "returns ready:true but auth is never validated"
- else
- local ready_error
- ready_error=$(json_field "$ready_result" "error")
- record "Auth" "isReadyToTrade" "FAIL" "${ready_error:-not ready}"
- fi
-
- # ── 9. Positions: getPositions ──
- echo "[test] Positions: getPositions..."
- local positions_result
- positions_result=$(eval_async '
- Engine.context.PerpsController.providers.get("myx").getPositions().then(function(p) {
- return JSON.stringify({count: p.length, sample: p[0] ? p[0] : null});
- }).catch(function(e) { return JSON.stringify({error: e.message}); })
- ') || positions_result='{"error":"eval failed"}'
- preview "positions" "$(json_decode "$positions_result")" 300
- local pos_error
- pos_error=$(json_field "$positions_result" "error")
- if [ -n "$pos_error" ] && [ "$pos_error" != "" ]; then
- record "Positions" "getPositions" "FAIL" "$pos_error"
- else
- local pos_count
- pos_count=$(json_field "$positions_result" "count")
- record "Positions" "getPositions" "UNVERIFIED" "returned ${pos_count} (auth not validated)"
- fi
-
- # ── 10. Orders: getOrders ──
- echo "[test] Orders: getOrders..."
- local orders_result
- orders_result=$(eval_async '
- Engine.context.PerpsController.providers.get("myx").getOrders().then(function(o) {
- return JSON.stringify({count: o.length, sample: o[0] ? o[0] : null});
- }).catch(function(e) { return JSON.stringify({error: e.message}); })
- ') || orders_result='{"error":"eval failed"}'
- preview "orders" "$(json_decode "$orders_result")" 300
- local ord_error
- ord_error=$(json_field "$orders_result" "error")
- if [ -n "$ord_error" ] && [ "$ord_error" != "" ]; then
- record "Orders" "getOrders" "FAIL" "$ord_error"
- else
- local ord_count
- ord_count=$(json_field "$orders_result" "count")
- record "Orders" "getOrders" "UNVERIFIED" "returned ${ord_count} (auth not validated)"
- fi
-
- # ── 11. Account: getAccountState ──
- echo "[test] Account: getAccountState..."
- local account_result
- account_result=$(eval_async '
- Engine.context.PerpsController.providers.get("myx").getAccountState().then(function(a) {
- return JSON.stringify(a || {});
- }).catch(function(e) { return JSON.stringify({error: e.message}); })
- ') || account_result='{"error":"eval failed"}'
- preview "accountState" "$(json_decode "$account_result")" 400
- local acct_error
- acct_error=$(json_field "$account_result" "error")
- if [ -n "$acct_error" ] && [ "$acct_error" != "" ]; then
- record "Account" "getAccountState" "FAIL" "$acct_error"
- else
- record "Account" "getAccountState" "UNVERIFIED" "returned data (auth not validated)"
- fi
-
- # ── 12. Ping: Health check ──
- echo "[test] Ping: Health check..."
- local ping_result
- ping_result=$(eval_async '
- (function() {
- var provider = Engine.context.PerpsController.providers.get("myx");
- if (typeof provider.ping === "function") {
- return provider.ping().then(function() { return JSON.stringify({ok: true}); }).catch(function(e) { return JSON.stringify({ok: false, error: e.message}); });
- }
- return Promise.resolve(JSON.stringify({ok: false, error: "ping not implemented"}));
- })()
- ') || ping_result='{"ok":false,"error":"eval failed"}'
- preview "ping" "$(json_decode "$ping_result")" 200
- local ping_ok
- ping_ok=$(json_field "$ping_result" "ok")
- if [ "$ping_ok" = "True" ] || [ "$ping_ok" = "true" ]; then
- record "Ping" "Health check" "PASS"
- else
- record "Ping" "Health check" "FAIL" "$(json_field "$ping_result" "error")"
- fi
-
- print_report "$net_label" "$chain_id"
-}
-
-# ── Main ────────────────────────────────────────────────────────────
-
-# Ensure MYX provider is initialized
-echo "[init] Initializing PerpsController..."
-eval_async 'Engine.context.PerpsController.init().then(function() { return JSON.stringify({ok: true}); })' >/dev/null 2>&1 || true
-sleep 2
-
-case "$NETWORK" in
- testnet) run_validation "true" ;;
- mainnet) run_validation "false" ;;
- both)
- run_validation "true"
- run_validation "false"
- ;;
- *) echo "Invalid --network value: $NETWORK (use testnet, mainnet, or both)"; exit 1 ;;
-esac
-
-# Restore initial network state
-echo ""
-echo "[cleanup] Restoring initial network state..."
-CURRENT_TESTNET=$(eval_sync "JSON.stringify(Engine.context.PerpsController.state.isTestnet)") || CURRENT_TESTNET="unknown"
-if [ "$CURRENT_TESTNET" != "$INITIAL_TESTNET" ]; then
- eval_async 'Engine.context.PerpsController.toggleTestnet().then(function(r) { return JSON.stringify(r); })' >/dev/null 2>&1 || true
- echo " Restored to isTestnet=$INITIAL_TESTNET"
-else
- echo " Already at isTestnet=$INITIAL_TESTNET"
-fi
diff --git a/scripts/perps/agentic/validate-pre-conditions.js b/scripts/perps/agentic/validate-pre-conditions.js
deleted file mode 100644
index 1d88c77700e3..000000000000
--- a/scripts/perps/agentic/validate-pre-conditions.js
+++ /dev/null
@@ -1,65 +0,0 @@
-#!/usr/bin/env node
-'use strict';
-
-const { checkAssert } = require('./lib/assert');
-const {
- getAppRoot,
- loadPreConditionRegistry,
- renderTemplate,
-} = require('./lib/catalog');
-
-function main() {
- const appRoot = getAppRoot();
- const registry = loadPreConditionRegistry(appRoot);
- const failures = [];
-
- Object.entries(registry).forEach(([name, entry]) => {
- const fixtures = entry.fixtures;
- if (!fixtures) {
- failures.push(` ${name}: missing fixtures.pass and fixtures.fail`);
- return;
- }
-
- if (!Object.prototype.hasOwnProperty.call(fixtures, 'pass')) {
- failures.push(` ${name}: missing fixtures.pass`);
- return;
- }
-
- if (!Object.prototype.hasOwnProperty.call(fixtures, 'fail')) {
- failures.push(` ${name}: missing fixtures.fail`);
- return;
- }
-
- const params = fixtures.params || {};
- const assertSpec = renderTemplate(entry.assert, params);
-
- const passResult = checkAssert(fixtures.pass, assertSpec);
- if (!passResult) {
- failures.push(
- ` ${name}: pass fixture did not satisfy assert\n` +
- ` fixture: ${fixtures.pass}\n` +
- ` assert: ${JSON.stringify(assertSpec)}`
- );
- }
-
- const failResult = checkAssert(fixtures.fail, assertSpec);
- if (failResult) {
- failures.push(
- ` ${name}: fail fixture unexpectedly satisfied assert\n` +
- ` fixture: ${fixtures.fail}\n` +
- ` assert: ${JSON.stringify(assertSpec)}`
- );
- }
- });
-
- if (failures.length > 0) {
- console.error(`Pre-condition assertion check FAILED:\n${failures.join('\n')}`);
- process.exit(1);
- }
-
- console.log(
- `All ${Object.keys(registry).length} pre-condition(s) pass assertion correctness checks.`
- );
-}
-
-main();
diff --git a/scripts/perps/agentic/validate-recipe.js b/scripts/perps/agentic/validate-recipe.js
deleted file mode 100644
index 7c29b1ebfea8..000000000000
--- a/scripts/perps/agentic/validate-recipe.js
+++ /dev/null
@@ -1,1929 +0,0 @@
-#!/usr/bin/env node
-'use strict';
-
-const fs = require('node:fs');
-const path = require('node:path');
-const readline = require('node:readline');
-const { spawnSync } = require('node:child_process');
-
-const { checkAssert, evaluateAssert, parseRaw } = require('./lib/assert');
-const { ensureParentDir, sanitizeFileSegment } = require('./lib/screenshot');
-const {
- EXECUTABLE_ACTIONS,
- deepClone,
- normalizeWorkflowDocument,
- renderWorkflowMermaid,
-} = require('./lib/workflow');
-const { backgroundApp, foregroundApp, restartApp } = require('./lib/app-lifecycle');
-const {
- applyAllowlist,
- captureFromMetro,
- computeReview,
- countByLevel,
- dedupeIssues,
- normalizeAppBufferEntries,
- writeArtifacts: writeIssueArtifacts,
-} = require('./lib/recipe-issues');
-const {
- getAppRoot,
- getTeamsDir,
- inferTeamFromPath,
- listEvalRefs,
- loadPreConditionRegistry,
- parsePreConditionSpec,
- renderTemplate,
- renderTemplateString,
- resolveEvalRef,
- resolveFlowRef,
-} = require('./lib/catalog');
-
-const DEFAULT_LOG_LINES = 400;
-
-function validateSchemaOrThrow(appRoot, recipePath) {
- const validatorPath = path.join(__dirname, 'validate-flow-schema.js');
- const result = spawnSync('node', [validatorPath, recipePath], {
- cwd: appRoot,
- encoding: 'utf8',
- });
-
- if (result.status === 0) {
- return;
- }
-
- const output = [result.stdout || '', result.stderr || '']
- .join('\n')
- .trim();
- throw new Error(`Schema validation failed for ${recipePath}\n${output}`);
-}
-
-function timestampSlug() {
- return new Date()
- .toISOString()
- .replaceAll(/[:.]/g, '-')
- .replaceAll('T', '_')
- .slice(0, 19);
-}
-
-function parseArgs(argv) {
- const options = {
- artifactsDir: '',
- dryRun: false,
- hud: true,
- inputOverrides: {},
- log: true,
- recipe: '',
- singleStep: '',
- skipManual: false,
- account: '',
- testnet: false,
- };
-
- for (let i = 0; i < argv.length; i += 1) {
- const arg = argv[i];
- switch (arg) {
- case '--artifacts-dir':
- options.artifactsDir = argv[i + 1] || '';
- i += 1;
- break;
- case '--dry-run':
- options.dryRun = true;
- break;
- case '--no-hud':
- options.hud = false;
- break;
- case '--no-log':
- options.log = false;
- break;
- case '--skip-manual':
- options.skipManual = true;
- break;
- case '--step':
- options.singleStep = argv[i + 1] || '';
- i += 1;
- break;
- case '--account':
- options.account = argv[i + 1] || '';
- i += 1;
- break;
- case '--testnet':
- options.testnet = true;
- break;
- case '--input': {
- const kv = argv[i + 1] || '';
- const eq = kv.indexOf('=');
- if (eq < 1) throw new Error('--input expects key=value (e.g. --input test_restart=true)');
- const k = kv.slice(0, eq);
- const raw = kv.slice(eq + 1);
- // Parse booleans and numbers
- options.inputOverrides[k] = raw === 'true' ? true : raw === 'false' ? false : raw !== '' && Number.isFinite(Number(raw)) ? Number(raw) : raw;
- i += 1;
- break;
- }
- case '--help':
- case '-h':
- printHelp();
- process.exit(0);
- break;
- default:
- if (arg.startsWith('-')) {
- throw new Error(`Unknown flag: ${arg}`);
- }
- if (options.recipe) {
- throw new Error(`Unexpected extra argument: ${arg}`);
- }
- options.recipe = arg;
- break;
- }
- }
-
- if (!options.recipe) {
- printHelp();
- process.exit(1);
- }
-
- return options;
-}
-
-function printHelp() {
- console.log(`Usage:
- validate-recipe.sh
- [--step ]
- [--skip-manual]
- [--no-hud]
- [--account ]
- [--testnet]
- [--artifacts-dir ]
- [--dry-run]
- [--no-log]
-
-The runner executes workflow files stored under:
- scripts/perps/agentic/teams//{flows,recipes}
-
-Runtime features:
- - HUD is enabled by default during live execution. Use --no-hud to disable it.
- - Scenarios use validate.workflow with explicit nodes, transitions, switch branches, and end nodes.
- - setup / teardown hooks live under validate.workflow.setup / validate.workflow.teardown.
- - Failures capture screenshots, route/state snapshots, eval refs, and recent logs.
- - Successful runs emit workflow.json, workflow.mmd, trace.json, summary.json, and run.log artifacts.
- - Console output is teed to run.log by default. Use --no-log to disable.`);
-}
-
-function resolveRecipeInput(appRoot, inputPath) {
- const absolute = path.resolve(inputPath);
- if (fs.existsSync(absolute) && fs.statSync(absolute).isDirectory()) {
- const recipeFile = path.join(absolute, 'recipe.json');
- if (!fs.existsSync(recipeFile)) {
- throw new Error(`recipe.json not found in directory: ${absolute}`);
- }
- return { recipePath: recipeFile, recipeDir: absolute };
- }
-
- if (!fs.existsSync(absolute)) {
- throw new Error(`Recipe not found: ${inputPath}`);
- }
-
- return { recipePath: absolute, recipeDir: path.dirname(absolute) };
-}
-
-function collectDefaultInputs(document) {
- return Object.fromEntries(
- Object.entries(document.inputs || {})
- .filter(([, value]) => value && Object.prototype.hasOwnProperty.call(value, 'default'))
- .map(([key, value]) => [key, value.default])
- );
-}
-
-function renderDocument(recipePath, inputParams = {}) {
- const source = fs.readFileSync(recipePath, 'utf8');
- const parsed = JSON.parse(source);
- const defaults = collectDefaultInputs(parsed);
- const params = { ...defaults, ...inputParams };
-
- let rendered = renderTemplateString(source, params);
- rendered = rendered.replace(/\{\{[^|}]+\|([^}]+)\}\}/g, '$1');
-
- return {
- document: JSON.parse(rendered),
- params,
- };
-}
-
-function formatResultPreview(result) {
- if (result == null) {
- return '';
- }
-
- const text = typeof result === 'string' ? result : JSON.stringify(result);
- return text.length > 220 ? `${text.slice(0, 220)}...` : text;
-}
-
-function rawResultString(result) {
- if (typeof result === 'string') {
- return result;
- }
- return JSON.stringify(result);
-}
-
-// ---------------------------------------------------------------------------
-// CDP bridge helpers
-// ---------------------------------------------------------------------------
-
-const SD = path.resolve(__dirname);
-
-function spawnBridge(appRoot, bridgeArgs) {
- const bridgePath = path.join(SD, 'cdp-bridge.js');
-
- const result = spawnSync('node', [bridgePath, ...bridgeArgs], {
- cwd: appRoot,
- env: { ...process.env, APP_ROOT: appRoot },
- encoding: 'utf8',
- });
-
- if (result.status !== 0) {
- throw new Error((result.stderr || result.stdout || 'Bridge command failed').trim());
- }
-
- const stdout = (result.stdout || '').trim();
- if (!stdout) {
- return '';
- }
-
- try {
- return JSON.parse(stdout);
- } catch {
- return stdout;
- }
-}
-
-function trySpawnBridge(appRoot, bridgeArgs) {
- try {
- return { ok: true, result: spawnBridge(appRoot, bridgeArgs) };
- } catch (error) {
- return { ok: false, error: String(error.message || error) };
- }
-}
-
-let hudWarningPrinted = false;
-
-function publishHudStep(appRoot, step, options = {}) {
- if (options.hud === false) {
- return;
- }
-
- try {
- spawnBridge(appRoot, ['show-step', String(step.id || '?'), step.description || '']);
- } catch (error) {
- if (!hudWarningPrinted) {
- hudWarningPrinted = true;
- console.warn(`HUD warning: ${String(error.message || error)}`);
- }
- }
-}
-
-function clearHudStep(appRoot, options = {}) {
- if (options.hud === false) {
- return;
- }
-
- try {
- spawnBridge(appRoot, ['hide-step']);
- } catch (error) {
- if (!hudWarningPrinted) {
- hudWarningPrinted = true;
- console.warn(`HUD warning: ${String(error.message || error)}`);
- }
- }
-}
-
-// ---------------------------------------------------------------------------
-// Log scanning
-// ---------------------------------------------------------------------------
-
-function readRecentLines(filePath, maxLines = DEFAULT_LOG_LINES) {
- if (!fs.existsSync(filePath)) {
- return [];
- }
-
- return fs.readFileSync(filePath, 'utf8').split('\n').slice(-maxLines);
-}
-
-function scanLog(step, metroLogPath) {
- const recentLines = readRecentLines(metroLogPath, 500);
- const mustNotAppear = step.must_not_appear || [];
- const watchFor = step.watch_for || [];
-
- const mustNotFound = mustNotAppear.filter((needle) =>
- recentLines.some((line) => line.toLowerCase().includes(String(needle).toLowerCase()))
- );
- const watchCounts = Object.fromEntries(
- watchFor.map((needle) => [
- needle,
- recentLines.filter((line) =>
- line.toLowerCase().includes(String(needle).toLowerCase())
- ).length,
- ])
- );
-
- return {
- pass: mustNotFound.length === 0,
- must_not_found: mustNotFound,
- watch_counts: watchCounts,
- };
-}
-
-// ---------------------------------------------------------------------------
-// Step description
-// ---------------------------------------------------------------------------
-
-function describeStep(step) {
- if (step.description) {
- return step.description;
- }
-
- switch (step.action) {
- case 'navigate':
- return `navigate to ${step.target}`;
- case 'wait':
- return `wait ${step.ms || 1000}ms`;
- case 'wait_for':
- return `wait for ${step.test_id || step.route || step.not_route || 'condition'}`;
- case 'press':
- return `press ${step.test_id}`;
- case 'scroll':
- return `scroll ${step.test_id || 'view'}`;
- case 'set_input':
- return `set ${step.test_id}`;
- case 'screenshot':
- return step.note || `capture screenshot ${step.id || step.filename || ''}`.trim();
- case 'call':
- return `call ${step.ref}`;
- case 'eval_ref':
- return `eval ref ${step.ref}`;
- case 'eval_sync':
- case 'eval_async':
- return step.action;
- case 'type_keypad':
- return `type ${step.value}`;
- case 'clear_keypad':
- return `clear keypad x${step.count || 8}`;
- case 'select_account':
- return `select account ${step.address}`;
- case 'toggle_testnet':
- return `toggle testnet=${step.enabled !== undefined ? step.enabled : 'true'}`;
- case 'switch_provider':
- return `switch provider ${step.provider}`;
- case 'app_background':
- return `background app ${step.duration_ms || 5000}ms`;
- case 'app_foreground':
- return 'foreground app';
- case 'app_restart':
- return 'restart app';
- case 'switch':
- return step.description || 'evaluate branch';
- case 'end':
- return `${step.status || 'pass'} end`;
- case 'manual':
- return step.note || 'manual step';
- case 'log_watch':
- return 'scan metro log';
- default:
- return step.action;
- }
-}
-
-// ---------------------------------------------------------------------------
-// Manual prompt
-// ---------------------------------------------------------------------------
-
-async function promptManual(note) {
- const rl = readline.createInterface({
- input: process.stdin,
- output: process.stdout,
- });
-
- if (note) {
- console.log(` note: ${note}`);
- }
-
- const answer = await new Promise((resolve) => {
- rl.question(' Press ENTER when done, or type "s" to skip: ', resolve);
- });
- rl.close();
- return String(answer || '').trim().toLowerCase() !== 's';
-}
-
-// ---------------------------------------------------------------------------
-// wait_for
-// ---------------------------------------------------------------------------
-
-function buildWaitForSpec(step) {
- if (step.route) {
- return {
- expression: 'JSON.stringify({route:globalThis.__AGENTIC__.getRoute().name})',
- assert: { operator: 'eq', field: 'route', value: step.route },
- label: `route=${step.route}`,
- async: false,
- };
- }
-
- if (step.not_route) {
- return {
- expression: 'JSON.stringify({route:globalThis.__AGENTIC__.getRoute().name})',
- assert: { operator: 'neq', field: 'route', value: step.not_route },
- label: `not_route=${step.not_route}`,
- async: false,
- };
- }
-
- if (step.test_id) {
- return {
- expression: `JSON.stringify({visible:globalThis.__AGENTIC__.findFiberByTestId(${JSON.stringify(step.test_id)})})`,
- assert: { operator: 'eq', field: 'visible', value: step.visible !== false },
- label: `test_id=${step.test_id}`,
- async: false,
- };
- }
-
- return {
- expression: step.expression || '',
- assert: step.assert || null,
- label: 'expression',
- async: step.async === true || String(step.expression || '').includes('.then('),
- };
-}
-
-async function waitForCondition(step, appRoot) {
- const waitSpec = buildWaitForSpec(step);
- const timeoutMs = Number(step.timeout_ms || 10000);
- const pollMs = Number(step.poll_ms || 500);
- const deadline = Date.now() + timeoutMs;
- let lastResult = '';
-
- while (Date.now() < deadline) {
- try {
- const bridgeResult = spawnBridge(
- appRoot,
- [waitSpec.async ? 'eval-async' : 'eval', waitSpec.expression]
- );
- lastResult = rawResultString(bridgeResult);
- if (checkAssert(lastResult, waitSpec.assert)) {
- return { ok: true, result: bridgeResult, label: waitSpec.label };
- }
- } catch (error) {
- lastResult = String(error.message || error);
- }
-
- await new Promise((resolve) => setTimeout(resolve, pollMs));
- }
-
- return { ok: false, result: lastResult, label: waitSpec.label };
-}
-
-// ---------------------------------------------------------------------------
-// Pre-conditions
-// ---------------------------------------------------------------------------
-
-function evaluatePreConditions(document, appRoot) {
- const registry = loadPreConditionRegistry(appRoot);
- const normalized = normalizeWorkflowDocument(document);
- const preConditions = normalized.hooks.pre_conditions || [];
- const failures = [];
- const passed = [];
-
- preConditions.forEach((spec) => {
- const parsed = parsePreConditionSpec(spec);
- const name = typeof parsed === 'string' ? parsed : parsed?.name;
- const params =
- typeof parsed === 'string'
- ? {}
- : Object.fromEntries(
- Object.entries(parsed).filter(([key]) => key !== 'name')
- );
-
- const entry = registry[name];
- if (!entry) {
- failures.push({ name, error: `Unknown pre-condition "${name}"` });
- return;
- }
-
- const expression =
- typeof entry.expression === 'function'
- ? entry.expression(params)
- : renderTemplate(entry.expression, params);
- const assertSpec = renderTemplate(entry.assert, params);
-
- try {
- const bridgeResult = spawnBridge(
- appRoot,
- [entry.async ? 'eval-async' : 'eval', expression]
- );
- const raw = rawResultString(bridgeResult);
- if (checkAssert(raw, assertSpec)) {
- passed.push(name);
- return;
- }
-
- failures.push({
- name,
- description: entry.description || '',
- got: formatResultPreview(bridgeResult),
- hint: entry.hint || '',
- });
- } catch (error) {
- failures.push({
- name,
- description: entry.description || '',
- error: String(error.message || error),
- hint: entry.hint || '',
- });
- }
- });
-
- return { ok: failures.length === 0, passed, failures };
-}
-
-// ---------------------------------------------------------------------------
-// Execution state & artifacts
-// ---------------------------------------------------------------------------
-
-function ensureExecutionState(runOptions) {
- if (!runOptions.executionState) {
- runOptions.executionState = {
- artifacts: null,
- failureArtifacts: [],
- referencedEvalRefs: new Set(),
- };
- }
- return runOptions.executionState;
-}
-
-function ensureRunArtifacts(runOptions, recipePath) {
- const state = ensureExecutionState(runOptions);
- if (state.artifacts || runOptions.dryRun) {
- return state.artifacts;
- }
-
- const explicitArtifactsDir = runOptions.artifactsDir
- ? path.resolve(runOptions.artifactsDir)
- : '';
- const baseDir =
- explicitArtifactsDir ||
- path.join(runOptions.appRoot, '.agent', 'recipe-runs');
- const recipeLabel = sanitizeFileSegment(path.basename(recipePath, path.extname(recipePath)));
- const rootDir = explicitArtifactsDir || path.resolve(path.join(baseDir, `${timestampSlug()}_${recipeLabel}`));
-
- const artifacts = {
- rootDir,
- screenshotsDir: path.join(rootDir, 'screenshots'),
- failuresDir: path.join(rootDir, 'failures'),
- logsDir: path.join(rootDir, 'logs'),
- tracesDir: path.join(rootDir, 'traces'),
- tracePath: path.join(rootDir, 'trace.json'),
- workflowPath: path.join(rootDir, 'workflow.json'),
- workflowMermaidPath: path.join(rootDir, 'workflow.mmd'),
- summaryPath: path.join(rootDir, 'summary.json'),
- runLogPath: path.join(rootDir, 'run.log'),
- };
-
- [artifacts.rootDir, artifacts.screenshotsDir, artifacts.failuresDir, artifacts.logsDir, artifacts.tracesDir]
- .forEach((dirPath) => fs.mkdirSync(dirPath, { recursive: true }));
-
- state.artifacts = artifacts;
- return artifacts;
-}
-
-function resolveMetroLogPath(runOptions) {
- if (process.env.METRO_LOG) {
- return process.env.METRO_LOG;
- }
- return path.join(runOptions.appRoot, '.agent', 'metro.log');
-}
-
-function armIssueTracker(runOptions) {
- const state = ensureExecutionState(runOptions);
- if (state.issueTracker || runOptions.dryRun) {
- return state.issueTracker;
- }
-
- const metroLogPath = resolveMetroLogPath(runOptions);
- let startOffset = 0;
- try {
- startOffset = fs.existsSync(metroLogPath) ? fs.statSync(metroLogPath).size : 0;
- } catch {
- startOffset = 0;
- }
-
- // Install in-app console/exception hooks. Best-effort: if the CDP bridge
- // isn't reachable yet (app booting, port not bound) we still fall back to
- // metro-log scraping at collection time.
- let armed = false;
- try {
- const armResponse = trySpawnBridge(runOptions.appRoot, ['issues-arm']);
- if (armResponse && armResponse.ok && armResponse.result?.installed) {
- armed = true;
- }
- } catch {
- armed = false;
- }
-
- state.issueTracker = { metroLogPath, startOffset, armed };
- return state.issueTracker;
-}
-
-function collectRecipeIssues(runOptions, document) {
- const state = ensureExecutionState(runOptions);
- const tracker = state.issueTracker;
- if (!tracker || runOptions.dryRun) {
- return null;
- }
-
- // Pull the in-app buffer (best-effort). Failures here don't mask the metro
- // log — we still synthesize review from whatever channel reported.
- let appEntries = [];
- if (tracker.armed) {
- try {
- const resp = trySpawnBridge(runOptions.appRoot, ['issues-collect']);
- if (resp && resp.ok && Array.isArray(resp.result?.entries)) {
- appEntries = resp.result.entries;
- }
- } catch {
- appEntries = [];
- }
- }
-
- const metroIssues = captureFromMetro(tracker.metroLogPath, tracker.startOffset);
- const appIssues = normalizeAppBufferEntries(appEntries);
- const allIssues = [...appIssues, ...metroIssues];
-
- const failOn =
- document && typeof document.fail_on_unexpected === 'object'
- ? document.fail_on_unexpected
- : null;
- const allowlist = failOn && Array.isArray(failOn.allowlist) ? failOn.allowlist : [];
-
- const { unexpected, informational } = applyAllowlist(allIssues, allowlist);
- const review = computeReview(unexpected, informational, failOn, {});
-
- const runDir = state.artifacts?.rootDir;
- let artifactFiles = {};
- if (runDir) {
- try {
- artifactFiles = writeIssueArtifacts(runDir, { unexpected, informational, review });
- } catch (error) {
- console.error(`recipe-issues artifact write failed: ${String(error.message || error)}`);
- artifactFiles = {};
- }
- }
- review.artifactFiles = artifactFiles;
-
- const captured = countByLevel(dedupeIssues(allIssues));
- const unexpectedCounts = countByLevel(dedupeIssues(unexpected));
-
- const recipeIssues = {
- captured,
- unexpected: unexpectedCounts,
- failOn: {
- levels: failOn && Array.isArray(failOn.levels) ? failOn.levels.slice() : [],
- textMatches:
- failOn && Array.isArray(failOn.textMatches)
- ? failOn.textMatches.slice()
- : [],
- },
- review,
- };
-
- state.recipeIssues = recipeIssues;
- return recipeIssues;
-}
-
-function writeRunSummary(runOptions, summary) {
- const state = ensureExecutionState(runOptions);
- if (!state.artifacts) {
- return;
- }
-
- fs.writeFileSync(state.artifacts.summaryPath, `${JSON.stringify(summary, null, 2)}\n`);
-}
-
-function serializeExecutionContext(executionContext) {
- if (!executionContext) {
- return null;
- }
-
- return {
- env: deepClone(executionContext.env || {}),
- inputs: deepClone(executionContext.inputs || {}),
- last: deepClone(executionContext.last ?? null),
- nodes: deepClone(executionContext.nodes || {}),
- trace: deepClone(executionContext.trace || []),
- vars: deepClone(executionContext.vars || {}),
- };
-}
-
-function writeWorkflowArtifacts(context) {
- if (context.runOptions.dryRun || context.depth !== 0) {
- return;
- }
-
- const artifacts = ensureRunArtifacts(context.runOptions, context.recipePath);
- if (!artifacts) {
- return;
- }
-
- fs.writeFileSync(
- artifacts.workflowPath,
- `${JSON.stringify(
- {
- title: context.normalizedDocument.title || '',
- description: context.normalizedDocument.description || '',
- sourcePath: context.normalizedDocument.sourcePath,
- hooks: context.hooks,
- workflow: context.workflow,
- },
- null,
- 2
- )}\n`
- );
- fs.writeFileSync(
- artifacts.workflowMermaidPath,
- renderWorkflowMermaid(context.normalizedDocument)
- );
-}
-
-function writeTraceArtifacts(context) {
- if (context.runOptions.dryRun || context.depth !== 0) {
- return;
- }
-
- const artifacts = ensureRunArtifacts(context.runOptions, context.recipePath);
- if (!artifacts) {
- return;
- }
-
- fs.writeFileSync(
- artifacts.tracePath,
- `${JSON.stringify(serializeExecutionContext(context.executionContext), null, 2)}\n`
- );
-}
-
-// ---------------------------------------------------------------------------
-// Failure capture
-// ---------------------------------------------------------------------------
-
-function trackEvalRef(runOptions, ref) {
- ensureExecutionState(runOptions).referencedEvalRefs.add(ref);
-}
-
-function collectRelevantEvalRefs(step, appRoot, defaultTeam, runOptions) {
- const refs = Array.from(ensureExecutionState(runOptions).referencedEvalRefs);
- if (step?.action === 'eval_ref' && step.ref) {
- try {
- const resolved = resolveEvalRef(step.ref, { appRoot, defaultTeam });
- if (!refs.includes(resolved.ref)) {
- refs.push(resolved.ref);
- }
- } catch {
- // Ignore ref resolution failures while collecting diagnostics.
- }
- }
-
- return refs.slice(-8);
-}
-
-function snapshotEvalRefs(appRoot, refs) {
- const snapshots = {};
- refs.forEach((ref) => {
- const response = trySpawnBridge(appRoot, ['eval-ref', ref]);
- snapshots[ref] = response.ok ? response.result : { error: response.error };
- });
- return snapshots;
-}
-
-function captureRecentLogs(appRoot, targetDir) {
- const metroLogPath = process.env.METRO_LOG || path.join(appRoot, '.agent', 'metro.log');
- const captured = [];
-
- if (fs.existsSync(metroLogPath)) {
- const filename = sanitizeFileSegment(path.basename(metroLogPath));
- const destination = path.join(targetDir, filename);
- const excerpt = readRecentLines(metroLogPath, DEFAULT_LOG_LINES).join('\n');
- fs.writeFileSync(destination, `${excerpt}\n`);
- captured.push({ source: path.resolve(metroLogPath), excerpt: path.resolve(destination) });
- }
-
- return captured;
-}
-
-function captureFailureArtifacts(context) {
- const {
- appRoot,
- runOptions,
- recipePath,
- document,
- defaultTeam,
- step,
- depth,
- error,
- executionContext,
- } = context;
-
- const artifacts = ensureRunArtifacts(runOptions, recipePath);
- if (!artifacts) {
- return null;
- }
-
- const state = ensureExecutionState(runOptions);
- const failureIndex = state.failureArtifacts.length + 1;
- const failureLabel = sanitizeFileSegment(step?.id || step?.action || 'failure');
- const failureDir = path.join(
- artifacts.failuresDir,
- `${String(failureIndex).padStart(2, '0')}_${failureLabel}`
- );
- fs.mkdirSync(failureDir, { recursive: true });
-
- const route = trySpawnBridge(appRoot, ['get-route']);
- const currentState = trySpawnBridge(appRoot, ['get-state']);
-
- // Screenshot via screenshot.sh
- let screenshotPath = '';
- try {
- const screenshotResult = spawnSync('bash', [path.join(SD, 'screenshot.sh'), `failure-${failureLabel}`], {
- cwd: appRoot, encoding: 'utf8',
- });
- const spath = (screenshotResult.stdout || '').trim();
- if (spath && fs.existsSync(spath)) {
- const dest = path.join(failureDir, path.basename(spath));
- fs.copyFileSync(spath, dest);
- screenshotPath = path.resolve(dest);
- }
- } catch {
- // Screenshot is best-effort
- }
-
- const evalRefs = snapshotEvalRefs(
- appRoot,
- collectRelevantEvalRefs(step, appRoot, defaultTeam, runOptions)
- );
- const logs = captureRecentLogs(appRoot, failureDir);
-
- const payload = {
- capturedAt: new Date().toISOString(),
- depth,
- recipePath,
- recipeTitle: document.title || '',
- step: step || null,
- error: String(error.message || error),
- route: route.ok ? route.result : { error: route.error },
- state: currentState.ok ? currentState.result : { error: currentState.error },
- workflowState: serializeExecutionContext(executionContext),
- evalRefs,
- logs,
- screenshot: screenshotPath,
- };
-
- const payloadPath = path.join(failureDir, 'failure.json');
- fs.writeFileSync(payloadPath, `${JSON.stringify(payload, null, 2)}\n`);
-
- const record = {
- dir: failureDir,
- details: payloadPath,
- stepId: step?.id || '',
- error: payload.error,
- };
- state.failureArtifacts.push(record);
- return record;
-}
-
-// ---------------------------------------------------------------------------
-// Interaction failure check
-// ---------------------------------------------------------------------------
-
-function buildInteractionFailureMessage(step, result) {
- if (result && typeof result === 'object' && result.ok === false) {
- return result.error || `${step.action} returned ok=false`;
- }
- return '';
-}
-
-// ---------------------------------------------------------------------------
-// Runtime context
-// ---------------------------------------------------------------------------
-
-function buildRuntimeContext(recipePath, runOptions, flowParams, depth) {
- const rendered = renderDocument(recipePath, flowParams);
- const document = rendered.document;
- const resolvedInputs = rendered.params;
- const appRoot = runOptions.appRoot;
- const defaultTeam = inferTeamFromPath(recipePath, appRoot);
- const normalizedDocument = normalizeWorkflowDocument(document, { sourcePath: recipePath });
- const stats = { total: 0, passed: 0, skipped: 0 };
-
- return {
- appRoot,
- defaultTeam,
- depth,
- document,
- executionContext: {
- env: { appRoot, recipePath, team: defaultTeam || '' },
- inputs: deepClone(resolvedInputs),
- last: null,
- nodes: {},
- trace: [],
- vars: {},
- },
- hooks: normalizedDocument.hooks,
- normalizedDocument,
- recipeDir: path.dirname(recipePath),
- recipePath,
- runOptions,
- stats,
- workflow: normalizedDocument.workflow,
- currentStepRef: { current: null },
- };
-}
-
-// ---------------------------------------------------------------------------
-// Node execution helpers
-// ---------------------------------------------------------------------------
-
-function shouldCountNode(node) {
- return node.action !== 'end';
-}
-
-function shouldSkipForSingleStep(node, context, options = {}) {
- const { applySingleStep = false } = options;
- const { depth, runOptions } = context;
-
- return (
- applySingleStep &&
- runOptions.singleStep &&
- depth === 0 &&
- EXECUTABLE_ACTIONS.has(node.action) &&
- node.id !== runOptions.singleStep
- );
-}
-
-function buildConditionPayload(context) {
- return {
- env: context.executionContext.env,
- inputs: context.executionContext.inputs,
- last: context.executionContext.last,
- nodes: context.executionContext.nodes,
- trace: context.executionContext.trace,
- vars: context.executionContext.vars,
- };
-}
-
-function evaluateWorkflowCondition(condition, context) {
- if (!condition) {
- return true;
- }
-
- return evaluateAssert(buildConditionPayload(context), condition);
-}
-
-function finalizeNodeRecord(context, node, details = {}) {
- const finishedAt = new Date().toISOString();
- const entry = {
- action: node.action,
- description: describeStep(node),
- finishedAt,
- id: node.id,
- status: details.status || 'pass',
- };
-
- if (details.startedAt) {
- entry.startedAt = details.startedAt;
- entry.durationMs =
- new Date(finishedAt).getTime() - new Date(details.startedAt).getTime();
- }
-
- if (Object.prototype.hasOwnProperty.call(details, 'next')) {
- entry.next = details.next || '';
- }
-
- if (Object.prototype.hasOwnProperty.call(details, 'note')) {
- entry.note = details.note;
- }
-
- if (Object.prototype.hasOwnProperty.call(details, 'branch')) {
- entry.branch = deepClone(details.branch);
- }
-
- if (Object.prototype.hasOwnProperty.call(details, 'result')) {
- entry.result = deepClone(details.result);
- context.executionContext.last = deepClone(details.result);
- if (node.save_as) {
- context.executionContext.vars[node.save_as] = deepClone(details.result);
- }
- }
-
- context.executionContext.nodes[node.id] = entry;
- context.executionContext.trace.push(entry);
- return entry;
-}
-
-function markNodeSkipped(node, context, reason, details = {}) {
- if (shouldCountNode(node)) {
- context.stats.total += 1;
- context.stats.skipped += 1;
- }
-
- console.log(`[${node.id || '?'}] ${describeStep(node)}`);
- console.log(` [SKIPPED - ${reason}]`);
- console.log('');
-
- finalizeNodeRecord(context, node, {
- ...details,
- note: reason,
- status: 'skipped',
- });
-}
-
-function printPassWithResult(result, prefix = 'result') {
- if (typeof result !== 'undefined') {
- console.log(` ${prefix}: ${formatResultPreview(result)}`);
- }
- console.log(' PASS');
- console.log('');
-}
-
-// ---------------------------------------------------------------------------
-// MetaMask-specific action handlers
-// ---------------------------------------------------------------------------
-
-function handleTypeKeypad(node, appRoot) {
- const value = String(node.value || '');
- for (let i = 0; i < value.length; i++) {
- const c = value[i];
- let keyId;
- if (c >= '0' && c <= '9') {
- keyId = `keypad-key-${c}`;
- } else if (c === '.') {
- keyId = 'keypad-key-dot';
- } else {
- continue;
- }
- spawnBridge(appRoot, ['press-test-id', keyId]);
- }
- return { ok: true, value };
-}
-
-function handleClearKeypad(node, appRoot) {
- const count = Number(node.count || 8);
- for (let i = 0; i < count; i++) {
- try {
- spawnBridge(appRoot, ['press-test-id', 'keypad-delete-button']);
- } catch {
- // best-effort
- }
- }
- return { ok: true, deleted: count };
-}
-
-function handleSelectAccount(node, appRoot) {
- return spawnBridge(appRoot, ['switch-account', node.address]);
-}
-
-function handleToggleTestnet(node, appRoot) {
- const desired = node.enabled !== undefined ? String(node.enabled) : 'true';
- const current = rawResultString(
- spawnBridge(appRoot, ['eval', 'Engine.context.PerpsController.state.isTestnet'])
- );
- if (current === desired) {
- return { ok: true, already: true };
- }
- return spawnBridge(appRoot, [
- 'eval-async',
- 'Engine.context.PerpsController.toggleTestnet().then(function(r){return JSON.stringify(r)})',
- ]);
-}
-
-function handleSwitchProvider(node, appRoot) {
- return spawnBridge(appRoot, [
- 'eval-async',
- `Engine.context.PerpsController.switchProvider('${node.provider}').then(function(r){return JSON.stringify(r)})`,
- ]);
-}
-
-// ---------------------------------------------------------------------------
-// Core node executor
-// ---------------------------------------------------------------------------
-
-async function runExecutableNode(node, context, options = {}) {
- const { appRoot, defaultTeam, depth, document, runOptions, stats } = context;
- const startedAt = new Date().toISOString();
-
- if (shouldSkipForSingleStep(node, context, options)) {
- markNodeSkipped(node, context, 'single-step mode', { next: node.next || '', startedAt });
- return { next: node.next || '' };
- }
-
- context.currentStepRef.current = node;
- if (shouldCountNode(node)) {
- stats.total += 1;
- }
- console.log(`[${node.id || '?'}] ${describeStep(node)}`);
-
- if (runOptions.dryRun) {
- stats.skipped += 1;
- console.log(' [DRY RUN - not executed]');
- console.log('');
- finalizeNodeRecord(context, node, {
- next: node.next || '', note: 'dry run', startedAt, status: 'dry_run',
- });
- context.currentStepRef.current = null;
- return { next: node.next || '' };
- }
-
- if (node.when && !evaluateWorkflowCondition(node.when, context)) {
- if (shouldCountNode(node)) {
- stats.skipped += 1;
- }
- console.log(' [SKIPPED - when condition did not match]');
- console.log('');
- finalizeNodeRecord(context, node, {
- next: node.next || '', note: 'when condition did not match', startedAt, status: 'skipped',
- });
- context.currentStepRef.current = null;
- return { next: node.next || '' };
- }
-
- if (node.unless && evaluateWorkflowCondition(node.unless, context)) {
- if (shouldCountNode(node)) {
- stats.skipped += 1;
- }
- console.log(' [SKIPPED - unless condition matched]');
- console.log('');
- finalizeNodeRecord(context, node, {
- next: node.next || '', note: 'unless condition matched', startedAt, status: 'skipped',
- });
- context.currentStepRef.current = null;
- return { next: node.next || '' };
- }
-
- if (!(node.action === 'manual' && runOptions.skipManual)) {
- publishHudStep(appRoot, {
- id: String(node.id || '?'),
- action: String(node.action || ''),
- description: describeStep(node),
- recipe: document.title || '',
- depth,
- }, runOptions);
- }
-
- // --- Manual ---
- if (node.action === 'manual') {
- if (runOptions.skipManual) {
- stats.skipped += 1;
- console.log(' [SKIPPED - manual step]');
- console.log('');
- finalizeNodeRecord(context, node, {
- next: node.next || '', note: 'manual step skipped', startedAt, status: 'skipped',
- });
- context.currentStepRef.current = null;
- return { next: node.next || '' };
- }
-
- const accepted = await promptManual(node.note || '');
- if (!accepted) {
- stats.skipped += 1;
- console.log(' [SKIPPED]');
- finalizeNodeRecord(context, node, {
- next: node.next || '', result: { accepted: false }, startedAt, status: 'skipped',
- });
- } else {
- stats.passed += 1;
- console.log(' PASS');
- finalizeNodeRecord(context, node, {
- next: node.next || '', result: { accepted: true }, startedAt,
- });
- }
- console.log('');
- context.currentStepRef.current = null;
- return { next: node.next || '' };
- }
-
- // --- Wait ---
- if (node.action === 'wait') {
- const ms = Number(node.ms || 1000);
- await new Promise((resolve) => setTimeout(resolve, ms));
- stats.passed += 1;
- console.log(` waited ${ms}ms`);
- console.log(' PASS');
- console.log('');
- finalizeNodeRecord(context, node, {
- next: node.next || '', result: { waitedMs: ms }, startedAt,
- });
- context.currentStepRef.current = null;
- return { next: node.next || '' };
- }
-
- // --- Wait for ---
- if (node.action === 'wait_for') {
- const waitResult = await waitForCondition(node, appRoot);
- if (!waitResult.ok) {
- throw new Error(
- `wait_for timed out after ${node.timeout_ms || 10000}ms (${waitResult.label})\n last result: ${formatResultPreview(waitResult.result)}`
- );
- }
-
- stats.passed += 1;
- printPassWithResult(waitResult.result);
- finalizeNodeRecord(context, node, {
- next: node.next || '', result: parseRaw(rawResultString(waitResult.result)), startedAt,
- });
- context.currentStepRef.current = null;
- return { next: node.next || '' };
- }
-
- // --- App lifecycle (delegated to lib/app-lifecycle.js) ---
- if (node.action === 'app_background') {
- const { bundleId } = backgroundApp();
- const durationMs = Number(node.duration_ms || 5000);
- await new Promise((resolve) => setTimeout(resolve, durationMs));
- stats.passed += 1;
- console.log(` backgrounded for ${durationMs}ms`);
- console.log(' PASS');
- console.log('');
- finalizeNodeRecord(context, node, {
- next: node.next || '', result: { backgrounded: true, durationMs, bundleId }, startedAt,
- });
- context.currentStepRef.current = null;
- return { next: node.next || '' };
- }
-
- if (node.action === 'app_foreground') {
- const { bundleId } = foregroundApp();
- await new Promise((resolve) => setTimeout(resolve, 2000));
- stats.passed += 1;
- console.log(` foregrounded (${bundleId})`);
- console.log(' PASS');
- console.log('');
- finalizeNodeRecord(context, node, {
- next: node.next || '', result: { foregrounded: true, bundleId }, startedAt,
- });
- context.currentStepRef.current = null;
- return { next: node.next || '' };
- }
-
- if (node.action === 'app_restart') {
- const { bundleId } = restartApp();
- const bootWaitMs = Number(node.boot_wait_ms || 15000);
- console.log(` waiting ${bootWaitMs}ms for app boot + Metro reconnect...`);
- await new Promise((resolve) => setTimeout(resolve, bootWaitMs));
- stats.passed += 1;
- console.log(` restarted (${bundleId})`);
- console.log(' PASS');
- console.log('');
- finalizeNodeRecord(context, node, {
- next: node.next || '', result: { restarted: true, bundleId }, startedAt,
- });
- context.currentStepRef.current = null;
- return { next: node.next || '' };
- }
-
- // --- Log watch ---
- if (node.action === 'log_watch') {
- const metroLogPath = process.env.METRO_LOG || path.join(appRoot, '.agent', 'metro.log');
- const result = scanLog(node, metroLogPath);
- if (!result.pass) {
- throw new Error(`must_not_appear strings were found: ${result.must_not_found.join(', ')}`);
- }
- stats.passed += 1;
- printPassWithResult(result.watch_counts, 'watch_for');
- finalizeNodeRecord(context, node, {
- next: node.next || '', result, startedAt,
- });
- context.currentStepRef.current = null;
- return { next: node.next || '' };
- }
-
- // --- Call (flow) ---
- if (node.action === 'call') {
- const flow = resolveFlowRef(node.ref, { appRoot, defaultTeam });
- const summary = await runRecipe(
- flow.filePath,
- { ...runOptions, singleStep: '' },
- node.params || {},
- depth + 1
- );
- stats.passed += 1;
- console.log(` flow: ${flow.ref}`);
- console.log(' PASS');
- console.log('');
- finalizeNodeRecord(context, node, {
- next: node.next || '',
- result: { ref: flow.ref, title: summary.title, counts: summary.counts },
- startedAt,
- });
- context.currentStepRef.current = null;
- return { next: node.next || '' };
- }
-
- // --- Screenshot ---
- if (node.action === 'screenshot') {
- const label = sanitizeFileSegment(node.filename || node.id || 'recipe');
- let screenshotPath = '';
- try {
- const screenshotResult = spawnSync('bash', [path.join(SD, 'screenshot.sh'), label], {
- cwd: appRoot, encoding: 'utf8',
- });
- screenshotPath = (screenshotResult.stdout || '').trim();
- } catch {
- // best-effort
- }
-
- const artifacts = ensureRunArtifacts(runOptions, context.recipePath);
- if (screenshotPath && artifacts && fs.existsSync(screenshotPath)) {
- const dest = path.join(artifacts.screenshotsDir, path.basename(screenshotPath));
- fs.copyFileSync(screenshotPath, dest);
- if (typeof node.note === 'string' && node.note.trim().length > 0) {
- fs.writeFileSync(
- `${dest}.json`,
- `${JSON.stringify({ note: node.note, nodeId: node.id }, null, 2)}\n`,
- );
- }
- }
-
- stats.passed += 1;
- console.log(` screenshot: ${screenshotPath || 'none'}`);
- console.log(' PASS');
- console.log('');
- finalizeNodeRecord(context, node, {
- next: node.next || '', result: { screenshot: screenshotPath }, startedAt,
- });
- context.currentStepRef.current = null;
- return { next: node.next || '' };
- }
-
- // --- MetaMask-specific actions ---
- let bridgeResult;
-
- switch (node.action) {
- case 'navigate': {
- const navArgs = ['navigate', node.target];
- if (node.params) {
- navArgs.push(JSON.stringify(node.params));
- }
- bridgeResult = spawnBridge(appRoot, navArgs);
- break;
- }
- case 'eval_sync':
- bridgeResult = spawnBridge(appRoot, ['eval', node.expression]);
- break;
- case 'eval_async':
- bridgeResult = spawnBridge(appRoot, ['eval-async', node.expression]);
- break;
- case 'eval_ref': {
- const evalRef = resolveEvalRef(node.ref, { appRoot, defaultTeam });
- trackEvalRef(runOptions, evalRef.ref);
- bridgeResult = spawnBridge(appRoot, ['eval-ref', evalRef.ref]);
- break;
- }
- case 'press':
- bridgeResult = spawnBridge(appRoot, ['press-test-id', node.test_id]);
- break;
- case 'scroll': {
- const scrollArgs = ['scroll-view'];
- if (node.test_id) {
- scrollArgs.push('--test-id', node.test_id);
- }
- scrollArgs.push('--offset', String(node.offset ?? 300));
- scrollArgs.push(node.animated === true ? '--animated' : '--no-animated');
- bridgeResult = spawnBridge(appRoot, scrollArgs);
- break;
- }
- case 'set_input':
- bridgeResult = spawnBridge(appRoot, ['set-input', node.test_id, String(node.value ?? '')]);
- break;
- case 'type_keypad':
- bridgeResult = handleTypeKeypad(node, appRoot);
- break;
- case 'clear_keypad':
- bridgeResult = handleClearKeypad(node, appRoot);
- break;
- case 'select_account':
- bridgeResult = handleSelectAccount(node, appRoot);
- break;
- case 'toggle_testnet':
- bridgeResult = handleToggleTestnet(node, appRoot);
- break;
- case 'switch_provider':
- bridgeResult = handleSwitchProvider(node, appRoot);
- break;
- case 'trace_start':
- bridgeResult = spawnBridge(appRoot, ['profiler-start']);
- break;
- case 'trace_stop': {
- const artifacts = ensureRunArtifacts(runOptions, context.recipePath);
- const traceLabel = sanitizeFileSegment(node.label || node.id || 'trace');
- const outPath = artifacts && artifacts.tracesDir
- ? path.join(artifacts.tracesDir, `trace-${traceLabel}.cpuprofile`)
- : '';
- const stopArgs = ['profiler-stop', '--label', traceLabel];
- if (outPath) {
- stopArgs.push('--out', outPath);
- }
- bridgeResult = spawnBridge(appRoot, stopArgs);
- break;
- }
- default:
- throw new Error(`Unknown action "${node.action}"`);
- }
-
- const failureMessage = buildInteractionFailureMessage(node, bridgeResult);
- if (failureMessage) {
- throw new Error(failureMessage);
- }
-
- const raw = rawResultString(bridgeResult);
- if (node.assert && !checkAssert(raw, node.assert)) {
- throw new Error(
- `Assertion failed for step "${node.id || '?'}"\n result: ${formatResultPreview(parseRaw(raw))}\n assert: ${JSON.stringify(node.assert)}`
- );
- }
-
- stats.passed += 1;
- printPassWithResult(bridgeResult);
- finalizeNodeRecord(context, node, {
- next: node.next || '', result: parseRaw(raw), startedAt,
- });
- context.currentStepRef.current = null;
- return { next: node.next || '' };
-}
-
-// ---------------------------------------------------------------------------
-// Switch & End nodes
-// ---------------------------------------------------------------------------
-
-async function executeSwitchNode(node, context) {
- const startedAt = new Date().toISOString();
- context.currentStepRef.current = node;
- context.stats.total += 1;
- console.log(`[${node.id || '?'}] ${describeStep(node)}`);
-
- const cases = Array.isArray(node.cases) ? node.cases : [];
- let selected = null;
-
- for (const entry of cases) {
- if (!entry.when || evaluateWorkflowCondition(entry.when, context)) {
- selected = { assumed: false, label: entry.label || '', next: entry.next };
- break;
- }
- }
-
- if (!selected && context.runOptions.dryRun && cases[0]?.next) {
- selected = { assumed: true, label: cases[0].label || '', next: cases[0].next };
- }
-
- if (!selected && node.default) {
- selected = { assumed: false, label: 'default', next: node.default };
- }
-
- if (!selected?.next) {
- throw new Error(`Switch node "${node.id}" did not resolve a branch target`);
- }
-
- const label = selected.label ? ` (${selected.label})` : '';
- if (selected.assumed) {
- context.stats.skipped += 1;
- console.log(` [DRY RUN - assuming${label || ' first branch'} -> ${selected.next}]`);
- } else {
- context.stats.passed += 1;
- console.log(` branch -> ${selected.next}${label}`);
- }
- console.log(' PASS');
- console.log('');
-
- finalizeNodeRecord(context, node, {
- branch: selected,
- next: selected.next,
- note: selected.assumed ? 'dry run branch assumption' : '',
- startedAt,
- status: selected.assumed ? 'dry_run' : 'pass',
- });
- context.currentStepRef.current = null;
- return { next: selected.next };
-}
-
-async function executeEndNode(node, context) {
- context.currentStepRef.current = node;
- finalizeNodeRecord(context, node, {
- note: node.message || '',
- result: { message: node.message || '', status: node.status || 'pass' },
- status: node.status || 'pass',
- });
-
- context.currentStepRef.current = null;
- if (String(node.status || 'pass').toLowerCase() === 'fail') {
- throw new Error(node.message || `Workflow terminated at end node "${node.id}"`);
- }
-
- return { done: true };
-}
-
-async function executeWorkflowNode(node, context, options = {}) {
- // Handle when-guard for switch/end nodes (runExecutableNode handles its own)
- if ((node.action === 'switch' || node.action === 'end') && node.when && !evaluateWorkflowCondition(node.when, context)) {
- const startedAt = new Date().toISOString();
- context.currentStepRef.current = node;
- if (shouldCountNode(node)) {
- context.stats.total += 1;
- context.stats.skipped += 1;
- }
- console.log(`[${node.id || '?'}] ${describeStep(node)}`);
- console.log(' [SKIPPED - when condition did not match]');
- console.log('');
- finalizeNodeRecord(context, node, {
- next: node.default || node.next || '',
- note: 'when condition did not match',
- startedAt,
- status: 'skipped',
- });
- context.currentStepRef.current = null;
- return { next: node.default || node.next || '' };
- }
-
- if (node.action === 'end') {
- return executeEndNode(node, context);
- }
-
- if (node.action === 'switch') {
- return executeSwitchNode(node, context);
- }
-
- return runExecutableNode(node, context, options);
-}
-
-// ---------------------------------------------------------------------------
-// Linear step collection (for setup/teardown hooks)
-// ---------------------------------------------------------------------------
-
-async function executeLinearStepCollection(steps, context, options = {}) {
- if (!Array.isArray(steps) || steps.length === 0) {
- return;
- }
-
- if (options.label) {
- console.log(`${options.label}:`);
- }
-
- for (let index = 0; index < steps.length; index += 1) {
- const step = steps[index];
- const node = {
- ...step,
- action: String(step.action || step.type || ''),
- id: String(step.id || `${options.label || 'hook'}-${index + 1}`),
- };
- await runExecutableNode(node, context, { applySingleStep: false });
- }
-}
-
-// ---------------------------------------------------------------------------
-// Workflow graph execution
-// ---------------------------------------------------------------------------
-
-async function executeWorkflowGraph(context, options = {}) {
- const { workflow } = context;
- let currentNodeId = workflow.entry;
- let traversed = 0;
- const maxTraversals = Math.max(Object.keys(workflow.nodes || {}).length * 20, 20);
-
- while (currentNodeId) {
- traversed += 1;
- if (traversed > maxTraversals) {
- throw new Error(
- `Workflow traversal exceeded ${maxTraversals} nodes. Refusing to continue.`
- );
- }
-
- const node = workflow.nodes[currentNodeId];
- if (!node) {
- throw new Error(`Workflow references missing node "${currentNodeId}"`);
- }
-
- const resolution = await executeWorkflowNode(node, context, options);
- if (resolution?.done) {
- return;
- }
-
- if (!resolution?.next) {
- throw new Error(`Node "${node.id}" did not resolve a next transition`);
- }
-
- currentNodeId = resolution.next;
- }
-}
-
-// ---------------------------------------------------------------------------
-// Recipe runner
-// ---------------------------------------------------------------------------
-
-async function runRecipe(recipePath, runOptions, flowParams = {}, depth = 0) {
- const context = buildRuntimeContext(recipePath, runOptions, flowParams, depth);
- const {
- appRoot,
- defaultTeam,
- document,
- executionContext,
- hooks,
- normalizedDocument,
- stats,
- workflow,
- } = context;
- const prefix = depth > 0 ? `${' '.repeat(depth)}> ` : '';
- const state = ensureExecutionState(runOptions);
- let currentStep = null;
- let failureError = null;
-
- if (!runOptions.dryRun) {
- ensureRunArtifacts(runOptions, recipePath);
- writeWorkflowArtifacts(context);
- if (depth === 0) {
- armIssueTracker(runOptions);
- }
- }
-
- console.log(`${prefix}Running recipe: ${document.title || 'Untitled'}`);
- if (defaultTeam) {
- console.log(`${prefix}Team: ${defaultTeam}`);
- }
- if (hooks.pre_conditions?.length) {
- const pcLabels = hooks.pre_conditions.map((spec) =>
- typeof spec === 'string' ? spec : spec.name || JSON.stringify(spec)
- );
- console.log(`${prefix}Pre-conditions: ${pcLabels.join(', ')}`);
- }
- if (hooks.setup?.length) {
- console.log(`${prefix}Setup: ${hooks.setup.length} step(s)`);
- }
- if (hooks.teardown?.length) {
- console.log(`${prefix}Teardown: ${hooks.teardown.length} step(s)`);
- }
- console.log(`${prefix}Workflow nodes: ${Object.keys(workflow.nodes || {}).length}`);
- console.log('');
-
- try {
- if (!runOptions.dryRun && hooks.pre_conditions?.length) {
- currentStep = { id: 'pre-conditions', action: 'pre_conditions', description: 'evaluate pre-conditions' };
- const preConditionsResult = evaluatePreConditions(document, appRoot);
- if (!preConditionsResult.ok) {
- console.log('PRE-CONDITIONS FAILED');
- preConditionsResult.failures.forEach((failure) => {
- console.log(` - ${failure.name}${failure.description ? `: ${failure.description}` : ''}`);
- if (failure.error) {
- console.log(` error: ${failure.error}`);
- }
- if (failure.got) {
- console.log(` got: ${failure.got}`);
- }
- if (failure.hint) {
- console.log(` hint: ${failure.hint}`);
- }
- });
- throw new Error('Recipe pre-conditions failed');
- }
-
- console.log('Pre-conditions: PASS');
- console.log('');
- currentStep = null;
- }
-
- if (hooks.setup?.length) {
- currentStep = { id: 'setup', action: 'setup', description: 'run setup hooks' };
- await executeLinearStepCollection(hooks.setup, context, { label: 'Setup hooks' });
- currentStep = null;
- }
-
- await executeWorkflowGraph(context, { applySingleStep: true });
-
- console.log('----------------------------------------');
- if (runOptions.dryRun) {
- console.log(`Results: ${stats.total} node(s) dry-run only`);
- console.log('Recipe: DRY RUN');
- } else {
- console.log(
- `Results: ${stats.passed}/${stats.total} passed${stats.skipped ? `, ${stats.skipped} skipped` : ''}`
- );
- console.log('Recipe: PASS');
- }
- console.log('');
- } catch (error) {
- failureError = error;
- if (!runOptions.dryRun) {
- captureFailureArtifacts({
- appRoot,
- defaultTeam,
- depth,
- document,
- error,
- executionContext,
- normalizedDocument,
- recipePath,
- runOptions,
- step: context.currentStepRef.current || currentStep,
- });
- }
- }
-
- // Teardown runs regardless of success/failure
- let pendingTeardownError = null;
- try {
- if (!runOptions.dryRun && hooks.teardown?.length) {
- currentStep = { id: 'teardown', action: 'teardown', description: 'run teardown hooks' };
- await executeLinearStepCollection(hooks.teardown, context, { label: 'Teardown hooks' });
- }
- } catch (teardownError) {
- if (!runOptions.dryRun) {
- captureFailureArtifacts({
- appRoot,
- defaultTeam,
- depth,
- document,
- error: teardownError,
- executionContext,
- normalizedDocument,
- recipePath,
- runOptions,
- step: context.currentStepRef.current || currentStep,
- });
- }
- if (!failureError) {
- pendingTeardownError = teardownError;
- } else {
- console.error(`Teardown warning: ${String(teardownError.message || teardownError)}`);
- }
- }
-
- if (depth === 0 && !runOptions.dryRun) {
- clearHudStep(appRoot, runOptions);
- writeTraceArtifacts(context);
- const recipeIssues = collectRecipeIssues(runOptions, document);
- const gatingTriggered =
- !!recipeIssues && recipeIssues.review?.status === 'gating';
- if (gatingTriggered && !failureError) {
- failureError = new Error(
- `Recipe issue review gating: ${recipeIssues.review?.note || 'fail_on_unexpected matched'}`
- );
- }
- writeRunSummary(runOptions, {
- status: failureError ? 'FAIL' : 'PASS',
- title: document.title || '',
- recipePath,
- counts: stats,
- failures: state.failureArtifacts,
- tracePath: state.artifacts?.tracePath || '',
- workflowMermaidPath: state.artifacts?.workflowMermaidPath || '',
- workflowPath: state.artifacts?.workflowPath || '',
- availableEvalRefs: listEvalRefs(appRoot).map((entry) => entry.ref),
- ...(recipeIssues ? { recipeIssues } : {}),
- });
- if (recipeIssues) {
- const { review } = recipeIssues;
- if (review.status === 'review') {
- console.log(
- `recipe-issues: review — ${review.observed.total} unexpected event(s). See ${review.artifactFiles?.reviewMd || ''}`
- );
- } else if (review.status === 'gating') {
- console.log(
- `recipe-issues: GATING — ${review.gating.total} event(s) matched fail_on_unexpected. See ${review.artifactFiles?.reviewMd || ''}`
- );
- }
- }
- }
-
- if (failureError) {
- throw failureError;
- }
- if (pendingTeardownError) {
- throw pendingTeardownError;
- }
-
- return {
- title: document.title || '',
- counts: stats,
- artifactDir: state.artifacts?.rootDir || '',
- failures: state.failureArtifacts,
- };
-}
-
-// ---------------------------------------------------------------------------
-// Main
-// ---------------------------------------------------------------------------
-
-async function main() {
- let teardownLog = null;
- try {
- const options = parseArgs(process.argv.slice(2));
- const appRoot = getAppRoot();
- const recipeInput = resolveRecipeInput(appRoot, options.recipe);
- const teamsDir = getTeamsDir(appRoot);
-
- if (!fs.existsSync(teamsDir)) {
- throw new Error(`No recipe teams directory found: ${teamsDir}`);
- }
-
- validateSchemaOrThrow(appRoot, recipeInput.recipePath);
-
- const runOptions = {
- appRoot,
- artifactsDir: options.artifactsDir,
- dryRun: options.dryRun,
- hud: options.hud,
- inputOverrides: options.inputOverrides,
- log: options.log,
- singleStep: options.singleStep,
- skipManual: options.skipManual,
- };
-
- // Set up log capture — tee stdout/stderr to a sanitized log file.
- // Redacts tokens, secrets, passwords, and bearer strings before writing
- // to avoid CodeQL clear-text-logging alerts.
- if (options.log && !options.dryRun) {
- const artifacts = ensureRunArtifacts(runOptions, recipeInput.recipePath);
- if (artifacts) {
- const REDACT_KEYS =
- /token|secret|password|passwd|authorization|api[_-]?key|client[_-]?key|id[_-]?token|refresh[_-]?token|access[_-]?token/i;
- const redactValue = (key, value) => {
- if (REDACT_KEYS.test(String(key))) {
- return '[REDACTED]';
- }
- return value;
- };
- const sanitizeArg = (arg) => {
- if (arg instanceof Error) {
- return `[${arg.name}] ${arg.message}`;
- }
- if (typeof arg === 'string') {
- return arg.replace(
- /(bearer\s+)[a-z0-9\-._~+/]+=*/gi,
- '$1[REDACTED]',
- );
- }
- if (arg && typeof arg === 'object') {
- try {
- return JSON.stringify(arg, redactValue);
- } catch (_) {
- return '[Unserializable Object]';
- }
- }
- return String(arg);
- };
- const formatArgs = (args) => args.map(sanitizeArg).join(' ');
-
- const logStream = fs.createWriteStream(artifacts.runLogPath, { flags: 'w' });
- const origLog = console.log.bind(console);
- const origError = console.error.bind(console);
- console.log = (...args) => {
- const formatted = formatArgs(args);
- origLog(formatted);
- logStream.write(formatted + '\n');
- };
- console.error = (...args) => {
- const formatted = formatArgs(args);
- origError(formatted);
- logStream.write(formatted + '\n');
- };
- teardownLog = () => {
- console.log = origLog;
- console.error = origError;
- logStream.end();
- };
- }
- }
-
- // Apply CLI initial conditions before running the recipe
- if (!options.dryRun) {
- if (options.account) {
- console.log(`[setup] switch-account ${options.account}`);
- trySpawnBridge(appRoot, ['switch-account', options.account]);
- }
- if (options.testnet) {
- const current = rawResultString(
- trySpawnBridge(appRoot, ['eval', 'Engine.context.PerpsController.state.isTestnet']).result || ''
- );
- if (current !== 'true') {
- console.log('[setup] toggle_testnet (enabling testnet)');
- trySpawnBridge(appRoot, [
- 'eval-async',
- 'Engine.context.PerpsController.toggleTestnet().then(function(r){return JSON.stringify(r)})',
- ]);
- }
- }
- }
-
- await runRecipe(recipeInput.recipePath, runOptions, runOptions.inputOverrides);
- if (teardownLog) {
- teardownLog();
- }
- } catch (error) {
- console.error(String(error.message || error));
- if (teardownLog) {
- teardownLog();
- }
- process.exit(1);
- }
-}
-
-main();
diff --git a/scripts/perps/agentic/validate-recipe.sh b/scripts/perps/agentic/validate-recipe.sh
deleted file mode 100755
index 9ad324973cad..000000000000
--- a/scripts/perps/agentic/validate-recipe.sh
+++ /dev/null
@@ -1,24 +0,0 @@
-#!/bin/bash
-# validate-recipe.sh — Thin wrapper that delegates to the Node.js runner.
-#
-# Usage:
-# validate-recipe.sh [--dry-run] [--step ] [--skip-manual] [--no-hud]
-#
-# See validate-recipe.js for full documentation.
-set -euo pipefail
-cd "$(dirname "$0")/../../.."
-
-# Source port config so WATCHER_PORT is in env for cdp-bridge.js.
-if [ -f .js.env ]; then
- while IFS= read -r _line || [ -n "$_line" ]; do
- [[ "$_line" =~ ^[[:space:]]*(#|$) ]] && continue
- _line="${_line#export }"
- _key="${_line%%=*}"
- _key="${_key//[[:space:]]/}"
- [[ -n "$_key" && -z "${!_key+x}" ]] && eval "export $_line" 2>/dev/null || true
- done < .js.env
- unset _line _key
-fi
-export WATCHER_PORT="${WATCHER_PORT:-8081}"
-
-exec node scripts/perps/agentic/validate-recipe.js "$@"
diff --git a/scripts/perps/agentic/wallet-fixture.example.json b/scripts/perps/agentic/wallet-fixture.example.json
index de4ebdbaebea..e859a626c3f3 100644
--- a/scripts/perps/agentic/wallet-fixture.example.json
+++ b/scripts/perps/agentic/wallet-fixture.example.json
@@ -1,14 +1,43 @@
{
"password": "your-password-here",
"accounts": [
- { "type": "mnemonic", "value": "your twelve word seed phrase goes here replace with real words" },
- { "type": "privateKey", "value": "0xYourPrivateKeyHex", "name": "Trading" }
+ {
+ "type": "mnemonic",
+ "value": "your twelve word seed phrase goes here replace with real words",
+ "name": "Primary",
+ "count": 3,
+ "names": [
+ "Primary",
+ "SRP Account 2",
+ "SRP Account 3"
+ ]
+ },
+ {
+ "type": "mnemonic",
+ "value": "optional second seed phrase goes here replace with real words",
+ "name": "Team SRP 1",
+ "count": 2,
+ "names": [
+ "Team SRP 1",
+ "Team SRP 2"
+ ]
+ },
+ {
+ "type": "privateKey",
+ "value": "0xYourPrivateKeyHex",
+ "name": "Trading"
+ },
+ {
+ "type": "privateKey",
+ "value": "0xYourSecondPrivateKeyHex",
+ "name": "MYXTrading"
+ }
],
"settings": {
- "metametrics": false,
+ "metametrics": true,
"skipGtmModals": true,
"skipPerpsTutorial": true,
"autoLockNever": true,
- "deviceAuthEnabled": false
+ "deviceAuthEnabled": true
}
}
diff --git a/tests/component-view/mocks.ts b/tests/component-view/mocks.ts
index a075983f8a05..aea6a9c48907 100644
--- a/tests/component-view/mocks.ts
+++ b/tests/component-view/mocks.ts
@@ -33,6 +33,13 @@ jest.mock('../../app/core/Engine', () => {
AccountTreeController: {
setAccountGroupName: jest.fn(),
setSelectedAccountGroup: jest.fn(),
+ getAccountsFromSelectedAccountGroup: jest.fn().mockReturnValue([
+ {
+ id: 'acc-1',
+ address: '0x0000000000000000000000000000000000000001',
+ type: 'eip155:eoa',
+ },
+ ]),
},
MultichainAccountService: {
alignWallets: jest.fn().mockResolvedValue(undefined),
@@ -266,7 +273,8 @@ jest.mock('../../app/core/Engine', () => {
.mockResolvedValue({ nextNonce: 0, releaseLock: jest.fn() }),
},
NetworkController: {
- state: { networksMetadata: {} },
+ state: { networksMetadata: {}, networkConfigurationsByChainId: {} },
+ addNetwork: jest.fn().mockResolvedValue(undefined),
getProviderAndBlockTracker() {
return {
provider: {
@@ -330,6 +338,11 @@ jest.mock('../../app/core/Engine', () => {
.mockResolvedValue({ markets: [], totalResults: 0 }),
getMarket: jest.fn().mockResolvedValue(null),
getBalance: jest.fn().mockResolvedValue(0),
+ getAccountState: jest.fn().mockResolvedValue({
+ address: '0x0000000000000000000000000000000000000001',
+ walletType: 'metamask',
+ }),
+ getActivity: jest.fn().mockResolvedValue([]),
getPositions: jest.fn().mockResolvedValue([]),
getPrices: jest.fn().mockResolvedValue({ providerId: '', results: [] }),
getMarketSeries: jest.fn().mockResolvedValue([]),
@@ -343,7 +356,11 @@ jest.mock('../../app/core/Engine', () => {
trackBannerAction: jest.fn(),
trackMarketDetailsOpened: jest.fn(),
trackGeoBlockTriggered: jest.fn(),
+ trackActivityViewed: jest.fn(),
refreshEligibility: jest.fn().mockResolvedValue(undefined),
+ claimWithConfirmation: jest.fn().mockResolvedValue(undefined),
+ depositWithConfirmation: jest.fn().mockResolvedValue(undefined),
+ prepareWithdraw: jest.fn().mockResolvedValue(undefined),
},
// Perps: stub so hooks (usePerpsClosePosition, usePerpsMarkets, etc.) do not throw
// getMarkets returns one market so PerpsTabView explore section renders "See all perps"
diff --git a/tests/component-view/renderers/predictPositions.tsx b/tests/component-view/renderers/predictPositions.tsx
new file mode 100644
index 000000000000..d6a01ba3bb1a
--- /dev/null
+++ b/tests/component-view/renderers/predictPositions.tsx
@@ -0,0 +1,62 @@
+import '../mocks';
+import React from 'react';
+import type { DeepPartial } from '../../../app/util/test/renderWithProvider';
+import type { RootState } from '../../../app/reducers';
+import { renderComponentViewScreen, renderScreenWithRoutes } from '../render';
+import Routes from '../../../app/constants/navigation/Routes';
+import PredictPositionsView from '../../../app/components/UI/Predict/views/PredictPositionsView';
+import { initialStatePredict } from '../presets/predict';
+
+interface RenderPredictPositionsViewOptions {
+ overrides?: DeepPartial;
+ initialParams?: Record;
+}
+
+const createWrappedPredictPositionsView = (): React.ComponentType =>
+ function WrappedPredictPositionsView(props: Record) {
+ return ;
+ };
+
+export function renderPredictPositionsView(
+ options: RenderPredictPositionsViewOptions = {},
+): ReturnType {
+ const { overrides, initialParams } = options;
+
+ const builder = initialStatePredict();
+ if (overrides) {
+ builder.withOverrides(overrides);
+ }
+ const state = builder.build();
+
+ return renderComponentViewScreen(
+ createWrappedPredictPositionsView(),
+ { name: Routes.PREDICT.POSITIONS },
+ { state },
+ initialParams,
+ );
+}
+
+interface RenderPredictPositionsViewWithRoutesOptions
+ extends RenderPredictPositionsViewOptions {
+ extraRoutes?: { name: string; Component?: React.ComponentType }[];
+}
+
+export function renderPredictPositionsViewWithRoutes(
+ options: RenderPredictPositionsViewWithRoutesOptions = {},
+): ReturnType {
+ const { overrides, initialParams, extraRoutes = [] } = options;
+
+ const builder = initialStatePredict();
+ if (overrides) {
+ builder.withOverrides(overrides);
+ }
+ const state = builder.build();
+
+ return renderScreenWithRoutes(
+ createWrappedPredictPositionsView(),
+ { name: Routes.PREDICT.POSITIONS },
+ extraRoutes,
+ { state },
+ initialParams,
+ );
+}
diff --git a/tests/flows/perps.flow.ts b/tests/flows/perps.flow.ts
index ddb068d862bf..979a6dda72f4 100644
--- a/tests/flows/perps.flow.ts
+++ b/tests/flows/perps.flow.ts
@@ -3,13 +3,70 @@ import {
asPlaywrightElement,
Assertions,
encapsulatedAction,
+ PlaywrightGestures,
} from '../framework';
import PerpsMarketDetailsView from '../page-objects/Perps/PerpsMarketDetailsView';
import PerpsHomeView from '../page-objects/Perps/PerpsHomeView';
import PerpsMarketListView from '../page-objects/Perps/PerpsMarketListView';
+import PerpsOnboarding from '../page-objects/Perps/PerpsOnboarding';
import PerpsOrderView from '../page-objects/Perps/PerpsOrderView';
import WalletView from '../page-objects/wallet/WalletView';
+const PERPS_GTM_MODAL_FALLBACK_WAIT_MS = 10_000;
+
+/**
+ * Resolves whether the Perps GTM onboarding tutorial should be handled.
+ * Uses feature flags when available; otherwise polls the tutorial for up to 10s.
+ */
+export const resolvePerpsGtmOnboardingModalEnabled = async (
+ productionFeatureFlags: Record | null,
+): Promise => {
+ const flagsSayEnabled =
+ productionFeatureFlags != null &&
+ (
+ productionFeatureFlags.perpsPerpGtmOnboardingModalEnabled as {
+ enabled?: boolean;
+ }
+ )?.enabled === true;
+
+ if (flagsSayEnabled) {
+ return true;
+ }
+
+ // Flags missing or disabled — tutorial may still appear; detect in UI.
+ try {
+ await (await asPlaywrightElement(PerpsOnboarding.tutorialTitle))
+ .unwrap()
+ .waitForDisplayed({ timeout: PERPS_GTM_MODAL_FALLBACK_WAIT_MS });
+ return true;
+ } catch {
+ return false;
+ }
+};
+
+/**
+ * Skips the Perps onboarding tutorial when it is on screen. No-op if not shown.
+ */
+export const dismissPerpsOnboardingTutorialIfPresent =
+ async (): Promise => {
+ try {
+ const skipButton = await asPlaywrightElement(PerpsOnboarding.skipButton);
+ await skipButton
+ .unwrap()
+ .waitForDisplayed({ timeout: PERPS_GTM_MODAL_FALLBACK_WAIT_MS });
+ await PlaywrightGestures.waitAndTap(skipButton, {
+ checkForDisplayed: true,
+ checkForEnabled: true,
+ timeout: 10_000,
+ });
+ await skipButton
+ .unwrap()
+ .waitForDisplayed({ reverse: true, timeout: 10_000 });
+ } catch {
+ // Tutorial not shown or already dismissed.
+ }
+ };
+
/**
* Checks if the position is open by checking if the close button is visible.
* @returns {Promise} True if the position is open, false otherwise.
diff --git a/tests/flows/wallet.flow.ts b/tests/flows/wallet.flow.ts
index 554e6c83e9e8..22a206a85400 100644
--- a/tests/flows/wallet.flow.ts
+++ b/tests/flows/wallet.flow.ts
@@ -41,6 +41,7 @@ import AccountListBottomSheet from '../page-objects/wallet/AccountListBottomShee
import MetaMetricsOptInView from '../page-objects/Onboarding/MetaMetricsOptInView';
import PredictModalView from '../page-objects/Predict/PredictModalView';
import OnboardingInterestQuestionnaireView from '../page-objects/Onboarding/OnboardingInterestQuestionnaireView';
+import { fetchProductionFeatureFlags } from '../performance/feature-flag-helper';
const logger = createLogger({
name: 'WalletFlow',
});
@@ -49,6 +50,7 @@ const validAccount = Accounts.getValidAccount();
const SEEDLESS_ONBOARDING_ENABLED =
process.env.SEEDLESS_ONBOARDING_ENABLED === 'true' ||
process.env.SEEDLESS_ONBOARDING_ENABLED === undefined;
+const testEnvironment = process.env.E2E_PERFORMANCE_BUILD_VARIANT || 'rc';
/**
* Gets the localhost URL for Ganache/Anvil network connection.
@@ -492,7 +494,86 @@ export const selectAccountByDevice = async (
await asPlaywrightElement(AccountListBottomSheet.accountList),
);
await AccountListBottomSheet.waitForAccountSyncToComplete();
- await AccountListBottomSheet.tapAccountByNameV2(accountName);
+ const isAccount3 = accountName === 'Account 3'; // Due to an issue with the account 3 being displayed as Account 3 (2)
+ await AccountListBottomSheet.tapAccountByNameV2(accountName, !isAccount3);
+};
+
+/** Max wait for the optional interest questionnaire to appear after MetaMetrics. */
+const ONBOARDING_INTEREST_QUESTIONNAIRE_POLL_MS = 3_000;
+const ONBOARDING_INTEREST_QUESTIONNAIRE_POLL_INTERVAL_MS = 250;
+
+/**
+ * Advances past the optional onboarding interest questionnaire (Playwright / Appium only).
+ * No-op when the app navigates straight to onboarding success (common on some builds/flags).
+ */
+export const dismissOnboardingInterestQuestionnaire =
+ async (): Promise => {
+ const deadline = Date.now() + ONBOARDING_INTEREST_QUESTIONNAIRE_POLL_MS;
+
+ while (Date.now() < deadline) {
+ try {
+ const successDoneButton = await asPlaywrightElement(
+ OnboardingSuccessView.doneButton,
+ );
+ if (await successDoneButton.unwrap().isExisting()) {
+ logger.debug(
+ 'Onboarding success already visible; skipping interest questionnaire',
+ );
+ return;
+ }
+
+ const continueButton = await asPlaywrightElement(
+ OnboardingInterestQuestionnaireView.continueButton,
+ );
+ if (await continueButton.unwrap().isExisting()) {
+ await PlaywrightGestures.waitAndTap(continueButton, {
+ timeout: 10_000,
+ checkForDisplayed: true,
+ checkForEnabled: true,
+ });
+ await continueButton
+ .unwrap()
+ .waitForDisplayed({ reverse: true, timeout: 10_000 });
+ return;
+ }
+ } catch {
+ // Stale element / screen transition while the next route loads.
+ }
+ await sleep(ONBOARDING_INTEREST_QUESTIONNAIRE_POLL_INTERVAL_MS);
+ }
+
+ logger.debug(
+ 'Onboarding Interest Questionnaire not shown within poll window; continuing',
+ );
+ };
+
+const PREDICT_GTM_MODAL_FALLBACK_WAIT_MS = 10_000;
+
+/**
+ * Resolves whether the Predict GTM onboarding modal should be handled.
+ * Uses feature flags when available; otherwise polls the modal for up to 10s.
+ */
+export const resolvePredictGtmOnboardingModalEnabled = async (
+ productionFeatureFlags: Record | null,
+): Promise => {
+ if (productionFeatureFlags != null) {
+ return (
+ (
+ productionFeatureFlags.predictGtmOnboardingModalEnabled as {
+ enabled?: boolean;
+ }
+ )?.enabled === true
+ );
+ }
+
+ try {
+ await (await asPlaywrightElement(PredictModalView.notNowButton))
+ .unwrap()
+ .waitForDisplayed({ timeout: PREDICT_GTM_MODAL_FALLBACK_WAIT_MS });
+ return true;
+ } catch {
+ return false;
+ }
};
/**
@@ -501,14 +582,31 @@ export const selectAccountByDevice = async (
* @function dismisspredictionsModalPlaywright
* @returns {Promise} Resolves when the predictions modal is dismissed.
*/
-export const dismisspredictionsModalPlaywright = async (): Promise => {
- try {
- await PlaywrightAssertions.expectElementToBeVisible(
- await asPlaywrightElement(PredictModalView.notNowButton),
- );
- await PredictModalView.tapNotNowButton();
- } catch {
- logger.error('Predict Modal Not Now Button is not visible');
+export const dismisspredictionsModalPlaywright = async (
+ maxRetries = 3,
+): Promise => {
+ for (let attempt = 1; attempt <= maxRetries; attempt++) {
+ try {
+ const btn = await asPlaywrightElement(PredictModalView.notNowButton);
+ await PlaywrightGestures.waitAndTap(btn, {
+ timeout: 10_000,
+ checkForDisplayed: true,
+ checkForEnabled: true,
+ });
+ const dismissedCheck = await asPlaywrightElement(
+ PredictModalView.notNowButton,
+ );
+ await dismissedCheck
+ .unwrap()
+ .waitForDisplayed({ reverse: true, timeout: 10_000 });
+ return;
+ } catch {
+ if (attempt === maxRetries) {
+ logger.error(
+ `Predict modal not dismissed after ${maxRetries} attempts`,
+ );
+ }
+ }
}
};
@@ -537,9 +635,6 @@ export const onboardingFlowImportSRPPlaywright = async (
await ImportWalletView.typeSecretRecoveryPhrase(srp, true);
await ImportWalletView.tapContinueButton();
- await PlaywrightAssertions.expectElementToBeVisible(
- await asPlaywrightElement(CreatePasswordView.newPasswordInput),
- );
await CreatePasswordView.enterPassword(
getPasswordForScenario('onboarding') ?? '',
@@ -549,17 +644,27 @@ export const onboardingFlowImportSRPPlaywright = async (
);
await CreatePasswordView.tapIUnderstandCheckBox();
await CreatePasswordView.tapCreatePasswordButton();
-
await PlaywrightAssertions.expectElementToBeVisible(
- await asPlaywrightElement(MetaMetricsOptInView.screenTitle),
+ await asPlaywrightElement(MetaMetricsOptInView.iAgreeButton),
);
await MetaMetricsOptInView.tapIAgreeButton();
-
+ await dismissOnboardingInterestQuestionnaire();
await PlaywrightAssertions.expectElementToBeVisible(
await asPlaywrightElement(OnboardingSuccessView.doneButton),
+ { timeout: 30_000 },
);
await OnboardingSuccessView.tapDone();
- await dismisspredictionsModalPlaywright();
+ const productionFeatureFlags = await fetchProductionFeatureFlags(
+ 'main',
+ testEnvironment,
+ );
+
+ const predictGtmOnboardingModalEnabled =
+ await resolvePredictGtmOnboardingModalEnabled(productionFeatureFlags);
+ if (predictGtmOnboardingModalEnabled) {
+ await dismisspredictionsModalPlaywright();
+ }
+
await PlaywrightAssertions.expectElementToBeVisible(
await asPlaywrightElement(WalletView.container),
);
diff --git a/tests/framework/Constants.ts b/tests/framework/Constants.ts
index 2ac00297754f..332bf0765cf8 100644
--- a/tests/framework/Constants.ts
+++ b/tests/framework/Constants.ts
@@ -12,9 +12,23 @@ export const LOCAL_NODE_RPC_URL = `http://localhost:${DEFAULT_ANVIL_PORT}`;
// Kept low to enable fast retries in polling loops; use withImplicitWait() for longer waits.
export const DEFAULT_IMPLICIT_WAIT_MS = 3_500;
+// Default WDA snapshot settings — must match the values in the Appium capability configs.
+export const DEFAULT_SNAPSHOT_MAX_DEPTH = 62;
+export const DEFAULT_SNAPSHOT_MAX_CHILDREN = 50;
+export const DEFAULT_CUSTOM_SNAPSHOT_TIMEOUT = 15;
+
// Default action timeout for Playwright/WebDriverIO actions (tap, click, type, etc.) in ms.
export const DEFAULT_ACTION_TIMEOUT_MS = 5_000;
+/** WebDriver HTTP timeout for BrowserStack session creation (grid can take several minutes). */
+export const DEFAULT_BROWSERSTACK_CONNECTION_RETRY_TIMEOUT_MS = 300_000;
+
+/** BrowserStack maximum allowed idle timeout between WebDriver commands, in seconds. */
+export const DEFAULT_BROWSERSTACK_IDLE_TIMEOUT_SECONDS = 300;
+
+/** Appium new command timeout for BrowserStack sessions, in seconds. */
+export const DEFAULT_BROWSERSTACK_NEW_COMMAND_TIMEOUT_SECONDS = 300;
+
// Port Constants
// Fallback ports - used in fixture data (app's persisted state)
// Android: These ports are mapped to actual PortManager-allocated ports via adb reverse
diff --git a/tests/framework/GestureStrategy.ts b/tests/framework/GestureStrategy.ts
index 73bc20c05b4a..564b8a34c4db 100644
--- a/tests/framework/GestureStrategy.ts
+++ b/tests/framework/GestureStrategy.ts
@@ -32,6 +32,12 @@ export interface UnifiedGestureOptions {
checkForDisplayed?: boolean;
/** Check if the element is enabled — Appium only; Detox ignores this */
checkForEnabled?: boolean;
+ /** Stricter enabled polling (Android attrs + stable reads) — Appium only */
+ waitForInteractive?: boolean;
+ /** Consecutive interactive polls required before tap — Appium only */
+ enabledStableReads?: number;
+ /** Extra wait (ms) after enabled/interactive, before click — Appium only */
+ postEnabledSettleMs?: number;
}
/**
@@ -333,11 +339,23 @@ export class AppiumGestureStrategy implements GestureStrategy {
/**
* Wait for an element to be visible and then tap it
* @param elem - The element to wait and tap
+ * @param opts - The options for the wait and tap
* @returns A promise that resolves when the wait and tap is complete
*/
- async waitAndTap(elem: EncapsulatedElementType): Promise {
+ async waitAndTap(
+ elem: EncapsulatedElementType,
+ opts?: UnifiedGestureOptions,
+ ): Promise {
const el = await asPlaywrightElement(elem);
- await PlaywrightGestures.waitAndTap(el);
+ await PlaywrightGestures.waitAndTap(el, {
+ timeout: opts?.timeout,
+ delay: opts?.delay,
+ checkForDisplayed: opts?.checkForDisplayed,
+ checkForEnabled: opts?.checkForEnabled,
+ waitForInteractive: opts?.waitForInteractive,
+ enabledStableReads: opts?.enabledStableReads,
+ postEnabledSettleMs: opts?.postEnabledSettleMs,
+ });
}
/**
diff --git a/tests/framework/PlaywrightAdapter.ts b/tests/framework/PlaywrightAdapter.ts
index 17ea61ba29ab..4e198cffc98e 100644
--- a/tests/framework/PlaywrightAdapter.ts
+++ b/tests/framework/PlaywrightAdapter.ts
@@ -86,6 +86,14 @@ export class PlaywrightElement {
return await this.elem.isEnabled();
}
+ /**
+ * Check if elem is displayed and enabled (WebdriverIO isClickable).
+ */
+ @boxedStep
+ async isClickable(): Promise {
+ return await this.elem.isClickable();
+ }
+
/**
* Get attribute value (Playwright-style)
* Maps to WebdriverIO's getAttribute()
diff --git a/tests/framework/PlaywrightAssertions.ts b/tests/framework/PlaywrightAssertions.ts
index c859a7363708..f9183f80570c 100644
--- a/tests/framework/PlaywrightAssertions.ts
+++ b/tests/framework/PlaywrightAssertions.ts
@@ -2,22 +2,140 @@ import { BASE_DEFAULTS, sleep } from './Utilities.ts';
import { AssertionOptions } from './types.ts';
import type { PlaywrightElement } from './PlaywrightAdapter.ts';
import PlaywrightMatchers from './PlaywrightMatchers.ts';
+import PlaywrightGestures from './PlaywrightGestures.ts';
+import {
+ addOverhead,
+ isOverheadTrackingActive,
+} from './PlaywrightUtilities.ts';
export interface VisibilityWithSettleOptions extends AssertionOptions {
settleMs?: number;
}
+/**
+ * Assertion helpers that integrate with TimerHelper's automatic overhead
+ * tracking so performance measurements reflect real app latency.
+ *
+ * ## How overhead compensation works
+ *
+ * Every Appium WebDriver command (findElement, isExisting, etc.) carries a
+ * round-trip cost that on BrowserStack can be 3-18 s per call. When a
+ * `measure()` block wraps an assertion the raw wall-clock duration would be
+ * dominated by that infrastructure latency, not by the app.
+ *
+ * To compensate we track two kinds of overhead:
+ *
+ * 1. **Element resolution** — the initial `$('~id')` HTTP call that locates
+ * the element selector. Tracked in `expectElementToBeVisible`.
+ *
+ * 2. **Overhead probe** — after the element is confirmed visible we
+ * immediately call `isExisting()` once more. Because the element is
+ * already on-screen the entire call duration is pure Appium/network
+ * overhead. This is the best single-call estimate we can get.
+ *
+ * `addOverhead()` is a no-op when no `measure()` is active, so these calls
+ * are zero-cost for tests that don't use TimerHelper.
+ */
export default class PlaywrightAssertions {
private static getTimeout(options: AssertionOptions): number {
return options.timeout ?? BASE_DEFAULTS.timeout;
}
+ /**
+ * Polls for element existence using a single WebDriver command per attempt
+ * (`isExisting`). This avoids the multiple internal HTTP round-trips that
+ * WebdriverIO's `waitForDisplayed` performs on each iteration.
+ *
+ * Overhead tracking logic:
+ * - If the **first** `isExisting()` already returns `true`, the element
+ * was visible before or during the network round-trip. The entire call
+ * duration is infra overhead (BrowserStack latency, WDA snapshot) so
+ * it is registered via `addOverhead`.
+ * - Subsequent failed calls (element not yet visible) are NOT overhead
+ * — the app is genuinely still loading during those calls.
+ * - After detection a {@link probeOverhead} call measures the pure
+ * per-command cost and registers it for subtraction.
+ */
+ private static async pollUntilVisible(
+ el: PlaywrightElement,
+ timeout: number,
+ ): Promise {
+ const interval = 300;
+ const start = Date.now();
+ let attempt = 0;
+ while (Date.now() - start < timeout - interval) {
+ try {
+ attempt++;
+ const t0 = Date.now();
+ const exists = await el.unwrap().isExisting();
+ if (exists) {
+ if (isOverheadTrackingActive()) {
+ await this.probeOverhead(el);
+ }
+ return;
+ }
+ } catch {
+ // element not ready yet
+ }
+ await sleep(interval);
+ }
+ await el.waitForDisplayed({
+ timeout: Math.max(interval, timeout - (Date.now() - start)),
+ });
+ if (isOverheadTrackingActive()) {
+ await this.probeOverhead(el);
+ }
+ }
+
+ /**
+ * Measures pure Appium/network overhead by running `isExisting()` on an
+ * element that is already visible. The entire call duration is infra cost
+ * (network round-trip + WDA snapshot) with no app-loading component.
+ */
+ private static async probeOverhead(el: PlaywrightElement): Promise {
+ const t0 = Date.now();
+ await el.unwrap().isExisting();
+ addOverhead(Date.now() - t0);
+ }
+
+ /**
+ * Asserts that a target element eventually becomes visible.
+ *
+ * When called inside a `TimerHelper.measure()` block two overhead sources
+ * are automatically tracked and later subtracted:
+ * - Element resolution (`await targetElement`) — the initial WebDriver
+ * `findElement` call that resolves the selector.
+ * - Post-detection probe — one extra `isExisting()` call after the element
+ * is confirmed visible (see {@link probeOverhead}).
+ */
static async expectElementToBeVisible(
targetElement: PlaywrightElement | Promise,
options: AssertionOptions = {},
): Promise {
+ const t0 = Date.now();
const el = await targetElement;
- await el.waitForDisplayed({ timeout: this.getTimeout(options) });
+ if (isOverheadTrackingActive()) {
+ addOverhead(Date.now() - t0);
+ }
+ await this.pollUntilVisible(el, this.getTimeout(options));
+ }
+
+ /**
+ * Waits until an element stays enabled (and on Android, native attrs are not false).
+ * Prefer waitForInteractive on waitAndTap for tap flows.
+ */
+ static async expectElementToBeInteractive(
+ targetElement: PlaywrightElement | Promise,
+ options: AssertionOptions = {},
+ ): Promise {
+ const el = await targetElement;
+ await PlaywrightGestures.waitUntilInteractive(
+ el,
+ this.getTimeout(options),
+ {
+ requiredStableReads: 3,
+ },
+ );
}
static async expectElementToBeVisibleWithSettle(
@@ -33,6 +151,71 @@ export default class PlaywrightAssertions {
}
}
+ /**
+ * Asserts that multiple elements become visible.
+ *
+ * Checks run sequentially because a WebDriver session serialises all
+ * commands — `Promise.all` would not actually parallelise anything and
+ * instead causes timeout and overhead-tracking issues.
+ *
+ * Sequential execution lets each element keep its full timeout and
+ * reuses the per-element overhead tracking that already works for
+ * single-element assertions.
+ */
+ static async expectAllElementsToBeVisible(
+ elements: (PlaywrightElement | Promise)[],
+ options: AssertionOptions = {},
+ ): Promise {
+ for (const el of elements) {
+ await this.expectElementToBeVisible(el, options);
+ }
+ }
+
+ /**
+ * Polls until an element's text content matches the expected value.
+ *
+ * Overhead tracking follows the same pattern as {@link pollUntilVisible}:
+ * if the text already matches on the first attempt the entire call
+ * duration is registered as infra overhead; a post-match probe captures
+ * the per-command cost.
+ */
+ static async expectElementText(
+ targetElement: PlaywrightElement | Promise,
+ expected: string,
+ options: AssertionOptions = {},
+ ): Promise {
+ const t0Resolve = Date.now();
+ const el = await targetElement;
+ if (isOverheadTrackingActive()) {
+ addOverhead(Date.now() - t0Resolve);
+ }
+
+ const timeout = this.getTimeout(options);
+ const interval = 300;
+ const start = Date.now();
+ let attempt = 0;
+ while (Date.now() - start < timeout) {
+ try {
+ attempt++;
+ const t0 = Date.now();
+ const text = await el.textContent();
+ if (text === expected) {
+ if (attempt === 1) {
+ addOverhead(Date.now() - t0);
+ }
+ if (isOverheadTrackingActive()) {
+ await this.probeOverhead(el);
+ }
+ return;
+ }
+ } catch {
+ // element not ready yet
+ }
+ await sleep(interval);
+ }
+ throw new Error(`Expected element text "${expected}" within ${timeout}ms`);
+ }
+
static async expectElementToNotBeVisible(
targetElement: PlaywrightElement | Promise,
options: AssertionOptions = {},
diff --git a/tests/framework/PlaywrightGestures.ts b/tests/framework/PlaywrightGestures.ts
index 026d6810647e..7cd103f06c6d 100644
--- a/tests/framework/PlaywrightGestures.ts
+++ b/tests/framework/PlaywrightGestures.ts
@@ -76,6 +76,71 @@ export default class PlaywrightGestures {
}
}
+ private static async isAndroidSession(): Promise {
+ const drv = getDriver();
+ const platform = String(
+ (await drv.capabilities)?.platformName ?? '',
+ ).toLowerCase();
+ return platform.includes('android');
+ }
+
+ /**
+ * Android often reports isEnabled=true while the RN control is still disabled.
+ * Uses isEnabled plus native clickable/enabled attributes only — isClickable()
+ * is a Web/DOM API and is not implemented on Appium Android (always fails).
+ */
+ private static async isElementInteractive(
+ elem: PlaywrightElement,
+ ): Promise {
+ if (!(await elem.isEnabled())) {
+ return false;
+ }
+
+ if (!(await this.isAndroidSession())) {
+ return true;
+ }
+
+ try {
+ const [clickableAttr, enabledAttr] = await Promise.all([
+ elem.getAttribute('clickable'),
+ elem.getAttribute('enabled'),
+ ]);
+ return clickableAttr !== 'false' && enabledAttr !== 'false';
+ } catch {
+ return true;
+ }
+ }
+
+ /**
+ * Polls until the element stays interactive for consecutive reads.
+ */
+ static async waitUntilInteractive(
+ elem: PlaywrightElement,
+ timeout: number,
+ options?: { requiredStableReads?: number; interval?: number },
+ ): Promise {
+ const interval = options?.interval ?? 250;
+ const requiredStableReads = options?.requiredStableReads ?? 6;
+ const start = Date.now();
+ let consecutiveInteractive = 0;
+
+ while (Date.now() - start < timeout - interval) {
+ if (await this.isElementInteractive(elem)) {
+ consecutiveInteractive += 1;
+ if (consecutiveInteractive >= requiredStableReads) {
+ return;
+ }
+ } else {
+ consecutiveInteractive = 0;
+ }
+ await new Promise((resolve) => setTimeout(resolve, interval));
+ }
+
+ throw new Error(
+ `Element was not interactive for ${requiredStableReads} consecutive checks within ${timeout}ms`,
+ );
+ }
+
@boxedStep
static async waitAndTap(
elem: PlaywrightElement,
@@ -84,23 +149,40 @@ export default class PlaywrightGestures {
timeout?: number;
checkForDisplayed?: boolean;
checkForEnabled?: boolean;
+ /** Stricter Android enabled polling (login unlock, etc.) */
+ waitForInteractive?: boolean;
checkForStable?: boolean;
+ enabledStableReads?: number;
+ postEnabledSettleMs?: number;
},
): Promise {
const {
+ timeout = 10000,
delay = 500,
- timeout,
checkForDisplayed = true,
checkForEnabled = true,
+ waitForInteractive = false,
checkForStable = false,
+ enabledStableReads = 3,
+ postEnabledSettleMs,
} = options || {};
if (checkForDisplayed) {
- await elem.unwrap().waitForDisplayed({ timeout: timeout ?? 10000 });
+ await elem.unwrap().waitForDisplayed({ timeout });
}
if (checkForEnabled) {
- await elem.unwrap().waitForEnabled({ timeout: timeout ?? 5000 });
+ if (waitForInteractive) {
+ await this.waitUntilInteractive(elem, timeout, {
+ requiredStableReads: enabledStableReads,
+ });
+ const settleMs = postEnabledSettleMs ?? 0;
+ if (settleMs > 0) {
+ await new Promise((resolve) => setTimeout(resolve, settleMs));
+ }
+ } else {
+ await elem.waitForEnabled({ timeout });
+ }
}
if (checkForStable) {
@@ -385,17 +467,14 @@ export default class PlaywrightGestures {
if (PlatformDetector.isAndroid()) {
await drv.hideKeyboard();
} else {
- // iOS - try pressKey strategy first, fallback to tap outside
+ // iOS - tapOutside dismisses the keyboard by tapping outside the focused
+ // element, which works regardless of keyboard type (password, numeric, etc.)
try {
await drv.executeScript('mobile: hideKeyboard', [
- {
- strategy: 'pressKey',
- key: keyName,
- },
+ { strategy: 'pressKey', key: keyName },
]);
} catch {
- // Fallback: tap outside the keyboard area (top of screen)
- await drv.tap({ x: 100, y: 150 });
+ // Keyboard may already be hidden
}
}
}
diff --git a/tests/framework/PlaywrightMatchers.ts b/tests/framework/PlaywrightMatchers.ts
index 7037bc93d201..1df588b910a9 100644
--- a/tests/framework/PlaywrightMatchers.ts
+++ b/tests/framework/PlaywrightMatchers.ts
@@ -51,7 +51,7 @@ export default class PlaywrightMatchers {
elementId: string,
options: MatcherOptions = {},
): Promise {
- const { exact = false } = options;
+ const { exact = true } = options;
let locator: string;
const isAndroid = await PlatformDetector.isAndroid();
@@ -61,10 +61,9 @@ export default class PlaywrightMatchers {
? `${locator}.resourceId("${elementId}")`
: `${locator}.resourceIdMatches(".*${elementId}.*")`;
} else {
- locator = '-ios predicate string:';
locator = exact
- ? `${locator}name == "${elementId}"`
- : `${locator}name CONTAINS "${elementId}"`;
+ ? `~${elementId}`
+ : `-ios predicate string:name CONTAINS "${elementId}"`;
}
const drv = getDriver();
@@ -78,15 +77,15 @@ export default class PlaywrightMatchers {
* @param text - The text to search for
* @returns The wrapped element
*/
- static async getElementByText(text: string): Promise {
- const isAndroid = await PlatformDetector.isAndroid();
-
- if (isAndroid) {
- return await this.getElementByAndroidUIAutomator(`.text("${text}")`);
+ static async getElementByText(
+ text: string,
+ exactMatch: boolean = false,
+ ): Promise {
+ let xpath = `//*[contains(@name,'${text}') or contains(@label,'${text}') or contains(@text,'${text}')]`;
+ if (exactMatch) {
+ xpath = `//*[@name='${text}' or @label='${text}' or @text='${text}']`;
}
- return await this.getElementByXPath(
- `//*[contains(@name,'${text}') or contains(@label,'${text}') or contains(@text,'${text}')]`,
- );
+ return await this.getElementByXPath(xpath);
}
/**
diff --git a/tests/framework/PlaywrightUtilities.ts b/tests/framework/PlaywrightUtilities.ts
index c70e484b4a1d..e6b6e6b18c28 100644
--- a/tests/framework/PlaywrightUtilities.ts
+++ b/tests/framework/PlaywrightUtilities.ts
@@ -5,6 +5,9 @@ import { PlaywrightElement } from './PlaywrightAdapter';
import {
CHROME_PACKAGE,
DEFAULT_IMPLICIT_WAIT_MS,
+ DEFAULT_SNAPSHOT_MAX_DEPTH,
+ DEFAULT_SNAPSHOT_MAX_CHILDREN,
+ DEFAULT_CUSTOM_SNAPSHOT_TIMEOUT,
FALLBACK_FIXTURE_SERVER_PORT,
FALLBACK_COMMAND_QUEUE_SERVER_PORT,
FALLBACK_MOCKSERVER_PORT,
@@ -57,6 +60,35 @@ export async function withImplicitWait(
}
}
+export interface SnapshotSettings {
+ snapshotMaxDepth?: number;
+ snapshotMaxChildren?: number;
+ customSnapshotTimeout?: number;
+}
+
+/**
+ * Runs a callback with temporarily adjusted WDA snapshot settings.
+ * Restores defaults afterward, even if the callback throws.
+ * Use this for heavy screens (e.g. token selector lists) where
+ * a smaller depth/children limit speeds up element lookups.
+ */
+export async function withSnapshotSettings(
+ settings: SnapshotSettings,
+ fn: () => Promise,
+): Promise {
+ const drv = getDriver();
+ await drv.updateSettings(settings);
+ try {
+ return await fn();
+ } finally {
+ await drv.updateSettings({
+ snapshotMaxDepth: DEFAULT_SNAPSHOT_MAX_DEPTH,
+ snapshotMaxChildren: DEFAULT_SNAPSHOT_MAX_CHILDREN,
+ customSnapshotTimeout: DEFAULT_CUSTOM_SNAPSHOT_TIMEOUT,
+ });
+ }
+}
+
/**
* Run an async step with a timeout. Rejects after ms so callers can catch
* and continue.
@@ -152,6 +184,50 @@ export function boxedStep(
return replacementMethod;
}
+/**
+ * Lightweight Appium overhead accumulator for performance measurements.
+ *
+ * Problem: every WebDriver HTTP call (findElement, isExisting, click …) adds
+ * infrastructure latency — on BrowserStack this can be 3-18 s per command.
+ * Without compensation a 3 s app-load would be reported as 20+ s.
+ *
+ * Solution: framework methods call `addOverhead(ms)` for operations whose
+ * duration is *pure infra cost* (element resolution, post-detection probes).
+ * `TimerHelper.measure()` activates tracking before the action and subtracts
+ * the accumulated value after the timer stops.
+ *
+ * When no `measure()` is active (`_tracking === false`) all functions are
+ * no-ops, so regular (non-performance) tests pay zero cost.
+ */
+let _overheadMs = 0;
+let _tracking = false;
+
+export function startOverheadTracking(): void {
+ if (_tracking) {
+ console.warn(
+ 'TimerHelper: startOverheadTracking() called while already active — nested measure() calls are not supported; inner call ignored',
+ );
+ return;
+ }
+ _overheadMs = 0;
+ _tracking = true;
+}
+
+export function addOverhead(ms: number): void {
+ if (_tracking) _overheadMs += ms;
+}
+
+export function stopOverheadTracking(): number {
+ _tracking = false;
+ const result = _overheadMs;
+ _overheadMs = 0;
+ return result;
+}
+
+export function isOverheadTrackingActive(): boolean {
+ return _tracking;
+}
+
class PlaywrightUtilities {
/**
* Get the device screen size.
@@ -185,7 +261,7 @@ class PlaywrightUtilities {
mapping[device.name] = 'Account 3';
} else if (device.category === 'low') {
// Low category Android devices use default Account 1
- mapping[device.name] = null;
+ mapping[device.name] = 'Account 1';
}
});
diff --git a/tests/framework/TimerHelper.ts b/tests/framework/TimerHelper.ts
index d37aa502fe3e..71e204bbf07a 100644
--- a/tests/framework/TimerHelper.ts
+++ b/tests/framework/TimerHelper.ts
@@ -1,4 +1,8 @@
import TimerStore from './TimerStore';
+import {
+ startOverheadTracking,
+ stopOverheadTracking,
+} from './PlaywrightUtilities';
/** Platform-specific threshold values in milliseconds. */
export interface PlatformThreshold {
@@ -15,6 +19,7 @@ const THRESHOLD_MARGIN = 0.1; // 10% margin
class TimerHelper {
private _id: string;
private _baseThreshold: number | null;
+ private readonly _platform?: 'android' | 'ios';
/**
* Creates a new TimerHelper and registers a timer in the store.
@@ -28,6 +33,7 @@ class TimerHelper {
currentPlatform?: 'android' | 'ios',
) {
this._id = id;
+ this._platform = currentPlatform;
this._baseThreshold = this._resolveThreshold(threshold, currentPlatform);
TimerStore.createTimer(this.id);
}
@@ -118,11 +124,53 @@ class TimerHelper {
/**
* Measures the execution time of an async action.
- * Starts the timer before the action and stops it after completion (or failure).
+ *
+ * - **iOS**: subtracts Appium infrastructure overhead (see {@link measureWithOverhead}).
+ * - **Android**: wall-clock only (see {@link measureRaw}) — overhead cannot be
+ * separated reliably when taps overlap with app loading.
+ *
+ * Pass `currentPlatform` in the constructor so the correct strategy is chosen.
+ *
* @param action - Async function to measure
* @returns This TimerHelper instance for chaining
*/
async measure(action: () => Promise): Promise {
+ if (this._platform === 'android') {
+ return this.measureRaw(action);
+ }
+ return this.measureWithOverhead(action);
+ }
+
+ /**
+ * iOS-only measurement path: subtracts Appium overhead from the recorded duration.
+ *
+ * @param action - Async function to measure
+ * @returns This TimerHelper instance for chaining
+ */
+ async measureWithOverhead(action: () => Promise): Promise {
+ startOverheadTracking();
+ this.start();
+ try {
+ await action();
+ } finally {
+ this.stop();
+ }
+ const overhead = stopOverheadTracking();
+ if (overhead > 0) {
+ this.subtractOverhead(overhead);
+ }
+ return this;
+ }
+
+ /**
+ * Android-oriented wall-clock measurement (no Appium overhead subtraction).
+ *
+ * Prefer {@link measure} in specs — it selects this path on Android automatically.
+ *
+ * @param action - Async function to measure
+ * @returns This TimerHelper instance for chaining
+ */
+ async measureRaw(action: () => Promise): Promise {
this.start();
try {
await action();
@@ -150,6 +198,21 @@ class TimerHelper {
return this._baseThreshold !== null;
}
+ /**
+ * Subtracts a measured overhead (e.g. Appium roundtrip) from the recorded duration.
+ * Useful to isolate real app performance from test framework latency.
+ * @param overheadMs - Overhead in milliseconds to subtract
+ */
+ subtractOverhead(overheadMs: number): void {
+ const timer = TimerStore.getTimer(this.id);
+ if (timer.duration === null) {
+ throw new Error(
+ `Timer "${this.id}" has no duration yet. Call stop() first.`,
+ );
+ }
+ timer.duration = Math.max(0, timer.duration - overheadMs);
+ }
+
/** The unique identifier for this timer. */
get id(): string {
return this._id;
diff --git a/tests/framework/config/ConfigHandler.ts b/tests/framework/config/ConfigHandler.ts
index a11d5dcab79a..65f2764d9588 100644
--- a/tests/framework/config/ConfigHandler.ts
+++ b/tests/framework/config/ConfigHandler.ts
@@ -23,7 +23,7 @@ const defaultConfig: PlaywrightTestConfig = {
// used across tests in a file where they run sequentially
fullyParallel: false,
forbidOnly: false,
- retries: isCI ? 1 : 0,
+ retries: isCI ? 2 : 0,
workers: 1,
reporter: [['list'], ['html', { open: 'always' }]],
timeout: 300_000,
diff --git a/tests/framework/fixture/index.ts b/tests/framework/fixture/index.ts
index 42513164a1d3..ba9d2db95d17 100644
--- a/tests/framework/fixture/index.ts
+++ b/tests/framework/fixture/index.ts
@@ -19,6 +19,7 @@ import {
hasQualityGateFailure,
markQualityGateFailure,
QualityGatesValidator,
+ QualityGateError,
} from '../quality-gates';
import { getTeamInfoFromTags } from '../utils/teams';
import { publishPerformanceScenarioToSentry } from '../../reporters/providers/sentry/PerformanceSentryPublisher';
@@ -247,21 +248,21 @@ export const test = base.extend({
const isSystemTestMode = process.env.SYSTEM_TEST_MODE === 'true';
const testId = getTestId(testInfo);
- // Skip retry if previous attempt failed due to quality gates
- // Quality gate failures should NOT be retried - the measurement was valid, only threshold exceeded
+ // Abort retry if previous attempt failed due to quality gates.
+ // Quality gate failures should NOT be retried - the measurement was valid,
+ // only the threshold was exceeded. We throw (not skip) so Playwright counts
+ // all attempts as failed and reports the test as "failed" rather than "flaky".
if (
!isSystemTestMode &&
testInfo.retry > 0 &&
hasQualityGateFailure(testId)
) {
console.log(
- `⏭️ Skipping retry for "${testInfo.title}" - previous attempt failed due to Quality Gates (threshold exceeded, not a test execution error)`,
+ `⏭️ Aborting retry for "${testInfo.title}" - previous attempt failed due to Quality Gates (threshold exceeded, not a test execution error)`,
);
- testInfo.skip(
- true,
- 'Skipped retry: Quality Gates failed in previous attempt. Performance threshold was exceeded but test execution was successful.',
+ throw new QualityGateError(
+ `Quality Gates failed on a previous attempt for "${testInfo.title}". Retries are not allowed for quality gate failures.`,
);
- return;
}
const performanceTracker = new PerformanceTracker();
@@ -292,6 +293,13 @@ export const test = base.extend({
console.log('⚠️ No timers found in performance tracker');
}
+ // Propagate BrowserStack session creation time (infra overhead, not counted in total)
+ if (deviceProvider.sessionCreationDurationMs !== undefined) {
+ performanceTracker.setSessionCreationDuration(
+ deviceProvider.sessionCreationDurationMs,
+ );
+ }
+
// Always try to attach performance metrics, even if test failed
let metrics: MetricsOutput | null = null;
try {
diff --git a/tests/framework/index.ts b/tests/framework/index.ts
index d6179b1e74b3..92f911a7f332 100644
--- a/tests/framework/index.ts
+++ b/tests/framework/index.ts
@@ -12,7 +12,15 @@ export {
deriveEventNamesForFetch,
shouldRunAnalyticsExpectations,
} from '../helpers/analytics/runAnalyticsExpectations.ts';
-export { boxedStep, getDriver } from './PlaywrightUtilities.ts';
+export {
+ boxedStep,
+ getDriver,
+ withSnapshotSettings,
+ startOverheadTracking,
+ addOverhead,
+ stopOverheadTracking,
+ isOverheadTrackingActive,
+} from './PlaywrightUtilities.ts';
// Mock server utilities
export { safeGetBodyText } from '../api-mocking/MockServerE2E.ts';
diff --git a/tests/framework/quality-gates/QualityGatesReportFormatter.ts b/tests/framework/quality-gates/QualityGatesReportFormatter.ts
index 6ec03094917d..f3acc056e3c8 100644
--- a/tests/framework/quality-gates/QualityGatesReportFormatter.ts
+++ b/tests/framework/quality-gates/QualityGatesReportFormatter.ts
@@ -12,7 +12,10 @@ class QualityGatesReportFormatter {
/**
* Format validation result as a console report
*/
- formatConsoleReport(result: QualityGatesResult): string {
+ formatConsoleReport(
+ result: QualityGatesResult,
+ sessionCreationDurationMs?: number,
+ ): string {
if (!result.hasThresholds) {
return `⚠️ No quality gates defined for: ${result.summary.testName}`;
}
@@ -28,6 +31,11 @@ class QualityGatesReportFormatter {
);
lines.push(`Test: ${result.summary.testName}`);
lines.push(`Status: ${result.passed ? '✅ PASSED' : '❌ FAILED'}`);
+ if (sessionCreationDurationMs !== undefined) {
+ lines.push(
+ `BrowserStack Session Creation: ${sessionCreationDurationMs}ms (${(sessionCreationDurationMs / 1000).toFixed(2)}s) — infra overhead`,
+ );
+ }
lines.push(
'───────────────────────────────────────────────────────────────',
);
diff --git a/tests/framework/services/common/base/BaseServiceProvider.ts b/tests/framework/services/common/base/BaseServiceProvider.ts
index acd58af633ac..005aca41423f 100644
--- a/tests/framework/services/common/base/BaseServiceProvider.ts
+++ b/tests/framework/services/common/base/BaseServiceProvider.ts
@@ -9,6 +9,7 @@ import { createLogger, type Logger } from '../../../logger';
*/
export abstract class BaseServiceProvider implements ServiceProvider {
sessionId?: string;
+ sessionCreationDurationMs?: number;
protected readonly project: ProjectConfig;
protected readonly logger: Logger;
diff --git a/tests/framework/services/common/interfaces/ServiceProvider.ts b/tests/framework/services/common/interfaces/ServiceProvider.ts
index a234db6397c4..4da4b7a97713 100644
--- a/tests/framework/services/common/interfaces/ServiceProvider.ts
+++ b/tests/framework/services/common/interfaces/ServiceProvider.ts
@@ -10,6 +10,12 @@ export interface ServiceProvider {
*/
sessionId?: string;
+ /**
+ * Time in milliseconds from session creation request to session ready.
+ * Only populated by providers that involve remote session allocation (e.g. BrowserStack).
+ */
+ sessionCreationDurationMs?: number;
+
/**
* Global setup - validates configuration before tests run
*/
diff --git a/tests/framework/services/common/types.ts b/tests/framework/services/common/types.ts
index d03faac6a15d..241df3043df2 100644
--- a/tests/framework/services/common/types.ts
+++ b/tests/framework/services/common/types.ts
@@ -19,5 +19,8 @@ export interface CommonCapabilities {
'appium:fullReset'?: boolean;
'appium:deviceOrientation'?: string;
'appium:settings[snapshotMaxDepth]'?: number;
+ 'appium:settings[snapshotMaxChildren]'?: number;
+ 'appium:settings[useFirstMatch]'?: boolean;
+ 'appium:settings[pageSourceExcludedAttributes]'?: string;
platformName?: string;
}
diff --git a/tests/framework/services/providers/browserstack/BrowserStackConfigBuilder.ts b/tests/framework/services/providers/browserstack/BrowserStackConfigBuilder.ts
index 4f866f982408..3b640bcde95e 100644
--- a/tests/framework/services/providers/browserstack/BrowserStackConfigBuilder.ts
+++ b/tests/framework/services/providers/browserstack/BrowserStackConfigBuilder.ts
@@ -2,6 +2,11 @@
import path from 'path';
import type { BrowserStackConfig } from '../../../types';
import type { ProjectConfig } from '../../common/types';
+import {
+ DEFAULT_BROWSERSTACK_CONNECTION_RETRY_TIMEOUT_MS,
+ DEFAULT_BROWSERSTACK_IDLE_TIMEOUT_SECONDS,
+ DEFAULT_BROWSERSTACK_NEW_COMMAND_TIMEOUT_SECONDS,
+} from '../../../Constants';
import { createLogger, LogLevel } from '../../../logger';
const logger = createLogger({
@@ -63,6 +68,21 @@ export class BrowserStackConfigBuilder {
`Building BrowserStack config with device build identifier: ${process.env.GITHUB_ACTIONS === 'true' ? `CI ${process.env.GITHUB_RUN_ID}` : process.env.USER}`,
);
+ const connectionRetryTimeoutMs = Number.parseInt(
+ process.env.BROWSERSTACK_CONNECTION_RETRY_TIMEOUT_MS ?? '',
+ 10,
+ );
+ const connectionRetryTimeout = Number.isFinite(connectionRetryTimeoutMs)
+ ? connectionRetryTimeoutMs
+ : DEFAULT_BROWSERSTACK_CONNECTION_RETRY_TIMEOUT_MS;
+
+ logger.info(
+ `BrowserStack WebDriver connectionRetryTimeout: ${connectionRetryTimeout}ms`,
+ );
+ logger.info(
+ `BrowserStack idleTimeout: ${DEFAULT_BROWSERSTACK_IDLE_TIMEOUT_SECONDS}s, newCommandTimeout: ${DEFAULT_BROWSERSTACK_NEW_COMMAND_TIMEOUT_SECONDS}s`,
+ );
+
return {
port: 443,
path: '/wd/hub',
@@ -71,6 +91,9 @@ export class BrowserStackConfigBuilder {
user: username,
key: accessKey,
hostname: 'hub.browserstack.com',
+ // Default webdriver is 120s; BS session POST often exceeds that on busy grids.
+ connectionRetryTimeout,
+ connectionRetryCount: 3,
capabilities: {
'bstack:options': {
debug: true,
@@ -81,13 +104,13 @@ export class BrowserStackConfigBuilder {
},
networkLogs: true,
appiumVersion: '3.1.0',
- idleTimeout: 180,
+ idleTimeout: DEFAULT_BROWSERSTACK_IDLE_TIMEOUT_SECONDS,
deviceName: device.name,
osVersion: device.osVersion,
platformName,
deviceOrientation: device.orientation,
projectName:
- process.env.BROWSERSTACK_BUILD_NAME ||
+ process.env.BROWSERSTACK_PROJECT_NAME ||
`${projectName} ${platformName}`,
buildName:
process.env.BROWSERSTACK_BUILD_NAME ||
@@ -113,11 +136,16 @@ export class BrowserStackConfigBuilder {
? {
'appium:appPackage': this.project.use.app?.packageName,
'appium:appActivity': this.project.use.app?.launchableActivity,
+ 'appium:disableIdLocatorAutocompletion': true,
}
: {
'appium:bundleId': this.project.use.app?.appId,
+ 'appium:shouldUseCompactResponses': true,
+ 'appium:elementResponseAttributes':
+ 'name,label,value,type,enabled,visible,rect',
}),
- 'appium:newCommandTimeout': 300,
+ 'appium:newCommandTimeout':
+ DEFAULT_BROWSERSTACK_NEW_COMMAND_TIMEOUT_SECONDS,
'appium:automationName':
platformName === 'android' ? 'UiAutomator2' : 'XCUITest',
'appium:autoGrantPermissions': true,
@@ -133,7 +161,7 @@ export class BrowserStackConfigBuilder {
'appium:waitForQuiescence': false, // Don't wait for app idle
'appium:animationCoolOffTimeout': 0, // Skip animation wait
'appium:reduceMotion': true, // Reduce iOS animations
- 'appium:customSnapshotTimeout': 15, // Snapshot timeout in seconds"
+ 'appium:customSnapshotTimeout': 15,
'appium:waitForIdleTimeout': 0, // Don't wait for idle
'appium:disableWindowAnimation': true, // Disable animations
'appium:skipDeviceInitialization': true, // Skip init (faster startup)
@@ -141,7 +169,7 @@ export class BrowserStackConfigBuilder {
enable: true,
samplesX: 3,
samplesY: 3,
- maxDepth: 15,
+ maxDepth: 100,
},
},
};
diff --git a/tests/framework/services/providers/browserstack/BrowserStackProvider.ts b/tests/framework/services/providers/browserstack/BrowserStackProvider.ts
index e3d34beae00c..f1550e952cc3 100644
--- a/tests/framework/services/providers/browserstack/BrowserStackProvider.ts
+++ b/tests/framework/services/providers/browserstack/BrowserStackProvider.ts
@@ -28,16 +28,20 @@ export class BrowserStackProvider extends BaseServiceProvider {
* Create and return WebDriver browser instance for BrowserStack
*/
async getDriver(): Promise {
- this.logger.debug('Creating driver for BrowserStack');
+ this.logger.info(
+ 'Creating BrowserStack session (this can take several minutes on a busy grid)…',
+ );
const configBuilder = new BrowserStackConfigBuilder(this.project);
const config = configBuilder.build();
+ const sessionCreationStart = Date.now();
const browser = await remote(config);
+ this.sessionCreationDurationMs = Date.now() - sessionCreationStart;
this.sessionId = browser.sessionId;
this.logger.info(
- `Driver created for BrowserStack with session: ${this.sessionId}`,
+ `Driver created for BrowserStack with session: ${this.sessionId} (session creation took ${this.sessionCreationDurationMs}ms)`,
);
return browser;
}
diff --git a/tests/framework/services/providers/emulator/EmulatorConfigBuilder.ts b/tests/framework/services/providers/emulator/EmulatorConfigBuilder.ts
index 52a5cec4b989..5b9bd94a89a3 100644
--- a/tests/framework/services/providers/emulator/EmulatorConfigBuilder.ts
+++ b/tests/framework/services/providers/emulator/EmulatorConfigBuilder.ts
@@ -65,17 +65,17 @@ export class EmulatorConfigBuilder {
'appium:fullReset': false,
'appium:noReset': true,
'appium:settings[snapshotMaxDepth]': 62,
+ 'appium:waitForQuiescence': false, // Don't wait for app idle
+ 'appium:animationCoolOffTimeout': 0, // Skip animation wait
+ 'appium:reduceMotion': true, // Reduce iOS animations
+ 'appium:waitForIdleTimeout': 0, // Don't wait for idle
'appium:wdaLaunchTimeout': 300_000,
'appium:includeSafariInWebviews': true,
'appium:settings[actionAcknowledgmentTimeout]': 3000,
'appium:settings[ignoreUnimportantViews]': true,
'appium:settings[waitForSelectorTimeout]': 1000,
'appium:chromedriverAutodownload': true,
- 'appium:waitForQuiescence': false, // Don't wait for app idle
- 'appium:animationCoolOffTimeout': 0, // Skip animation wait
- 'appium:reduceMotion': true, // Reduce iOS animations
- 'appium:customSnapshotTimeout': 15, // Snapshot timeout in seconds"
- 'appium:waitForIdleTimeout': 0, // Don't wait for idle
+ 'appium:customSnapshotTimeout': 15, // Snapshot timeout in seconds
'appium:disableWindowAnimation': true, // Disable animations
'appium:skipDeviceInitialization': true, // Skip init (faster startup)
},
diff --git a/tests/framework/utils/TestConstants.js b/tests/framework/utils/TestConstants.js
index 04ffbd7e83ad..394e605e463d 100644
--- a/tests/framework/utils/TestConstants.js
+++ b/tests/framework/utils/TestConstants.js
@@ -32,6 +32,7 @@ export const TEST_SRP = {
SRP_1: process.env.TEST_SRP_1,
SRP_2: process.env.TEST_SRP_2,
SRP_3: process.env.TEST_SRP_3,
+ SRP_4: process.env.TEST_SRP_4,
};
function getRequiredLoginPassword() {
diff --git a/tests/page-objects/Confirmation/TransactionPayConfirmation.ts b/tests/page-objects/Confirmation/TransactionPayConfirmation.ts
index 20e5303c3bce..e889aa4cac44 100644
--- a/tests/page-objects/Confirmation/TransactionPayConfirmation.ts
+++ b/tests/page-objects/Confirmation/TransactionPayConfirmation.ts
@@ -2,6 +2,7 @@ import {
ConfirmationRowComponentIDs,
TransactionPayComponentIDs,
} from '../../../app/components/Views/confirmations/ConfirmationView.testIds';
+import { getAssetTestId } from '../../selectors/Wallet/WalletView.selectors';
import { getNetworkFilterTestId } from '../../../app/components/Views/confirmations/components/network-filter/network-filter.testIds';
import { TEXTFIELDSEARCH_TEST_ID } from '../../../app/component-library/components/Form/TextFieldSearch/TextFieldSearch.constants';
import enContent from '../../../locales/languages/en.json';
@@ -198,6 +199,14 @@ class TransactionPayConfirmation {
});
}
+ getTokenBySymbol(symbol: string): EncapsulatedElementType {
+ const testId = getAssetTestId(symbol);
+ return encapsulated({
+ detox: () => Matchers.getElementByID(testId),
+ appium: () => PlaywrightMatchers.getElementById(testId, { exact: true }),
+ });
+ }
+
getTokenOptionAt(
tokenSymbol: string,
index: number,
@@ -250,10 +259,16 @@ class TransactionPayConfirmation {
getKeypadButton(key: string): EncapsulatedElementType {
return encapsulated({
detox: () => Matchers.getElementByText(key),
- appium: () =>
- PlaywrightMatchers.getElementById(getKeypadKeyTestId(key), {
- exact: true,
- }),
+ appium: {
+ android: () =>
+ PlaywrightMatchers.getElementById(getKeypadKeyTestId(key), {
+ exact: true,
+ }),
+ ios: () =>
+ PlaywrightMatchers.getElementByAccessibilityId(
+ getKeypadKeyTestId(key),
+ ),
+ },
});
}
@@ -328,19 +343,24 @@ class TransactionPayConfirmation {
if (await PlatformDetector.isIOS()) {
await PlaywrightGestures.dblTap(resolvedFilter);
- return;
+ } else {
+ await PlaywrightGestures.waitAndTap(resolvedFilter, {
+ checkForDisplayed: true,
+ checkForEnabled: true,
+ });
}
- await PlaywrightGestures.waitAndTap(resolvedFilter, {
- checkForDisplayed: true,
- checkForEnabled: true,
+ await PlaywrightGestures.waitForElementStable(resolvedFilter, {
+ timeout: 3000,
+ interval: 200,
+ stableCount: 4,
});
},
});
}
async tapFirstUsdc(tokenName: string): Promise {
- const tokenElement = this.getFirstTokenOption(tokenName);
+ const tokenElement = this.getTokenBySymbol(tokenName);
await encapsulatedAction({
detox: async () => {
diff --git a/tests/page-objects/MMConnect/UniswapDapp.ts b/tests/page-objects/MMConnect/UniswapDapp.ts
index c585d87c2956..018bee1da451 100644
--- a/tests/page-objects/MMConnect/UniswapDapp.ts
+++ b/tests/page-objects/MMConnect/UniswapDapp.ts
@@ -54,9 +54,7 @@ class UniswapDapp {
'//android.widget.Button[@text="MetaMask MetaMask"]',
),
ios: () =>
- PlaywrightMatchers.getElementById('MetaMask MetaMask', {
- exact: true,
- }),
+ PlaywrightMatchers.getElementByAccessibilityId('MetaMaskMetaMask'),
},
});
}
diff --git a/tests/page-objects/Onboarding/CreatePasswordView.ts b/tests/page-objects/Onboarding/CreatePasswordView.ts
index f6088f40832b..99c6445d88be 100644
--- a/tests/page-objects/Onboarding/CreatePasswordView.ts
+++ b/tests/page-objects/Onboarding/CreatePasswordView.ts
@@ -13,6 +13,7 @@ import { encapsulatedAction } from '../../framework/encapsulatedAction';
import PlaywrightAssertions from '../../framework/PlaywrightAssertions';
import PlaywrightMatchers from '../../framework/PlaywrightMatchers';
import UnifiedGestures from '../../framework/UnifiedGestures';
+import { PlatformDetector } from '../../framework/PlatformLocator';
import { ImportFromSeedSelectorsIDs } from '../../../app/components/Views/ImportFromSecretRecoveryPhrase/ImportFromSeed.testIds';
class CreatePasswordView {
@@ -53,7 +54,6 @@ class CreatePasswordView {
PlaywrightMatchers.getElementById(
ImportFromSeedSelectorsIDs.NEW_PASSWORD_VISIBILITY_ID,
),
-
ios: () =>
PlaywrightMatchers.getElementByCatchAll(
ImportFromSeedSelectorsIDs.NEW_PASSWORD_VISIBILITY_ID,
@@ -62,6 +62,19 @@ class CreatePasswordView {
});
}
+ get confirmPasswordVisibilityIcon(): EncapsulatedElementType {
+ return encapsulated({
+ detox: () =>
+ Matchers.getElementByID(
+ ImportFromSeedSelectorsIDs.CONFIRM_PASSWORD_VISIBILITY_ID,
+ ),
+ appium: () =>
+ PlaywrightMatchers.getElementById(
+ ImportFromSeedSelectorsIDs.CONFIRM_PASSWORD_VISIBILITY_ID,
+ ),
+ });
+ }
+
get confirmPasswordInput(): EncapsulatedElementType {
return encapsulated({
detox: () =>
@@ -137,13 +150,19 @@ class CreatePasswordView {
return encapsulated({
detox: () =>
Matchers.getElementByID(ChoosePasswordSelectorsIDs.SUBMIT_BUTTON_ID),
- appium: () =>
- PlaywrightMatchers.getElementById(
- ChoosePasswordSelectorsIDs.SUBMIT_BUTTON_ID,
- {
- exact: true,
- },
- ),
+ appium: {
+ android: () =>
+ PlaywrightMatchers.getElementById(
+ ChoosePasswordSelectorsIDs.SUBMIT_BUTTON_ID,
+ {
+ exact: true,
+ },
+ ),
+ ios: () =>
+ PlaywrightMatchers.getElementByAccessibilityId(
+ ChoosePasswordSelectorsIDs.SUBMIT_BUTTON_ID,
+ ),
+ },
});
}
@@ -179,11 +198,15 @@ class CreatePasswordView {
);
},
appium: async () => {
- await UnifiedGestures.typeText(this.newPasswordInput, password, {
- // once merged, remove me and create a typeText in Playwright Gestures
-
- description: 'Create Password New Password Input',
- });
+ const isIOS = await PlatformDetector.isIOS();
+ await UnifiedGestures.typeText(
+ this.newPasswordInput,
+ isIOS ? `${password}\n` : password,
+ {
+ // once merged, remove me and create a typeText in Playwright Gestures
+ description: 'Create Password New Password Input',
+ },
+ );
},
});
}
@@ -203,9 +226,14 @@ class CreatePasswordView {
);
},
appium: async () => {
- await UnifiedGestures.typeText(this.confirmPasswordInput, password, {
- description: 'Create Password Confirm Password Input',
- });
+ const isIOS = await PlatformDetector.isIOS();
+ await UnifiedGestures.typeText(
+ this.confirmPasswordInput,
+ isIOS ? `${password}\n` : password,
+ {
+ description: 'Create Password Confirm Password Input',
+ },
+ );
},
});
}
@@ -256,6 +284,21 @@ class CreatePasswordView {
});
}
+ async tapConfirmPasswordVisibilityIcon(): Promise {
+ await encapsulatedAction({
+ detox: async () => {
+ await Gestures.tap(asDetoxElement(this.confirmPasswordVisibilityIcon), {
+ elemDescription: 'Create Password Confirm Password Visibility Icon',
+ });
+ },
+ appium: async () => {
+ await UnifiedGestures.waitAndTap(this.confirmPasswordVisibilityIcon, {
+ description: 'Create Password Confirm Password Visibility Icon',
+ });
+ },
+ });
+ }
+
async isVisible(): Promise {
await encapsulatedAction({
detox: async () => {
diff --git a/tests/page-objects/Onboarding/ImportWalletView.ts b/tests/page-objects/Onboarding/ImportWalletView.ts
index 998a65c30e8c..26e106187fdc 100644
--- a/tests/page-objects/Onboarding/ImportWalletView.ts
+++ b/tests/page-objects/Onboarding/ImportWalletView.ts
@@ -75,7 +75,7 @@ class ImportWalletView {
android: () =>
PlaywrightMatchers.getElementById(
index === 0
- ? ImportFromSeedSelectorsIDs.SEED_PHRASE_INPUT_FIELD
+ ? ImportFromSeedSelectorsIDs.SEED_PHRASE_INPUT_ID
: `${ImportFromSeedSelectorsIDs.SEED_PHRASE_INPUT_ID}_${index}`,
{
exact: true,
diff --git a/tests/page-objects/Onboarding/MetaMetricsOptInView.ts b/tests/page-objects/Onboarding/MetaMetricsOptInView.ts
index e278d195a9fb..75f02be8f3c4 100644
--- a/tests/page-objects/Onboarding/MetaMetricsOptInView.ts
+++ b/tests/page-objects/Onboarding/MetaMetricsOptInView.ts
@@ -4,12 +4,15 @@ import Matchers from '../../framework/Matchers';
import Gestures from '../../framework/Gestures';
import {
asDetoxElement,
+ asPlaywrightElement,
encapsulated,
EncapsulatedElementType,
} from '../../framework/EncapsulatedElement';
import { encapsulatedAction } from '../../framework/encapsulatedAction';
import PlaywrightMatchers from '../../framework/PlaywrightMatchers';
+import { PlatformDetector } from '../../framework/PlatformLocator';
import UnifiedGestures from '../../framework/UnifiedGestures';
+import { PlaywrightGestures } from '../../framework';
class MetaMetricsOptIn {
get container(): DetoxElement {
@@ -88,9 +91,17 @@ class MetaMetricsOptIn {
});
},
appium: async () => {
- await UnifiedGestures.tap(this.iAgreeButton, {
- description: 'Opt-in Metrics Continue Button',
- });
+ if (await PlatformDetector.isAndroid()) {
+ await PlaywrightGestures.hideKeyboard();
+ }
+ await PlaywrightGestures.waitAndTap(
+ await asPlaywrightElement(this.iAgreeButton),
+ {
+ checkForDisplayed: true,
+ checkForEnabled: true,
+ timeout: 15_000,
+ },
+ );
},
});
}
diff --git a/tests/page-objects/Onboarding/OnboardingInterestQuestionnaireView.ts b/tests/page-objects/Onboarding/OnboardingInterestQuestionnaireView.ts
index 7f32ba515705..e47d1773a7e1 100644
--- a/tests/page-objects/Onboarding/OnboardingInterestQuestionnaireView.ts
+++ b/tests/page-objects/Onboarding/OnboardingInterestQuestionnaireView.ts
@@ -51,14 +51,16 @@ class OnboardingInterestQuestionnaireView {
}
async tapContinueButton(): Promise {
- await UnifiedGestures.waitAndTap(this.continueButton, {
+ await UnifiedGestures.tap(this.continueButton, {
description: 'Onboarding Interest Questionnaire Continue Button',
+ timeout: 2000,
});
}
async tapOption(id: InterestOptionId): Promise {
await UnifiedGestures.waitAndTap(this.getOptionById(id), {
description: `Onboarding Interest Questionnaire Option: ${id}`,
+ timeout: 2000,
});
}
}
diff --git a/tests/page-objects/Onboarding/OnboardingSuccessView.ts b/tests/page-objects/Onboarding/OnboardingSuccessView.ts
index 19fbaa53fbd5..a7e50f12441e 100644
--- a/tests/page-objects/Onboarding/OnboardingSuccessView.ts
+++ b/tests/page-objects/Onboarding/OnboardingSuccessView.ts
@@ -30,6 +30,7 @@ class OnboardingSuccessView {
async tapDone(): Promise {
await UnifiedGestures.waitAndTap(this.doneButton, {
description: 'Onboarding Success Done Button',
+ timeout: 15_000,
});
}
}
diff --git a/tests/page-objects/Onboarding/ProtectYourWalletView.ts b/tests/page-objects/Onboarding/ProtectYourWalletView.ts
index b2560bfe8547..c477e1dceb68 100644
--- a/tests/page-objects/Onboarding/ProtectYourWalletView.ts
+++ b/tests/page-objects/Onboarding/ProtectYourWalletView.ts
@@ -1,11 +1,16 @@
import { ManualBackUpStepsSelectorsIDs } from '../../../app/components/Views/ManualBackupStep1/ManualBackUpSteps.testIds';
import Matchers from '../../framework/Matchers';
+import Gestures from '../../framework/Gestures';
import {
+ asDetoxElement,
+ asPlaywrightElement,
encapsulated,
EncapsulatedElementType,
} from '../../framework/EncapsulatedElement';
+import { encapsulatedAction } from '../../framework/encapsulatedAction';
+import PlaywrightGestures from '../../framework/PlaywrightGestures';
import PlaywrightMatchers from '../../framework/PlaywrightMatchers';
-import UnifiedGestures from '../../framework/UnifiedGestures';
+import { PlatformDetector } from '../../framework/PlatformLocator';
class ProtectYourWalletView {
get container(): DetoxElement {
@@ -20,22 +25,33 @@ class ProtectYourWalletView {
Matchers.getElementByID(
ManualBackUpStepsSelectorsIDs.REMIND_ME_LATER_BUTTON,
),
- appium: {
- android: () => PlaywrightMatchers.getElementByText('Remind me later'),
- ios: () =>
- PlaywrightMatchers.getElementByXPath(
- '(//XCUIElementTypeStaticText[@name="Remind me later"])[2]',
- {
- exact: true,
- },
- ),
- },
+ appium: () =>
+ PlaywrightMatchers.getElementById(
+ ManualBackUpStepsSelectorsIDs.REMIND_ME_LATER_BUTTON,
+ { exact: true },
+ ),
});
}
async tapOnRemindMeLaterButton(): Promise {
- await UnifiedGestures.waitAndTap(this.remindMeLaterButton, {
- description: 'Protect Your Wallet Remind Me Later Button',
+ await encapsulatedAction({
+ detox: async () => {
+ await Gestures.waitAndTap(asDetoxElement(this.remindMeLaterButton), {
+ elemDescription: 'Protect Your Wallet Remind Me Later Button',
+ });
+ },
+ appium: async () => {
+ if (await PlatformDetector.isIOS()) {
+ await PlaywrightGestures.hideKeyboard();
+ }
+ const button = await asPlaywrightElement(this.remindMeLaterButton);
+ await PlaywrightGestures.scrollIntoView(button);
+ await PlaywrightGestures.waitAndTap(button, {
+ checkForDisplayed: true,
+ checkForEnabled: true,
+ timeout: 15_000,
+ });
+ },
});
}
diff --git a/tests/page-objects/Onboarding/SocialLoginView.ts b/tests/page-objects/Onboarding/SocialLoginView.ts
index a423634791c9..a470e45062f0 100644
--- a/tests/page-objects/Onboarding/SocialLoginView.ts
+++ b/tests/page-objects/Onboarding/SocialLoginView.ts
@@ -3,6 +3,7 @@ import {
Gestures,
Matchers,
PlaywrightAssertions,
+ PlaywrightGestures,
PlaywrightMatchers,
UnifiedGestures,
asDetoxElement,
@@ -350,6 +351,48 @@ class SocialLoginView {
},
});
}
+ get updateModalContinueButton(): EncapsulatedElementType {
+ return encapsulated({
+ detox: () => Matchers.getElementByID('Continue'),
+ appium: () =>
+ PlaywrightMatchers.getElementById('Continue', { exact: true }),
+ });
+ }
+
+ /**
+ * Dismiss the "iOS Update Required" modal if present by tapping "Continue".
+ * Silently does nothing if the modal is not showing.
+ */
+ async dismissUpdateModalIfPresent(): Promise {
+ await encapsulatedAction({
+ detox: async () => {
+ try {
+ await Assertions.expectElementToBeVisible(
+ asDetoxElement(this.updateModalContinueButton),
+ { timeout: 3000, description: 'iOS update modal' },
+ );
+ await Gestures.waitAndTap(
+ asDetoxElement(this.updateModalContinueButton),
+ { elemDescription: 'Continue button on iOS update modal' },
+ );
+ } catch {
+ // Modal not present
+ }
+ },
+ appium: async () => {
+ try {
+ const btn = asPlaywrightElement(this.updateModalContinueButton);
+ await PlaywrightAssertions.expectElementToBeVisible(btn, {
+ timeout: 3000,
+ description: 'iOS update modal',
+ });
+ await PlaywrightGestures.waitAndTap(await btn);
+ } catch {
+ // Modal not present
+ }
+ },
+ });
+ }
}
export default new SocialLoginView();
diff --git a/tests/page-objects/Perps/PerpsDepositView.ts b/tests/page-objects/Perps/PerpsDepositView.ts
index 86c2bc5d8d6f..6f255d4703f3 100644
--- a/tests/page-objects/Perps/PerpsDepositView.ts
+++ b/tests/page-objects/Perps/PerpsDepositView.ts
@@ -67,9 +67,21 @@ class PerpsDepositView {
return Matchers.getElementByID('confirm-button');
}
+ get infoRow(): EncapsulatedElementType {
+ return encapsulated({
+ detox: () => Matchers.getElementByText('Info'),
+ appium: () =>
+ PlaywrightMatchers.getElementById('info-row', { exact: true }),
+ });
+ }
+
// Pay with row (open selector)
- get payWithRow(): DetoxElement {
- return Matchers.getElementByText('Pay with');
+ get payWithRow(): EncapsulatedElementType {
+ return encapsulated({
+ detox: () => Matchers.getElementByText('Pay with'),
+ appium: () =>
+ PlaywrightMatchers.getElementById('pay-with', { exact: true }),
+ });
}
get usdcOption(): DetoxElement {
diff --git a/tests/page-objects/Perps/PerpsMarketDetailsView.ts b/tests/page-objects/Perps/PerpsMarketDetailsView.ts
index c79badb4ba56..456e59d4560e 100644
--- a/tests/page-objects/Perps/PerpsMarketDetailsView.ts
+++ b/tests/page-objects/Perps/PerpsMarketDetailsView.ts
@@ -167,16 +167,23 @@ class PerpsMarketDetailsView {
});
}
- // Trading action buttons
+ // Trading action buttons — On Android, Reanimated's AnimatedPressable
+ // inside ButtonSemantic doesn't propagate testID to resource-id, so Appium
+ // targets the plain View wrapper (LONG/SHORT_BUTTON_WRAPPER) instead.
get longButton(): EncapsulatedElementType {
return encapsulated({
detox: () =>
Matchers.getElementByID(PerpsMarketDetailsViewSelectorsIDs.LONG_BUTTON),
- appium: () =>
- PlaywrightMatchers.getElementById(
- PerpsMarketDetailsViewSelectorsIDs.LONG_BUTTON,
- { exact: true },
- ),
+ appium: {
+ android: () =>
+ PlaywrightMatchers.getElementById(
+ PerpsMarketDetailsViewSelectorsIDs.LONG_BUTTON,
+ ),
+ ios: () =>
+ PlaywrightMatchers.getElementById(
+ PerpsMarketDetailsViewSelectorsIDs.LONG_BUTTON,
+ ),
+ },
});
}
@@ -186,11 +193,16 @@ class PerpsMarketDetailsView {
Matchers.getElementByID(
PerpsMarketDetailsViewSelectorsIDs.SHORT_BUTTON,
),
- appium: () =>
- PlaywrightMatchers.getElementById(
- PerpsMarketDetailsViewSelectorsIDs.SHORT_BUTTON,
- { exact: true },
- ),
+ appium: {
+ android: () =>
+ PlaywrightMatchers.getElementById(
+ PerpsMarketDetailsViewSelectorsIDs.SHORT_BUTTON,
+ ),
+ ios: () =>
+ PlaywrightMatchers.getElementById(
+ PerpsMarketDetailsViewSelectorsIDs.SHORT_BUTTON,
+ ),
+ },
});
}
@@ -236,8 +248,14 @@ class PerpsMarketDetailsView {
});
},
appium: async () => {
+ console.log('tapLongButton appium');
await PlaywrightGestures.waitAndTap(
await asPlaywrightElement(this.longButton),
+ {
+ checkForDisplayed: true,
+ checkForEnabled: true,
+ checkForStable: true,
+ },
);
},
});
@@ -251,6 +269,11 @@ class PerpsMarketDetailsView {
appium: async () => {
await PlaywrightGestures.waitAndTap(
await asPlaywrightElement(this.shortButton),
+ {
+ checkForDisplayed: true,
+ checkForEnabled: true,
+ checkForStable: true,
+ },
);
},
});
diff --git a/tests/page-objects/Perps/PerpsMarketListView.ts b/tests/page-objects/Perps/PerpsMarketListView.ts
index b3fe30acddbc..1516ddebdf7a 100644
--- a/tests/page-objects/Perps/PerpsMarketListView.ts
+++ b/tests/page-objects/Perps/PerpsMarketListView.ts
@@ -7,6 +7,7 @@ import {
import Gestures from '../../framework/Gestures';
import Matchers from '../../framework/Matchers';
import {
+ asPlaywrightElement,
encapsulated,
EncapsulatedElementType,
} from '../../framework/EncapsulatedElement';
@@ -71,16 +72,38 @@ class PerpsMarketListView {
get header(): EncapsulatedElementType {
return encapsulated({
- appium: () =>
- // TODO: Create a testIds.ts const with this selector
- PlaywrightMatchers.getElementById('perps-home', { exact: true }),
+ detox: () =>
+ Matchers.getElementByID(PerpsMarketListViewSelectorsIDs.MARKET_LIST),
+ appium: {
+ android: () =>
+ PlaywrightMatchers.getElementById(
+ PerpsMarketListViewSelectorsIDs.MARKET_LIST,
+ { exact: true },
+ ),
+ ios: () =>
+ PlaywrightMatchers.getElementByAccessibilityId(
+ PerpsMarketListViewSelectorsIDs.MARKET_LIST,
+ ),
+ },
});
}
- get marketRowItemBTC() {
- return Matchers.getElementByID(
- getPerpsMarketRowItemSelector.rowItem('BTC'),
- );
+ get marketRowItemBTC(): EncapsulatedElementType {
+ return encapsulated({
+ detox: () =>
+ Matchers.getElementByID(getPerpsMarketRowItemSelector.rowItem('BTC')),
+ appium: {
+ android: () =>
+ PlaywrightMatchers.getElementById(
+ getPerpsMarketRowItemSelector.rowItem('BTC'),
+ { exact: true },
+ ),
+ ios: () =>
+ PlaywrightMatchers.getElementByAccessibilityId(
+ getPerpsMarketRowItemSelector.rowItem('BTC'),
+ ),
+ },
+ });
}
// Generic selector for first market row item (regardless of coin)
@@ -107,18 +130,12 @@ class PerpsMarketListView {
// Actions
async tapMarketRowItemBTC() {
- await Gestures.scrollToElement(
- this.marketRowItemBTC,
- this.scrollableContainer,
- {
- direction: 'down',
- scrollAmount: 200,
- elemDescription: 'Perps Market Row BTC',
+ await encapsulatedAction({
+ appium: async () => {
+ const marketElement = await asPlaywrightElement(this.marketRowItemBTC);
+ await PlaywrightGestures.scrollIntoView(marketElement);
+ await PlaywrightGestures.waitAndTap(marketElement);
},
- );
- await Gestures.waitAndTap(this.marketRowItemBTC, {
- elemDescription: 'Perps Market Row BTC',
- checkStability: true,
});
}
@@ -150,7 +167,6 @@ class PerpsMarketListView {
await Gestures.waitAndTap(this.container);
}
- // Helper method to select a specific market by text
async selectMarket(marketName: string) {
await encapsulatedAction({
detox: async () => {
@@ -160,12 +176,12 @@ class PerpsMarketListView {
await Gestures.waitAndTap(marketElement);
},
appium: async () => {
- // TODO: Create a testIds.ts const with this selector
const marketSelector = `${PerpsMarketRowItemSelectorsIDs.ROW_ITEM}-${marketName}`;
const marketElement = await PlaywrightMatchers.getElementById(
marketSelector,
{ exact: true },
);
+ await PlaywrightGestures.scrollIntoView(marketElement);
await PlaywrightGestures.waitAndTap(marketElement);
},
});
diff --git a/tests/page-objects/Perps/PerpsOrderView.ts b/tests/page-objects/Perps/PerpsOrderView.ts
index c699fea5c314..36e0580f388a 100644
--- a/tests/page-objects/Perps/PerpsOrderView.ts
+++ b/tests/page-objects/Perps/PerpsOrderView.ts
@@ -81,7 +81,7 @@ class PerpsOrderView {
return Matchers.getElementByText('Set Leverage');
}
- async tapPlaceOrderButton() {
+ async tapPlaceOrderButton(): Promise {
await encapsulatedAction({
detox: async () => {
const el = asDetoxElement(this.placeOrderButton);
@@ -103,8 +103,10 @@ class PerpsOrderView {
appium: async () => {
const el = await asPlaywrightElement(this.placeOrderButton);
await PlaywrightGestures.waitAndTap(el, {
+ checkForDisplayed: true,
checkForEnabled: true,
checkForStable: true,
+ delay: 1000,
});
},
});
@@ -185,6 +187,7 @@ class PerpsOrderView {
appium: () =>
PlaywrightMatchers.getElementById(
PerpsAmountDisplaySelectorsIDs.CONTAINER,
+ { exact: true },
),
});
}
@@ -196,33 +199,52 @@ class PerpsOrderView {
appium: () =>
PlaywrightMatchers.getElementById(
PerpsAmountDisplaySelectorsIDs.AMOUNT_LABEL,
+ { exact: true },
),
});
}
- async setAmountUSD(amount: string) {
+ getKeypadKey(key: string): EncapsulatedElementType {
+ return encapsulated({
+ detox: () => Matchers.getElementByText(key),
+ appium: () => PlaywrightMatchers.getElementByText(key),
+ });
+ }
+
+ getDoneButton(): EncapsulatedElementType {
+ return encapsulated({
+ detox: () => Matchers.getElementByText('Done'),
+ appium: () => PlaywrightMatchers.getElementByText('Done'),
+ });
+ }
+
+ // Required for next test
+ async setAmountUSD(amount: string): Promise {
await encapsulatedAction({
detox: async () => {
- const el = asDetoxElement(this.amountValue);
+ // Open keypad by tapping the value by ID (more reliable than tapping the container)
await device.disableSynchronization();
- await Assertions.expectElementToBeVisible(el, {
- description: 'Amount value is visible',
- });
- await Gestures.waitAndTap(el, {
+ await Assertions.expectElementToBeVisible(
+ asDetoxElement(this.amountValue),
+ {
+ description: 'Amount value is visible',
+ },
+ );
+ await Gestures.waitAndTap(asDetoxElement(this.amountValue), {
elemDescription: 'Open amount keypad by tapping amount label',
checkEnabled: false,
checkVisibility: false,
});
+ // Type each character using the native keypad (buttons 0-9 and '.')
for (const ch of amount) {
- const key = Matchers.getElementByText(ch) as DetoxElement;
- await Gestures.waitAndTap(key, {
+ await Gestures.waitAndTap(asDetoxElement(this.getKeypadKey(ch)), {
elemDescription: `Keypad: ${ch}`,
checkEnabled: false,
checkVisibility: false,
});
}
- const doneByText = Matchers.getElementByText('Done') as DetoxElement;
- await Gestures.waitAndTap(doneByText, {
+ // Close the keypad using the Done button
+ await Gestures.waitAndTap(asDetoxElement(this.getDoneButton()), {
elemDescription: 'Tap Done (by text) to close keypad',
checkEnabled: false,
checkVisibility: false,
@@ -230,22 +252,27 @@ class PerpsOrderView {
await device.enableSynchronization();
},
appium: async () => {
- const el = await asPlaywrightElement(this.amountValue);
- await el.unwrap().waitForDisplayed({ timeout: 8000 });
- await PlaywrightGestures.waitAndTap(el);
- const deleteButton = await asPlaywrightElement(this.keypadDeleteButton);
- await PlaywrightGestures.waitAndTap(deleteButton);
+ const amountEl = await asPlaywrightElement(this.amountValue);
+ await PlaywrightGestures.waitAndTap(amountEl, {
+ checkForDisplayed: true,
+ checkForEnabled: true,
+ });
+ // Type each character using the native keypad (buttons 0-9 and '.')
+ await UnifiedGestures.waitAndTap(this.keypadDeleteButton, {
+ checkForDisplayed: true,
+ checkForEnabled: true,
+ });
for (const ch of amount) {
- const key = await PlaywrightMatchers.getElementByText(ch);
- await PlaywrightGestures.waitAndTap(key, {
- checkForDisplayed: false,
- checkForEnabled: false,
+ const keyEl = await asPlaywrightElement(this.getKeypadKey(ch));
+ await PlaywrightGestures.waitAndTap(keyEl, {
+ checkForDisplayed: true,
+ delay: 300,
});
}
- const doneBtn = await PlaywrightMatchers.getElementByText('Done');
- await PlaywrightGestures.waitAndTap(doneBtn, {
- checkForDisplayed: false,
- checkForEnabled: false,
+ // Close the keypad using the Done button
+ const doneEl = await asPlaywrightElement(this.getDoneButton());
+ await PlaywrightGestures.waitAndTap(doneEl, {
+ checkForDisplayed: true,
});
},
});
@@ -388,16 +415,18 @@ class PerpsOrderView {
await asPlaywrightElement(this.leverageRowLabel),
);
- // Tap the leverage option (e.g. "40x")
+ // Tap the leverage quick-select button (e.g. "40x")
+ const leverageSelector = `${leverageX}x`;
let optionEl: PlaywrightElement;
- if (await PlatformDetector.isIOS()) {
- optionEl = await PlaywrightMatchers.getElementByText(`${leverageX}x`);
- } else {
+ if (PlatformDetector.isAndroid()) {
optionEl = await PlaywrightMatchers.getElementByXPath(
- `//*[@content-desc="${leverageX}x"]`,
+ `//android.view.ViewGroup[@content-desc="${leverageSelector}"]`,
+ );
+ } else {
+ optionEl = await PlaywrightMatchers.getElementByAccessibilityId(
+ `quick-select-button-${leverageSelector}`,
);
}
-
await PlaywrightGestures.waitAndTap(optionEl);
// Tap confirm button (e.g. "Set 40x")
diff --git a/tests/page-objects/Predict/PredictDetailsPage.ts b/tests/page-objects/Predict/PredictDetailsPage.ts
index a7a69f24796c..0eca95abd77a 100644
--- a/tests/page-objects/Predict/PredictDetailsPage.ts
+++ b/tests/page-objects/Predict/PredictDetailsPage.ts
@@ -248,11 +248,13 @@ class PredictDetailsPage {
async tapAboutTab(): Promise {
await UnifiedGestures.waitAndTap(this.aboutTab, {
description: 'About tab',
+ checkForDisplayed: false,
});
}
async tapOutcomesTab(): Promise {
await UnifiedGestures.waitAndTap(this.outcomesTab, {
description: 'Outcomes tab',
+ checkForDisplayed: false,
});
}
async tapCashOutButton(): Promise {
diff --git a/tests/page-objects/Predict/PredictMarketList.ts b/tests/page-objects/Predict/PredictMarketList.ts
index e1d1cfee6c81..94038de5e6d0 100644
--- a/tests/page-objects/Predict/PredictMarketList.ts
+++ b/tests/page-objects/Predict/PredictMarketList.ts
@@ -9,6 +9,7 @@ import {
PredictBalanceSelectorsIDs,
PredictBalanceSelectorsText,
PredictMarketListSelectorsIDs,
+ getPredictFeedSelector,
getPredictMarketListSelector,
} from '../../../app/components/UI/Predict/Predict.testIds';
@@ -105,6 +106,50 @@ class PredictMarketList {
});
}
+ get trendingSkeleton(): EncapsulatedElementType {
+ return encapsulated({
+ detox: () =>
+ Matchers.getElementByID(
+ getPredictFeedSelector.skeletonLoading('trending', 1),
+ ),
+ appium: () =>
+ PlaywrightMatchers.getElementById(
+ getPredictFeedSelector.skeletonLoading('trending', 1),
+ { exact: true },
+ ),
+ });
+ }
+
+ get firstTrendingMarketCard(): EncapsulatedElementType {
+ return encapsulated({
+ detox: () =>
+ Matchers.getElementByID(
+ getPredictMarketListSelector.marketCardByCategory('trending', 1),
+ ),
+ appium: () =>
+ PlaywrightMatchers.getElementById(
+ getPredictMarketListSelector.marketCardByCategory('trending', 1),
+ ),
+ });
+ }
+
+ get firstYesButton(): EncapsulatedElementType {
+ return encapsulated({
+ detox: () => Matchers.getElementByText('Yes'),
+ appium: () => PlaywrightMatchers.getElementByText('Yes'),
+ });
+ }
+
+ get getIsraelXHezbollahCeasefireButton(): EncapsulatedElementType {
+ return encapsulated({
+ detox: () => Matchers.getElementByText('No'),
+ appium: () =>
+ PlaywrightMatchers.getElementByXPath(
+ '//*[contains(@content-desc, "Israel x Hezbollah ceasefire by")]',
+ ),
+ });
+ }
+
getMarketCard(
category: CategoryTab,
cardIndex: number,
diff --git a/tests/page-objects/swaps/QuoteView.ts b/tests/page-objects/swaps/QuoteView.ts
index c2717014a084..a7120810c3ae 100644
--- a/tests/page-objects/swaps/QuoteView.ts
+++ b/tests/page-objects/swaps/QuoteView.ts
@@ -72,10 +72,28 @@ class QuoteView {
});
}
- get searchToken(): Promise {
- return Matchers.getElementByID(
- QuoteViewSelectorIDs.TOKEN_SEARCH_INPUT,
- ) as Promise;
+ get destinationTokenInput(): EncapsulatedElementType {
+ return encapsulated({
+ detox: () =>
+ Matchers.getElementByID(QuoteViewSelectorIDs.DESTINATION_TOKEN_INPUT),
+ appium: () =>
+ PlaywrightMatchers.getElementById(
+ QuoteViewSelectorIDs.DESTINATION_TOKEN_INPUT,
+ { exact: true },
+ ),
+ });
+ }
+
+ get searchToken(): EncapsulatedElementType {
+ return encapsulated({
+ detox: () =>
+ Matchers.getElementByID(QuoteViewSelectorIDs.TOKEN_SEARCH_INPUT),
+ appium: () =>
+ PlaywrightMatchers.getElementById(
+ QuoteViewSelectorIDs.TOKEN_SEARCH_INPUT,
+ { exact: true },
+ ),
+ });
}
get seeAllButton(): DetoxElement {
@@ -105,16 +123,30 @@ class QuoteView {
/** Fee disclaimer (e.g. "Includes 0.875% MetaMask fee") - used for isQuoteDisplayed. */
get feeDisclaimerLabel(): EncapsulatedElementType {
return encapsulated({
- detox: () => Matchers.getElementByID(QuoteViewSelectorIDs.FEE_DISCLAIMER),
+ detox: () =>
+ Matchers.getElementByID(QuoteViewSelectorIDs.PRICE_IMPACT_INFO_BUTTON),
appium: () =>
- PlaywrightMatchers.getElementById(QuoteViewSelectorIDs.FEE_DISCLAIMER, {
- exact: true,
- }),
+ PlaywrightMatchers.getElementById(
+ QuoteViewSelectorIDs.PRICE_IMPACT_INFO_BUTTON,
+ {
+ exact: true,
+ },
+ ),
});
}
- get keypadDeleteButton(): DetoxElement {
- return Matchers.getElementByID(QuoteViewSelectorIDs.KEYPAD_DELETE_BUTTON);
+ get keypadDeleteButton(): EncapsulatedElementType {
+ return encapsulated({
+ detox: () =>
+ Matchers.getElementByID(QuoteViewSelectorIDs.KEYPAD_DELETE_BUTTON),
+ appium: () =>
+ PlaywrightMatchers.getElementById(
+ QuoteViewSelectorIDs.KEYPAD_DELETE_BUTTON,
+ {
+ exact: true,
+ },
+ ),
+ });
}
get maxLink(): DetoxElement {
@@ -148,8 +180,21 @@ class QuoteView {
}
async tapSearchToken(): Promise {
- await Gestures.waitAndTap(this.searchToken, {
- elemDescription: 'Tap on token search input element',
+ await encapsulatedAction({
+ detox: async () => {
+ await Gestures.waitAndTap(asDetoxElement(this.searchToken), {
+ elemDescription: 'Tap on token search input element',
+ });
+ },
+ appium: async () => {
+ await PlaywrightGestures.waitAndTap(
+ await asPlaywrightElement(this.searchToken),
+ {
+ checkForDisplayed: true,
+ checkForEnabled: true,
+ },
+ );
+ },
});
}
@@ -200,8 +245,16 @@ class QuoteView {
}
async typeSearchToken(symbol: string): Promise {
- await Gestures.typeText(this.searchToken, symbol, {
- elemDescription: `Search Token with symbol ${symbol}`,
+ await encapsulatedAction({
+ detox: async () => {
+ await Gestures.typeText(asDetoxElement(this.searchToken), symbol, {
+ elemDescription: `Search Token with symbol ${symbol}`,
+ });
+ },
+ appium: async () => {
+ const searchField = await asPlaywrightElement(this.searchToken);
+ await searchField.fill(symbol);
+ },
});
}
@@ -357,8 +410,8 @@ class QuoteView {
}
/**
- * Asserts the quote is displayed (fee disclaimer visible).
- * BridgeScreen.isQuoteDisplayed equivalent.
+ * Asserts the quote is displayed by verifying the destination token input
+ * contains a numeric value (meaning a quote result has populated the field).
*/
async isQuoteDisplayed(): Promise {
await encapsulatedAction({
@@ -366,18 +419,26 @@ class QuoteView {
await Assertions.expectElementToBeVisible(
asDetoxElement(this.feeDisclaimerLabel),
{
+ description: 'Fee disclaimer label is visible (quote displayed)',
timeout: TIMEOUT.QUOTE_DISPLAYED,
- description: 'Fee disclaimer (quote) should be visible',
},
);
},
appium: async () => {
- await PlaywrightAssertions.expectElementToBeVisible(
- asPlaywrightElement(this.feeDisclaimerLabel),
- {
- timeout: TIMEOUT.QUOTE_DISPLAYED,
- description: 'Fee disclaimer (quote) should be visible',
- },
+ const el = await asPlaywrightElement(this.destinationTokenInput);
+ const timeout = TIMEOUT.QUOTE_DISPLAYED;
+ const interval = 300;
+ const start = Date.now();
+ while (Date.now() - start < timeout) {
+ const text = await el.textContent();
+ if (text && /\d/.test(text) && parseFloat(text) > 0) {
+ return;
+ }
+ await new Promise((r) => setTimeout(r, interval));
+ }
+ const finalText = await el.textContent();
+ throw new Error(
+ `Destination token input does not contain a numeric value after ${timeout}ms, got: "${finalText}"`,
);
},
});
@@ -392,6 +453,7 @@ class QuoteView {
if (network !== 'Ethereum') {
await this.selectNetwork(network);
}
+ await this.typeSearchToken(token);
const chainId = getChainIdForNetwork(network);
await this.tapToken(chainId, token);
}
@@ -407,6 +469,14 @@ class QuoteView {
},
appium: async () => {
await this.tapSourceAmountInput();
+ await PlaywrightGestures.waitAndTap(
+ await asPlaywrightElement(this.keypadDeleteButton),
+ {
+ checkForDisplayed: true,
+ checkForEnabled: true,
+ delay: 1000,
+ },
+ );
const isAndroid = await PlatformDetector.isAndroid();
for (const digit of amount.split('')) {
const keyName =
diff --git a/tests/page-objects/wallet/AccountListBottomSheet.ts b/tests/page-objects/wallet/AccountListBottomSheet.ts
index e6e2375fe626..cc34ee3f3ea5 100644
--- a/tests/page-objects/wallet/AccountListBottomSheet.ts
+++ b/tests/page-objects/wallet/AccountListBottomSheet.ts
@@ -278,7 +278,10 @@ class AccountListBottomSheet {
});
}
- async tapAccountByNameV2(accountName: string): Promise {
+ async tapAccountByNameV2(
+ accountName: string,
+ exactMatch: boolean = false,
+ ): Promise {
await encapsulatedAction({
detox: async () => {
const accountEl = this.getAccountElementByAccountNameV2(accountName);
@@ -287,8 +290,10 @@ class AccountListBottomSheet {
});
},
appium: async () => {
- const accountEl =
- await PlaywrightMatchers.getElementByText(accountName);
+ const accountEl = await PlaywrightMatchers.getElementByText(
+ accountName,
+ exactMatch,
+ );
await PlaywrightGestures.scrollIntoView(accountEl);
await PlaywrightGestures.waitAndTap(accountEl);
},
@@ -360,7 +365,7 @@ class AccountListBottomSheet {
* @param timeout - The timeout in milliseconds.
* @returns {Promise} Resolves when the account sync is complete.
*/
- async waitForAccountSyncToComplete(timeout = 60000): Promise {
+ async waitForAccountSyncToComplete(timeout = 90000): Promise {
logger.debug('⏳ waitForSyncingToComplete: Starting...');
const startTime = Date.now();
const pollInterval = 500;
diff --git a/tests/page-objects/wallet/LoginView.ts b/tests/page-objects/wallet/LoginView.ts
index a6b3f771725c..83046d0bbef6 100644
--- a/tests/page-objects/wallet/LoginView.ts
+++ b/tests/page-objects/wallet/LoginView.ts
@@ -1,7 +1,4 @@
-import {
- LoginViewSelectors,
- LoginViewSelectorText,
-} from '../../../app/components/Views/Login/LoginView.testIds';
+import { LoginViewSelectors } from '../../../app/components/Views/Login/LoginView.testIds';
import Matchers from '../../framework/Matchers';
import Gestures from '../../framework/Gestures';
import { PlaywrightAssertions } from '../../framework';
@@ -12,7 +9,9 @@ import {
} from '../../framework/EncapsulatedElement';
import { encapsulatedAction } from '../../framework/encapsulatedAction';
import PlaywrightMatchers from '../../framework/PlaywrightMatchers';
+import PlaywrightGestures from '../../framework/PlaywrightGestures';
import UnifiedGestures from '../../framework/UnifiedGestures';
+import Utilities from '../../framework/Utilities';
class LoginView {
get container(): EncapsulatedElementType {
@@ -34,8 +33,8 @@ class LoginView {
Matchers.getElementByLabel(LoginViewSelectors.PASSWORD_INPUT),
appium: {
android: () =>
- PlaywrightMatchers.getElementByAccessibilityId(
- LoginViewSelectors.PASSWORD_INPUT,
+ PlaywrightMatchers.getElementByAndroidUIAutomator(
+ `.description("${LoginViewSelectors.PASSWORD_INPUT}")`,
),
ios: () =>
PlaywrightMatchers.getElementById(LoginViewSelectors.PASSWORD_INPUT, {
@@ -57,9 +56,9 @@ class LoginView {
return encapsulated({
detox: () => Matchers.getElementByID(LoginViewSelectors.LOGIN_BUTTON_ID),
appium: () =>
- PlaywrightMatchers.getElementByText(
- LoginViewSelectorText.UNLOCK_BUTTON,
- ),
+ PlaywrightMatchers.getElementById(LoginViewSelectors.LOGIN_BUTTON_ID, {
+ exact: true,
+ }),
});
}
@@ -74,8 +73,18 @@ class LoginView {
}
async enterPassword(password: string): Promise {
- await UnifiedGestures.typeText(this.passwordInput, password, {
- description: 'Password Input',
+ await encapsulatedAction({
+ detox: async () => {
+ await UnifiedGestures.typeText(this.passwordInput, password, {
+ description: 'Password Input',
+ });
+ },
+ appium: async () => {
+ await UnifiedGestures.typeText(this.passwordInput, password, {
+ description: 'Password Input',
+ });
+ await PlaywrightGestures.hideKeyboard();
+ },
});
}
@@ -92,8 +101,23 @@ class LoginView {
}
async tapLoginButton(): Promise {
- await UnifiedGestures.waitAndTap(this.loginButton, {
- description: 'Login Button',
+ await encapsulatedAction({
+ detox: async () => {
+ await UnifiedGestures.waitAndTap(this.loginButton, {
+ description: 'Login Button',
+ });
+ },
+ appium: async () => {
+ await UnifiedGestures.waitAndTap(this.loginButton, {
+ description: 'Login Button',
+ checkForDisplayed: true,
+ checkForEnabled: true,
+ waitForInteractive: true,
+ timeout: 20_000,
+ enabledStableReads: 4,
+ postEnabledSettleMs: 1500,
+ });
+ },
});
}
diff --git a/tests/page-objects/wallet/TokenOverview.ts b/tests/page-objects/wallet/TokenOverview.ts
index 2f4a7452731f..a42ae755d728 100644
--- a/tests/page-objects/wallet/TokenOverview.ts
+++ b/tests/page-objects/wallet/TokenOverview.ts
@@ -59,12 +59,34 @@ class TokenOverview {
get todaysChange(): EncapsulatedElementType {
return encapsulated({
detox: () =>
- Matchers.getElementByText(
- TokenOverviewSelectorsText.TODAYS_CHANGE_SUFFIX,
+ Matchers.getElementByID(TokenOverviewSelectorsIDs.TODAYS_CHANGE),
+ appium: () =>
+ PlaywrightMatchers.getElementById(
+ TokenOverviewSelectorsIDs.TODAYS_CHANGE,
+ ),
+ });
+ }
+
+ get priceChartDotEnd(): EncapsulatedElementType {
+ return encapsulated({
+ detox: () =>
+ Matchers.getElementByID(TokenOverviewSelectorsIDs.PRICE_CHART_DOT_END),
+ appium: () =>
+ PlaywrightMatchers.getElementById(
+ TokenOverviewSelectorsIDs.TOKEN_PRICE,
+ ),
+ });
+ }
+
+ get priceChartContainer(): EncapsulatedElementType {
+ return encapsulated({
+ detox: () =>
+ Matchers.getElementByID(
+ TokenOverviewSelectorsIDs.PRICE_CHART_CONTAINER,
),
appium: () =>
- PlaywrightMatchers.getElementByCatchAll(
- TokenOverviewSelectorsText.TODAYS_CHANGE_SUFFIX,
+ PlaywrightMatchers.getElementById(
+ TokenOverviewSelectorsIDs.PRICE_CHART_CONTAINER,
),
});
}
diff --git a/tests/page-objects/wallet/WalletActionsBottomSheet.ts b/tests/page-objects/wallet/WalletActionsBottomSheet.ts
index ff891f1e770d..333a3ed67e8d 100644
--- a/tests/page-objects/wallet/WalletActionsBottomSheet.ts
+++ b/tests/page-objects/wallet/WalletActionsBottomSheet.ts
@@ -53,11 +53,17 @@ class WalletActionsBottomSheet {
Matchers.getElementByID(
WalletActionsBottomSheetSelectorsIDs.PERPS_BUTTON,
),
- appium: () =>
- PlaywrightMatchers.getElementById(
- WalletActionsBottomSheetSelectorsIDs.PERPS_BUTTON,
- { exact: true },
- ),
+ appium: {
+ android: () =>
+ PlaywrightMatchers.getElementById(
+ WalletActionsBottomSheetSelectorsIDs.PERPS_BUTTON,
+ { exact: true },
+ ),
+ ios: () =>
+ PlaywrightMatchers.getElementByAccessibilityId(
+ WalletActionsBottomSheetSelectorsIDs.PERPS_BUTTON,
+ ),
+ },
});
}
@@ -67,11 +73,17 @@ class WalletActionsBottomSheet {
Matchers.getElementByID(
WalletActionsBottomSheetSelectorsIDs.PREDICT_BUTTON,
),
- appium: () =>
- PlaywrightMatchers.getElementById(
- WalletActionsBottomSheetSelectorsIDs.PREDICT_BUTTON,
- { exact: true },
- ),
+ appium: {
+ android: () =>
+ PlaywrightMatchers.getElementById(
+ WalletActionsBottomSheetSelectorsIDs.PREDICT_BUTTON,
+ { exact: true },
+ ),
+ ios: () =>
+ PlaywrightMatchers.getElementByAccessibilityId(
+ WalletActionsBottomSheetSelectorsIDs.PREDICT_BUTTON,
+ ),
+ },
});
}
diff --git a/tests/page-objects/wallet/WalletView.ts b/tests/page-objects/wallet/WalletView.ts
index f80eea1c0fcf..69bfdd5b3339 100644
--- a/tests/page-objects/wallet/WalletView.ts
+++ b/tests/page-objects/wallet/WalletView.ts
@@ -37,13 +37,19 @@ class WalletView {
return encapsulated({
detox: () =>
Matchers.getElementByID(WalletViewSelectorsIDs.WALLET_CONTAINER),
- appium: () =>
- PlaywrightMatchers.getElementById(
- WalletViewSelectorsIDs.WALLET_CONTAINER,
- {
- exact: true,
- },
- ),
+ appium: {
+ android: () =>
+ PlaywrightMatchers.getElementById(
+ WalletViewSelectorsIDs.WALLET_CONTAINER,
+ {
+ exact: true,
+ },
+ ),
+ ios: () =>
+ PlaywrightMatchers.getElementByAccessibilityId(
+ WalletViewSelectorsIDs.EYE_SLASH_ICON,
+ ),
+ },
});
}
@@ -232,6 +238,17 @@ class WalletView {
);
}
+ async checkActiveAccount(
+ expectedName: string,
+ timeout = 10_000,
+ ): Promise {
+ await PlaywrightAssertions.expectElementText(
+ asPlaywrightElement(this.accountNameLabelText),
+ expectedName,
+ { timeout },
+ );
+ }
+
get accountNameLabelInput(): EncapsulatedElementType {
return encapsulated({
detox: () =>
@@ -303,8 +320,16 @@ class WalletView {
);
}
// Wallet-specific action buttons (from AssetDetailsActions in Wallet view)
- get walletBuyButton(): DetoxElement {
- return Matchers.getElementByID(WalletViewSelectorsIDs.WALLET_BUY_BUTTON);
+ get walletBuyButton(): EncapsulatedElementType {
+ return encapsulated({
+ detox: () =>
+ Matchers.getElementByID(WalletViewSelectorsIDs.WALLET_BUY_BUTTON),
+ appium: () =>
+ PlaywrightMatchers.getElementById(
+ WalletViewSelectorsIDs.WALLET_BUY_BUTTON,
+ { exact: true },
+ ),
+ });
}
get walletSwapButton(): EncapsulatedElementType {
@@ -455,10 +480,17 @@ class WalletView {
tokenRow(token: string, index = 0): EncapsulatedElementType {
return encapsulated({
detox: () => Matchers.getElementByText(token, index),
- appium: () =>
- PlaywrightMatchers.getElementById(getAssetTestId(token), {
- exact: true,
- }),
+ appium: {
+ android: () =>
+ PlaywrightMatchers.getElementById(getAssetTestId(token), {
+ exact: true,
+ }),
+ // iOS: TokenListItem sets accessibilityLabel to "Name, $fiat, balance"
+ // so the iOS predicate `name` (= accessibilityLabel) differs from testID.
+ // Use `~testID` which maps to accessibilityIdentifier (= testID).
+ ios: () =>
+ PlaywrightMatchers.getElementByNameiOS(getAssetTestId(token)),
+ },
});
}
@@ -473,7 +505,7 @@ class WalletView {
// Wait for the token list to finish loading/reordering before tapping.
// New tokens appearing asynchronously can shift positions mid-tap.
await Utilities.waitForElementToStopMoving(elem, {
- timeout: 10000,
+ timeout: 20000,
interval: 500,
stableCount: 6,
});
@@ -798,11 +830,19 @@ class WalletView {
get tokensSection(): EncapsulatedElementType {
return encapsulated({
detox: () =>
- Matchers.getElementByText(WalletViewSelectorsText.TOKENS_SECTION),
- appium: () =>
- PlaywrightMatchers.getElementByText(
- WalletViewSelectorsText.TOKENS_SECTION,
+ Matchers.getElementByID(
+ WalletViewSelectorsIDs.TOKENS_SECTION_CONTAINER,
),
+ appium: {
+ android: () =>
+ PlaywrightMatchers.getElementById(
+ WalletViewSelectorsIDs.HOMEPAGE_SECTION_TITLE('tokens'),
+ ),
+ ios: () =>
+ PlaywrightMatchers.getElementByAccessibilityId(
+ `${WalletViewSelectorsIDs.HOMEPAGE_SECTION_TITLE('tokens')}`,
+ ),
+ },
});
}
diff --git a/tests/performance/README.md b/tests/performance/README.md
index 4190b2967a15..575f0d2336e2 100644
--- a/tests/performance/README.md
+++ b/tests/performance/README.md
@@ -623,6 +623,7 @@ BROWSERSTACK_IOS_CLEAN_APP_URL=bs://your-clean-ios-app-id
TEST_SRP_1="your test recovery phrase 1"
TEST_SRP_2="your test recovery phrase 2"
TEST_SRP_3="your test recovery phrase 3"
+TEST_SRP_4='your test recovery phrase 4" // user for Perps
BROWSERSTACK_USERNAME='YOUR_BS_USERNAME'
BROWSERSTACK_ACCESS_KEY='YOUR_BS_ACCESS_KEY'
E2E_PASSWORD='WALLET_PASSWORD' // 1Password
@@ -630,6 +631,13 @@ E2E_PASSWORD='WALLET_PASSWORD' // 1Password
# Test Passwords (can be found in 1Password)
TEST_PASSWORD_LOGIN="your test password"
TEST_PASSWORD_ONBOARDING="your onboarding password"
+
+# Feature flags for performance tests (client-config API: rc | exp | test; not e2e)
+E2E_PERFORMANCE_BUILD_VARIANT=rc
+
+# CI note: scheduled/feature-branch performance workflows use build_variant=e2e
+# (GitHub environment build-e2e). E2E_PERFORMANCE_BUILD_VARIANT=rc is set separately
+# in performance-test-runner for the flags API. Release workflows use build_variant=rc.
```
### Sentry Performance Instrumentation (Optional)
diff --git a/tests/performance/device-matrix.json b/tests/performance/device-matrix.json
index 4e8cdac9de23..f6b1348810c0 100644
--- a/tests/performance/device-matrix.json
+++ b/tests/performance/device-matrix.json
@@ -4,7 +4,7 @@
"name": "Samsung Galaxy S25 Ultra",
"os_version": "15.0",
"category": "high",
- "description": "High-end Samsung flagship device with Android 13"
+ "description": "High-end Samsung flagship device with Android 15"
},
{
"name": "Google Pixel 8 Pro",
diff --git a/tests/performance/feature-flag-helper.ts b/tests/performance/feature-flag-helper.ts
index d84fb8817d0d..a5e0756256d5 100644
--- a/tests/performance/feature-flag-helper.ts
+++ b/tests/performance/feature-flag-helper.ts
@@ -1,37 +1,164 @@
-import axios from 'axios';
+import axios, { isAxiosError } from 'axios';
import { createLogger } from '../framework/logger';
+import { sleep } from '../framework/Utilities.ts';
const CLIENT_CONFIG_URL = 'https://client-config.api.cx.metamask.io/v1/flags';
+const MAX_RETRIES_ON_4XX = 3;
+const RETRY_DELAY_MS = 1_000;
+
const logger = createLogger({ name: 'feature-flag-helper' });
+function is4xxStatus(status: number): boolean {
+ return status >= 400 && status < 500;
+}
+
+function buildFeatureFlagUrl(
+ distribution: string,
+ environment: string,
+): string {
+ const params = new URLSearchParams({
+ client: 'mobile',
+ distribution,
+ environment,
+ });
+ return `${CLIENT_CONFIG_URL}?${params.toString()}`;
+}
+
+function formatResponseBody(data: unknown): string {
+ if (data === undefined || data === null) {
+ return '(empty)';
+ }
+ if (typeof data === 'string') {
+ return data.slice(0, 500);
+ }
+ try {
+ return JSON.stringify(data).slice(0, 500);
+ } catch {
+ return String(data);
+ }
+}
+
+function toFeatureFlagRequestError(
+ error: unknown,
+ distribution: string,
+ environment: string,
+): Error {
+ const url = buildFeatureFlagUrl(distribution, environment);
+ const context = `distribution="${distribution}", environment="${environment}", url=${url}`;
+
+ if (isAxiosError(error)) {
+ const status = error.response?.status;
+ const statusText = error.response?.statusText ?? '';
+ const responseBody = formatResponseBody(error.response?.data);
+ const axiosCode = error.code ? `, code=${error.code}` : '';
+
+ const message =
+ `Feature flags request failed: HTTP ${status ?? 'unknown'} ${statusText}${axiosCode}. ` +
+ `${context}. ` +
+ `Response body: ${responseBody}`;
+ const wrapped = new Error(message);
+ (wrapped as Error & { cause?: unknown }).cause = error;
+ return wrapped;
+ }
+
+ if (error instanceof Error) {
+ const wrapped = new Error(
+ `Feature flags request failed: ${error.message}. ${context}`,
+ );
+ (wrapped as Error & { cause?: unknown }).cause = error;
+ return wrapped;
+ }
+
+ return new Error(
+ `Feature flags request failed: ${String(error)}. ${context}`,
+ );
+}
+
+function mergeFeatureFlagPayload(
+ data: Record[] | Record,
+): Record {
+ // The API returns an array of single-key objects like [{ "flagName": { ... } }, ...]
+ // Merge into a single flat object so consumers can access flags by name directly
+ if (Array.isArray(data)) {
+ return Object.assign({}, ...data) as Record;
+ }
+ return data;
+}
+
/**
* Fetches the current production feature flags from the MetaMask client-config API.
+ * Retries up to {@link MAX_RETRIES_ON_4XX} times when the API responds with HTTP 4xx.
+ * Returns `null` when the request fails or the payload is empty (callers may fall back to UI).
* @param distribution - The distribution channel (e.g. 'main').
* @param environment - The environment (e.g. 'prod').
- * @returns The production feature flags as a JSON object.
+ * @returns The production feature flags, or `null` if unavailable.
*/
export const fetchProductionFeatureFlags = async (
distribution: string,
environment: string,
-): Promise> => {
+): Promise | null> => {
logger.info(
`Fetching feature flags for distribution: ${distribution} and environment: ${environment}`,
);
logger.info(
- `Feature flag url: ${CLIENT_CONFIG_URL}?client=mobile&distribution=${distribution}&environment=${environment}`,
+ `Feature flag url: ${buildFeatureFlagUrl(distribution, environment)}`,
);
- const { data } = await axios.get<
- Record[] | Record
- >(CLIENT_CONFIG_URL, {
- params: { client: 'mobile', distribution, environment },
- headers: { Accept: 'application/json' },
- });
- // The API returns an array of single-key objects like [{ "flagName": { ... } }, ...]
- // Merge into a single flat object so consumers can access flags by name directly
- if (Array.isArray(data)) {
- return Object.assign({}, ...data) as Record;
+ let lastError: unknown;
+
+ for (let attempt = 1; attempt <= MAX_RETRIES_ON_4XX; attempt++) {
+ try {
+ const { data } = await axios.get<
+ Record[] | Record
+ >(CLIENT_CONFIG_URL, {
+ params: { client: 'mobile', distribution, environment },
+ headers: { Accept: 'application/json' },
+ });
+
+ const flags = mergeFeatureFlagPayload(data);
+ if (!flags || Object.keys(flags).length === 0) {
+ logger.warn(
+ `Feature flags response empty for distribution="${distribution}", environment="${environment}"`,
+ );
+ return null;
+ }
+ return flags;
+ } catch (error) {
+ lastError = error;
+
+ const status = isAxiosError(error) ? error.response?.status : undefined;
+ const shouldRetry =
+ status !== undefined &&
+ is4xxStatus(status) &&
+ attempt < MAX_RETRIES_ON_4XX;
+
+ if (!shouldRetry) {
+ const detailedError = toFeatureFlagRequestError(
+ error,
+ distribution,
+ environment,
+ );
+ logger.warn(detailedError.message);
+ return null;
+ }
+
+ const responseBody = isAxiosError(error)
+ ? formatResponseBody(error.response?.data)
+ : '';
+ logger.warn(
+ `Feature flags request returned HTTP ${status}; retrying (${attempt}/${MAX_RETRIES_ON_4XX}). ` +
+ `Response body: ${responseBody}`,
+ );
+ await sleep(RETRY_DELAY_MS * attempt);
+ }
}
- return data;
+
+ const detailedError = toFeatureFlagRequestError(
+ lastError,
+ distribution,
+ environment,
+ );
+ logger.warn(detailedError.message);
+ return null;
};
diff --git a/tests/performance/login/asset-balances.spec.ts b/tests/performance/login/asset-balances.spec.ts
index e1266b2b6347..99ffba434e98 100644
--- a/tests/performance/login/asset-balances.spec.ts
+++ b/tests/performance/login/asset-balances.spec.ts
@@ -22,7 +22,7 @@ test.describe(`${Performance} ${System} ${PerformanceLogin} ${PerformanceAssetLo
const balanceStableTimer = new TimerHelper(
'Time since the user navigates to wallet tab until the balance stabilizes',
- { ios: 25000, android: 15000 },
+ { ios: 40000, android: 20000 },
currentDeviceDetails.platform,
);
await balanceStableTimer.measure(async () => {
diff --git a/tests/performance/login/asset-view.spec.ts b/tests/performance/login/asset-view.spec.ts
index 588245772d53..bb4a663b1839 100644
--- a/tests/performance/login/asset-view.spec.ts
+++ b/tests/performance/login/asset-view.spec.ts
@@ -25,27 +25,19 @@ perfTest.describe(
const assetViewScreen = new TimerHelper(
'Time since the user clicks on the asset view button until the user sees the token overview screen',
- { ios: 600, android: 4500 },
+ { ios: 6000, android: 2500 },
currentDeviceDetails.platform,
);
await WalletView.tapOnTokensSection();
- await WalletView.tapOnToken('USDC');
+ await WalletView.tapOnToken('ETH');
await assetViewScreen.measure(async () => {
await PlaywrightAssertions.expectElementToBeVisible(
- asPlaywrightElement(TokenOverview.container),
+ asPlaywrightElement(TokenOverview.priceChartContainer),
);
await PlaywrightAssertions.expectElementToBeVisible(
- asPlaywrightElement(TokenOverview.sendButton),
- );
- // Replicating the logic of the old spec to wait for the todays change to be visible isTodaysChangeVisible method in the TokenOverview wdio screen object
- await PlaywrightAssertions.expectElementToBeVisibleWithSettle(
- asPlaywrightElement(TokenOverview.todaysChange),
- {
- timeout: 10000,
- settleMs: 500,
- },
+ asPlaywrightElement(TokenOverview.container),
);
});
diff --git a/tests/performance/login/cross-chain-swap-flow.spec.ts b/tests/performance/login/cross-chain-swap-flow.spec.ts
index 8800844fe0fe..ee66c2c786a9 100644
--- a/tests/performance/login/cross-chain-swap-flow.spec.ts
+++ b/tests/performance/login/cross-chain-swap-flow.spec.ts
@@ -7,6 +7,7 @@ import {
PerformanceSwaps,
} from '../../tags.performance.js';
import { loginToAppPlaywright } from '../../flows/wallet.flow.js';
+import { asPlaywrightElement, PlaywrightAssertions } from '../../framework';
import WalletView from '../../page-objects/wallet/WalletView.js';
import QuoteView from '../../page-objects/swaps/QuoteView.js';
import { checkSwapActivity } from '../../helpers/swap/swap-unified-ui';
@@ -17,6 +18,11 @@ test.describe(`${Performance} ${System} ${PerformanceLogin} ${PerformanceSwaps}`
'Cross-chain swap flow - ETH to SOL - 50+ accounts, SRP 1 + SRP 2 + SRP 3',
{ tag: '@swap-bridge-dev-team' },
async ({ currentDeviceDetails, driver, performanceTracker }, testInfo) => {
+ test.skip(
+ currentDeviceDetails.platform === 'ios',
+ 'Skipped on iOS — cross-chain swap flow under investigation',
+ );
+
await loginToAppPlaywright();
const timer1 = new TimerHelper(
@@ -26,10 +32,15 @@ test.describe(`${Performance} ${System} ${PerformanceLogin} ${PerformanceSwaps}`
);
await WalletView.tapWalletSwapButton();
- await timer1.measure(() => QuoteView.isVisible());
+
+ await timer1.measure(async () => {
+ await PlaywrightAssertions.expectElementToBeVisibleWithSettle(
+ asPlaywrightElement(QuoteView.amountInput),
+ );
+ });
await QuoteView.selectNetworkAndTokenTo('Solana', 'SOL');
- await QuoteView.enterSourceTokenAmount('1');
+ await QuoteView.enterSourceTokenAmount('0.1');
const timer2 = new TimerHelper(
'Time since the user enters the amount until the quote is displayed',
diff --git a/tests/performance/login/eth-swap-flow.spec.ts b/tests/performance/login/eth-swap-flow.spec.ts
index a1abf798e2bf..eae7a7174ed4 100644
--- a/tests/performance/login/eth-swap-flow.spec.ts
+++ b/tests/performance/login/eth-swap-flow.spec.ts
@@ -18,6 +18,11 @@ test.describe(`${Performance} ${System} ${PerformanceLogin} ${PerformanceSwaps}`
'Swap flow - ETH to LINK, SRP 1 + SRP 2 + SRP 3',
{ tag: '@swap-bridge-dev-team' },
async ({ currentDeviceDetails, driver, performanceTracker }, testInfo) => {
+ test.skip(
+ currentDeviceDetails.platform === 'ios',
+ 'Skipped on iOS — swap flow under investigation',
+ );
+
await loginToAppPlaywright();
const swapLoadTimer = new TimerHelper(
@@ -27,6 +32,7 @@ test.describe(`${Performance} ${System} ${PerformanceLogin} ${PerformanceSwaps}`
);
await WalletView.tapWalletSwapButton();
+
await swapLoadTimer.measure(() => QuoteView.isVisible());
const swapTimer = new TimerHelper(
diff --git a/tests/performance/login/import-multiple-srps.spec.ts b/tests/performance/login/import-multiple-srps.spec.ts
index 24942bd68aea..6190940ad5a8 100644
--- a/tests/performance/login/import-multiple-srps.spec.ts
+++ b/tests/performance/login/import-multiple-srps.spec.ts
@@ -40,7 +40,7 @@ perfTest.describe(
);
const addAccountTimer = new TimerHelper(
'Time since the user clicks on "Add account" button until the next modal is visible',
- { ios: 1000, android: 1700 },
+ { ios: 1000, android: 1200 },
currentDeviceDetails.platform,
);
const importSrpTimer = new TimerHelper(
@@ -66,11 +66,13 @@ perfTest.describe(
await AccountListBottomSheet.waitForAccountSyncToComplete();
await AccountListBottomSheet.tapAddAccountButton();
+
await addAccountTimer.measure(async () => {
await PlaywrightAssertions.expectElementToBeVisible(
asPlaywrightElement(AddAccountBottomSheet.importSrpButton),
{
description: 'Add account bottom sheet should be visible',
+ timeout: 60000,
},
);
});
diff --git a/tests/performance/login/launch-times/cold-start-to-login.spec.ts b/tests/performance/login/launch-times/cold-start-to-login.spec.ts
index 08c59502c1da..64d2800e6215 100644
--- a/tests/performance/login/launch-times/cold-start-to-login.spec.ts
+++ b/tests/performance/login/launch-times/cold-start-to-login.spec.ts
@@ -50,7 +50,7 @@ perfTest.describe(
const timer1 = new TimerHelper(
'Time since the the app is launched, until login screen appears',
- { ios: 3000, android: 4000 },
+ { ios: 3000, android: 4500 },
currentDeviceDetails.platform,
);
diff --git a/tests/performance/login/perps-add-funds.spec.ts b/tests/performance/login/perps-add-funds.spec.ts
index 90076f244f89..52407181d15c 100644
--- a/tests/performance/login/perps-add-funds.spec.ts
+++ b/tests/performance/login/perps-add-funds.spec.ts
@@ -19,17 +19,17 @@ test.describe(`${Performance} ${PerformancePreps}`, () => {
const selectPerpsMainScreenTimer = new TimerHelper(
'Select Perps Main Screen',
- { ios: 1500, android: 2500 },
+ { ios: 1500, android: 4200 },
currentDeviceDetails.platform,
);
const openAddFundsTimer = new TimerHelper(
'Open Add Funds',
- { ios: 5000, android: 4500 },
+ { ios: 5000, android: 3500 },
currentDeviceDetails.platform,
);
const getQuoteTimer = new TimerHelper(
'Get Quote',
- { ios: 6000, android: 8000 },
+ { ios: 6000, android: 7000 },
currentDeviceDetails.platform,
);
@@ -54,7 +54,7 @@ test.describe(`${Performance} ${PerformancePreps}`, () => {
);
});
- await PerpsDepositView.typeUSD('2');
+ await PerpsDepositView.typeUSD('1');
await PerpsDepositView.tapContinue();
// Get quote
@@ -63,7 +63,10 @@ test.describe(`${Performance} ${PerformancePreps}`, () => {
await asPlaywrightElement(PerpsDepositView.addFundsButton),
);
await PlaywrightAssertions.expectElementToBeVisible(
- await asPlaywrightElement(PerpsDepositView.totalText),
+ await asPlaywrightElement(PerpsDepositView.infoRow),
+ );
+ await PlaywrightAssertions.expectElementToBeVisible(
+ await asPlaywrightElement(PerpsDepositView.payWithRow),
);
});
diff --git a/tests/performance/login/predict/predict-available-balance.spec.ts b/tests/performance/login/predict/predict-available-balance.spec.ts
index 3d23de4dbac8..1d668e785bf8 100644
--- a/tests/performance/login/predict/predict-available-balance.spec.ts
+++ b/tests/performance/login/predict/predict-available-balance.spec.ts
@@ -28,22 +28,20 @@ perfTest.describe(`${Performance} ${PerformancePredict}`, () => {
async ({ currentDeviceDetails, driver, performanceTracker }, testInfo) => {
// Login to the app
await loginToAppPlaywright();
-
+ perfTest.setTimeout(15 * 60 * 1000);
// Timer 1: Navigate to Predict tab and verify available balance
const timer1 = new TimerHelper(
'Time since user taps Predict button until Available Balance is displayed',
- { ios: 4500, android: 8000 },
+ { ios: 4500, android: 5000 },
currentDeviceDetails.platform,
);
-
await TabBarComponent.tapActions();
+
await WalletActionsBottomSheet.tapPredictButton();
await timer1.measure(async () => {
await PlaywrightAssertions.expectElementToBeVisible(
- asPlaywrightElement(PredictMarketList.balanceCard),
- );
- await PlaywrightAssertions.expectElementToBeVisible(
- asPlaywrightElement(PredictMarketList.availableBalanceLabel),
+ asPlaywrightElement(PredictMarketList.container),
+ { timeout: 60000 },
);
});
diff --git a/tests/performance/login/predict/predict-deposit.spec.ts b/tests/performance/login/predict/predict-deposit.spec.ts
index 64720f868d16..4c3e5336df8b 100644
--- a/tests/performance/login/predict/predict-deposit.spec.ts
+++ b/tests/performance/login/predict/predict-deposit.spec.ts
@@ -49,7 +49,7 @@ perfTest.describe(`${Performance} ${PerformancePredict}`, () => {
// Timer 2: Open deposit screen
const timer2 = new TimerHelper(
'Time since user taps Add Funds button until Predict Deposit screen is visible',
- { ios: 1000, android: 4500 },
+ { ios: 2500, android: 6000 },
currentDeviceDetails.platform,
);
@@ -60,23 +60,6 @@ perfTest.describe(`${Performance} ${PerformancePredict}`, () => {
);
});
- // Timer 3: Change default asset
- const timer3 = new TimerHelper(
- 'Time since user taps Pay with button until select payment method modal is displayed',
- { ios: 5000, android: 1500 },
- currentDeviceDetails.platform,
- );
-
- await TransactionPayConfirmation.tapPayWithRow();
- await timer3.measure(async () => {
- await PlaywrightAssertions.expectElementToBeVisible(
- asPlaywrightElement(TransactionPayConfirmation.payWithTokenList),
- );
- });
-
- await TransactionPayConfirmation.searchToken('USDC');
- await TransactionPayConfirmation.tapByNetworkFilter('Arbitrum');
- await TransactionPayConfirmation.tapFirstUsdc('USDC');
await TransactionPayConfirmation.tapKeyboardAmount('1');
// Timer 4: Proceed to confirmation screen
@@ -97,20 +80,18 @@ perfTest.describe(`${Performance} ${PerformancePredict}`, () => {
});
// Add all timers to performance tracker
- performanceTracker.addTimers(timer1, timer2, timer3, timer4);
+ performanceTracker.addTimers(timer1, timer2, timer4);
// Attach performance metrics to test report
console.log('Predict Deposit Performance Test completed');
console.log(`Navigate to Predict: ${timer1.getDuration()}ms`);
console.log(`Open Deposit Screen: ${timer2.getDuration()}ms`);
- console.log(`Change Asset: ${timer3.getDuration()}ms`);
console.log(`Open Confirmation: ${timer4.getDuration()}ms`);
console.log(
`Total Time: ${
(timer1.getDuration() ?? 0) +
(timer2.getDuration() ?? 0) +
- (timer3.getDuration() ?? 0) +
(timer4.getDuration() ?? 0)
}ms`,
);
diff --git a/tests/performance/login/predict/predict-market-details.spec.ts b/tests/performance/login/predict/predict-market-details.spec.ts
index 7d28502496d6..b4892b8e4095 100644
--- a/tests/performance/login/predict/predict-market-details.spec.ts
+++ b/tests/performance/login/predict/predict-market-details.spec.ts
@@ -23,8 +23,8 @@ import { Performance, PerformancePredict } from '../../../tags.performance.js';
* 3. Time to open About tab content
* 4. Time to open Outcomes tab content when available
*/
-perfTest.describe(`${Performance} ${PerformancePredict}`, () => {
- perfTest.setTimeout(10 * 60 * 1000);
+perfTest.describe(PerformancePredict, () => {
+ perfTest.setTimeout(15 * 60 * 1000);
perfTest(
'Predict Market Details - Complete Flow Performance',
@@ -36,7 +36,7 @@ perfTest.describe(`${Performance} ${PerformancePredict}`, () => {
// Timer 1: Navigate to Predict tab
const timer1 = new TimerHelper(
'Time since user taps Predict button until Predict Market List is displayed',
- { ios: 8000, android: 8000 },
+ { ios: 8000, android: 5000 },
currentDeviceDetails.platform,
);
@@ -65,7 +65,7 @@ perfTest.describe(`${Performance} ${PerformancePredict}`, () => {
// Timer 3: Open About tab
const timer3 = new TimerHelper(
'Time since user taps About tab until About tab is visible',
- { ios: 750, android: 1800 },
+ { ios: 750, android: 2500 },
currentDeviceDetails.platform,
);
diff --git a/tests/performance/login/uniswap-interaction.spec.ts b/tests/performance/login/uniswap-interaction.spec.ts
index abbdf3f6563f..ba5fc829569e 100644
--- a/tests/performance/login/uniswap-interaction.spec.ts
+++ b/tests/performance/login/uniswap-interaction.spec.ts
@@ -25,13 +25,13 @@ perfTest.describe(`${PerformanceLogin}`, () => {
const metamaskTimer = new TimerHelper(
'Time since the user selects Metamask until Metamask app is opened',
- { ios: 15000, android: 20000 },
+ { ios: 18000, android: 20000 },
platform,
);
const connectTimer = new TimerHelper(
'Time since the user taps Connect in MetaMask until Uniswap is displayed',
- { ios: 15000, android: 20000 },
+ { ios: 15000, android: 5000 },
platform,
);
await loginToAppPlaywright();
diff --git a/tests/performance/mm-connect/connection-evm-account.spec.ts b/tests/performance/mm-connect/connection-evm-account.spec.ts
index 2f24ea72a7c7..fa02da64d1ba 100644
--- a/tests/performance/mm-connect/connection-evm-account.spec.ts
+++ b/tests/performance/mm-connect/connection-evm-account.spec.ts
@@ -89,7 +89,8 @@ test.describe(Performance, () => {
// 6. CLEANUP
// - Tap disconnect to reset dapp state
- test('@metamask/connect-evm - Account switching and wallet-side verification', async ({
+ // This test is currently being skipped as it is flaky - https://consensyssoftware.atlassian.net/browse/WAPI-1511
+ test.skip('@metamask/connect-evm - Account switching and wallet-side verification', async ({
currentDeviceDetails,
driver,
}) => {
diff --git a/tests/performance/mm-connect/connection-evm-rejection.spec.ts b/tests/performance/mm-connect/connection-evm-rejection.spec.ts
index 22a6014a1df2..2895d3cbebc7 100644
--- a/tests/performance/mm-connect/connection-evm-rejection.spec.ts
+++ b/tests/performance/mm-connect/connection-evm-rejection.spec.ts
@@ -80,7 +80,8 @@ test.describe(Performance, () => {
// 6. CLEANUP
// - Tap disconnect to reset dapp state
- test('@metamask/connect-evm - Rejection response value verification', async ({
+ // This test is currently being skipped as it is flaky - https://consensyssoftware.atlassian.net/browse/WAPI-1511
+ test.skip('@metamask/connect-evm - Rejection response value verification', async ({
currentDeviceDetails,
driver,
}) => {
diff --git a/tests/performance/mm-connect/connection-evm-sign.spec.ts b/tests/performance/mm-connect/connection-evm-sign.spec.ts
index 81d98dd8c23d..29d2fe699ad6 100644
--- a/tests/performance/mm-connect/connection-evm-sign.spec.ts
+++ b/tests/performance/mm-connect/connection-evm-sign.spec.ts
@@ -85,8 +85,8 @@ test.describe(Performance, () => {
//
// 7. CLEANUP
// - Tap disconnect to reset dapp state
-
- test('@metamask/connect-evm - Sign and transaction cancel flows', async ({
+ // This test is currently being skipped as it is flaky - https://consensyssoftware.atlassian.net/browse/WAPI-1511
+ test.skip('@metamask/connect-evm - Sign and transaction cancel flows', async ({
currentDeviceDetails,
driver,
}) => {
diff --git a/tests/performance/mm-connect/connection-multiclient-resilience.spec.ts b/tests/performance/mm-connect/connection-multiclient-resilience.spec.ts
index 53ef06eafd59..d35912287d4f 100644
--- a/tests/performance/mm-connect/connection-multiclient-resilience.spec.ts
+++ b/tests/performance/mm-connect/connection-multiclient-resilience.spec.ts
@@ -90,7 +90,8 @@ test.describe(Performance, () => {
//
// 4. CLEANUP
// - Tap Solana disconnect and legacy EVM disconnect
- test('@metamask/connect-multichain (multiple clients) - Disconnect, reconnect, and resilience via Multichain API', async ({
+ // This test is currently being skipped as it is flaky - https://consensyssoftware.atlassian.net/browse/WAPI-1511
+ test.skip('@metamask/connect-multichain (multiple clients) - Disconnect, reconnect, and resilience via Multichain API', async ({
currentDeviceDetails,
driver,
}) => {
diff --git a/tests/performance/mm-connect/connection-multiclient.spec.ts b/tests/performance/mm-connect/connection-multiclient.spec.ts
index 178d712e1b61..83eecc487558 100644
--- a/tests/performance/mm-connect/connection-multiclient.spec.ts
+++ b/tests/performance/mm-connect/connection-multiclient.spec.ts
@@ -91,7 +91,8 @@ test.describe(Performance, () => {
// - Wagmi personal sign -> confirm -> assert signature starts with 0x
// - Assert: Solana scope still visible, Solana still connected
// - Solana sign message -> confirm -> assert correct signed result
- test('@metamask/connect-multichain (multiple clients) - Connect multiple clients via Multichain API to Local Browser Playground', async ({
+ // This test is currently being skipped as it is flaky - https://consensyssoftware.atlassian.net/browse/WAPI-1511
+ test.skip('@metamask/connect-multichain (multiple clients) - Connect multiple clients via Multichain API to Local Browser Playground', async ({
currentDeviceDetails,
}) => {
// Get platform-specific URL
diff --git a/tests/performance/mm-connect/connection-wagmi-chains.spec.ts b/tests/performance/mm-connect/connection-wagmi-chains.spec.ts
index 3ac86fe3e591..707ad55f238e 100644
--- a/tests/performance/mm-connect/connection-wagmi-chains.spec.ts
+++ b/tests/performance/mm-connect/connection-wagmi-chains.spec.ts
@@ -93,7 +93,9 @@ test.describe(Performance, () => {
//
// 6. CLEANUP
// - Tap disconnect to clean up
- test('@metamask/connect-evm (wagmi) - Chain switching via Wagmi', async ({
+ //
+ // This test is currently being skipped as it is flaky - https://consensyssoftware.atlassian.net/browse/WAPI-1511
+ test.skip('@metamask/connect-evm (wagmi) - Chain switching via Wagmi', async ({
currentDeviceDetails,
driver,
}) => {
diff --git a/tests/performance/mm-connect/legacy-evm-rn-connect.spec.ts b/tests/performance/mm-connect/legacy-evm-rn-connect.spec.ts
index 2561487efb24..69ff2dc4c204 100644
--- a/tests/performance/mm-connect/legacy-evm-rn-connect.spec.ts
+++ b/tests/performance/mm-connect/legacy-evm-rn-connect.spec.ts
@@ -19,7 +19,8 @@ async function returnToPlayground() {
}
test.describe(Performance, () => {
- test('@metamask/connect-legacy-evm-rn - Connect via Legacy EVM, sign, send transaction, and switch chains', async ({
+ // This test is currently being skipped as it is flaky - https://consensyssoftware.atlassian.net/browse/WAPI-1511
+ test.skip('@metamask/connect-legacy-evm-rn - Connect via Legacy EVM, sign, send transaction, and switch chains', async ({
currentDeviceDetails,
driver,
}) => {
diff --git a/tests/performance/mm-connect/multichain-rn-evm.spec.ts b/tests/performance/mm-connect/multichain-rn-evm.spec.ts
index 250be3e0f8da..aa4ad048665a 100644
--- a/tests/performance/mm-connect/multichain-rn-evm.spec.ts
+++ b/tests/performance/mm-connect/multichain-rn-evm.spec.ts
@@ -79,7 +79,8 @@ async function returnToPlayground() {
// - Switch to MetaMask and unlock if needed to confirm no active session
test.describe(Performance, () => {
- test('@metamask/connect-multichain-rn-evm - Connect across 3 EVM chains, invoke read/write methods, and disconnect', async ({
+ // This test is currently being skipped as it is flaky - https://consensyssoftware.atlassian.net/browse/WAPI-1511
+ test.skip('@metamask/connect-multichain-rn-evm - Connect across 3 EVM chains, invoke read/write methods, and disconnect', async ({
currentDeviceDetails,
driver,
}) => {
diff --git a/tests/performance/mm-connect/multichain-rn-solana.spec.ts b/tests/performance/mm-connect/multichain-rn-solana.spec.ts
index 3db661cff790..90aef2714dca 100644
--- a/tests/performance/mm-connect/multichain-rn-solana.spec.ts
+++ b/tests/performance/mm-connect/multichain-rn-solana.spec.ts
@@ -67,7 +67,8 @@ async function returnToPlayground() {
// - Switch to MetaMask and unlock if needed to confirm no active session
test.describe(Performance, () => {
- test('@metamask/connect-multichain-rn-solana - Connect with Solana, invoke signMessage, and disconnect', async ({
+ // This test is currently being skipped as it is flaky - https://consensyssoftware.atlassian.net/browse/WAPI-1511
+ test.skip('@metamask/connect-multichain-rn-solana - Connect with Solana, invoke signMessage, and disconnect', async ({
currentDeviceDetails,
driver,
}) => {
diff --git a/tests/performance/onboarding/import-wallet.spec.ts b/tests/performance/onboarding/import-wallet.spec.ts
index f5f434931770..0ddff418bd7a 100644
--- a/tests/performance/onboarding/import-wallet.spec.ts
+++ b/tests/performance/onboarding/import-wallet.spec.ts
@@ -5,6 +5,7 @@ import { Performance, PerformanceOnboarding } from '../../tags.performance.js';
import OnboardingView from '../../page-objects/Onboarding/OnboardingView';
import {
asPlaywrightElement,
+ PlatformDetector,
PlaywrightAssertions,
PlaywrightGestures,
} from '../../framework';
@@ -15,53 +16,63 @@ import MetaMetricsOptInView from '../../page-objects/Onboarding/MetaMetricsOptIn
import OnboardingSuccessView from '../../page-objects/Onboarding/OnboardingSuccessView';
import PredictModalView from '../../page-objects/Predict/PredictModalView';
import WalletView from '../../page-objects/wallet/WalletView';
-import { dismisspredictionsModalPlaywright } from '../../flows/wallet.flow';
+import {
+ dismissOnboardingInterestQuestionnaire,
+ dismisspredictionsModalPlaywright,
+ resolvePredictGtmOnboardingModalEnabled,
+} from '../../flows/wallet.flow';
import { fetchProductionFeatureFlags } from '../feature-flag-helper';
const testEnvironment = 'test'; // hard coding this for now. We need a new FF env in LD for e2e. An admin needs to create it..
/* Scenario 4: Imported wallet with +50 accounts */
-test.describe(`${Performance} ${PerformanceOnboarding}`, () => {
- test.setTimeout(240000);
+test.describe(PerformanceOnboarding, () => {
test(
'Onboarding Import SRP with +50 accounts, SRP 3',
{ tag: '@metamask-onboarding-team' },
- async ({ currentDeviceDetails, driver, performanceTracker }, testInfo) => {
+ async ({ currentDeviceDetails, driver, performanceTracker }) => {
const timer1 = new TimerHelper(
'Time since the user clicks on "Create new wallet" button until "Social sign up" is visible',
- { ios: 1000, android: 1800 },
+ { ios: 1500, android: 1800 },
currentDeviceDetails.platform,
);
const timer2 = new TimerHelper(
'Time since the user clicks on "Import using SRP" button until SRP field is displayed',
- { ios: 1000, android: 1500 },
+ { ios: 2000, android: 1500 },
currentDeviceDetails.platform,
);
const timer3 = new TimerHelper(
'Time since the user clicks on "Continue" button on SRP screen until Password fields are visible',
- { ios: 2500, android: 1800 },
+ { ios: 1000, android: 1500 },
currentDeviceDetails.platform,
);
const timer4 = new TimerHelper(
'Time since the user clicks on "Create Password" button until Metrics screen is displayed',
- { ios: 1600, android: 1600 },
+ { ios: 2000, android: 1500 },
currentDeviceDetails.platform,
);
const timer5 = new TimerHelper(
'Time since the user clicks on "I agree" button on Metrics screen until Onboarding Success screen is visible',
- { ios: 2200, android: 1700 },
+ { ios: 2000, android: 1500 },
currentDeviceDetails.platform,
);
const timer6 = new TimerHelper(
'Time since the user clicks on "Done" button until feature sheet is visible',
- { ios: 2500, android: 3100 },
+ { ios: 3000, android: 3000 },
currentDeviceDetails.platform,
);
const timer7 = new TimerHelper(
- 'Time since the user clicks on "Not now" button On feature sheet until native token is visible',
- { ios: 90000, android: 90000 },
+ 'Time since the user clicks on "Done" button until ETH and BTC are visible',
+ // +50 accounts on BrowserStack can take longer than local emulator.
+ { ios: 21000, android: 5000 },
currentDeviceDetails.platform,
);
+ const walletTokenLoadTimeoutMs = 60_000;
+
+ const productionFeatureFlags = await fetchProductionFeatureFlags(
+ 'main',
+ testEnvironment,
+ );
await OnboardingView.tapHaveAnExistingWallet();
await timer1.measure(async () => {
@@ -96,7 +107,13 @@ test.describe(`${Performance} ${PerformanceOnboarding}`, () => {
await CreatePasswordView.reEnterPassword(
getPasswordForScenario('import') || '',
);
+
+ await CreatePasswordView.tapPasswordVisibilityIcon();
+ await CreatePasswordView.tapConfirmPasswordVisibilityIcon();
await CreatePasswordView.tapIUnderstandCheckBox();
+ if (await PlatformDetector.isAndroid()) {
+ await PlaywrightGestures.hideKeyboard();
+ }
await CreatePasswordView.tapCreatePasswordButton();
await timer4.measure(async () => {
@@ -106,52 +123,31 @@ test.describe(`${Performance} ${PerformanceOnboarding}`, () => {
});
await MetaMetricsOptInView.tapIAgreeButton();
+ await dismissOnboardingInterestQuestionnaire();
await timer5.measure(async () => {
await PlaywrightAssertions.expectElementToBeVisible(
await asPlaywrightElement(OnboardingSuccessView.doneButton),
+ { timeout: 30_000 },
);
});
-
await OnboardingSuccessView.tapDone();
- const productionFeatureFlags = await fetchProductionFeatureFlags(
- 'main',
- testEnvironment,
- );
+ const predictGtmOnboardingModalEnabled =
+ await resolvePredictGtmOnboardingModalEnabled(productionFeatureFlags);
- const predictGtmOnboardingModalEnabled = (
- productionFeatureFlags?.predictGtmOnboardingModalEnabled as {
- enabled?: boolean;
- }
- )?.enabled;
- console.log(
- `Predict GTM Onboarding Modal Enabled: ${predictGtmOnboardingModalEnabled}`,
- );
- if (
- predictGtmOnboardingModalEnabled &&
- predictGtmOnboardingModalEnabled === true
- ) {
+ if (predictGtmOnboardingModalEnabled) {
await timer6.measure(async () => {
await PlaywrightAssertions.expectElementToBeVisible(
await asPlaywrightElement(PredictModalView.notNowButton),
);
});
- await dismisspredictionsModalPlaywright();
}
- await PlaywrightAssertions.expectElementToBeVisible(
- await asPlaywrightElement(WalletView.tokensSection),
- );
- await WalletView.tapOnTokensSection();
+ await dismisspredictionsModalPlaywright();
await timer7.measure(async () => {
await PlaywrightAssertions.expectElementToBeVisible(
- await asPlaywrightElement(WalletView.tokenRow('BNB')),
- );
- await PlaywrightAssertions.expectElementToBeVisible(
- await asPlaywrightElement(WalletView.tokenRow('SOL')),
- );
- await PlaywrightAssertions.expectElementToBeVisible(
- await asPlaywrightElement(WalletView.tokenRow('BTC')),
+ await asPlaywrightElement(WalletView.tokensSection),
+ { timeout: walletTokenLoadTimeoutMs },
);
});
diff --git a/tests/performance/onboarding/launch-times/cold-start-after-wallet-import.spec.ts b/tests/performance/onboarding/launch-times/cold-start-after-wallet-import.spec.ts
index c6bcb5823273..2eb7b8e2403d 100644
--- a/tests/performance/onboarding/launch-times/cold-start-after-wallet-import.spec.ts
+++ b/tests/performance/onboarding/launch-times/cold-start-after-wallet-import.spec.ts
@@ -13,9 +13,8 @@ import {
onboardingFlowImportSRPPlaywright,
} from '../../../flows/wallet.flow';
import TimerHelper from '../../../framework/TimerHelper';
-import WalletView from '../../../page-objects/wallet/WalletView';
-
-test.describe(`${Performance} ${PerformanceOnboarding} ${PerformanceLaunch}`, () => {
+import WalletView from '../../../page-objects/wallet/WalletView.js';
+test.describe(`${PerformanceOnboarding} ${PerformanceLaunch}`, () => {
test(
'Cold Start after importing a wallet',
{ tag: '@metamask-mobile-platform' },
@@ -36,14 +35,14 @@ test.describe(`${Performance} ${PerformanceOnboarding} ${PerformanceLaunch}`, ()
const timer = new TimerHelper(
'Time since the user clicks on unlock button, until the app unlocks',
{
- ios: 2000,
- android: 2000,
+ ios: 21000, // this number is because Appium DOM screenshot in iOS takes too long, but visually the button is visible in just a few seconds, so we assume that this time is approximately 2 seconds, any change in the real time, will impact this as well.
+ android: 1500,
},
currentDeviceDetails.platform,
);
await timer.measure(async () => {
await PlaywrightAssertions.expectElementToBeVisible(
- await asPlaywrightElement(WalletView.hamburgerMenuButton),
+ await asPlaywrightElement(WalletView.walletBuyButton),
);
});
diff --git a/tests/performance/onboarding/launch-times/cold-start-to-onboarding.spec.ts b/tests/performance/onboarding/launch-times/cold-start-to-onboarding.spec.ts
index 14f9a750018a..a24a916db232 100644
--- a/tests/performance/onboarding/launch-times/cold-start-to-onboarding.spec.ts
+++ b/tests/performance/onboarding/launch-times/cold-start-to-onboarding.spec.ts
@@ -16,7 +16,7 @@ test.describe(`${Performance} ${PerformanceOnboarding} ${PerformanceLaunch}`, ()
async ({ currentDeviceDetails, driver, performanceTracker }, testInfo) => {
const timer1 = new TimerHelper(
'Time since the the app is installed, until onboarding screen appears',
- { ios: 3000, android: 3900 },
+ { ios: 3000, android: 4000 },
currentDeviceDetails.platform,
);
await timer1.measure(
diff --git a/tests/performance/onboarding/new-wallet-account-creation.spec.ts b/tests/performance/onboarding/new-wallet-account-creation.spec.ts
index 654db15a0c86..b5a62319e7ed 100644
--- a/tests/performance/onboarding/new-wallet-account-creation.spec.ts
+++ b/tests/performance/onboarding/new-wallet-account-creation.spec.ts
@@ -18,11 +18,15 @@ import CreatePasswordView from '../../page-objects/Onboarding/CreatePasswordView
import ProtectYourWalletView from '../../page-objects/Onboarding/ProtectYourWalletView.js';
import MetaMetricsOptInView from '../../page-objects/Onboarding/MetaMetricsOptInView.js';
import OnboardingSuccessView from '../../page-objects/Onboarding/OnboardingSuccessView.js';
-import { dismisspredictionsModalPlaywright } from '../../flows/wallet.flow.js';
+import {
+ dismissOnboardingInterestQuestionnaire,
+ dismisspredictionsModalPlaywright,
+} from '../../flows/wallet.flow.js';
import WalletView from '../../page-objects/wallet/WalletView.js';
import AccountListBottomSheet from '../../page-objects/wallet/AccountListBottomSheet.js';
import { fetchProductionFeatureFlags } from '../feature-flag-helper';
import PredictModalView from '../../page-objects/Predict/PredictModalView.js';
+import OnboardingInterestQuestionnaireView from '../../page-objects/Onboarding/OnboardingInterestQuestionnaireView.js';
const testEnvironment = 'test'; // hard coding this for now. We need a new FF env in LD for e2e. An admin needs to create it..
@@ -36,7 +40,7 @@ test.describe(`${Performance} ${System} ${PerformanceOnboarding} ${PerformanceAc
await PlaywrightAssertions.expectElementToBeVisible(
await asPlaywrightElement(OnboardingSheet.importSeedButton),
);
-
+ test.setTimeout(10 * 60 * 1000);
await OnboardingSheet.tapImportSeedButton();
await PlaywrightAssertions.expectElementToBeVisible(
await asPlaywrightElement(CreatePasswordView.newPasswordInput),
@@ -56,6 +60,7 @@ test.describe(`${Performance} ${System} ${PerformanceOnboarding} ${PerformanceAc
await asPlaywrightElement(MetaMetricsOptInView.screenTitle),
);
await MetaMetricsOptInView.tapAgreeButton();
+ await dismissOnboardingInterestQuestionnaire();
await PlaywrightAssertions.expectElementToBeVisible(
await asPlaywrightElement(OnboardingSuccessView.doneButton),
);
@@ -81,26 +86,26 @@ test.describe(`${Performance} ${System} ${PerformanceOnboarding} ${PerformanceAc
await dismisspredictionsModalPlaywright();
}
- await PlaywrightAssertions.expectElementToBeVisible(
- await asPlaywrightElement(WalletView.container),
- );
-
const screen1Timer = new TimerHelper(
'Time since the user clicks on "Account list" button until the account list is visible',
- { ios: 3000, android: 3000 },
+ { ios: 2000, android: 2000 },
currentDeviceDetails.platform,
);
const screen2Timer = new TimerHelper(
'Time since the user clicks on "Create account" button until the account is in the account list',
- { ios: 1300, android: 2000 },
+ { ios: 1800, android: 1500 },
currentDeviceDetails.platform,
);
const screen3Timer = new TimerHelper(
'Time since the user clicks on new account created until the Token list is visible',
- { ios: 3000, android: 3000 },
+ { ios: 2000, android: 2000 },
currentDeviceDetails.platform,
);
+ await PlaywrightAssertions.expectElementToBeVisible(
+ await asPlaywrightElement(WalletView.walletBuyButton),
+ );
+
await WalletView.tapIdenticon();
await screen1Timer.measure(
async () =>
@@ -122,22 +127,14 @@ test.describe(`${Performance} ${System} ${PerformanceOnboarding} ${PerformanceAc
await AccountListBottomSheet.tapAccountByName('Account 2');
await screen3Timer.measure(async () => {
- const timeout = 10_000;
- const interval = 100;
- const start = Date.now();
- while (Date.now() - start < timeout) {
- try {
- const accountEl = await asPlaywrightElement(
- WalletView.accountNameLabelText,
- );
- const text = await accountEl.textContent();
- if (text === 'Account 2') return;
- } catch {
- // Element not found yet, continue polling
- }
- await new Promise((resolve) => setTimeout(resolve, interval));
- }
- throw new Error('Expected account "Account 2" to be visible after 10s');
+ await WalletView.checkActiveAccount('Account 2');
+ await PlaywrightAssertions.expectElementToBeVisible(
+ await asPlaywrightElement(WalletView.tokensSection),
+ {
+ description:
+ 'token list should be visible after selecting the new account',
+ },
+ );
});
performanceTracker.addTimers(screen1Timer, screen2Timer, screen3Timer);
diff --git a/tests/performance/login/perps-position-management.spec.ts b/tests/performance/onboarding/perps-position-management.spec.ts
similarity index 60%
rename from tests/performance/login/perps-position-management.spec.ts
rename to tests/performance/onboarding/perps-position-management.spec.ts
index 2e0622a0893f..01c867a018b3 100644
--- a/tests/performance/login/perps-position-management.spec.ts
+++ b/tests/performance/onboarding/perps-position-management.spec.ts
@@ -1,32 +1,35 @@
-import { test } from '../../framework/fixture';
+import { test } from '../../framework/fixture/index.js';
-import TimerHelper from '../../framework/TimerHelper';
+import TimerHelper from '../../framework/TimerHelper.js';
import { Performance, PerformancePreps } from '../../tags.performance.js';
import {
loginToAppPlaywright,
+ onboardingFlowImportSRPPlaywright,
selectAccountByDevice,
-} from '../../flows/wallet.flow';
-import TabBarComponent from '../../page-objects/wallet/TabBarComponent';
-import WalletActionsBottomSheet from '../../page-objects/wallet/WalletActionsBottomSheet';
-import PerpsOnboarding from '../../page-objects/Perps/PerpsOnboarding';
-import PerpsMarketListView from '../../page-objects/Perps/PerpsMarketListView';
-import PerpsMarketDetailsView from '../../page-objects/Perps/PerpsMarketDetailsView';
-import PerpsOrderView from '../../page-objects/Perps/PerpsOrderView';
+} from '../../flows/wallet.flow.js';
+import TabBarComponent from '../../page-objects/wallet/TabBarComponent.js';
+import WalletActionsBottomSheet from '../../page-objects/wallet/WalletActionsBottomSheet.js';
+import PerpsOnboarding from '../../page-objects/Perps/PerpsOnboarding.js';
+import PerpsMarketListView from '../../page-objects/Perps/PerpsMarketListView.js';
+import PerpsMarketDetailsView from '../../page-objects/Perps/PerpsMarketDetailsView.js';
+import PerpsOrderView from '../../page-objects/Perps/PerpsOrderView.js';
import {
+ dismissPerpsOnboardingTutorialIfPresent,
isPositionOpen,
- waitForOrderScreenVisible,
- waitForPositionOpen,
-} from '../../flows/perps.flow';
-import PlaywrightAssertions from '../../framework/PlaywrightAssertions';
-import { asPlaywrightElement } from '../../framework/EncapsulatedElement';
-
+ resolvePerpsGtmOnboardingModalEnabled,
+} from '../../flows/perps.flow.js';
+import PlaywrightAssertions from '../../framework/PlaywrightAssertions.js';
+import { asPlaywrightElement } from '../../framework/EncapsulatedElement.js';
+import { fetchProductionFeatureFlags } from '../feature-flag-helper.js';
+const testEnvironment = process.env.E2E_PERFORMANCE_BUILD_VARIANT || 'rc';
/* Scenario 5: Perps onboarding + add funds 10 USD ARB.USDC + Open Position + Close Position */
test.describe(`${Performance} ${PerformancePreps}`, () => {
test(
'Perps open position and close it',
{ tag: '@mm-perps-engineering-team' },
async ({ currentDeviceDetails, driver, performanceTracker }, testInfo) => {
- test.setTimeout(10 * 60 * 1000); // 10 minutes
+ const timeoutMinutes = currentDeviceDetails.platform === 'ios' ? 15 : 10;
+ test.setTimeout(timeoutMinutes * 60 * 1000);
const selectPerpsMainScreenTimer = new TimerHelper(
'Perps tutorial screen visible',
@@ -36,17 +39,17 @@ test.describe(`${Performance} ${PerformancePreps}`, () => {
const selectMarketTimer = new TimerHelper(
'Market list screen visible',
- { ios: 7500, android: 2000 },
+ { ios: 7500, android: 5000 },
currentDeviceDetails.platform,
);
const openOrderScreenTimer = new TimerHelper(
'Open Order Screen',
- { ios: 1500, android: 3000 },
+ { ios: 3000, android: 5000 },
currentDeviceDetails.platform,
);
const openPositionTimer = new TimerHelper(
'Position opened',
- { ios: 10500, android: 13000 },
+ { ios: 10500, android: 14000 },
currentDeviceDetails.platform,
);
@@ -56,30 +59,44 @@ test.describe(`${Performance} ${PerformancePreps}`, () => {
currentDeviceDetails.platform,
);
- await loginToAppPlaywright();
+ await onboardingFlowImportSRPPlaywright(process.env.TEST_SRP_4 ?? '');
// Perps requires independent account for each device to avoid clashes when running tests in parallel
await selectAccountByDevice(currentDeviceDetails.deviceName);
await TabBarComponent.tapActions();
+ await WalletActionsBottomSheet.checkModalVisibility();
await WalletActionsBottomSheet.tapPerpsButton();
- await selectPerpsMainScreenTimer.measure(async () => {
- await PlaywrightAssertions.expectElementToBeVisible(
- await asPlaywrightElement(PerpsOnboarding.tutorialTitle),
- );
- });
+ const productionFeatureFlags = await fetchProductionFeatureFlags(
+ 'main',
+ testEnvironment,
+ );
+
+ const perpsGtmOnboardingModalEnabled =
+ await resolvePerpsGtmOnboardingModalEnabled(productionFeatureFlags);
+
+ if (perpsGtmOnboardingModalEnabled) {
+ await selectPerpsMainScreenTimer.measure(async () => {
+ await PlaywrightAssertions.expectElementToBeVisible(
+ await asPlaywrightElement(PerpsOnboarding.tutorialTitle),
+ );
+ });
+ }
+
+ await dismissPerpsOnboardingTutorialIfPresent();
- await PerpsOnboarding.tapSkipButton();
await selectMarketTimer.measure(async () => {
await PlaywrightAssertions.expectElementToBeVisible(
await asPlaywrightElement(PerpsMarketListView.header),
);
});
- await PerpsMarketListView.selectMarket('BTC');
+ await PerpsMarketListView.tapMarketRowItemBTC();
- await MarketDetailsScreenTimer.measure(
- async () => await PerpsMarketDetailsView.isContainerDisplayed(),
- );
+ await MarketDetailsScreenTimer.measure(async () => {
+ await PlaywrightAssertions.expectElementToBeVisible(
+ await asPlaywrightElement(PerpsMarketDetailsView.header),
+ );
+ });
// Check if there's an existing position and close it before continuing
if (await isPositionOpen()) {
console.log(
@@ -92,20 +109,24 @@ test.describe(`${Performance} ${PerformancePreps}`, () => {
console.error('❌ Error closing existing position:', error);
}
}
-
await PerpsMarketDetailsView.tapLongButton();
// Open Position
- await openOrderScreenTimer.measure(
- async () => await waitForOrderScreenVisible(),
- );
+ await openOrderScreenTimer.measure(async () => {
+ await PlaywrightAssertions.expectElementToBeVisible(
+ await asPlaywrightElement(PerpsOrderView.placeOrderButton),
+ );
+ });
await PerpsOrderView.setLeverageAppium(40);
await PerpsOrderView.setAmountUSD('10');
await PerpsOrderView.tapPlaceOrder();
- await openPositionTimer.measure(
- async () => await waitForPositionOpen(20000),
- );
+ await openPositionTimer.measure(async () => {
+ await PlaywrightAssertions.expectElementToBeVisible(
+ await asPlaywrightElement(PerpsMarketDetailsView.closeButton),
+ { timeout: 30000 },
+ );
+ });
try {
await PerpsMarketDetailsView.closePositionWithRetry();
@@ -114,8 +135,10 @@ test.describe(`${Performance} ${PerformancePreps}`, () => {
console.error('❌ Error closing position:', error);
}
+ if (perpsGtmOnboardingModalEnabled) {
+ performanceTracker.addTimer(selectPerpsMainScreenTimer);
+ }
performanceTracker.addTimers(
- selectPerpsMainScreenTimer,
selectMarketTimer,
openOrderScreenTimer,
openPositionTimer,
diff --git a/tests/performance/onboarding/seedless-apple-onboarding.spec.ts b/tests/performance/onboarding/seedless-apple-onboarding.spec.ts
index b9dee63c4f05..40aa7cd85766 100644
--- a/tests/performance/onboarding/seedless-apple-onboarding.spec.ts
+++ b/tests/performance/onboarding/seedless-apple-onboarding.spec.ts
@@ -1,8 +1,15 @@
import { test } from '../../framework/fixture';
import TimerHelper from '../../framework/TimerHelper';
-import { asPlaywrightElement, PlaywrightAssertions } from '../../framework';
+import {
+ asPlaywrightElement,
+ PlaywrightAssertions,
+ PlaywrightGestures,
+} from '../../framework';
import { getPasswordForScenario } from '../../framework/utils/TestConstants.js';
-import { dismisspredictionsModalPlaywright } from '../../flows/wallet.flow';
+import {
+ dismissOnboardingInterestQuestionnaire,
+ dismisspredictionsModalPlaywright,
+} from '../../flows/wallet.flow';
import {
Performance,
System,
@@ -16,7 +23,6 @@ import OnboardingSuccessView from '../../page-objects/Onboarding/OnboardingSucce
import PredictModalView from '../../page-objects/Predict/PredictModalView';
import WalletView from '../../page-objects/wallet/WalletView';
import LoginView from '../../page-objects/wallet/LoginView';
-import PlaywrightGestures from '../../framework/PlaywrightGestures';
const waitForFirstSuccessful = async (promises: Promise