diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..99f0aee --- /dev/null +++ b/.gitattributes @@ -0,0 +1,7 @@ +# Force LF for shell scripts so `tools/*` work in WSL / Git Bash on Windows. +# Without this, Windows clones with core.autocrlf=true rewrite to CRLF and bash +# fails with `$'\r': command not found` and `set: invalid option`. +tools/install text eol=lf +tools/sync text eol=lf +tools/deploy text eol=lf +*.sh text eol=lf diff --git a/.gitignore b/.gitignore index 3f20da4..14794f2 100644 --- a/.gitignore +++ b/.gitignore @@ -33,3 +33,6 @@ logs/ .env.local .env.*.local secrets.json + +# Maintainer-local config for tools/deploy +.targets.local diff --git a/.targets.local.example b/.targets.local.example new file mode 100644 index 0000000..f4713cb --- /dev/null +++ b/.targets.local.example @@ -0,0 +1,17 @@ +# tools/deploy reads this file (after you copy it to `.targets.local`). +# Format: = +# +# - LHS is the value passed to `tools/install --repo`. Must match a name +# the install CLI knows (e.g. metamask-extension, metamask-mobile). +# - RHS is a path to the consumer checkout. Globs are expanded, so one +# line can hit many worktrees at once. +# - $HOME and ~ are expanded. +# - Lines starting with `#` are ignored. Repeated LHS is fine. + +# Worktree fan-out (one line, matches all checkouts of that repo): +metamask-extension=$HOME/dev/metamask/metamask-extension* +metamask-mobile=$HOME/dev/metamask/metamask-mobile* + +# Or list individual paths explicitly (e.g. only one worktree, or non-default name): +# metamask-extension=$HOME/dev/metamask/metamask-extension-4 +# metamask-mobile=$HOME/work/mobile diff --git a/README.md b/README.md index 3f09ad4..c3dba55 100644 --- a/README.md +++ b/README.md @@ -1,79 +1,270 @@ -# MetaMask OpenClaw Skills +# MetaMask/skills -A curated library of [OpenClaw](https://github.com/BankrBot/openclaw-skills) skills approved and recommended by MetaMask developers. This repository serves as a trusted collection of AI agent skills for building, testing, and interacting with MetaMask and the broader Ethereum ecosystem. +Agent skills, rules, and domain knowledge for the MetaMask ecosystem. -## What are OpenClaw Skills? +Two audiences share this repo: -OpenClaw skills are modular instruction sets that enable AI coding agents to perform specialized tasks. Each skill contains a `SKILL.md` file with structured guidance that AI agents can follow to accomplish specific goals—from deploying smart contracts to interacting with DeFi protocols. +1. **dApp / Web3 developers** — skills under `domains/web3-tools/` teach AI + agents how to use MetaMask developer libraries (`gator-cli`, + `smart-accounts-kit`, OpenCode plugin, etc.). Drop them into your + editor/agent and it will operate the tooling correctly. +2. **MetaMask product engineers** — skills under `domains/perps/`, + `domains/testing/`, `domains/pr-workflow/`, etc. carry the conventions + and review heuristics for `metamask-extension` and `metamask-mobile`. + These install into consumer repos via a small CLI. -## Repository Structure +Single source of truth, multi-operator output (Claude Code, Cursor, +Codex/OpenAI), public canonical, private overlay optional. +> **Private overlay:** internal-only skills (Consensys-wide tooling, +> in-progress experiments, non-public products) live in a separate +> private repo, [`Consensys/skills`](https://github.com/Consensys/skills). +> Engineers with access can layer those skills on top of the public set. +> See [Federation](#federation-public--private). + +## Quickstart + +### For dApp / Web3 developers (use a single skill) + +Point your agent at this repo and load the skill that matches your +tooling. For example, to use the `gator-cli` skill in Claude Code: + +```bash +# One-time clone: +git clone https://github.com/MetaMask/skills ~/dev/metamask/skills + +# Copy or symlink the skill you want into your project (or ~/.claude/skills): +cp -r ~/dev/metamask/skills/domains/web3-tools/skills/gator-cli/* \ + ~/.claude/skills/gator-cli/ +``` + +Or use the installer to drop all `web3-tools/` skills at once: + +```bash +~/dev/metamask/skills/tools/install \ + --repo metamask-extension --target . --domain web3-tools ``` -metamask-openclaw-skills/ -├── provider-name/ -│ ├── skill-name/ -│ │ ├── SKILL.md # Main skill definition (required) -│ │ ├── references/ # Supporting documentation (optional) -│ │ └── scripts/ # Helper scripts (optional) -│ └── another-skill/ -│ └── SKILL.md -├── CONTRIBUTING.md -└── README.md + +### For engineers in `metamask-extension` / `metamask-mobile` + +```bash +# One time: +git clone https://github.com/MetaMask/skills ~/dev/metamask/skills +export METAMASK_SKILLS_DIR=~/dev/metamask/skills + +# Then, from inside the consumer repo: +yarn skills ``` -## Available Skills +`yarn skills` runs `tools/sync`, which pulls the latest skills and writes +them into `.claude/skills/`, `.cursor/rules/`, and `.agents/skills/`. + +### For cloud agents (Cursor cloud, Codex cloud, etc.) -| Provider | Skill | Description | -|----------|-------|-------------| -| *Coming soon* | — | Skills will be added via community PRs | +No SSH key, no env var — one curl pipe to bash inside the consumer repo: -## Installation +```bash +curl -fsSL https://raw.githubusercontent.com/MetaMask/skills/main/tools/bootstrap | \ + bash -s -- --repo metamask-extension +``` + +The bootstrap script clones this repo into a cache dir under +`$HOME/.cache/metamask-skills` and runs the installer against the current +directory. -Point your AI agent to this repository URL: +## Repo structure ``` -https://github.com/MetaMask/openclaw-skills +domains// + skills// + skill.md # base skill + references/ # optional supporting docs + scripts/ # optional helper scripts + repos/.md # optional repo-specific overlay + knowledge/ # optional shared domain reference +tools/ + install # core writer (mms- prefix, multi-operator output) + sync # Flow 2: `yarn skills` wrapper for engineers + deploy # Flow 1: maintainer push to multiple targets + bootstrap # zero-config installer for cloud agents +.targets.local.example # template for maintainer config ``` -The agent will present available skills for installation. +## Domains today -## Adding a New Skill +| Domain | Audience | Examples | +| -------------- | ----------------- | ------------------------------------------- | +| `web3-tools` | dApp builders | `gator-cli`, `smart-accounts-kit`, `oh-my-opencode` | +| `coding` | MM product eng | Coding guidelines, controller patterns | +| `general` | All agents | `codex`, `gemini` CLI usage guides | +| `performance` | MM product eng | React rendering, hooks, state perf | +| `perps` | MM product eng | Perps feature dev + review | +| `pr-workflow` | MM product eng | PR title, description, changelog | +| `swaps` | MM product eng | Non-EVM swap integration | +| `testing` | MM product eng | E2E, unit, visual, perf testing | +| `ui` | MM product eng | Component development | -We welcome contributions from the community! To add a new skill: +## Two distribution flows -1. **Fork this repository** and create a new branch -2. **Create your provider directory** (if it doesn't exist): - ```bash - mkdir -p your-provider-name/your-skill-name/ - ``` -3. **Add the required `SKILL.md`** following our [skill template](.github/SKILL_TEMPLATE.md) -4. **Add optional supporting files**: - - `references/` — Additional documentation - - `scripts/` — Helper scripts -5. **Submit a Pull Request** with a clear description +### Flow 1 — Push (maintainer) -See [CONTRIBUTING.md](CONTRIBUTING.md) for detailed guidelines. +From inside `MetaMask/skills`, push the current state to one or more +consumer checkouts. -## Skill Guidelines +**One target:** -Skills included in this repository should: +```bash +tools/install --repo metamask-extension --target ~/dev/metamask/metamask-extension +``` -- ✅ Be relevant to MetaMask users and Ethereum developers -- ✅ Follow security best practices -- ✅ Include clear documentation and examples -- ✅ Be tested before submission -- ✅ Not include malicious code or instructions +**All configured targets:** -## Security +```bash +cp .targets.local.example .targets.local # one-time +# edit paths +tools/deploy +tools/deploy --domain perps --dry-run # forwarded to install +``` + +`.targets.local` is gitignored. + +### Flow 2 — Pull (engineer) + +From inside a consumer repo: + +```bash +yarn skills # interactive prompt +SKILLS_DOMAINS=perps,testing yarn skills # non-interactive +METAMASK_SKILLS_DIR=/some/path yarn skills # override location +``` + +`yarn skills` calls `tools/sync`, which pulls the source repos, then +execs `tools/install` with each as `--source`. + +If neither env var is set, the script prints setup instructions and exits. +**No silent auto-clone** — engineers see the path they're opting into. + +## Manual install (the primitive) + +Both flows ultimately invoke `tools/install`: + +```bash +tools/install \ + --repo metamask-mobile \ + --target ~/dev/metamask/metamask-mobile \ + --domain perps \ + --dry-run +``` + +### Flags + +| Flag | Default | Purpose | +| ---------------- | -------- | -------------------------------------------------------------------------------- | +| `--target` | required | Path to consuming repo | +| `--repo` | auto | Consuming repo name. Auto-detected from `/package.json` `name` (fallback: target dirname). | +| `--source` | this repo | Skill source dir (repeatable, ordered; later overrides earlier on name collision) | +| `--domain` | all | Comma-separated domain filter. Default installs **all** domains; pass to opt out. | +| `--maturity` | `stable` | Min maturity: `experimental`, `stable`, `deprecated` | +| `--include-user` | off | Also install `scope: user` skills (writes to `$HOME` — outside the target repo). Default skips them with a warning. | +| `--dry-run` | off | Preview without writing | + +**Install-all default.** Skills install for every domain by default. Engineers +opt out per-machine by editing `.skills.local` (`SKILLS_DOMAINS=perps,testing`) +or by running `yarn skills --select` for an interactive picker. New domains +land automatically on the next sync — that's by design so new tooling is +discoverable. -If you discover a security vulnerability in any skill, please report it responsibly. Do not open a public issue. Instead, follow MetaMask's [security policy](https://github.com/MetaMask/metamask-extension/security/policy). +**User-scope skills (`scope: user` in frontmatter).** Some skills target the +engineer's home dir (`$HOME/.claude/skills`, `$HOME/.codex/skills`) instead +of the target repo. They are **never auto-installed** — installer lists them +in a final warning. Run with `--include-user` to install manually. + +### Output + +Per consuming repo, the CLI writes: + +- `.claude/skills/mms-/SKILL.md` — Claude Code, OpenCode +- `.cursor/rules/mms-/RULE.md` — Cursor +- `.agents/skills/mms-/SKILL.md` + `agents/openai.yaml` — Codex, OpenCode + +All output names are prefixed `mms-` (managed metamask skill). Source +frontmatter `name:` stays unprefixed; the prefix is applied at install time. + +Each output file carries a `` banner. Synced content +is additive and intended to be `.gitignore`'d in consuming repos. + +## Federation (public + private) + +When both env vars are set, `tools/sync` walks both sources and the +private overlay overrides the public skill on name conflict. This matches +the "most-local wins" priority below. + +```bash +export METAMASK_SKILLS_DIR=~/dev/metamask/skills # public, this repo +export CONSENSYS_SKILLS_DIR=~/dev/Consensys/skills # private overlay +yarn skills +``` + +Engineers without access to the private repo simply don't set +`CONSENSYS_SKILLS_DIR` — the public set installs cleanly on its own. + +## Authoring a skill + +``` +domains// + skills// + skill.md + references/ # optional supporting docs + scripts/ # optional helper scripts + repos/metamask-extension.md # optional repo overlay + repos/metamask-mobile.md # optional repo overlay +``` + +### `skill.md` frontmatter + +```yaml +--- +name: +description: <≤1,536 chars including when_to_use cues> +maturity: stable # experimental | stable | deprecated (default stable) +--- +``` + +Extra metadata blocks (e.g. OpenClaw-style `metadata:` with emoji and +homepage) are preserved through install — only `name`, `description`, +`maturity`, `mandatory`, and `scope` are read by the CLI. + +### Overlay frontmatter + +```yaml +--- +repo: metamask-extension +parent: +--- +``` + +Overlays merge into the base body at install time. Skills with a `repos/` +subdir but no overlay matching `--repo` are skipped for that target. +Skills with no `repos/` subdir at all install for any target. + +## Multi-operator priority + +``` +Enterprise > User (~/.claude/skills/) > Project (.claude/skills/) +``` + +Most-local wins on name conflict. The `mms-` prefix on output names +prevents collision with personal skills. + +## Contributing + +See [CONTRIBUTING.md](CONTRIBUTING.md). ## License -This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. +MIT — see [LICENSE](LICENSE). -## Links +## Security -- [MetaMask Extension](https://github.com/MetaMask/metamask-extension) -- [MetaMask Developer Docs](https://docs.metamask.io/) -- [OpenClaw Skills (Original)](https://github.com/BankrBot/openclaw-skills) +See [SECURITY.md](SECURITY.md) for how to report issues with skills or +the installer. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..c569f78 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,25 @@ +# Security policy + +This repo contains agent instructions and prompt templates only — no +runtime code that ships in MetaMask products. Reports about the **content** +of a skill (e.g., guidance that could lead to insecure code) are welcome. + +For vulnerabilities in shipped MetaMask products, follow the disclosure +flow at https://metamask.io/security/. + +## Reporting an issue with a skill + +- Open a public issue on https://github.com/MetaMask/skills/issues if the + problem is non-sensitive (typo, outdated reference, broken link). +- Email `security@metamask.io` if the skill instructs an agent to perform + an action with safety implications (e.g., bypassing review controls, + leaking secrets, weakening test coverage), so a maintainer can patch + before discussion is public. + +## Out of scope + +- The installer scripts (`tools/install`, `tools/sync`, `tools/deploy`, + `tools/bootstrap`) run locally on engineer machines or cloud agents and + do not handle credentials. Treat bugs as ordinary issues. +- Skills are advisory — agents and reviewers are expected to apply + judgment. A skill recommendation is not a security guarantee. diff --git a/_example/README.md b/_example/README.md deleted file mode 100644 index 364f30d..0000000 --- a/_example/README.md +++ /dev/null @@ -1,17 +0,0 @@ -# Example Provider - -This directory contains example skills to demonstrate the expected repository structure. - -**Do not use these skills directly** — they are for reference only. - -## Structure - -``` -_example/ -└── sample-skill/ - ├── SKILL.md # Main skill definition - └── references/ # Supporting documentation - └── api-reference.md -``` - -When creating your own skill, follow this structure under your provider directory. diff --git a/_example/sample-skill/SKILL.md b/_example/sample-skill/SKILL.md deleted file mode 100644 index 23506d6..0000000 --- a/_example/sample-skill/SKILL.md +++ /dev/null @@ -1,67 +0,0 @@ -# Sample Skill (Example) - -> This is an example skill to demonstrate the expected format. Do not use this skill directly. - -## Overview - -This sample skill demonstrates the structure and format expected for skills in this repository. Use this as a reference when creating your own skills. - -## Prerequisites - -- Node.js 18+ installed -- MetaMask extension installed -- Access to an Ethereum RPC endpoint - -## Instructions - -### Step 1: Verify Environment - -Check that the required tools are available: - -```bash -node --version -npm --version -``` - -### Step 2: Connect to Network - -Ensure the user has configured their network settings appropriately. - -### Step 3: Execute the Task - -Perform the main action of the skill with proper error handling. - -## Examples - -### Example 1: Basic Usage - -``` -User: "Help me with the sample task" -Agent: I'll help you with the sample task. First, let me verify your environment... -``` - -## Configuration - -| Parameter | Type | Required | Default | Description | -|-----------|------|----------|---------|-------------| -| `network` | string | No | `mainnet` | Target network | -| `timeout` | number | No | `30000` | Request timeout in ms | - -## Troubleshooting - -### Connection Issues - -**Problem:** Unable to connect to the network. - -**Solution:** Verify your RPC endpoint is accessible and your network settings are correct. - -## Security Considerations - -- This skill does not handle private keys -- All transactions require user confirmation via MetaMask -- No sensitive data is stored or transmitted - -## References - -- [MetaMask Documentation](https://docs.metamask.io/) -- [Ethereum JSON-RPC API](https://ethereum.org/en/developers/docs/apis/json-rpc/) diff --git a/_example/sample-skill/references/api-reference.md b/_example/sample-skill/references/api-reference.md deleted file mode 100644 index 4b08ebf..0000000 --- a/_example/sample-skill/references/api-reference.md +++ /dev/null @@ -1,15 +0,0 @@ -# API Reference (Example) - -This file demonstrates how to include additional reference documentation for a skill. - -## Endpoints - -Document any API endpoints the skill interacts with. - -## Data Structures - -Document any data structures used by the skill. - -## Notes - -This is a placeholder file to show the expected structure of the `references/` directory. diff --git a/domains/coding/skills/coding-guidelines/repos/metamask-extension.md b/domains/coding/skills/coding-guidelines/repos/metamask-extension.md new file mode 100644 index 0000000..8c36362 --- /dev/null +++ b/domains/coding/skills/coding-guidelines/repos/metamask-extension.md @@ -0,0 +1,797 @@ +--- +repo: metamask-extension +parent: coding-guidelines +--- + + +Reference: [MetaMask Coding Guidelines](https://github.com/MetaMask/metamask-extension/blob/main/.github/guidelines/CODING_GUIDELINES.md) + +# MetaMask Extension - General Coding Guidelines + +## TypeScript + +### Use TypeScript for All New Code + +- **ALWAYS write new components and utilities in TypeScript** +- Enforce proper typing (avoid `any` unless absolutely necessary) +- Refactor existing JavaScript to TypeScript when time allows +- If replacing a component, use TypeScript + +Reference: [MetaMask TypeScript Guidelines](https://github.com/MetaMask/contributor-docs/blob/main/docs/typescript.md) + +### TypeScript Best Practices + +- Define explicit types for function parameters and return values +- Use interfaces for object shapes +- Use type aliases for complex types +- Leverage union types and discriminated unions +- Use `unknown` instead of `any` when type is truly unknown + +Example: + +```typescript +✅ CORRECT: +interface Token { + address: string; + symbol: string; + decimals: number; +} + +function addToken(token: Token): void { + // Implementation +} + +const tokens: Token[] = []; + +❌ WRONG: +function addToken(token: any) { // No return type, uses any + // Implementation +} + +const tokens = []; // No type annotation +``` + +## React Components + +### Use Functional Components and Hooks + +- **ALWAYS use functional components instead of class components** +- Use hooks for state management and side effects +- Functional components are more concise and readable + +Example: + +```tsx +✅ CORRECT: Functional component with hooks +import React, { useState, useEffect } from 'react'; + +const TokenList = ({ initialTokens }: TokenListProps) => { + const [tokens, setTokens] = useState(initialTokens); + + useEffect(() => { + fetchTokens().then(setTokens); + }, []); + + return ( +
+ {tokens.map(token => )} +
+ ); +}; + +❌ WRONG: Class component +class TokenList extends React.Component { + constructor(props) { + super(props); + this.state = { tokens: props.initialTokens }; + } + + componentDidMount() { + fetchTokens().then(tokens => this.setState({ tokens })); + } + + render() { + return ( +
+ {this.state.tokens.map(token => ( + + ))} +
+ ); + } +} +``` + +### Component Optimization + +#### Break Down Large Components + +- Avoid large return statements +- Break components into smaller, focused sub-components +- Each component should have a single responsibility + +Example: + +```tsx +❌ WRONG: Large monolithic component +const Dashboard = () => { + return ( +
+
+
...
+ +
...
+
+
+
...
+
+
...
+
...
+
...
+
+
+
+ ); +}; + +✅ CORRECT: Broken into sub-components +const Dashboard = () => { + return ( +
+ + +
+ ); +}; + +const DashboardHeader = () => ( +
+ + + +
+); + +const DashboardMain = () => ( +
+ + +
+); + +const DashboardContent = () => ( +
+ + + +
+); +``` + +#### Use Memoization Techniques + +- Use `useMemo` for expensive computed values +- Use `useCallback` for function references passed to child components +- Follow React's recommended guidance on optimization +- Don't over-optimize - profile first + +Example: + +```tsx +✅ CORRECT: Using memoization +import React, { useMemo, useCallback } from 'react'; + +const TokenList = ({ tokens, onTokenClick }: TokenListProps) => { + // Memoize expensive computation + const sortedTokens = useMemo(() => { + return [...tokens].sort((a, b) => + b.balance.localeCompare(a.balance) + ); + }, [tokens]); + + // Memoize callback to prevent child re-renders + const handleTokenClick = useCallback( + (token: Token) => { + onTokenClick(token.address); + }, + [onTokenClick], + ); + + return ( +
+ {sortedTokens.map(token => ( + + ))} +
+ ); +}; + +❌ WRONG: No memoization, causing unnecessary re-renders +const TokenList = ({ tokens, onTokenClick }: TokenListProps) => { + // Recalculated on every render + const sortedTokens = [...tokens].sort((a, b) => + b.balance.localeCompare(a.balance) + ); + + // New function created on every render + const handleTokenClick = (token: Token) => { + onTokenClick(token.address); + }; + + return ( +
+ {sortedTokens.map(token => ( + + ))} +
+ ); +}; +``` + +#### Use useEffect Judiciously + +- Use `useEffect` for side effects (data fetching, DOM manipulation, subscriptions) +- Don't overuse effects - many things don't need effects +- Consider if the operation should happen during render instead +- Always clean up side effects + +Reference: [You Might Not Need an Effect](https://react.dev/learn/you-might-not-need-an-effect) + +Example: + +```tsx +✅ CORRECT: Appropriate useEffect usage +const TokenBalance = ({ address }: TokenBalanceProps) => { + const [balance, setBalance] = useState('0'); + + useEffect(() => { + let cancelled = false; + + fetchBalance(address).then(newBalance => { + if (!cancelled) { + setBalance(newBalance); + } + }); + + // Cleanup function + return () => { + cancelled = true; + }; + }, [address]); + + return
{balance}
; +}; + +❌ WRONG: Unnecessary effect +const TokenDisplay = ({ token }: TokenDisplayProps) => { + const [displayName, setDisplayName] = useState(''); + + // This doesn't need an effect! + useEffect(() => { + setDisplayName(`${token.symbol} (${token.name})`); + }, [token]); + + return
{displayName}
; +}; + +✅ CORRECT: Compute during render +const TokenDisplay = ({ token }: TokenDisplayProps) => { + const displayName = `${token.symbol} (${token.name})`; + return
{displayName}
; +}; +``` + +### Use Object Destructuring for Props + +- **ALWAYS destructure props in function parameters** +- Improves readability and avoids repetitive `props.` references +- Makes it clear what props the component uses + +Example: + +```tsx +✅ CORRECT: +interface MyComponentProps { + id: string; + title: string; + onClose: () => void; +} + +const MyComponent = ({ id, title, onClose }: MyComponentProps) => { + return ( +
+

{title}

+ +
+ ); +}; + +❌ WRONG: +const MyComponent = (props: MyComponentProps) => { + return ( +
+

{props.title}

+ +
+ ); +}; +``` + +## File Organization + +### Organize Related Files in One Folder + +- Group all files related to a component in a single directory +- Follow consistent structure across components +- Use index.ts for clean imports + +Standard component structure: + +``` +component-name/ +├── component-name.tsx # Main component file +├── component-name.types.ts # TypeScript types/interfaces +├── component-name.test.tsx # Unit tests +├── component-name.stories.tsx # Storybook stories +├── component-name.scss # Component styles +├── __snapshots__/ # Jest snapshots +│ └── component-name.test.tsx.snap +├── README.md # Component documentation +└── index.ts # Public exports +``` + +Example index.ts: + +```typescript +export { ComponentName } from './component-name'; +export type { ComponentNameProps } from './component-name.types'; +``` + +## Naming Conventions + +### Component Names + +- **ALWAYS use PascalCase for components** +- Names should be descriptive and specific +- Avoid generic names like `Component` or `Container` + +Examples: + +```typescript +✅ CORRECT: +TextField +NavMenu +SuccessButton +TokenListItem +AccountAvatar + +❌ WRONG: +textfield +nav_menu +success-button +component +Container +``` + +### Function Names + +- **Use camelCase for functions declared inside components** +- Use descriptive action verbs +- Handlers should start with `handle` + +Examples: + +```typescript +✅ CORRECT: +const handleInputChange = () => { }; +const handleSubmit = () => { }; +const showElement = () => { }; +const validateForm = () => { }; +const fetchUserData = () => { }; + +❌ WRONG: +const HandleInput = () => { }; +const submit = () => { }; +const show_element = () => { }; +const validate = () => { }; +``` + +### Hook Names + +- **Use `use` prefix for custom hooks** +- Name should describe what the hook does + +Examples: + +```typescript +✅ CORRECT: +const useTokenBalance = () => { }; +const useAccountData = () => { }; +const useDebounce = () => { }; +const useLocalStorage = () => { }; + +❌ WRONG: +const tokenBalance = () => { }; +const withAccountData = () => { }; +const getDebounce = () => { }; +``` + +### Higher-Order Component Names + +- **Use `with` prefix for HOCs** +- Describe the functionality being added + +Examples: + +```typescript +✅ CORRECT: +const withAuth = (Component) => { }; +const withLoading = (Component) => { }; +const withErrorBoundary = (Component) => { }; + +❌ WRONG: +const authHOC = (Component) => { }; +const loading = (Component) => { }; +``` + +## Code Reusability + +### Avoid Repetitive Code (DRY Principle) + +- **If writing duplicated code, extract into reusable utilities** +- Create shared components for repeated UI patterns +- Create custom hooks for repeated logic +- Use "scalable intention" - abstract when it makes sense + +Reference: [The Wrong Abstraction](https://sandimetz.com/blog/2016/1/20/the-wrong-abstraction) + +Example: + +```tsx +❌ WRONG: Duplicated code +const TokenA = () => { + const [balance, setBalance] = useState('0'); + const [loading, setLoading] = useState(false); + + useEffect(() => { + setLoading(true); + fetchBalance(addressA) + .then(setBalance) + .finally(() => setLoading(false)); + }, []); + + return
{loading ? 'Loading...' : balance}
; +}; + +const TokenB = () => { + const [balance, setBalance] = useState('0'); + const [loading, setLoading] = useState(false); + + useEffect(() => { + setLoading(true); + fetchBalance(addressB) + .then(setBalance) + .finally(() => setLoading(false)); + }, []); + + return
{loading ? 'Loading...' : balance}
; +}; + +✅ CORRECT: Extracted into custom hook +const useTokenBalance = (address: string) => { + const [balance, setBalance] = useState('0'); + const [loading, setLoading] = useState(false); + + useEffect(() => { + setLoading(true); + fetchBalance(address) + .then(setBalance) + .finally(() => setLoading(false)); + }, [address]); + + return { balance, loading }; +}; + +const TokenA = () => { + const { balance, loading } = useTokenBalance(addressA); + return
{loading ? 'Loading...' : balance}
; +}; + +const TokenB = () => { + const { balance, loading } = useTokenBalance(addressB); + return
{loading ? 'Loading...' : balance}
; +}; +``` + +## Documentation + +### Document Components + +- **Add documentation for all public components** +- Include props descriptions +- Include usage examples +- Document any non-obvious behavior + +Example component README: + +```markdown +# TokenListItem + +Displays a single token in a list with its symbol, name, and balance. + +## Props + +- `token` (Token): The token object to display +- `onClick` (function): Callback when the item is clicked +- `isSelected` (boolean): Whether this token is currently selected + +## Usage + +\`\`\`tsx + console.log(token)} +isSelected={false} +/> +\`\`\` +``` + +### Document Utilities with TSDoc + +- **Use TSDoc format for all utility functions** +- Document parameters, return values, and exceptions +- Include examples for complex functions +- Add links to relevant resources + +Example: + +````typescript +/** + * Formats a token balance for display, handling decimals and large numbers. + * + * @param balance - The raw token balance as a string + * @param decimals - Number of decimal places for the token + * @param options - Formatting options + * @returns Formatted balance string suitable for display + * + * @example + * ```typescript + * formatBalance('1000000000000000000', 18) + * // Returns: '1.00' + * + * formatBalance('123456789', 6, { maximumFractionDigits: 4 }) + * // Returns: '123.4567' + * ``` + * + * @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/NumberFormat + */ +export function formatBalance( + balance: string, + decimals: number, + options?: FormatOptions, +): string { + // Implementation +} +```` + +## Testing + +### Write Tests for All Components and Utilities + +- **ALWAYS write tests for new code** +- Tests reduce possibilities of errors and regressions +- Ensure components behave as expected +- Use Jest as the testing framework + +Reference: [MetaMask Unit Testing Guidelines](https://github.com/MetaMask/contributor-docs/blob/main/docs/unit-testing.md) + +Example: + +```typescript +import { render, screen, fireEvent } from '@testing-library/react'; +import { TokenListItem } from './token-list-item'; + +describe('TokenListItem', () => { + const mockToken = { + address: '0x123', + symbol: 'DAI', + name: 'Dai Stablecoin', + balance: '100', + }; + + describe('rendering', () => { + it('displays token symbol and name', () => { + render(); + + expect(screen.getByText('DAI')).toBeInTheDocument(); + expect(screen.getByText('Dai Stablecoin')).toBeInTheDocument(); + }); + + it('displays token balance', () => { + render(); + + expect(screen.getByText('100')).toBeInTheDocument(); + }); + }); + + describe('interactions', () => { + it('calls onClick when clicked', () => { + const handleClick = jest.fn(); + render(); + + fireEvent.click(screen.getByRole('button')); + + expect(handleClick).toHaveBeenCalledWith(mockToken); + }); + }); +}); +``` + +## External Packages + +### Use Well-Maintained Packages Only + +- **Only add packages if functionality doesn't exist and can't be easily implemented** +- Before adding a package, check if a small utility function would suffice +- Use [Snyk Advisor](https://snyk.io/advisor/) to assess: + - Popularity and adoption + - Maintenance status + - Security analysis + - License compatibility +- Package must be in good standing to be added + +### Evaluation Checklist + +Before adding a package: + +- [ ] Functionality can't be implemented with a small utility +- [ ] Package is actively maintained (recent updates) +- [ ] Package has good security score on Snyk +- [ ] Package has reasonable bundle size +- [ ] Package has TypeScript support (types included or available) +- [ ] Package has good documentation +- [ ] Package license is compatible with project + +### Update Dependencies + +- **Update dependencies when you notice they are out of date** +- Check for security vulnerabilities regularly +- Test thoroughly after updates +- Update incrementally rather than in large batches + +## Code Style + +### General Guidelines + +- Use consistent formatting (Prettier handles this) +- Follow ESLint rules configured in the project +- Keep functions small and focused +- Avoid deep nesting (max 3-4 levels) +- Use early returns to reduce nesting +- Add comments for complex logic only +- Remove commented-out code +- Remove console.logs before committing + +### Early Returns + +Example: + +```typescript +❌ WRONG: Deep nesting +function processToken(token: Token | null) { + if (token) { + if (token.balance) { + if (token.balance > 0) { + return formatBalance(token.balance); + } else { + return '0'; + } + } else { + return 'Unknown'; + } + } else { + return null; + } +} + +✅ CORRECT: Early returns +function processToken(token: Token | null): string | null { + if (!token) { + return null; + } + + if (!token.balance) { + return 'Unknown'; + } + + if (token.balance <= 0) { + return '0'; + } + + return formatBalance(token.balance); +} +``` + +## Checklist for New Code + +Before submitting a PR, ensure: + +### TypeScript + +- [ ] New code is written in TypeScript +- [ ] Types are explicitly defined (no implicit any) +- [ ] Interfaces/types are properly documented + +### React Components + +- [ ] Functional components used (not classes) +- [ ] Hooks used appropriately +- [ ] Props are destructured +- [ ] Component is broken into smaller sub-components if needed +- [ ] Memoization used where appropriate (but not over-optimized) +- [ ] useEffect used judiciously with proper cleanup + +### File Organization + +- [ ] Files organized in component-specific folders +- [ ] Follows standard structure (component, types, tests, styles) +- [ ] index.ts exports public API + +### Naming + +- [ ] Components use PascalCase +- [ ] Functions use camelCase +- [ ] Hooks use `use` prefix +- [ ] HOCs use `with` prefix +- [ ] Names are descriptive and meaningful + +### Code Quality + +- [ ] No duplicated code +- [ ] Reusable utilities/components extracted +- [ ] Code is readable and maintainable +- [ ] Early returns used to reduce nesting +- [ ] No console.logs or commented code + +### Documentation + +- [ ] Component has README with props and examples +- [ ] Utility functions have TSDoc comments +- [ ] Complex logic has explanatory comments + +### Testing + +- [ ] Unit tests written for components +- [ ] Unit tests written for utilities +- [ ] Tests follow naming conventions +- [ ] Tests cover happy paths and error cases + +### Dependencies + +- [ ] No new dependencies added without justification +- [ ] New dependencies evaluated with Snyk Advisor +- [ ] Dependencies are up to date + +### Linting & Formatting + +- [ ] Code passes ESLint checks +- [ ] Code is formatted with Prettier +- [ ] No TypeScript errors +- [ ] All imports are used + +## References + +- [MetaMask Coding Guidelines](https://github.com/MetaMask/metamask-extension/blob/main/.github/guidelines/CODING_GUIDELINES.md) +- [MetaMask TypeScript Guidelines](https://github.com/MetaMask/contributor-docs/blob/main/docs/typescript.md) +- [MetaMask Unit Testing Guidelines](https://github.com/MetaMask/contributor-docs/blob/main/docs/unit-testing.md) +- [React Documentation](https://react.dev/) +- [You Might Not Need an Effect](https://react.dev/learn/you-might-not-need-an-effect) +- [TSDoc](https://tsdoc.org/) diff --git a/domains/coding/skills/coding-guidelines/repos/metamask-mobile.md b/domains/coding/skills/coding-guidelines/repos/metamask-mobile.md new file mode 100644 index 0000000..8a40ff4 --- /dev/null +++ b/domains/coding/skills/coding-guidelines/repos/metamask-mobile.md @@ -0,0 +1,48 @@ +--- +repo: metamask-mobile +parent: coding-guidelines +--- + + +# General Coding Guidelines + +## Required Reading Before Development + +**ALWAYS** check: `.github/guidelines/CODING_GUIDELINES.md` • `README.md` • Relevant `/docs` before coding + +**Docs Structure**: `/docs/readme/` (core) • `/docs/` (features) • `README.md` (overview) + +## Development Workflow + +**Before Starting**: Read README.md → Check coding guidelines → Review relevant docs → Understand architecture + +**Code Quality**: +- TypeScript guidelines from contributor docs • Functional components + hooks • PascalCase (components) / camelCase (functions) +- Reusable components/utilities • TSDoc format • Comprehensive tests • Apply `.cursor/rules/unit-testing-guidelines.mdc` + +**File Organization**: +``` +ComponentName/ +├── ComponentName.{constants,stories,styles,test,types}.ts(x) +├── ComponentName.tsx +├── README.md +└── index.ts +``` + +## Documentation Quick Reference + +**Core**: `/docs/readme/` (architecture, testing, debugging, performance, environment, expo-environment, storybook, troubleshooting, expo-e2e-testing, reassure, release-build-profiler) + +**Features**: `/docs/` (deeplinks, animations, tailwind, confirmations, confirmation-refactoring) • `app/component-library/README.md` • `tests/MOCKING.md` • `CHANGELOG.md` • `app/core/{Analytics,Engine}/README.md` + +**External**: [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) • [TypeScript Guidelines](https://github.com/MetaMask/contributor-docs/blob/main/docs/typescript.md) • [Unit Testing](https://github.com/MetaMask/contributor-docs/blob/main/docs/testing/unit-testing.md) + +## Enforcement (MANDATORY) + +**Documentation**: Read `.github/guidelines/`, `README.md`, and relevant `/docs` before implementing + +**Commands**: ONLY use `.claude/commands/` + yarn command + +**Testing**: see .claude/commands/unit-test.md + +**Forbidden**: ❌ npm/npx commands diff --git a/domains/coding/skills/coding-guidelines/skill.md b/domains/coding/skills/coding-guidelines/skill.md new file mode 100644 index 0000000..fb5f8c6 --- /dev/null +++ b/domains/coding/skills/coding-guidelines/skill.md @@ -0,0 +1,4 @@ +--- +name: coding-guidelines +description: General coding guidelines +--- diff --git a/domains/coding/skills/controller-guidelines/repos/metamask-extension.md b/domains/coding/skills/controller-guidelines/repos/metamask-extension.md new file mode 100644 index 0000000..8d4f965 --- /dev/null +++ b/domains/coding/skills/controller-guidelines/repos/metamask-extension.md @@ -0,0 +1,722 @@ +--- +repo: metamask-extension +parent: controller-guidelines +--- + + +Reference: [MetaMask Controller Guidelines](https://github.com/MetaMask/core/blob/main/docs/controller-guidelines.md) + +# MetaMask Extension - Controller Development Guidelines + +## Controller Architecture + +### Purpose of Controllers + +Controllers are foundational pieces within MetaMask's architecture that: + +- Keep and manage wallet-centric data (accounts, transactions, preferences, etc.) +- Contain business logic that powers functionality in the product +- Act as a communication layer between service layers (blockchains, APIs, etc.) +- Divide the application into logical modules maintained by different teams + +### When to Use BaseController + +- **ALWAYS inherit from `BaseController`** for classes that manage state +- BaseController is from `@metamask/base-controller` package +- Provides standard interface, messenger, state management, and consolidated constructor + +### When NOT to Use BaseController + +- **NEVER use `BaseController` for non-controllers** +- If a class does not capture any data in state, it doesn't need BaseController +- State management is the uniquely identifying feature of a controller + +## Controller Naming and API + +### Naming Convention + +- Controller name should reflect its responsibility +- If difficult to name, define the responsibility first +- Follow pattern: `${Responsibility}Controller` (e.g., `TokensController`, `AccountsController`) + +### API Clarity + +- Each public method should have a clear purpose +- Each state property should have a clear purpose +- Method and property names should be readable and reflect purpose clearly +- **If something doesn't need to be public, make it private** +- **If something is unnecessary, remove it** + +Example: + +```typescript +✅ CORRECT: +class TokensController extends BaseController<...> { + // Clear public API + addToken(token: Token): void { } + removeToken(address: string): void { } + + // Private implementation details + #validateToken(token: Token): boolean { } +} + +❌ WRONG: +class TokensController extends BaseController<...> { + // Unclear or unnecessary public methods + setTokenData(data: any): void { } + doStuff(): void { } + internalValidation(token: Token): boolean { } // Should be private +} +``` + +## State Management + +### Accept Partial State + +- **ALWAYS accept an optional, partial representation of state** +- Controllers should merge partial state with defaults +- The `state` argument should be optional in constructor + +Example: + +```typescript +type FooControllerState = { + items: Item[]; + lastUpdated: number; +}; + +function getDefaultFooControllerState(): FooControllerState { + return { + items: [], + lastUpdated: 0, + }; +} + +class FooController extends BaseController { + constructor({ + messenger, + state = {}, + }: { + messenger: FooControllerMessenger; + state?: Partial; + }) { + super({ + messenger, + state: { ...getDefaultFooControllerState(), ...state }, + }); + } +} +``` + +### Provide Default State Function + +- **ALWAYS export a `getDefault${ControllerName}State` function** +- Return a new object reference each time (prevents accidental mutations) +- Do NOT export the default state object directly + +Example: + +```typescript +✅ CORRECT: +// FooController.ts +export function getDefaultFooControllerState(): FooControllerState { + return { + items: [], + lastUpdated: 0, + }; +} + +// index.ts +export { FooController, getDefaultFooControllerState } from './FooController'; + +❌ WRONG: +// Don't export object directly +export const defaultFooControllerState = { + items: [], + lastUpdated: 0, +}; +``` + +### Define State Metadata + +- **ALWAYS define metadata for each state property** +- Create a `${controllerName}Metadata` variable +- Pass metadata to `BaseController` constructor + +#### Metadata Properties (Current): + +- `includeInDebugSnapshot`: Include in Sentry debug logs? (true/false) - Must exclude PII +- `includeInStateLogs`: Include in user-downloaded state logs? (true/false) - Must exclude sensitive data +- `persist`: Should property be in persistent storage? (true/false) +- `usedInUi`: Is property used in the UI? (true/false) + +**Alternative metadata (can be used instead of `includeInDebugSnapshot`):** + +- `anonymous`: Has no PII, safe for Sentry? (true/false) - Can be used as an alternative to `includeInDebugSnapshot` + +Example: + +```typescript +const keyringControllerMetadata = { + vault: { + // This property can be used to identify a user, so we want to make sure we + // do not include it in Sentry. + includeInDebugSnapshot: false, + // We don't want to include this in state logs because it contains sensitive key material. + includeInStateLogs: false, + // We want to persist this property so it's restored automatically, as we + // cannot reconstruct it otherwise. + persist: true, + // This property is only used in the controller, not in the UI. + usedInUi: false, + }, + isUnlocked: { + // This value is not sensitive, and is useful for diagnosing errors reported through support. + includeInStateLogs: true, + // We do not need to persist this property in state, as we want to + // initialize state with the wallet locked. + persist: false, + // This property has no PII, so it is safe to send to Sentry. + // (Can use 'anonymous: true' as alternative to 'includeInDebugSnapshot: true') + anonymous: true, + // This is used in the UI + usedInUi: true, + }, +}; + +class KeyringController extends BaseController { + constructor({ messenger, state = {} }: KeyringControllerOptions) { + super({ + name: 'KeyringController', + metadata: keyringControllerMetadata, + messenger, + state: { ...getDefaultKeyringControllerState(), ...state }, + }); + } +} +``` + +### Update State Correctly + +- **ALWAYS use `this.update()` to modify state** +- NEVER directly mutate `this.state` +- Update method receives a draft state that can be mutated (uses Immer) + +Example: + +```typescript +✅ CORRECT: +addToken(token: Token) { + this.update((state) => { + state.tokens.push(token); + }); +} + +❌ WRONG: +addToken(token: Token) { + this.state.tokens.push(token); // Direct mutation! +} +``` + +## Constructor Patterns + +### Single Options Bag + +- **ALWAYS use a single "options bag" for constructor arguments** +- Include all BaseController requirements: `messenger`, `metadata`, `name`, `state` +- Include any controller-specific options in the same bag +- NO additional positional arguments + +Example: + +```typescript +✅ CORRECT: +class FooController extends BaseController { + constructor({ + messenger, + state = {}, + isEnabled, + apiKey, + }: { + messenger: FooControllerMessenger; + state?: Partial; + isEnabled: boolean; + apiKey: string; + }) { + super({ + name: 'FooController', + metadata: fooControllerMetadata, + messenger, + state: { ...getDefaultFooControllerState(), ...state }, + }); + + this.#isEnabled = isEnabled; + this.#apiKey = apiKey; + } +} + +❌ WRONG: +class FooController extends BaseController { + constructor( + { + messenger, + state = {}, + }: { + messenger: FooControllerMessenger; + state?: Partial; + }, + isEnabled: boolean, // Separate positional argument! + ) { + // ... + } +} +``` + +## Messenger Usage + +### Use Messenger Instead of Callbacks + +- **ALWAYS use messenger for inter-controller communication** +- NEVER pass callback functions in constructor options +- Messenger reduces coupling and number of options + +Example: + +```typescript +❌ WRONG: Using callbacks +class FooController extends BaseController { + constructor({ + onBarStateChange, + }: { + onBarStateChange: (state: BarControllerState) => void; + }) { + onBarStateChange((state) => { + // do something + }); + } +} + +✅ CORRECT: Using messenger +class FooController extends BaseController { + constructor({ messenger }: { messenger: FooControllerMessenger }) { + super({ name: 'FooController', metadata, messenger, state }); + + this.messagingSystem.subscribe( + 'BarController:stateChange', + (barState) => { + // do something + }, + ); + } +} +``` + +### Messenger Type Definitions + +- Define allowed actions and events for type safety +- Use discriminated unions for action types +- Follow naming convention: `${ControllerName}:${actionOrEventName}` + +Example: + +```typescript +export type TokensControllerGetStateAction = ControllerGetStateAction< + 'TokensController', + TokensControllerState +>; + +export type TokensControllerAddTokenAction = { + type: 'TokensController:addToken'; + handler: TokensController['addToken']; +}; + +export type TokensControllerActions = + | TokensControllerGetStateAction + | TokensControllerAddTokenAction; + +export type TokensControllerStateChangeEvent = ControllerStateChangeEvent< + 'TokensController', + TokensControllerState +>; + +export type TokensControllerEvents = TokensControllerStateChangeEvent; + +export type TokensControllerMessenger = RestrictedControllerMessenger< + 'TokensController', + TokensControllerActions | AllowedActions, + TokensControllerEvents | AllowedEvents, + AllowedActions['type'], + AllowedEvents['type'] +>; +``` + +### Subscribe to Other Controllers + +- Use `messenger.call()` to invoke actions on other controllers +- Use `messenger.subscribe()` to listen to events from other controllers +- Define `AllowedActions` and `AllowedEvents` types + +Example: + +```typescript +type AllowedActions = NetworkControllerGetStateAction; + +type AllowedEvents = NetworkControllerStateChangeEvent; + +class TokensController extends BaseController { + constructor({ messenger }: { messenger: TokensControllerMessenger }) { + super({ name: 'TokensController', metadata, messenger, state }); + + // Subscribe to network changes + this.messagingSystem.subscribe( + 'NetworkController:stateChange', + this.#handleNetworkChange.bind(this), + ); + } + + async fetchTokens() { + // Call other controller + const { chainId } = this.messagingSystem.call('NetworkController:getState'); + + // Use chainId for fetching + } +} +``` + +## Selectors + +### Use Selectors Instead of Getters + +- **NEVER add getter methods to controllers for derived state** +- **ALWAYS export selectors as pure functions** +- Place selectors under `${controllerName}Selectors` object +- Use `reselect` library for memoization + +Example: + +```typescript +❌ WRONG: Using getter methods +class AccountsController extends BaseController { + getActiveAccounts() { + return this.state.accounts.filter((account) => account.isActive); + } +} + +✅ CORRECT: Using selectors +import { createSelector } from 'reselect'; + +type AccountsControllerState = { + accounts: Account[]; +}; + +const selectAccounts = (state: AccountsControllerState) => state.accounts; + +const selectActiveAccounts = createSelector( + [selectAccounts], + (accounts) => accounts.filter((account) => account.isActive), +); + +const selectInactiveAccounts = createSelector( + [selectAccounts], + (accounts) => accounts.filter((account) => !account.isActive), +); + +export const accountsControllerSelectors = { + selectAccounts, + selectActiveAccounts, + selectInactiveAccounts, +}; +``` + +### Benefits of Selectors + +- Can be used without controller instance +- Can be used without messenger +- Work in Redux selectors and React components +- Memoization improves performance +- Easier to test as pure functions + +### Using Selectors in Other Controllers + +```typescript +import { accountsControllerSelectors } from '@metamask/accounts-controller'; + +class TokensController extends BaseController { + fetchTokens() { + const accountsState = this.messagingSystem.call( + 'AccountsController:getState', + ); + + const activeAccounts = + accountsControllerSelectors.selectActiveAccounts(accountsState); + + // Use active accounts + } +} +``` + +## Action Methods + +### Model Actions as Events + +- **Methods should represent high-level user actions, not low-level setters** +- Name methods after what the user is doing +- Avoid generic setters like `setState()`, `setProperty()` + +Example: + +```typescript +❌ WRONG: Low-level setters +class AlertsController extends BaseController { + setAlertShown(alertId: string, isShown: boolean) { + this.update((state) => { + state.alerts[alertId].isShown = isShown; + }); + } +} + +✅ CORRECT: High-level actions +class AlertsController extends BaseController { + showAlert(alertId: string) { + this.update((state) => { + state.alerts[alertId].isShown = true; + state.alerts[alertId].shownAt = Date.now(); + }); + } + + dismissAlert(alertId: string) { + this.update((state) => { + state.alerts[alertId].isShown = false; + state.alerts[alertId].dismissedAt = Date.now(); + }); + } +} +``` + +### Action Method Guidelines + +- Validate inputs before updating state +- Throw descriptive errors for invalid operations +- Update related state properties together +- Emit appropriate events (handled automatically by BaseController) +- Include side effects (API calls, other controller interactions) + +Example: + +```typescript +class TokensController extends BaseController { + addToken(token: Token) { + // Validate + if (!token.address) { + throw new Error('Token address is required'); + } + + if (this.#tokenExists(token.address)) { + throw new Error(`Token already exists: ${token.address}`); + } + + // Update state + this.update((state) => { + state.tokens.push(token); + state.tokenCount = state.tokens.length; + state.lastUpdated = Date.now(); + }); + + // Side effects + this.#notifyUI(token); + this.#syncWithRemote(token); + } +} +``` + +## State Derivation + +### Keep State Minimal + +- **NEVER store derived values in state** +- Use selectors to compute derived values +- Store only the minimal necessary data + +Reference: [Redux Style Guide - Keep State Minimal](https://redux.js.org/style-guide/#keep-state-minimal-and-derive-additional-values) + +Example: + +```typescript +❌ WRONG: Storing derived values +type TokensControllerState = { + tokens: Token[]; + tokenCount: number; // Derived from tokens.length + hasTokens: boolean; // Derived from tokens.length > 0 +}; + +✅ CORRECT: Derive values with selectors +type TokensControllerState = { + tokens: Token[]; +}; + +// Use selectors for derived values +const selectTokenCount = (state: TokensControllerState) => state.tokens.length; + +const selectHasTokens = (state: TokensControllerState) => state.tokens.length > 0; + +export const tokensControllerSelectors = { + selectTokens: (state: TokensControllerState) => state.tokens, + selectTokenCount, + selectHasTokens, +}; +``` + +### Subscribe to State Changes with Selectors + +- Use messenger selector parameter to listen to specific state changes +- Avoid unnecessary re-renders or callbacks + +Example: + +```typescript +class PreferencesController extends BaseController { + constructor({ messenger }: { messenger: PreferencesControllerMessenger }) { + super({ name: 'PreferencesController', metadata, messenger, state }); + + // Only triggered when selectedAccount changes, not all state changes + this.messagingSystem.subscribe( + 'AccountsController:stateChange', + (selectedAccount) => { + this.#updateSelectedAddress(selectedAccount.address); + }, + (accountsState) => accountsState.selectedAccount, // Selector function + ); + } +} +``` + +## Controller Lifecycle + +### Initialization + +- Initialize with default state merged with partial state +- Set up messenger subscriptions in constructor +- Start background tasks if needed (polling, etc.) +- Validate dependencies are provided + +### Cleanup + +- Implement `destroy()` method if controller has cleanup needs +- Stop polling intervals +- Unsubscribe from events +- Clean up any external resources + +Example: + +```typescript +class TokensController extends BaseController { + #pollInterval: NodeJS.Timeout | null = null; + + constructor(options: TokensControllerOptions) { + super({ + name: 'TokensController', + metadata: tokensControllerMetadata, + messenger: options.messenger, + state: { ...getDefaultTokensControllerState(), ...options.state }, + }); + + // Set up subscriptions + this.messagingSystem.subscribe( + 'NetworkController:stateChange', + this.#handleNetworkChange.bind(this), + ); + + // Start polling if enabled + if (options.enablePolling) { + this.#startPolling(); + } + } + + destroy() { + // Stop polling + if (this.#pollInterval) { + clearInterval(this.#pollInterval); + this.#pollInterval = null; + } + + // BaseController will handle unsubscribing messenger events + super.destroy(); + } + + #startPolling() { + this.#pollInterval = setInterval(() => { + this.fetchTokens(); + }, 30000); + } +} +``` + +## Best Practices Checklist + +Before submitting a controller, ensure: + +### Architecture + +- [ ] Controller inherits from `BaseController` (if it manages state) +- [ ] Controller name clearly reflects its responsibility +- [ ] Controller has a single, well-defined purpose + +### State Management + +- [ ] State type is clearly defined +- [ ] `getDefault${ControllerName}State` function is exported +- [ ] Constructor accepts optional `Partial` +- [ ] State metadata is defined for all properties +- [ ] Metadata includes required properties (`includeInDebugSnapshot` or `anonymous`, `includeInStateLogs`, `persist`, `usedInUi`) +- [ ] State is updated only via `this.update()` +- [ ] State is minimal (no derived values stored) + +### Constructor + +- [ ] Uses single options bag pattern +- [ ] All options are named parameters +- [ ] Required BaseController arguments included +- [ ] No additional positional arguments + +### Messenger + +- [ ] Messenger types properly defined +- [ ] Action and event types follow naming convention +- [ ] `AllowedActions` and `AllowedEvents` types defined +- [ ] Callbacks replaced with messenger subscriptions +- [ ] Messenger used for inter-controller communication + +### API Design + +- [ ] Public methods have clear, descriptive names +- [ ] Methods model high-level actions, not setters +- [ ] Private implementation details are marked private +- [ ] No unnecessary public methods +- [ ] Methods validate inputs and throw descriptive errors + +### Selectors + +- [ ] Derived state accessed via selectors, not getters +- [ ] Selectors exported under `${controllerName}Selectors` object +- [ ] Selectors are pure functions +- [ ] `reselect` used for memoization where appropriate + +### Lifecycle + +- [ ] Controller initializes properly with partial state +- [ ] `destroy()` method implemented if cleanup needed +- [ ] Subscriptions set up in constructor +- [ ] Resources cleaned up in `destroy()` + +### Documentation + +- [ ] JSDoc comments on public methods +- [ ] Complex logic explained with inline comments +- [ ] State properties documented +- [ ] Type definitions are clear and complete + +## References + +- [MetaMask Controller Guidelines](https://github.com/MetaMask/core/blob/main/docs/controller-guidelines.md) +- [Redux Style Guide](https://redux.js.org/style-guide/) +- [Reselect Documentation](https://github.com/reduxjs/reselect) diff --git a/domains/coding/skills/controller-guidelines/skill.md b/domains/coding/skills/controller-guidelines/skill.md new file mode 100644 index 0000000..176ca11 --- /dev/null +++ b/domains/coding/skills/controller-guidelines/skill.md @@ -0,0 +1,4 @@ +--- +name: controller-guidelines +description: BaseController development patterns +--- diff --git a/domains/coding/skills/deeplink-handler/repos/metamask-mobile.md b/domains/coding/skills/deeplink-handler/repos/metamask-mobile.md new file mode 100644 index 0000000..a457fa6 --- /dev/null +++ b/domains/coding/skills/deeplink-handler/repos/metamask-mobile.md @@ -0,0 +1,380 @@ +--- +repo: metamask-mobile +parent: deeplink-handler +--- + + +# Creating New Deeplink Handlers + +This guide walks you through adding new deeplink handlers to MetaMask Mobile. Follow these steps exactly to ensure your handler integrates correctly with the existing architecture. + +## Quick Reference + +The **`deposit` deeplink** (`metamask://deposit`, `/deposit`) is **deprecated** and not handled; do not add handlers under Ramp Deposit `deeplink/` for it. + +| File | Purpose | +|------|---------| +| `app/constants/deeplinks.ts` | Define action constants | +| `app/core/DeeplinkManager/handlers/legacy/handleUniversalLink.ts` | Add to SUPPORTED_ACTIONS enum and switch statement | +| `app/core/DeeplinkManager/handlers/legacy/handleYourAction.ts` | Your handler implementation | +| `app/core/DeeplinkManager/handlers/legacy/__tests__/handleYourAction.test.ts` | Unit tests | + +## Step 1: Define the Action Constant + +Add your action to the `ACTIONS` enum in `app/constants/deeplinks.ts`: + +```typescript +export enum ACTIONS { + // ... existing actions + YOUR_NEW_ACTION = 'your-new-action', // URL path: /your-new-action +} +``` + +**Important**: The enum value becomes the URL path segment (e.g., `https://link.metamask.io/your-new-action`). + +Also add a prefix entry if needed: + +```typescript +export const PREFIXES = { + // ... existing prefixes + [ACTIONS.YOUR_NEW_ACTION]: '', +}; +``` + +## Step 2: Create the Handler File + +Create `app/core/DeeplinkManager/handlers/legacy/handleYourAction.ts`: + +```typescript +import NavigationService from '../../../NavigationService'; +import Routes from '../../../../constants/navigation/Routes'; +import DevLogger from '../../../SDKConnect/utils/DevLogger'; + +interface HandleYourActionParams { + actionPath: string; +} + +/** + * Interface for parsed navigation parameters + * Document all supported URL parameters here + */ +interface YourActionNavigationParams { + someParam?: string; + anotherParam?: string; +} + +/** + * Parse URL parameters into typed navigation parameters + */ +const parseNavigationParams = ( + actionPath: string, +): YourActionNavigationParams => { + const urlParams = new URLSearchParams( + actionPath.includes('?') ? actionPath.split('?')[1] : '', + ); + + return { + someParam: urlParams.get('someParam') || undefined, + anotherParam: urlParams.get('anotherParam') || undefined, + }; +}; + +/** + * Your action deeplink handler + * + * Supported URL formats: + * - https://link.metamask.io/your-new-action + * - https://link.metamask.io/your-new-action?someParam=value + * - https://link.metamask.io/your-new-action?someParam=value&anotherParam=value2 + * + * @param params Object containing the action path + */ +export const handleYourAction = async ({ + actionPath, +}: HandleYourActionParams) => { + DevLogger.log( + '[handleYourAction] Starting deeplink handling with path:', + actionPath, + ); + + try { + const navParams = parseNavigationParams(actionPath); + DevLogger.log('[handleYourAction] Parsed parameters:', navParams); + + // Navigate to your screen with parsed parameters + NavigationService.navigation.navigate(Routes.YOUR_SCREEN, { + someParam: navParams.someParam, + anotherParam: navParams.anotherParam, + }); + } catch (error) { + DevLogger.log('Failed to handle your action deeplink:', error); + // Fallback navigation on error - typically wallet home + NavigationService.navigation.navigate(Routes.WALLET.HOME); + } +}; +``` + +### Handler Pattern Requirements + +1. **Always use `DevLogger.log`** for debugging (not `console.log`) +2. **Always wrap in try/catch** with fallback navigation +3. **Define TypeScript interfaces** for params and navigation +4. **Document supported URL formats** in TSDoc comments +5. **Parse URLSearchParams** from the path (it includes the `?` prefix) + +## Step 3: Add to SUPPORTED_ACTIONS + +In `app/core/DeeplinkManager/handlers/legacy/handleUniversalLink.ts`: + +### 3a. Import your handler + +```typescript +import { handleYourAction } from './handleYourAction'; +``` + +### 3b. Add to SUPPORTED_ACTIONS enum + +```typescript +enum SUPPORTED_ACTIONS { + // ... existing actions + YOUR_NEW_ACTION = ACTIONS.YOUR_NEW_ACTION, +} +``` + +### 3c. Add switch case (CRITICAL!) + +Add a case in the switch statement (around line 254+): + +```typescript +switch (action) { + // ... existing cases + case SUPPORTED_ACTIONS.YOUR_NEW_ACTION: { + handleYourAction({ + actionPath: actionBasedRampPath, + }); + break; + } +} +``` + +> ⚠️ **CRITICAL**: Forgetting this step is a common bug! Your action will be in SUPPORTED_ACTIONS (passing validation) but will silently do nothing without the switch case. + +## Step 4: Write Unit Tests + +Create `app/core/DeeplinkManager/handlers/legacy/__tests__/handleYourAction.test.ts`: + +```typescript +import { handleYourAction } from '../handleYourAction'; +import NavigationService from '../../../../NavigationService'; +import Routes from '../../../../../constants/navigation/Routes'; + +jest.mock('../../../../NavigationService', () => ({ + navigation: { + navigate: jest.fn(), + }, +})); + +jest.mock('../../../../SDKConnect/utils/DevLogger', () => ({ + log: jest.fn(), +})); + +describe('handleYourAction', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('navigates to YOUR_SCREEN with no parameters', async () => { + await handleYourAction({ actionPath: '' }); + + expect(NavigationService.navigation.navigate).toHaveBeenCalledWith( + Routes.YOUR_SCREEN, + expect.objectContaining({}), + ); + }); + + it('navigates with someParam parameter', async () => { + await handleYourAction({ actionPath: '?someParam=testValue' }); + + expect(NavigationService.navigation.navigate).toHaveBeenCalledWith( + Routes.YOUR_SCREEN, + expect.objectContaining({ + someParam: 'testValue', + }), + ); + }); + + it('navigates with multiple parameters', async () => { + await handleYourAction({ + actionPath: '?someParam=value1&anotherParam=value2' + }); + + expect(NavigationService.navigation.navigate).toHaveBeenCalledWith( + Routes.YOUR_SCREEN, + expect.objectContaining({ + someParam: 'value1', + anotherParam: 'value2', + }), + ); + }); + + it('falls back to WALLET.HOME on error', async () => { + // Force an error by mocking navigate to throw + (NavigationService.navigation.navigate as jest.Mock) + .mockImplementationOnce(() => { + throw new Error('Navigation failed'); + }); + + await handleYourAction({ actionPath: '?someParam=test' }); + + // Second call should be fallback + expect(NavigationService.navigation.navigate).toHaveBeenLastCalledWith( + Routes.WALLET.HOME, + ); + }); +}); +``` + +Run tests with: `yarn jest app/core/DeeplinkManager/handlers/legacy/__tests__/handleYourAction.test.ts --no-coverage` + +## Step 5: Optional Configurations + +### Whitelist Action (Skip Interstitial Modal) + +If your action needs to bypass the security interstitial (like WalletConnect does): + +```typescript +// In handleUniversalLink.ts +const WHITELISTED_ACTIONS: SUPPORTED_ACTIONS[] = [ + SUPPORTED_ACTIONS.WC, + SUPPORTED_ACTIONS.ENABLE_CARD_BUTTON, + SUPPORTED_ACTIONS.YOUR_NEW_ACTION, // Add here +]; +``` + +> ⚠️ **Security Warning**: Only whitelist actions that have their own security mechanisms or don't access sensitive data. + +### Whitelist Specific URLs + +If only specific URL patterns should bypass the interstitial: + +```typescript +// In handleUniversalLink.ts +const interstitialWhitelistUrls = [ + `${PROTOCOLS.HTTPS}://${AppConstants.MM_IO_UNIVERSAL_LINK_HOST}/${SUPPORTED_ACTIONS.PERPS_ASSET}`, + `${PROTOCOLS.HTTPS}://${AppConstants.MM_IO_UNIVERSAL_LINK_HOST}/${SUPPORTED_ACTIONS.YOUR_NEW_ACTION}`, +] as const; +``` + +### Handle in metamask:// Protocol + +If your action should also work with `metamask://your-new-action`, it will automatically work since `parseDeeplink.ts` converts `metamask://` to `https://link.metamask.io/`. + +## Common Pitfalls + +### ❌ Missing Switch Case + +```typescript +// Action is in SUPPORTED_ACTIONS but NO case handler +enum SUPPORTED_ACTIONS { + YOUR_ACTION = ACTIONS.YOUR_ACTION, // ✅ Defined +} + +switch (action) { + // ❌ No case for YOUR_ACTION - silently does nothing! +} +``` + +### ❌ Wrong Path Parsing + +```typescript +// WRONG - actionPath already includes the ? +const urlParams = new URLSearchParams(actionPath); + +// CORRECT - split on ? first +const urlParams = new URLSearchParams( + actionPath.includes('?') ? actionPath.split('?')[1] : '', +); +``` + +### ❌ Forgetting Fallback Navigation + +```typescript +// WRONG - no try/catch +export const handleYourAction = async ({ actionPath }) => { + const params = parseParams(actionPath); // Could throw! + NavigationService.navigation.navigate(Routes.YOUR_SCREEN, params); +}; + +// CORRECT - always have fallback +export const handleYourAction = async ({ actionPath }) => { + try { + const params = parseParams(actionPath); + NavigationService.navigation.navigate(Routes.YOUR_SCREEN, params); + } catch (error) { + DevLogger.log('Failed:', error); + NavigationService.navigation.navigate(Routes.WALLET.HOME); + } +}; +``` + +### ❌ Using console.log Instead of DevLogger + +```typescript +// WRONG - won't appear in dev builds correctly +console.log('handling deeplink'); + +// CORRECT +DevLogger.log('[handleYourAction] handling deeplink'); +``` + +## Testing Deeplinks Manually + +### iOS Simulator + +```bash +xcrun simctl openurl booted "https://link-test.metamask.io/your-new-action?someParam=test" +``` + +### Android Emulator + +```bash +adb shell am start -W -a android.intent.action.VIEW \ + -d "https://link-test.metamask.io/your-new-action?someParam=test" \ + io.metamask.debug +``` + +### Using metamask:// Scheme + +```bash +# iOS +xcrun simctl openurl booted "metamask://your-new-action?someParam=test" + +# Android +adb shell am start -W -a android.intent.action.VIEW \ + -d "metamask://your-new-action?someParam=test" \ + io.metamask.debug +``` + +## Checklist Before PR + +- [ ] Action added to `ACTIONS` enum in `app/constants/deeplinks.ts` +- [ ] Prefix added to `PREFIXES` (if needed) +- [ ] Handler file created in `handlers/legacy/` +- [ ] Handler imported in `handleUniversalLink.ts` +- [ ] Action added to `SUPPORTED_ACTIONS` enum +- [ ] Switch case added for action (most commonly forgotten!) +- [ ] Unit tests written and passing +- [ ] Documentation updated in `docs/readme/deeplinking.md` +- [ ] Test URLs documented in `docs/deeplink-test-urls.md` + +## References + +- [Main Deeplink Documentation](../../docs/readme/deeplinking.md) +- [Visual Flow Diagrams](../../docs/readme/deeplinking-diagrams.md) +- [Test URLs Reference](../../docs/deeplink-test-urls.md) +- [Deeplink Constants](../../app/constants/deeplinks.ts) +- [Universal Link Handler](../../app/core/DeeplinkManager/handlers/legacy/handleUniversalLink.ts) + +@app/constants/deeplinks.ts +@app/core/DeeplinkManager/handlers/legacy/handleUniversalLink.ts +@docs/readme/deeplinking.md diff --git a/domains/coding/skills/deeplink-handler/skill.md b/domains/coding/skills/deeplink-handler/skill.md new file mode 100644 index 0000000..84437b1 --- /dev/null +++ b/domains/coding/skills/deeplink-handler/skill.md @@ -0,0 +1,4 @@ +--- +name: deeplink-handler +description: Deeplink handler creation guidelines +--- diff --git a/domains/general/skills/codex/skill.md b/domains/general/skills/codex/skill.md new file mode 100644 index 0000000..3708011 --- /dev/null +++ b/domains/general/skills/codex/skill.md @@ -0,0 +1,42 @@ +--- +name: codex +description: Use when the user asks to run Codex CLI (codex exec, codex resume) or references OpenAI Codex for code analysis, refactoring, or automated editing +maturity: stable +--- + +# Codex Skill Guide + +## Running a Task +1. Ask the user (via `AskUserQuestion`) which model to run (`gpt-5.2-codex` or `gpt-5.2`) AND which reasoning effort to use (`xhigh`, `high`, `medium`, or `low`) in a **single prompt with two questions**. +2. Select the sandbox mode required for the task; default to `--sandbox read-only` unless edits or network access are necessary. +3. Assemble the command with the appropriate options: + - `-m, --model ` + - `--config model_reasoning_effort=""` + - `--sandbox ` + - `--full-auto` + - `-C, --cd ` + - `--skip-git-repo-check` +3. Always use --skip-git-repo-check. +4. When continuing a previous session, use `codex exec --skip-git-repo-check resume --last` via stdin. When resuming don't use any configuration flags unless explicitly requested by the user e.g. if he species the model or the reasoning effort when requesting to resume a session. Resume syntax: `echo "your prompt here" | codex exec --skip-git-repo-check resume --last 2>/dev/null`. All flags have to be inserted between exec and resume. +5. **IMPORTANT**: By default, append `2>/dev/null` to all `codex exec` commands to suppress thinking tokens (stderr). Only show stderr if the user explicitly requests to see thinking tokens or if debugging is needed. +6. Run the command, capture stdout/stderr (filtered as appropriate), and summarize the outcome for the user. +7. **After Codex completes**, inform the user: "You can resume this Codex session at any time by saying 'codex resume' or asking me to continue with additional analysis or changes." + +### Quick Reference +| Use case | Sandbox mode | Key flags | +| --- | --- | --- | +| Read-only review or analysis | `read-only` | `--sandbox read-only 2>/dev/null` | +| Apply local edits | `workspace-write` | `--sandbox workspace-write --full-auto 2>/dev/null` | +| Permit network or broad access | `danger-full-access` | `--sandbox danger-full-access --full-auto 2>/dev/null` | +| Resume recent session | Inherited from original | `echo "prompt" \| codex exec --skip-git-repo-check resume --last 2>/dev/null` (no flags allowed) | +| Run from another directory | Match task needs | `-C ` plus other flags `2>/dev/null` | + +## Following Up +- After every `codex` command, immediately use `AskUserQuestion` to confirm next steps, collect clarifications, or decide whether to resume with `codex exec resume --last`. +- When resuming, pipe the new prompt via stdin: `echo "new prompt" | codex exec resume --last 2>/dev/null`. The resumed session automatically uses the same model, reasoning effort, and sandbox mode from the original session. +- Restate the chosen model, reasoning effort, and sandbox mode when proposing follow-up actions. + +## Error Handling +- Stop and report failures whenever `codex --version` or a `codex exec` command exits non-zero; request direction before retrying. +- Before you use high-impact flags (`--full-auto`, `--sandbox danger-full-access`, `--skip-git-repo-check`) ask the user for permission using AskUserQuestion unless it was already given. +- When output includes warnings or partial results, summarize them and ask how to adjust using `AskUserQuestion`. diff --git a/domains/general/skills/gemini/skill.md b/domains/general/skills/gemini/skill.md new file mode 100644 index 0000000..84d0fe5 --- /dev/null +++ b/domains/general/skills/gemini/skill.md @@ -0,0 +1,214 @@ +--- +name: gemini +description: Use when the user asks to run Gemini CLI for code review, plan review, or big context (>200k) processing. Ideal for comprehensive analysis requiring large context windows. Uses Gemini 3 Pro by default for state-of-the-art reasoning and coding. +maturity: stable +--- + +# Gemini Skill Guide + +## When to Use Gemini +- WHEN ASKED TO BE ACTIVATED +- **Code Review**: Comprehensive code reviews across multiple files +- **Plan Review**: Analyzing architectural plans, technical specifications, or project roadmaps +- **Big Context Processing**: Tasks requiring >200k tokens of context (entire codebases, documentation sets) +- **Multi-file Analysis**: Understanding relationships and patterns across many files + +## Critical: Background/Non-Interactive Mode Warning + +**NEVER use `--approval-mode default` in background or non-interactive shells** (like Claude Code tool calls). It will hang indefinitely waiting for approval prompts that cannot be provided. + +**For automated/background reviews:** +- Use `--approval-mode yolo` for fully automated execution +- OR wrap with timeout: `timeout 300 gemini ...` +- NEVER use `--approval-mode default` without interactive terminal + +**Symptoms of hung Gemini:** +- Process running 20+ minutes with 0% CPU usage +- No network activity +- Process state shows 'S' (sleeping) + +**Fix hung process:** +```bash +# Check if hung +ps aux | grep gemini | grep -v grep + +# Kill if necessary +pkill -9 -f "gemini.*gemini-3-pro-preview" +``` + +## Running a Task + +1. Ask the user (via `AskUserQuestion`) which model to use in a **single prompt**. Available models: + - `gemini-3-pro-preview` (flagship model, best for coding & complex reasoning, 35% better at software engineering than 2.5 Pro) + - `gemini-3-flash-preview` (sub-second latency, distilled from 3 Pro, best for speed-critical tasks) + +2. Select the approval mode based on the task: + - `default`: Prompt for approval (ONLY for interactive terminal sessions) + - `auto_edit`: Auto-approve edit tools only (for code reviews with suggestions) + - `yolo`: Auto-approve all tools (REQUIRED for background/automated tasks) + +3. Assemble the command with appropriate options: + - `-m, --model ` - Model selection + - `--approval-mode ` - Control tool approval + - `-y, --yolo` - Alternative to `--approval-mode yolo` + - `-i, --prompt-interactive "prompt"` - Execute prompt and continue interactively + - `--include-directories ` - Additional directories to include in workspace + - `-s, --sandbox` - Run in sandbox mode for isolation + +4. **For background/automated tasks, ALWAYS use `--approval-mode yolo`** or add timeout wrapper. NEVER use `default` in non-interactive shells. + +5. Run the command and capture the output. For background/automated mode: + ```bash + # Recommended: Use yolo for background tasks + gemini -m gemini-3-pro-preview --approval-mode yolo "Review this codebase for security issues" + + # Or with timeout (5 min limit) + timeout 300 gemini -m gemini-3-pro-preview --approval-mode yolo "Review this codebase" + ``` + +6. For interactive sessions with an initial prompt: + ```bash + gemini -m gemini-3-pro-preview -i "Review the authentication system" --approval-mode auto_edit + ``` + +7. **After Gemini completes**, inform the user: "The Gemini analysis is complete. You can start a new Gemini session for follow-up analysis or continue exploring the findings." + +### Quick Reference + +| Use case | Approval mode | Key flags | +| --- | --- | --- | +| Background code review | `yolo` | `-m gemini-3-pro-preview --approval-mode yolo` | +| Background analysis | `yolo` | `-m gemini-3-pro-preview --approval-mode yolo` | +| Background with timeout | `yolo` | `timeout 300 gemini -m gemini-3-pro-preview --approval-mode yolo` | +| Interactive code review | `default` | `-m gemini-3-pro-preview --approval-mode default` (interactive terminal only) | +| Code review with auto-edits | `auto_edit` | `-m gemini-3-pro-preview --approval-mode auto_edit` | +| Automated refactoring | `yolo` | `-m gemini-3-pro-preview --approval-mode yolo` | +| Speed-critical background | `yolo` | `-m gemini-3-flash-preview --approval-mode yolo` | +| Multi-directory analysis | `yolo` (if background) | `--include-directories --include-directories ` | +| Interactive with prompt | `auto_edit` or `default` | `-i "prompt" --approval-mode ` | + +### Model Selection Guide + +| Model | Best for | Context window | Key features | +| --- | --- | --- | --- | +| `gemini-3-pro-preview` | **Flagship model**: Complex reasoning, coding, agentic tasks | 1M input / 64k output | Vibe coding, 76.2% SWE-bench, $2-4/M input | +| `gemini-3-flash-preview` | Sub-second latency, speed-critical applications | 1M input / 64k output | Distilled from 3 Pro, TPU-optimized | + +**Gemini 3 Advantages**: 35% higher accuracy in software engineering, state-of-the-art on SWE-bench (76.2%), GPQA Diamond (91.9%), and WebDev Arena (1487 Elo). Knowledge cutoff: January 2025. + +## Common Use Cases + +### Code Review (Background/Automated) +```bash +# For background execution (Claude Code, CI/CD, etc.) +gemini -m gemini-3-pro-preview --approval-mode yolo \ + "Perform a comprehensive code review focusing on: + 1. Security vulnerabilities + 2. Performance issues + 3. Code quality and maintainability + 4. Best practices violations" + +# With timeout safety (5 minutes) +timeout 300 gemini -m gemini-3-pro-preview --approval-mode yolo \ + "Perform a comprehensive code review..." +``` + +### Plan Review (Background/Automated) +```bash +# For background execution +gemini -m gemini-3-pro-preview --approval-mode yolo \ + "Review this architectural plan for: + 1. Scalability concerns + 2. Missing components + 3. Integration challenges + 4. Alternative approaches" +``` + +### Big Context Analysis (Background/Automated) +```bash +# For background execution +gemini -m gemini-3-pro-preview --approval-mode yolo \ + "Analyze the entire codebase to understand: + 1. Overall architecture + 2. Key patterns and conventions + 3. Potential technical debt + 4. Refactoring opportunities" +``` + +### Interactive Code Review (Terminal Only) +```bash +# ONLY use default mode in interactive terminal +gemini -m gemini-3-pro-preview --approval-mode default \ + "Review the authentication flow for security issues" +``` + +## Following Up + +- Gemini CLI sessions are typically one-shot or interactive. Unlike Codex, there's no built-in resume functionality. +- For follow-up analysis, start a new Gemini session with context from previous findings. +- When proposing follow-up actions, restate the chosen model and approval mode. +- Use `AskUserQuestion` after each Gemini command to confirm next steps or gather clarifications. + +## Error Handling + +- Stop and report failures whenever `gemini --version` or a Gemini command exits non-zero. +- Request direction before retrying failed commands. +- Before using high-impact flags (`--approval-mode yolo`, `-y`, `--sandbox`), ask the user for permission using `AskUserQuestion` unless already granted. +- When output includes warnings or partial results, summarize them and ask how to adjust using `AskUserQuestion`. + +## Troubleshooting Hung Gemini Processes + +### Detection +```bash +# Check for hung processes +ps aux | grep -E "gemini.*gemini-3" | grep -v grep + +# Look for these symptoms: +# - Process running 20+ minutes +# - CPU usage at 0% +# - Process state 'S' (sleeping) +# - No network connections +``` + +### Diagnosis +```bash +# Get detailed process info +ps -o pid,etime,pcpu,stat,command -p + +# Check network activity +lsof -p 2>/dev/null | grep -E "(TCP|ESTABLISHED)" | wc -l +# If result is 0, process is hung +``` + +### Resolution +```bash +# Kill hung Gemini processes +pkill -9 -f "gemini.*gemini-3-pro-preview" + +# Or kill specific PID +kill -9 + +# Verify cleanup +ps aux | grep gemini | grep -v grep +``` + +### Prevention +- **ALWAYS use `--approval-mode yolo` for background/automated tasks** +- Add timeout wrapper for safety: `timeout 300 gemini ...` +- Never use `--approval-mode default` in non-interactive shells +- Monitor first run with `ps` to ensure process completes + +## Tips for Large Context Processing + +1. **Be specific**: Provide clear, structured prompts for what to analyze +2. **Use include-directories**: Explicitly specify all relevant directories +3. **Choose the right model**: + - Use `gemini-3-pro-preview` for complex reasoning, coding tasks, and maximum analysis quality (recommended default) + - Use `gemini-3-flash-preview` for speed-critical tasks requiring sub-second response times +4. **Leverage Gemini 3's strengths**: 35% better at software engineering tasks, exceptional at agentic workflows and vibe coding +5. **Break down complex tasks**: Even with large context, structured analysis is more effective +6. **Save findings**: Ask Gemini to output structured reports that can be saved for reference + +## CLI Version + +Requires Gemini CLI v0.16.0 or later for Gemini 3 model support. Check version: `gemini --version` diff --git a/domains/performance/skills/perf-hooks-effects/repos/metamask-extension.md b/domains/performance/skills/perf-hooks-effects/repos/metamask-extension.md new file mode 100644 index 0000000..349d952 --- /dev/null +++ b/domains/performance/skills/perf-hooks-effects/repos/metamask-extension.md @@ -0,0 +1,936 @@ +--- +repo: metamask-extension +parent: perf-hooks-effects +--- + + +# Front-End Performance Rules: Hooks & Effects + +This file covers React hooks and effects optimization rules including useEffect best practices, dependency management, cleanup patterns, and preventing cascading re-renders. + +### Rule: Don't Overuse useEffect + +**DO:** + +- Calculate derived state during render instead of using useEffect +- Only use useEffect for side effects (data fetching, DOM manipulation, subscriptions) + +**DON'T:** + +- Use useEffect for derived state that can be calculated during render + +**Reference:** See: You Might Not Need an Effect + +**Example - WRONG:** + +```typescript +const TokenDisplay = ({ token }: TokenDisplayProps) => { + const [displayName, setDisplayName] = useState(''); + + useEffect(() => { + setDisplayName(`${token.symbol} (${token.name})`); + }, [token]); + + return
{displayName}
; +}; +``` + +**Example - CORRECT:** + +```typescript +const TokenDisplay = ({ token }: TokenDisplayProps) => { + const displayName = `${token.symbol} (${token.name})`; + return
{displayName}
; +}; +``` + +### Rule: Minimize useEffect Dependencies + +**DO:** + +- Reduce dependencies by moving values to default parameters when possible +- Only include dependencies that actually trigger the effect + +**DON'T:** + +- Include unnecessary dependencies that cause effects to run too often + +**Example - WRONG:** + +```typescript +const TokenBalance = ({ address, network, refreshInterval }: Props) => { + const [balance, setBalance] = useState('0'); + + useEffect(() => { + const fetch = async () => { + const result = await fetchBalance(address, network); + setBalance(result); + }; + + fetch(); + const interval = setInterval(fetch, refreshInterval); + return () => clearInterval(interval); + }, [address, network, refreshInterval]); // Effect runs too often +}; +``` + +**Example - CORRECT:** + +```typescript +const TokenBalance = ({ address, network, refreshInterval = 10000 }: Props) => { + const [balance, setBalance] = useState('0'); + + useEffect(() => { + const fetch = async () => { + const result = await fetchBalance(address, network); + setBalance(result); + }; + + fetch(); + const interval = setInterval(fetch, refreshInterval); + return () => clearInterval(interval); + }, [address, network]); // refreshInterval moved to default param +}; +``` + +### Rule: Never Use JSON.stringify in useEffect Dependencies + +**DO:** + +- Use useEqualityCheck hook for deep equality checks (recommended) +- Use useRef with deep equality check in effect +- Normalize to stable primitives when possible +- Use createDeepEqualSelector for Redux selectors + +**DON'T:** + +- Use JSON.stringify in dependencies (expensive, unreliable, breaks with functions/circular refs) +- Use useMemo with JSON.stringify (defeats purpose) + +**Why:** JSON.stringify executes on every render, string comparison is slower than reference comparison, creates new string objects defeating memoization, can cause infinite loops, and doesn't handle circular references or functions. + +**When You Need Deep Equality:** + +- Nested properties of an object change (deep equality) +- Array elements change (deep equality) +- Object reference changes but values are the same (should NOT trigger) + +**Example - WRONG:** + +```typescript +const usePolling = (input: PollingInput) => { + useEffect(() => { + startPolling(input); + }, [input && JSON.stringify(input)]); // Expensive! Runs every render +}; +``` + +**Example - CORRECT: Option 1 - Use useEqualityCheck hook (Recommended)** + +```typescript +import { useEqualityCheck } from './hooks/useEqualityCheck'; +import { isEqual } from 'lodash'; + +const usePolling = (input: PollingInput) => { + const stableInput = useEqualityCheck(input, isEqual); + + useEffect(() => { + startPolling(stableInput); + }, [stableInput]); // Only triggers when deep values actually change +}; +``` + +**Example - CORRECT: Option 2 - useRef with deep equality check** + +```typescript +import { isEqual } from 'lodash'; + +const usePolling = (input: PollingInput) => { + const inputRef = useRef(input); + + useEffect(() => { + // Only execute if deep values changed + if (!isEqual(input, inputRef.current)) { + inputRef.current = input; + startPolling(input); + } + }, [input]); +}; +``` + +**Example - CORRECT: Option 3 - Normalize to stable primitives** + +```typescript +const usePolling = (input: PollingInput) => { + const inputId = useMemo(() => input.id, [input.id]); + const inputChainId = useMemo(() => input.chainId, [input.chainId]); + const inputRpcUrl = useMemo(() => input.rpcUrl, [input.rpcUrl]); + + useEffect(() => { + startPolling(input); + }, [inputId, inputChainId, inputRpcUrl]); // Only depends on primitives +}; +``` + +**When to Use Each Approach:** + +| Approach | Use When | Pros | Cons | +| ----------------------- | ------------------------------------------- | ------------------------------------ | -------------------- | +| useEqualityCheck | Objects/arrays from props or external state | Simple, reusable, handles edge cases | Requires hook import | +| useRef + isEqual | One-off cases, custom logic needed | Full control, no extra hook | More boilerplate | +| Normalize to primitives | Can extract stable IDs/values | Most performant, clear dependencies | Not always possible | + +**Key Principles:** + +- Use deep equality when object references change frequently but values don't +- Prefer useEqualityCheck hook - Already implemented in codebase +- Normalize when possible - Extract stable primitives (IDs, strings, numbers) +- Never use JSON.stringify +- Don't skip dependencies - Always include dependencies, use deep equality to stabilize them + +### Rule: Include All Dependencies in useEffect + +**DO:** + +- Include all values used in the effect in the dependency array +- Use useRef with a flag if you truly only want to track once + +**DON'T:** + +- Use empty dependency arrays when values from closure are used (creates stale closures) +- Skip dependencies to avoid re-running effects + +**Example - WRONG:** + +```typescript +const Name = ({ type, name }: NameProps) => { + useEffect(() => { + trackEvent({ + properties: { + petname_category: type, // Uses 'type' from closure + has_petname: Boolean(name?.length), // Uses 'name' from closure + }, + }); + }, []); // Empty deps - 'type' and 'name' are stale! +}; +``` + +**Example - CORRECT:** + +```typescript +const Name = ({ type, name }: NameProps) => { + useEffect(() => { + trackEvent({ + properties: { + petname_category: type, + has_petname: Boolean(name?.length), + }, + }); + }, [type, name]); // Include all dependencies + + // OR if you truly only want to track once: + const hasTrackedRef = useRef(false); + useEffect(() => { + if (!hasTrackedRef.current) { + trackEvent({ + properties: { + petname_category: type, + has_petname: Boolean(name?.length), + }, + }); + hasTrackedRef.current = true; + } + }, [type, name]); +}; +``` + +### Rule: Include All Dependencies in useMemo/useCallback + +**DO:** + +- Always include all dependencies in useMemo/useCallback dependency arrays +- Use ESLint rule react-hooks/exhaustive-deps to catch missing dependencies + +**DON'T:** + +- Skip dependencies (causes stale closures and bugs) + +**Example - WRONG:** + +```typescript +const TokenList = ({ tokens, filter }: TokenListProps) => { + const filteredTokens = useMemo(() => { + return tokens.filter((token) => token.symbol.includes(filter)); + }, [tokens]); // Missing filter dependency! +}; +``` + +**Example - CORRECT:** + +```typescript +const TokenList = ({ tokens, filter }: TokenListProps) => { + const filteredTokens = useMemo(() => { + return tokens.filter((token) => token.symbol.includes(filter)); + }, [tokens, filter]); // All dependencies included +}; +``` + +### Rule: Avoid Cascading useEffect Chains + +**DO:** + +- Combine effects or compute during render using useMemo +- Use single effect for async operations + +**DON'T:** + +- Create multiple effects where one sets state that triggers another (causes unnecessary re-renders) + +**Example - WRONG:** + +```typescript +const useHistoricalPrices = () => { + const [prices, setPrices] = useState([]); + const [metadata, setMetadata] = useState(null); + + // First effect fetches and updates Redux + useEffect(() => { + fetchPrices(); + const intervalId = setInterval(fetchPrices, 60000); + return () => clearInterval(intervalId); + }, [chainId, address]); + + // Second effect responds to Redux state change + useEffect(() => { + const pricesToSet = historicalPricesNonEvm?.[address]?.intervals ?? []; + setPrices(pricesToSet); // Triggers third effect + }, [historicalPricesNonEvm, address]); + + // Third effect depends on state from second effect + useEffect(() => { + const metadataToSet = deriveMetadata(prices); + setMetadata(metadataToSet); + }, [prices]); +}; +``` + +**Example - CORRECT:** + +```typescript +const useHistoricalPrices = () => { + // Compute prices during render from Redux state + const prices = useMemo(() => { + return historicalPricesNonEvm?.[address]?.intervals ?? []; + }, [historicalPricesNonEvm, address]); + + // Compute metadata during render from prices + const metadata = useMemo(() => { + return deriveMetadata(prices); + }, [prices]); + + // Single effect for async operations + useEffect(() => { + fetchPrices(); + const intervalId = setInterval(fetchPrices, 60000); + return () => clearInterval(intervalId); + }, [chainId, address]); + + return { prices, metadata }; +}; +``` + +### Rule: Avoid Conditional Early Returns with All Dependencies + +**DO:** + +- Split effects when conditional logic excludes some dependencies +- Ensure all dependencies in array are actually used + +**DON'T:** + +- Include dependencies that aren't used when condition is true + +**Example - WRONG:** + +```typescript +const useHistoricalPrices = ({ isEvm, chainId, address }: Props) => { + useEffect(() => { + if (isEvm) { + return; // Early return + } + // Only uses chainId and address when not EVM + fetchPrices(chainId, address); + }, [isEvm, chainId, address]); // Includes all deps even when unused +}; +``` + +**Example - CORRECT:** + +```typescript +// Option 1: Split effects +const useHistoricalPrices = ({ isEvm, chainId, address }: Props) => { + useEffect(() => { + if (!isEvm) { + fetchPrices(chainId, address); + } + }, [isEvm, chainId, address]); // All deps are used + + // OR Option 2: Separate effects + useEffect(() => { + if (isEvm) return; + fetchPrices(chainId, address); + }, [isEvm]); // Only depends on condition + + useEffect(() => { + if (!isEvm) { + fetchPrices(chainId, address); + } + }, [chainId, address]); // Only when not EVM +}; +``` + +### Rule: Use useRef for Persistent Values + +**DO:** + +- Use useRef for values that need to persist across renders +- Use refs for mounted flags, intervals, and other persistent state + +**DON'T:** + +- Use regular variables for values that need to persist (they get reset on every render) + +**Example - WRONG:** + +```typescript +const usePolling = (input: PollingInput) => { + let isMounted = false; // Gets reset every render! + + useEffect(() => { + isMounted = true; + startPolling(input); + + return () => { + isMounted = false; + }; + }, [input]); +}; +``` + +**Example - CORRECT:** + +```typescript +const usePolling = (input: PollingInput) => { + const isMountedRef = useRef(false); + + useEffect(() => { + isMountedRef.current = true; + startPolling(input); + + return () => { + isMountedRef.current = false; + }; + }, [input]); +}; +``` + +### Rule: Always Call Hooks Unconditionally + +**DO:** + +- Always call hooks in the same order on every render +- Use conditional logic inside hooks, not conditional hook calls + +**DON'T:** + +- Call hooks conditionally (breaks Rules of Hooks) +- Create hooks dynamically or in loops + +**Example - WRONG:** + +```typescript +const TokenDisplay = ({ token, showDetails }: TokenDisplayProps) => { + const [balance, setBalance] = useState('0'); + + if (showDetails) { + // ⚠️ Hook called conditionally - breaks Rules of Hooks! + const [metadata, setMetadata] = useState(null); + useEffect(() => { + fetchMetadata(token.id).then(setMetadata); + }, [token.id]); + } + + return
{balance}
; +}; +``` + +**Example - CORRECT:** + +```typescript +const TokenDisplay = ({ token, showDetails }: TokenDisplayProps) => { + const [balance, setBalance] = useState('0'); + const [metadata, setMetadata] = useState(null); + + useEffect(() => { + if (showDetails) { + fetchMetadata(token.id).then(setMetadata); + } + }, [token.id, showDetails]); + + return ( +
+
Balance: {balance}
+ {showDetails && metadata &&
Metadata: {metadata.name}
} +
+ ); +}; +``` + +**Example - WRONG: Dynamic hook creation** + +```typescript +const AssetList = ({ assets }: AssetListProps) => { + // ⚠️ Number of hooks changes based on assets.length! + const balances = assets.map((asset) => { + const [balance, setBalance] = useState('0'); // Wrong! + useEffect(() => { + fetchBalance(asset.id).then(setBalance); + }, [asset.id]); + return balance; + }); +}; +``` + +**Example - CORRECT:** + +```typescript +// Option 1: Custom hook for single asset +const useAssetBalance = (assetId: string) => { + const [balance, setBalance] = useState('0'); + + useEffect(() => { + fetchBalance(assetId).then(setBalance); + }, [assetId]); + + return balance; +}; + +const AssetList = ({ assets }: AssetListProps) => { + return ( +
+ {assets.map(asset => ( + + ))} +
+ ); +}; + +// Option 2: Component with its own hooks +const AssetItem = ({ asset }: { asset: Asset }) => { + const balance = useAssetBalance(asset.id); + return
{asset.name}: {balance}
; +}; +``` + +### Rule: Prevent Cascading Re-renders from Hook Dependencies + +**DO:** + +- Use selectors and memoization to break re-render chains +- Isolate hook dependencies by extracting stable values +- Use component composition to prevent unnecessary re-renders + +**DON'T:** + +- Create effects that depend on frequently changing values without memoization + +**Example - WRONG:** + +```typescript +const Dashboard = () => { + const accounts = useSelector((state) => state.accounts); // Large array + const [filteredAccounts, setFilteredAccounts] = useState([]); + + // Effect runs whenever accounts array reference changes + useEffect(() => { + const filtered = accounts.filter((a) => a.isActive); + setFilteredAccounts(filtered); // Triggers re-render + }, [accounts]); // accounts reference changes frequently + + // Another effect depends on filteredAccounts + useEffect(() => { + updateAnalytics(filteredAccounts); // Triggers another update + }, [filteredAccounts]); +}; +``` + +**Example - CORRECT:** + +```typescript +// In selectors file: +const selectAccounts = (state) => state.accounts; +const selectActiveAccounts = createSelector([selectAccounts], (accounts) => + accounts.filter((a) => a.isActive), +); + +// In component: +const Dashboard = () => { + // Selector handles memoization - only changes when accounts actually change + const activeAccounts = useSelector(selectActiveAccounts); + + // Memoize analytics update to prevent unnecessary calls + const analyticsRef = useRef(activeAccounts); + useEffect(() => { + if (analyticsRef.current !== activeAccounts) { + updateAnalytics(activeAccounts); + analyticsRef.current = activeAccounts; + } + }, [activeAccounts]); +}; +``` + +**Example - WRONG: Hook depends on frequently changing object** + +```typescript +const TokenCard = ({ token }: TokenCardProps) => { + const [formattedBalance, setFormattedBalance] = useState(''); + + useEffect(() => { + // token object reference changes frequently + setFormattedBalance(formatBalance(token.balance, token.decimals)); + }, [token]); // Re-runs too often +}; +``` + +**Example - CORRECT:** + +```typescript +const TokenCard = ({ token }: TokenCardProps) => { + // Extract primitive values that change less frequently + const balance = token.balance; + const decimals = token.decimals; + + // Calculate during render instead of effect + const formattedBalance = useMemo( + () => formatBalance(balance, decimals), + [balance, decimals] + ); + + return
{formattedBalance}
; +}; +``` + +### Rule: Use Component Composition to Prevent Re-renders + +**DO:** + +- Move state down to components that need it +- Pass children as props to prevent re-renders +- Isolate state changes to specific components + +**DON'T:** + +- Keep all state at the top level causing unnecessary re-renders + +**Example - WRONG:** + +```typescript +const Dashboard = () => { + const [count, setCount] = useState(0); + + return ( +
+ + {/* Re-renders unnecessarily! */} + {/* Re-renders unnecessarily! */} +
+ ); +}; +``` + +**Example - CORRECT: Move state down** + +```typescript +const Dashboard = () => { + return ( +
+ {/* Only this re-renders */} + + +
+ ); +}; + +const Counter = () => { + const [count, setCount] = useState(0); + return ( + + ); +}; +``` + +**Example - CORRECT: Pass children as props** + +```typescript +const Dashboard = ({ children }: { children: React.ReactNode }) => { + const [count, setCount] = useState(0); + + return ( +
+ + {children} {/* Children don't re-render! */} +
+ ); +}; + +// Usage: + + + + +``` + +### Rule: Prevent State Updates After Component Unmount + +**DO:** + +- Check mounted state before updating state in async operations +- Use cancelled flag pattern with cleanup + +**DON'T:** + +- Update state after component unmount (causes memory leaks and React warnings) + +**Example - WRONG:** + +```typescript +const TokenBalance = ({ address }: TokenBalanceProps) => { + const [balance, setBalance] = useState('0'); + + useEffect(() => { + fetchBalance(address).then((result) => { + setBalance(result); // ⚠️ May update after unmount! + }); + }, [address]); +}; +``` + +**Example - CORRECT:** + +```typescript +const TokenBalance = ({ address }: TokenBalanceProps) => { + const [balance, setBalance] = useState('0'); + + useEffect(() => { + let cancelled = false; + + const fetch = async () => { + const result = await fetchBalance(address); + if (!cancelled) { + setBalance(result); + } + }; + + fetch(); + + return () => { + cancelled = true; + }; + }, [address]); +}; +``` + +### Rule: Use AbortController for Fetch Requests + +**DO:** + +- Use AbortController to cancel fetch requests on unmount +- Check if request was aborted before updating state +- Handle AbortError appropriately + +**DON'T:** + +- Leave fetch requests running after component unmount + +**Example - WRONG:** + +```typescript +const AssetList = ({ chainId }: AssetListProps) => { + const [assets, setAssets] = useState([]); + + useEffect(() => { + fetch(`/api/assets/${chainId}`) + .then((res) => res.json()) + .then((data) => setAssets(data)); // Request continues after unmount! + }, [chainId]); +}; +``` + +**Example - CORRECT:** + +```typescript +const AssetList = ({ chainId }: AssetListProps) => { + const [assets, setAssets] = useState([]); + + useEffect(() => { + const controller = new AbortController(); + + fetch(`/api/assets/${chainId}`, { signal: controller.signal }) + .then((res) => res.json()) + .then((data) => { + if (!controller.signal.aborted) { + setAssets(data); + } + }) + .catch((error) => { + if (error.name !== 'AbortError') { + console.error('Failed to fetch assets:', error); + } + }); + + return () => { + controller.abort(); // Cancels request on unmount + }; + }, [chainId]); +}; +``` + +### Rule: Clean Up Intervals and Subscriptions + +**DO:** + +- Always clean up intervals, timeouts, and subscriptions in useEffect cleanup +- Use cancelled flag pattern for async operations in intervals + +**DON'T:** + +- Leave intervals or subscriptions running after unmount + +**Example - WRONG:** + +```typescript +const PriceTicker = ({ tokenAddress }: PriceTickerProps) => { + const [price, setPrice] = useState(0); + + useEffect(() => { + const interval = setInterval(async () => { + const newPrice = await fetchPrice(tokenAddress); + setPrice(newPrice); + }, 1000); // ⚠️ Interval continues after unmount! + + // Missing cleanup! + }, [tokenAddress]); +}; +``` + +**Example - CORRECT:** + +```typescript +const PriceTicker = ({ tokenAddress }: PriceTickerProps) => { + const [price, setPrice] = useState(0); + + useEffect(() => { + let cancelled = false; + + const fetchPrice = async () => { + const newPrice = await fetchPriceData(tokenAddress); + if (!cancelled) { + setPrice(newPrice); + } + }; + + fetchPrice(); // Initial fetch + const interval = setInterval(fetchPrice, 1000); + + return () => { + cancelled = true; + clearInterval(interval); // Cleanup on unmount + }; + }, [tokenAddress]); +}; +``` + +### Rule: Avoid Large Object Retention in Closures + +**DO:** + +- Extract only needed data from large objects +- Use refs for stable references to avoid capturing large objects in closures +- Minimize what's captured in closure scope + +**DON'T:** + +- Capture large objects in closures (prevents garbage collection) + +**Example - WRONG:** + +```typescript +const TransactionList = ({ transactions }: TransactionListProps) => { + const [filtered, setFiltered] = useState([]); + + useEffect(() => { + // Large transactions array captured in closure + const expensiveFilter = () => { + return transactions + .filter((tx) => tx.status === 'pending') + .map((tx) => expensiveTransform(tx)); // Large object retained! + }; + + const interval = setInterval(() => { + setFiltered(expensiveFilter()); + }, 5000); + + return () => clearInterval(interval); + }, [transactions]); // transactions array reference changes frequently +}; +``` + +**Example - CORRECT:** + +```typescript +const TransactionList = ({ transactions }: TransactionListProps) => { + const [filtered, setFiltered] = useState([]); + const transactionsRef = useRef(transactions); + + // Update ref without causing effect to re-run + useEffect(() => { + transactionsRef.current = transactions; + }, [transactions]); + + useEffect(() => { + let cancelled = false; + + const expensiveFilter = () => { + // Use ref to avoid capturing transactions in closure + const currentTransactions = transactionsRef.current; + return currentTransactions + .filter((tx) => tx.status === 'pending') + .map((tx) => ({ + id: tx.id, + amount: tx.amount, + // Only extract needed properties, not entire object + })); + }; + + const updateFiltered = () => { + if (!cancelled) { + setFiltered(expensiveFilter()); + } + }; + + updateFiltered(); + const interval = setInterval(updateFiltered, 5000); + + return () => { + cancelled = true; + clearInterval(interval); + }; + }, []); // Empty deps - uses ref instead +}; +``` diff --git a/domains/performance/skills/perf-hooks-effects/skill.md b/domains/performance/skills/perf-hooks-effects/skill.md new file mode 100644 index 0000000..871b0c3 --- /dev/null +++ b/domains/performance/skills/perf-hooks-effects/skill.md @@ -0,0 +1,4 @@ +--- +name: perf-hooks-effects +description: React hooks and effects optimization +--- diff --git a/domains/performance/skills/perf-react-compiler/repos/metamask-extension.md b/domains/performance/skills/perf-react-compiler/repos/metamask-extension.md new file mode 100644 index 0000000..4ef9d41 --- /dev/null +++ b/domains/performance/skills/perf-react-compiler/repos/metamask-extension.md @@ -0,0 +1,730 @@ +--- +repo: metamask-extension +parent: perf-react-compiler +--- + + +# Front-End Performance Rules: React Compiler & Anti-Patterns + +This file covers React Compiler considerations and common performance anti-patterns. React Compiler automatically optimizes React applications, but manual memoization is still required in certain cases. + +**Note:** This codebase uses React Compiler, a build-time tool that automatically optimizes React applications by memoizing components and hooks. React Compiler understands the Rules of React and works with existing JavaScript/TypeScript code without requiring rewrites. + +**Reference:** React Compiler Introduction + +### What React Compiler Does + +React Compiler automatically applies memoization to improve update performance (re-renders). It focuses on two main use cases: + +1. Skipping cascading re-rendering of components - Fine-grained reactivity where only changed parts re-render +2. Skipping expensive calculations - Memoizing expensive computations within components and hooks + +**Example: Automatic Fine-Grained Reactivity** + +```typescript +// React Compiler automatically prevents unnecessary re-renders +function FriendList({ friends }) { + const onlineCount = useFriendOnlineCount(); + + if (friends.length === 0) { + return ; + } + + return ( +
+ {onlineCount} online + {friends.map((friend) => ( + + ))} + {/* Won't re-render when onlineCount changes! */} +
+ ); +} +``` + +**Example: Automatic Memoization of Expensive Calculations** + +```typescript +// React Compiler automatically memoizes expensive computations +function TableContainer({ items }) { + // This expensive calculation is automatically memoized + const data = expensivelyProcessAReallyLargeArrayOfObjects(items); + return ; +} +``` + +**Note:** For truly expensive functions used across multiple components, consider implementing memoization outside React, as React Compiler only memoizes within components/hooks and doesn't share memoization across components. + +### React Compiler Assumptions + +React Compiler assumes your code: + +- Is valid, semantic JavaScript +- Tests nullable/optional values before accessing (e.g., enable strictNullChecks in TypeScript) +- Follows the Rules of React + +React Compiler can verify many Rules of React statically and will skip compilation when it detects errors. Install eslint-plugin-react-compiler to see compilation errors. + +### React Compiler Limitations + +#### Single-File Compilation + +React Compiler operates on a single file at a time - it only uses information within that file to perform optimizations. This means: + +- Works well for most React code (React's programming model uses plain JavaScript values) +- Cannot see across file boundaries +- Cannot use TypeScript/Flow type information (has its own internal type system) +- Cannot optimize based on information from other files + +**Impact:** Code that depends on values from other files may not be optimized as effectively. + +#### Effects and Dependency Memoization (Open Research Area) + +Effects memoization is still an open area of research. React Compiler can sometimes memoize differently from manual memoization, which can cause issues with effects that rely on dependencies not changing to prevent infinite loops. + +**Recommendation:** + +- Keep existing useMemo() and useCallback() calls - Especially for effect dependencies to ensure behavior doesn't change +- Write new code without useMemo/useCallback - Let React Compiler handle it automatically +- React Compiler will statically validate that auto-memoization matches existing manual memoization +- If it can't prove they're the same, the component/hook is safely skipped over + +**Example - CORRECT: Keep existing useMemo for effect dependencies** + +```typescript +const TokenBalance = ({ address }: Props) => { + const network = useSelector(getNetwork); + + // Keep useMemo to ensure effect behavior is preserved + const networkConfig = useMemo( + () => ({ + chainId: network.chainId, + rpcUrl: network.rpcUrl, + }), + [network.chainId, network.rpcUrl], + ); + + useEffect(() => { + fetchBalance(address, networkConfig); + }, [address, networkConfig]); // Stable reference prevents infinite loops +}; +``` + +**Example - CORRECT: New code - no manual memoization needed** + +```typescript +const TokenList = ({ tokens }: TokenListProps) => { + // React Compiler handles this automatically + const sortedTokens = tokens + .slice() + .sort((a, b) => parseFloat(b.balance) - parseFloat(a.balance)); + + return ( +
+ {sortedTokens.map(token => ( + + ))} +
+ ); +}; +``` + +### When Manual Memoization is Still Required + +Due to React Compiler's single-file compilation limitation and inability to see across file boundaries, manual memoization is required for: + +#### 1. Cross-File Dependencies + +**DO:** + +- Use manual memoization for computations that depend on values from other files + +**DON'T:** + +- Rely on React Compiler to optimize cross-file dependencies + +**Example - WRONG:** + +```typescript +// file1.ts +export const getProcessedTokens = (tokens: Token[]) => { + return tokens.map(/* expensive processing */); +}; + +// file2.tsx +import { getProcessedTokens } from './file1'; + +const AssetList = () => { + const tokens = useSelector(getTokens); + + // React Compiler can't see into getProcessedTokens from another file + const processed = getProcessedTokens(tokens); // Runs on every render! +}; +``` + +**Example - CORRECT:** + +```typescript +const AssetList = () => { + const tokens = useSelector(getTokens); + + // Manual memoization required - function from another file + const processed = useMemo(() => getProcessedTokens(tokens), [tokens]); +}; +``` + +**Why:** React Compiler only sees code within the current file. Functions imported from other files are opaque. + +#### 2. Redux Selectors and External State Management + +**DO:** + +- Use manual memoization for Redux-derived values + +**DON'T:** + +- Rely on React Compiler to optimize Redux selectors + +**Example - WRONG:** + +```typescript +const AssetList = () => { + const tokens = useSelector(getTokens); // External state + const balances = useSelector(getBalances); // External state + + // React Compiler can't see into Redux - manual memoization needed + const tokensWithBalances = tokens.map((token) => ({ + ...token, + balance: balances[token.address], + })); +}; +``` + +**Example - CORRECT:** + +```typescript +const AssetList = () => { + const tokens = useSelector(getTokens); + const balances = useSelector(getBalances); + + // Manual memoization required - React Compiler can't optimize Redux values + const tokensWithBalances = useMemo( + () => + tokens.map((token) => ({ + ...token, + balance: balances[token.address], + })), + [tokens, balances], + ); +}; +``` + +**Why:** Redux selectors return values from outside React's compilation scope. React Compiler operates on single files and can't track changes to external state. + +#### 3. Values from External Hooks or Libraries + +**DO:** + +- Use manual memoization for values returned from hooks in external libraries or custom hooks defined in other files + +**Example - WRONG:** + +```typescript +// hooks.ts (different file) +export function useTokenTracker({ tokens }) { + // Complex logic using Redux, context, etc. + return { tokensWithBalances: /* ... */ }; +} + +// component.tsx +import { useTokenTracker } from './hooks'; + +const TokenTracker = ({ tokens }: Props) => { + const { tokensWithBalances } = useTokenTracker({ tokens }); // External hook + + // React Compiler can't see into useTokenTracker from another file + const formattedTokens = tokensWithBalances.map(token => ({ + ...token, + formattedBalance: formatCurrency(token.balance), + })); +}; +``` + +**Example - CORRECT:** + +```typescript +const TokenTracker = ({ tokens }: Props) => { + const { tokensWithBalances } = useTokenTracker({ tokens }); + + // Manual memoization required - hook from another file + const formattedTokens = useMemo( + () => + tokensWithBalances.map((token) => ({ + ...token, + formattedBalance: formatCurrency(token.balance), + })), + [tokensWithBalances, formatCurrency], + ); +}; +``` + +**Why:** React Compiler operates on single files. Hooks defined in other files are opaque, especially if they use Redux, context, or other external state. + +#### 4. Conditional Logic with External State + +**DO:** + +- Use manual memoization when conditional logic combines props/state with external state + +**Example - WRONG:** + +```typescript +const AssetPicker = ({ hideZeroBalance }: Props) => { + const tokens = useSelector(getTokens); // External state + const balances = useSelector(getBalances); // External state + + // Conditional filtering - React Compiler may not optimize this + const filteredTokens = hideZeroBalance + ? tokens.filter((t) => balances[t.address] > 0) + : tokens; +}; +``` + +**Example - CORRECT:** + +```typescript +const AssetPicker = ({ hideZeroBalance }: Props) => { + const tokens = useSelector(getTokens); + const balances = useSelector(getBalances); + + // Manual memoization required - conditional + external state + const filteredTokens = useMemo( + () => + hideZeroBalance ? tokens.filter((t) => balances[t.address] > 0) : tokens, + [hideZeroBalance, tokens, balances], + ); +}; +``` + +**Why:** React Compiler can optimize simple conditionals based on props/state within the same file, but struggles when combined with external state from other files. + +#### 5. Functions Passed to Third-Party Components + +**DO:** + +- Use manual useCallback when passing functions to components from external libraries + +**Example - WRONG:** + +```typescript +import { ThirdPartyList } from 'some-library'; // External library + +const TokenList = ({ tokens, onSelect }: Props) => { + const dispatch = useDispatch(); + + // React Compiler can't see into third-party component from node_modules + return ( + { + dispatch(selectToken(token)); + onSelect(token); + }} + /> + ); +}; +``` + +**Example - CORRECT:** + +```typescript +const TokenList = ({ tokens, onSelect }: Props) => { + const dispatch = useDispatch(); + + // Manual useCallback required - external component from another file/library + const handleItemClick = useCallback( + (token: Token) => { + dispatch(selectToken(token)); + onSelect(token); + }, + [dispatch, onSelect] + ); + + return ( + + ); +}; +``` + +**Why:** React Compiler operates on single files. Components from node_modules or other files are opaque and cannot be analyzed. + +#### 6. Computations Dependent on Refs or DOM Values + +**DO:** + +- Use manual memoization when computations depend on useRef values, DOM queries, or other mutable values + +**Example - WRONG:** + +```typescript +const TokenInput = ({ tokens }: Props) => { + const inputRef = useRef(null); + const [filter, setFilter] = useState(''); + + // React Compiler can't track ref.current changes statically + const filteredTokens = tokens.filter((token) => { + const inputValue = inputRef.current?.value || filter; + return token.symbol.includes(inputValue); + }); +}; +``` + +**Example - CORRECT:** + +```typescript +const TokenInput = ({ tokens }: Props) => { + const inputRef = useRef(null); + const [filter, setFilter] = useState(''); + + // Manual memoization required - refs are mutable + const filteredTokens = useMemo(() => { + const inputValue = inputRef.current?.value || filter; + return tokens.filter((token) => token.symbol.includes(inputValue)); + }, [tokens, filter]); // Note: ref.current not in deps (intentional) +}; +``` + +**Why:** Refs are mutable values that React Compiler cannot track statically. DOM queries and other runtime values also cannot be analyzed at compile time. + +#### 7. Reselect Selectors and Complex Compositions + +**DO:** + +- Use Reselect selectors (already memoized) when available +- Use manual memoization if selector not available + +**Example - WRONG:** + +```typescript +// selectors.ts (different file) +export const selectTotalBalance = createSelector( + [getAccounts, getBalances], + (accounts, balances) => + accounts.reduce((sum, acc) => sum + balances[acc.address], 0), +); + +// component.tsx +import { selectTotalBalance } from './selectors'; + +const Dashboard = () => { + const accounts = useSelector(getAccounts); + const balances = useSelector(getBalances); + + // React Compiler can't see into selectTotalBalance from another file + const totalBalance = accounts.reduce( + (sum, acc) => sum + balances[acc.address], + 0, + ); +}; +``` + +**Example - CORRECT:** + +```typescript +// Option 1: Use Reselect selector (preferred - already memoized) +const Dashboard = () => { + const totalBalance = useSelector(selectTotalBalance); // Reselect handles memoization +}; + +// Option 2: Manual memoization if selector not available +const Dashboard = () => { + const accounts = useSelector(getAccounts); + const balances = useSelector(getBalances); + + const totalBalance = useMemo( + () => accounts.reduce((sum, acc) => sum + balances[acc.address], 0), + [accounts, balances], + ); +}; +``` + +**Why:** Reselect selectors defined in other files are opaque to React Compiler. However, Reselect already provides memoization, so this is often not an issue. + +#### 8. Effect Dependencies (Keep Existing Memoization) + +**DO:** + +- Keep existing useMemo/useCallback for effect dependencies + +**DON'T:** + +- Remove existing memoization for effect dependencies + +**Example - CORRECT:** + +```typescript +const TokenBalance = ({ address }: Props) => { + const [balance, setBalance] = useState('0'); + const network = useSelector(getNetwork); + + // Keep useMemo to ensure effect behavior is preserved + const networkConfig = useMemo( + () => ({ + chainId: network.chainId, + rpcUrl: network.rpcUrl, + }), + [network.chainId, network.rpcUrl], + ); + + useEffect(() => { + // Stable networkConfig reference prevents infinite loops + fetchBalance(address, networkConfig); + }, [address, networkConfig]); +}; +``` + +**Why:** React Compiler will statically validate that auto-memoization matches existing manual memoization. If it can't prove they're the same, it safely skips compilation. To ensure effect behavior doesn't change, keep existing useMemo/useCallback calls for effect dependencies. + +**Recommendation:** + +- Keep existing useMemo/useCallback for effects +- Write new code without manual memoization (let React Compiler handle it) +- If you notice unexpected effect behavior, file an issue + +#### 9. Context Values from External Providers + +**DO:** + +- Use manual memoization if computation is expensive when consuming context from providers defined in other files + +**Example - WRONG:** + +```typescript +// context.tsx (different file) +export const ExternalI18nContext = createContext(/* ... */); + +// component.tsx +import { ExternalI18nContext } from './context'; + +const TokenDisplay = ({ token }: Props) => { + const { formatCurrency, locale } = useContext(ExternalI18nContext); + + // React Compiler may not optimize context values from another file + const formattedBalance = formatCurrency(token.balance, locale); +}; +``` + +**Example - CORRECT:** + +```typescript +const TokenDisplay = ({ token }: Props) => { + const { formatCurrency, locale } = useContext(ExternalI18nContext); + + // Manual memoization if this computation is expensive + const formattedBalance = useMemo( + () => formatCurrency(token.balance, locale), + [formatCurrency, token.balance, locale], + ); +}; +``` + +**Why:** Context providers defined in other files may not be fully analyzed by React Compiler. However, simple context consumption often works fine without manual memoization. + +#### 10. Computations with Multiple Cross-File Dependencies + +**DO:** + +- Use manual memoization when computations combine multiple external sources from different files + +**Example - WRONG:** + +```typescript +import { formatCurrency } from './utils'; // External function +import { CurrencyContext } from './context'; // External context + +const AssetCard = ({ assetId }: Props) => { + const asset = useSelector((state) => selectAsset(state, assetId)); // Redux + const { locale } = useContext(CurrencyContext); // Context from another file + + // Multiple external dependencies from different files + const displayData = { + name: asset.name, + balance: formatCurrency(asset.balance, locale), // Function from another file + }; +}; +``` + +**Example - CORRECT:** + +```typescript +const AssetCard = ({ assetId }: Props) => { + const asset = useSelector((state) => selectAsset(state, assetId)); + const { locale } = useContext(CurrencyContext); + + // Manual memoization required - multiple external sources from different files + const displayData = useMemo( + () => ({ + name: asset.name, + balance: formatCurrency(asset.balance, locale), + }), + [asset, locale], // formatCurrency is stable if from another file + ); +}; +``` + +**Why:** React Compiler can optimize simple prop/state combinations within a single file, but struggles with complex dependency chains spanning multiple files. + +### Decision Tree: Do You Need Manual Memoization? + +**Is the computation/value:** + +- From another file (imported function/hook)? → ✅ Manual memoization required +- From Redux selectors? → ✅ Manual memoization required +- From external library (node_modules)? → ✅ Manual memoization required +- Used as useEffect dependency? → ✅ Keep existing useMemo/useCallback +- Depends on refs or DOM values? → ✅ Manual memoization required +- Combines multiple cross-file dependencies? → ✅ Manual memoization required +- Simple props/state within same file? → ❌ React Compiler handles it + +**Is the callback:** + +- Used as useEffect dependency? → ✅ Keep existing useCallback +- Passed to external component/library? → ✅ Manual useCallback required +- Depends on imported functions/hooks? → ✅ Manual useCallback required +- Simple prop handler within file? → ❌ React Compiler handles it + +### Summary: React Compiler Capabilities and Limitations + +**React Compiler CAN optimize:** + +- Components and hooks within the same file +- Expensive calculations within components/hooks +- Fine-grained reactivity (preventing cascading re-renders) +- Inline objects/functions with React-controlled dependencies +- Derived state from props/state within the file +- Simple conditional memoization based on props/state + +**React Compiler CANNOT optimize:** + +- Code across file boundaries (single-file compilation) +- Functions/hooks imported from other files +- Redux selectors and external state management +- Components from external libraries (node_modules) +- Computations dependent on refs or DOM values +- TypeScript/Flow type information (uses own type system) +- Effect dependencies (keep existing useMemo/useCallback - open research area) + +**Key Limitations:** + +- Single-file compilation - Cannot see across files +- No type information - Doesn't use TypeScript/Flow types +- Effects memoization - Still an open research area + +**Best Practices:** + +- Write new code without useMemo/useCallback - let React Compiler handle it +- Keep existing useMemo/useCallback for effect dependencies +- Use manual memoization for cross-file dependencies +- Install eslint-plugin-react-compiler to catch compilation errors + +**Rule of thumb:** If it's within the same file and uses props/state, React Compiler handles it. If it crosses file boundaries (imports, Redux, external libraries), use manual memoization. + +## Common Performance Anti-Patterns + +### ❌ Anti-Pattern: Memoizing Everything + +**Problem:** Over-optimizing with unnecessary memoization adds complexity without benefit. + +**Solution:** Only memoize expensive operations or when passing to memoized children. + +**Example - WRONG:** + +```typescript +// This is overkill and adds unnecessary complexity +const SimpleComponent = React.memo(() => { + const value1 = useMemo(() => prop1 + prop2, [prop1, prop2]); + const value2 = useMemo(() => prop3 * 2, [prop3]); + const handler = useCallback(() => {}, []); + + return
{value1} {value2}
; +}); +``` + +**Example - CORRECT:** + +```typescript +// Only memoize when actually needed +const SimpleComponent = ({ prop1, prop2, prop3 }: Props) => { + const value1 = prop1 + prop2; // Simple calculation - no memo needed + const value2 = prop3 * 2; // Simple calculation - no memo needed + const handler = () => {}; // Simple handler - no callback needed + + return
{value1} {value2}
; +}; +``` + +### ❌ Anti-Pattern: Wrong Dependencies in useMemo/useCallback + +**Problem:** Missing dependencies causes stale closures and bugs. + +**Solution:** Always include all dependencies. Use ESLint rule `react-hooks/exhaustive-deps`. + +**Example - WRONG:** + +```typescript +const TokenList = ({ tokens, filter }: TokenListProps) => { + // Dependencies are wrong - should include filter! + const filteredTokens = useMemo(() => { + return tokens.filter(token => token.symbol.includes(filter)); + }, [tokens]); // Missing filter dependency! + + return
...
; +}; +``` + +**Example - CORRECT:** + +```typescript +const TokenList = ({ tokens, filter }: TokenListProps) => { + const filteredTokens = useMemo(() => { + return tokens.filter(token => token.symbol.includes(filter)); + }, [tokens, filter]); // All dependencies included + + return
...
; +}; +``` + +### ❌ Anti-Pattern: Using Index as Key for Dynamic Lists + +**Problem:** Breaks React's reconciliation when lists can be reordered, filtered, or have items added/removed. + +**Solution:** Use unique, stable identifiers from data. + +**Example - WRONG:** + +```typescript +const TokenList = ({ tokens }: TokenListProps) => { + // If tokens can be reordered/filtered, this breaks React's reconciliation + return ( +
+ {tokens.map((token, index) => ( + + ))} +
+ ); +}; +``` + +**Example - CORRECT:** + +```typescript +const TokenList = ({ tokens }: TokenListProps) => { + return ( +
+ {tokens.map(token => ( + + ))} +
+ ); +}; +``` diff --git a/domains/performance/skills/perf-react-compiler/skill.md b/domains/performance/skills/perf-react-compiler/skill.md new file mode 100644 index 0000000..c272fab --- /dev/null +++ b/domains/performance/skills/perf-react-compiler/skill.md @@ -0,0 +1,4 @@ +--- +name: perf-react-compiler +description: React Compiler optimization patterns +--- diff --git a/domains/performance/skills/perf-rendering/repos/metamask-extension.md b/domains/performance/skills/perf-rendering/repos/metamask-extension.md new file mode 100644 index 0000000..ed8e7b6 --- /dev/null +++ b/domains/performance/skills/perf-rendering/repos/metamask-extension.md @@ -0,0 +1,659 @@ +--- +repo: metamask-extension +parent: perf-rendering +--- + + +# Front-End Performance Rules: Rendering Performance + +This file covers rendering performance optimization rules including list keys, virtualization, React.memo, code splitting, lazy loading, memoization, and Web Workers. + +### Rule: Use Proper Keys for Lists + +**DO:** + +- Use unique, stable identifiers from data (address, id, uuid) as keys +- Ensure keys are stable across re-renders +- Only use index if list never reorders and items don't have IDs + +**DON'T:** + +- Use array index as key for dynamic lists that can reorder, filter, or have items added/removed +- Use random values or Math.random() as keys + +**Why:** Using array index as key breaks React's reconciliation when lists can be reordered, filtered, or items added/removed. This causes state to get mixed up between items, bugs with form inputs, focus, animations, and performance issues from unnecessary re-renders. + +**Example - WRONG:** + +```typescript +const TokenList = ({ tokens }: TokenListProps) => { + return ( +
+ {tokens.map((token, index) => ( + // Bad if list can reorder! + ))} +
+ ); +}; +``` + +**Example - CORRECT:** + +```typescript +const TokenList = ({ tokens }: TokenListProps) => { + return ( +
+ {tokens.map(token => ( + + ))} +
+ ); +}; +``` + +### Rule: Virtualize Long Lists + +**DO:** + +- Use virtualization libraries (react-window or react-virtualized) for lists with 100+ items +- Only render visible items to improve performance + +**DON'T:** + +- Render 1000+ items at once without virtualization + +**Example - WRONG:** + +```typescript +const TransactionList = ({ transactions }: TransactionListProps) => { + return ( +
+ {transactions.map(tx => ( + + ))} +
+ ); +}; +``` + +**Example - CORRECT:** + +```typescript +import { FixedSizeList } from 'react-window'; + +const TransactionList = ({ transactions }: TransactionListProps) => { + const Row = ({ index, style }: { index: number; style: React.CSSProperties }) => ( +
+ +
+ ); + + return ( + + {Row} + + ); +}; +``` + +**Recommended libraries:** + +- react-window - Lightweight, recommended for most use cases +- react-virtualized - More features, larger bundle size + +**Example - CORRECT: Variable Size Virtual Scrolling** + +```typescript +import { VariableSizeList } from 'react-window'; + +const TransactionList = ({ transactions }: TransactionListProps) => { + const listRef = useRef(null); + + // Calculate item size based on content + const getItemSize = (index: number) => { + const tx = transactions[index]; + // Different heights for different transaction types + return tx.type === 'complex' ? 120 : 80; + }; + + const Row = ({ index, style }: { index: number; style: React.CSSProperties }) => ( +
+ +
+ ); + + return ( + + {Row} + + ); +}; +``` + +### Rule: Use React.memo for Expensive Components + +**DO:** + +- Wrap expensive components in React.memo to skip re-renders when props haven't changed +- Use custom comparison function when needed +- Apply to components that render often with same props + +**DON'T:** + +- Use React.memo on components whose props change frequently +- Use React.memo on simple components that are already fast to render + +**When to use React.memo:** + +- ✅ Component renders often with same props +- ✅ Component is expensive to render (complex calculations, large lists) +- ✅ Component is in the middle of a frequently updating tree +- ❌ Props change frequently +- ❌ Component is already fast to render + +**Example - WRONG:** + +```typescript +const TokenListItem = ({ token, onSelect }: TokenListItemProps) => { + return ( +
onSelect(token)}> + {token.symbol} - {token.balance} +
+ ); +}; +``` + +**Example - CORRECT: Memoized component** + +```typescript +const TokenListItem = React.memo(({ token, onSelect }: TokenListItemProps) => { + return ( +
onSelect(token)}> + {token.symbol} - {token.balance} +
+ ); +}); +``` + +**Example - CORRECT: With custom comparison** + +```typescript +const TokenListItem = React.memo( + ({ token, onSelect }: TokenListItemProps) => { + return ( +
onSelect(token)}> + {token.symbol} - {token.balance} +
+ ); + }, + (prevProps, nextProps) => { + // Return true if props are equal (skip re-render) + return prevProps.token.address === nextProps.token.address && + prevProps.token.balance === nextProps.token.balance; + } +); +``` + +### Rule: Use Pagination and Infinite Scroll for Large Datasets + +**DO:** + +- Load data in chunks for very large datasets +- Implement progressive pagination with page size limits +- Use refs for page tracking to avoid unnecessary re-renders + +**DON'T:** + +- Load all assets/data at once (1000+ items) which blocks UI + +**Example - WRONG:** + +```typescript +const AssetList = ({ accountId }: AssetListProps) => { + const [assets, setAssets] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + // Loads ALL assets at once - blocks UI + fetchAllAssets(accountId).then(allAssets => { + setAssets(allAssets); // 1000+ assets loaded at once! + setLoading(false); + }); + }, [accountId]); + + return loading ? :
{assets.map(a => }
; +}; +``` + +**Example - CORRECT:** + +```typescript +const AssetList = ({ accountId }: AssetListProps) => { + const [assets, setAssets] = useState([]); + const [hasMore, setHasMore] = useState(true); + const [loading, setLoading] = useState(false); + const pageRef = useRef(0); + const PAGE_SIZE = 50; + + const loadPage = useCallback(async () => { + if (loading || !hasMore) return; + + setLoading(true); + try { + const result = await fetchAssetsPage(accountId, pageRef.current, PAGE_SIZE); + setAssets(prev => [...prev, ...result.items]); + setHasMore(result.hasMore); + pageRef.current += 1; + } finally { + setLoading(false); + } + }, [accountId, loading, hasMore]); + + useEffect(() => { + // Load first page on mount + loadPage(); + }, [accountId]); // Reset on account change + + return ( +
+ {assets.map(a => )} + {hasMore && ( + + )} +
+ ); +}; +``` + +### Rule: Use React.lazy for Route-Based Code Splitting + +**DO:** + +- Use React.lazy() and Suspense for route-based code splitting +- Lazy load pages and heavy components +- Provide fallback UI in Suspense boundaries + +**DON'T:** + +- Import all pages upfront + +**Example - WRONG:** + +```typescript +import Settings from './pages/Settings'; +import Tokens from './pages/Tokens'; +import Activity from './pages/Activity'; + +const App = () => { + return ( + + } /> + } /> + } /> + + ); +}; +``` + +**Example - CORRECT:** + +```typescript +import { lazy, Suspense } from 'react'; + +const Settings = lazy(() => import('./pages/Settings')); +const Tokens = lazy(() => import('./pages/Tokens')); +const Activity = lazy(() => import('./pages/Activity')); + +const App = () => { + return ( + }> + + } /> + } /> + } /> + + + ); +}; +``` + +### Rule: Lazy Load Heavy Components + +**DO:** + +- Lazy load modals and heavy components that aren't immediately visible +- Use IntersectionObserver for lazy loading images +- Start loading images before they become visible (use rootMargin) + +**Example - CORRECT:** + +```typescript +const QRCodeScanner = lazy(() => import('./components/QRCodeScanner')); + +const SendToken = () => { + const [showScanner, setShowScanner] = useState(false); + + return ( +
+ + + + {showScanner && ( + Loading scanner...
}> + + + )} + + ); +}; +``` + +**Example - CORRECT: Lazy load asset images** + +```typescript +const AssetCard = ({ asset }: AssetCardProps) => { + const [imageLoaded, setImageLoaded] = useState(false); + const imgRef = useRef(null); + + useEffect(() => { + if (!imgRef.current) return; + + const observer = new IntersectionObserver( + ([entry]) => { + if (entry.isIntersecting) { + setImageLoaded(true); + observer.disconnect(); + } + }, + { rootMargin: '50px' } // Start loading 50px before visible + ); + + observer.observe(imgRef.current); + + return () => observer.disconnect(); + }, []); + + return ( +
+
{asset.name}
+ {imageLoaded ? ( + {asset.name} + ) : ( +
Loading image...
+ )} +
+ ); +}; +``` + +### Rule: Memoize Expensive Computations + +**DO:** + +- Use useMemo for Map creation, array transformations, and expensive operations +- Memoize computations that depend on props/state that change frequently +- Move complex computations to Redux selectors when possible + +**DON'T:** + +- Create Maps, Sets, or complex objects during render without memoization +- Run expensive map/filter/reduce operations on every render +- Perform expensive operations in components when they can be moved to selectors + +**Example - WRONG:** + +```typescript +const UnconnectedAccountAlert = () => { + const internalAccounts = useSelector(getInternalAccounts); + const connectedAccounts = useSelector(getOrderedConnectedAccountsForActiveTab); + + // Map creation runs on every render + const internalAccountsMap = new Map( + internalAccounts.map((acc) => [acc.address, acc]), + ); + + // Array mapping runs on every render + const connectedAccountsWithName = connectedAccounts.map((account) => ({ + ...account, + name: internalAccountsMap.get(account.address)?.metadata.name, + })); + + return
{connectedAccountsWithName.map(...)}
; +}; +``` + +**Example - CORRECT:** + +```typescript +const UnconnectedAccountAlert = () => { + const internalAccounts = useSelector(getInternalAccounts); + const connectedAccounts = useSelector(getOrderedConnectedAccountsForActiveTab); + + // Memoize Map creation + const internalAccountsMap = useMemo( + () => new Map(internalAccounts.map((acc) => [acc.address, acc])), + [internalAccounts] + ); + + // Memoize array transformation + const connectedAccountsWithName = useMemo( + () => + connectedAccounts.map((account) => ({ + ...account, + name: internalAccountsMap.get(account.address)?.metadata.name, + })), + [connectedAccounts, internalAccountsMap] + ); + + return
{connectedAccountsWithName.map(...)}
; +}; +``` + +**Example - WRONG: Expensive operations without memoization** + +```typescript +const AssetDashboard = ({ assets, filters }: AssetDashboardProps) => { + // These run on EVERY render, even if assets/filters haven't changed + const filtered = assets + .filter(asset => matchesFilters(asset, filters)) + .map(asset => enrichAssetData(asset)) // Expensive transformation + .sort((a, b) => compareAssets(a, b)); // Expensive comparison + + const aggregated = filtered.reduce((acc, asset) => { + acc.totalValue += parseFloat(asset.balance) * asset.price; + acc.byChain[asset.chainId] = (acc.byChain[asset.chainId] || 0) + asset.value; + return acc; + }, { totalValue: 0, byChain: {} }); + + return ( +
+ + {filtered.map(asset => )} +
+ ); +}; +``` + +**Example - CORRECT:** + +```typescript +const AssetDashboard = ({ assets, filters }: AssetDashboardProps) => { + // Memoize filtered and enriched assets + const filtered = useMemo(() => { + return assets + .filter(asset => matchesFilters(asset, filters)) + .map(asset => enrichAssetData(asset)) + .sort((a, b) => compareAssets(a, b)); + }, [assets, filters]); // Only recompute when dependencies change + + // Memoize aggregated data + const aggregated = useMemo(() => { + return filtered.reduce((acc, asset) => { + acc.totalValue += parseFloat(asset.balance) * asset.price; + acc.byChain[asset.chainId] = (acc.byChain[asset.chainId] || 0) + asset.value; + return acc; + }, { totalValue: 0, byChain: {} }); + }, [filtered]); // Depends on filtered, which is already memoized + + return ( +
+ + {filtered.map(asset => )} +
+ ); +}; +``` + +### Rule: Move Complex Computations to Selectors + +**DO:** + +- Move expensive computations from components to Redux selectors +- Use createSelector for memoized selectors +- Keep selectors focused on specific state slices + +**DON'T:** + +- Perform expensive computations in component render functions + +**Example - WRONG:** + +```typescript +const AssetList = () => { + const assets = useSelector(state => state.assets); + const filters = useSelector(state => state.filters); + + // Expensive computation runs in component render + const filtered = assets + .filter(asset => matchesFilters(asset, filters)) + .map(asset => expensiveTransform(asset)); + + return
{filtered.map(a => )}
; +}; +``` + +**Example - CORRECT:** + +```typescript +// In selectors file: +const selectAssets = (state) => state.assets; +const selectFilters = (state) => state.filters; + +const selectFilteredAssets = createSelector( + [selectAssets, selectFilters], + (assets, filters) => { + // Only recomputes when assets or filters change + return assets + .filter(asset => matchesFilters(asset, filters)) + .map(asset => expensiveTransform(asset)); + }, +); + +// In component: +const AssetList = () => { + // Selector handles memoization automatically + const filteredAssets = useSelector(selectFilteredAssets); + + return
{filteredAssets.map(a => )}
; +}; +``` + +### Rule: Use Web Workers for Heavy Computations + +**DO:** + +- Use Web Workers for very expensive computations (crypto operations, large data transformations) +- Terminate workers on component unmount +- Use refs to maintain worker references + +**Example - CORRECT:** + +```typescript +// worker.ts +self.onmessage = (e) => { + const { assets, filters } = e.data; + + // Heavy computation in worker thread + const result = assets + .filter(asset => matchesFilters(asset, filters)) + .map(asset => expensiveCryptoOperation(asset)) + .sort((a, b) => compareAssets(a, b)); + + self.postMessage(result); +}; + +// Component +const AssetList = ({ assets, filters }: AssetListProps) => { + const [processed, setProcessed] = useState([]); + const workerRef = useRef(null); + + useEffect(() => { + workerRef.current = new Worker(new URL('./worker.ts', import.meta.url)); + + workerRef.current.onmessage = (e) => { + setProcessed(e.data); + }; + + return () => { + workerRef.current?.terminate(); + }; + }, []); + + useEffect(() => { + if (workerRef.current) { + workerRef.current.postMessage({ assets, filters }); + } + }, [assets, filters]); + + return
{processed.map(a => )}
; +}; +``` + +### Rule: Debounce Frequent Updates + +**DO:** + +- Debounce rapid updates to avoid UI jitter +- Use debounced values for frequently changing data like balances + +**Example - CORRECT:** + +```typescript +import { useDebouncedValue } from './hooks/useDebouncedValue'; + +const AssetBalance = ({ assetId }: AssetBalanceProps) => { + const balance = useSelector(state => selectAssetBalance(state, assetId)); + + // Debounce rapid updates to avoid jitter + const debouncedBalance = useDebouncedValue(balance, 300); + + return
{debouncedBalance}
; +}; + +// Hook implementation +function useDebouncedValue(value: T, delay: number): T { + const [debounced, setDebounced] = useState(value); + + useEffect(() => { + const timer = setTimeout(() => setDebounced(value), delay); + return () => clearTimeout(timer); + }, [value, delay]); + + return debounced; +} +``` diff --git a/domains/performance/skills/perf-rendering/skill.md b/domains/performance/skills/perf-rendering/skill.md new file mode 100644 index 0000000..05133e1 --- /dev/null +++ b/domains/performance/skills/perf-rendering/skill.md @@ -0,0 +1,4 @@ +--- +name: perf-rendering +description: Rendering performance optimization +--- diff --git a/domains/performance/skills/perf-state-management/repos/metamask-extension.md b/domains/performance/skills/perf-state-management/repos/metamask-extension.md new file mode 100644 index 0000000..56fa88e --- /dev/null +++ b/domains/performance/skills/perf-state-management/repos/metamask-extension.md @@ -0,0 +1,1043 @@ +--- +repo: metamask-extension +parent: perf-state-management +--- + + +# Front-End Performance Rules: State Management + +This file covers Redux and state management optimization rules including reducer best practices, selector optimization, state normalization, and batching updates. + +### Rule: Never Mutate State in Reducers + +**DO:** + +- Always create new objects/arrays for state updates +- Use spread operators or immutability helpers +- Use Redux Toolkit (uses Immer internally) + +**DON'T:** + +- Mutate state directly (most common cause of Redux bugs) + +**Example - WRONG:** + +```typescript +function todosReducer(state = [], action) { + switch (action.type) { + case 'ADD_TODO': + state.push(action.payload); // Mutates state! + return state; + default: + return state; + } +} +``` + +**Example - CORRECT:** + +```typescript +function todosReducer(state = [], action) { + switch (action.type) { + case 'ADD_TODO': + return [...state, action.payload]; + default: + return state; + } +} +``` + +### Rule: Reducers Must Not Have Side Effects + +**DO:** + +- Keep reducers pure (only depend on state and action arguments) +- Move side effects to action creators or middleware + +**DON'T:** + +- Include API calls, random values, or date calculations in reducers + +**Example - WRONG:** + +```typescript +function myReducer(state = initialState, action) { + switch (action.type) { + case 'FETCH_DATA': + // Side effect: API call in reducer! + fetch('/api/data') + .then((response) => response.json()) + .then((data) => { + state.data = data; // Also mutates state! + }); + return state; + default: + return state; + } +} +``` + +**Example - CORRECT:** + +```typescript +function myReducer(state = initialState, action) { + switch (action.type) { + case 'SET_DATA': + return { + ...state, + data: action.payload, + }; + default: + return state; + } +} + +// Side effect in action creator +function fetchData() { + return (dispatch) => { + fetch('/api/data') + .then((response) => response.json()) + .then((data) => { + dispatch({ type: 'SET_DATA', payload: data }); + }); + }; +} +``` + +### Rule: Do Not Put Non-Serializable Values in State + +**DO:** + +- Store only plain objects, arrays, and primitives +- Keep state serializable for time-travel debugging and persistence + +**DON'T:** + +- Store Promises, Symbols, Maps/Sets, functions, or class instances + +**Example - WRONG:** + +```typescript +const initialState = { + data: new Map(), // Map is not serializable + callback: () => {}, // Function is not serializable + promise: fetch('/api'), // Promise is not serializable +}; +``` + +**Example - CORRECT:** + +```typescript +const initialState = { + data: {}, // Plain object + items: [], // Plain array + count: 0, // Primitive +}; +``` + +### Rule: Batch Actions When Possible + +**DO:** + +- Combine multiple related actions into a single action when possible +- Reduces number of reducer calls and re-renders + +**DON'T:** + +- Dispatch multiple separate actions for related state updates + +**Example - WRONG:** + +```typescript +function updateUserAndPosts(user, posts) { + return (dispatch) => { + dispatch({ type: 'UPDATE_USER', payload: user }); + dispatch({ type: 'UPDATE_POSTS', payload: posts }); + }; +} +``` + +**Example - CORRECT:** + +```typescript +function updateUserAndPosts(user, posts) { + return { + type: 'UPDATE_USER_AND_POSTS', + payload: { user, posts }, + }; +} + +// Reducer handling combined action +function rootReducer(state = initialState, action) { + switch (action.type) { + case 'UPDATE_USER_AND_POSTS': + return { + ...state, + user: action.payload.user, + posts: action.payload.posts, + }; + default: + return state; + } +} +``` + +### Rule: Normalize State Shape + +**DO:** + +- Use normalized state with `byId` and `allIds` patterns for complex data +- Avoid deeply nested structures +- Makes updates more efficient and prevents duplication + +**DON'T:** + +- Store deeply nested relational data + +**Example - WRONG:** + +```typescript +const state = { + users: { + byId: { + user_1a2b: { + id: 'user_1a2b', + name: 'Alice', + posts: [{ id: 'post_1a2b', title: 'Post 1' }], + }, + }, + }, +}; +``` + +**Example - CORRECT:** + +```typescript +const normalizedState = { + users: { + byId: { + user_1a2b: { id: 'user_1a2b', name: 'Alice', postIds: ['post_1a2b'] }, + }, + allIds: ['user_1a2b'], + }, + posts: { + byId: { + post_1a2b: { id: 'post_1a2b', title: 'Post 1' }, + }, + allIds: ['post_1a2b'], + }, +}; +``` + +### Rule: Use Immer for Deep Updates + +**DO:** + +- Use Redux Toolkit (uses Immer internally) +- Write "mutating" logic that's actually immutable +- Simplifies complex state updates + +**Example - CORRECT: Using Redux Toolkit with Immer** + +```typescript +import { createSlice } from '@reduxjs/toolkit'; + +const usersSlice = createSlice({ + name: 'users', + initialState: { + byId: {}, + allIds: [], + }, + reducers: { + addUser: (state, action) => { + // Looks like mutation, but Immer makes it immutable + const { id, name } = action.payload; + state.byId[id] = { id, name }; + state.allIds.push(id); + }, + updateUser: (state, action) => { + const { id, name } = action.payload; + // Direct "mutation" that's actually immutable + state.byId[id].name = name; + }, + }, +}); +``` + +### Rule: Batch State Updates + +**DO:** + +- Combine multiple state updates into a single update when possible +- Reduces number of re-renders + +**DON'T:** + +- Update state multiple times separately for related changes + +**Example - WRONG:** + +```typescript +const updateMultipleTokens = ( + updates: Array<{ tokenId: string; balance: string }>, +) => { + updates.forEach(({ tokenId, balance }) => { + setState((prev) => ({ + ...prev, + tokens: { + ...prev.tokens, + byId: { + ...prev.tokens.byId, + [tokenId]: { ...prev.tokens.byId[tokenId], balance }, + }, + }, + })); + }); // Multiple re-renders! +}; +``` + +**Example - CORRECT:** + +```typescript +const updateMultipleTokens = ( + updates: Array<{ tokenId: string; balance: string }>, +) => { + setState((prev) => ({ + ...prev, + tokens: { + ...prev.tokens, + byId: { + ...prev.tokens.byId, + ...updates.reduce( + (acc, { tokenId, balance }) => { + acc[tokenId] = { ...prev.tokens.byId[tokenId], balance }; + return acc; + }, + {} as Record, + ), + }, + }, + })); // Single re-render! +}; +``` + +### Rule: Avoid Identity Functions as Output Selectors + +**DO:** + +- Always transform data in output selector +- Use createDeepEqualSelector if you need deep equality + +**DON'T:** + +- Use identity functions in createSelector (provides no memoization benefit) + +**Example - WRONG:** + +```typescript +export const getInternalAccounts = createSelector( + (state: AccountsState) => + Object.values(state.metamask.internalAccounts.accounts), + (accounts) => accounts, // Identity function - no transformation! +); +``` + +**Example - CORRECT:** + +```typescript +export const getInternalAccounts = createSelector( + (state: AccountsState) => state.metamask.internalAccounts.accounts, + (accountsObject) => { + // Only create array when accountsObject actually changes + const accounts = Object.values(accountsObject); + return accounts; + }, +); + +// OR: Use createDeepEqualSelector if you need deep equality +export const getInternalAccounts = createDeepEqualSelector( + (state: AccountsState) => state.metamask.internalAccounts.accounts, + (accountsObject) => Object.values(accountsObject), +); +``` + +### Rule: Select Only Needed Properties in Selectors + +**DO:** + +- Select only the specific properties needed from state +- Use granular input selectors + +**DON'T:** + +- Return entire state objects or large state slices + +**Example - WRONG:** + +```typescript +const selectAccountTreeStateForBalances = createSelector( + (state: BalanceCalculationState) => state.metamask, + (metamaskState) => metamaskState, // Returns entire metamask state! +); +``` + +**Example - CORRECT:** + +```typescript +const selectAccountTreeStateForBalances = createSelector( + [ + (state: BalanceCalculationState) => state.metamask.accountTree, + (state: BalanceCalculationState) => state.metamask.accountGroupsMetadata, + (state: BalanceCalculationState) => state.metamask.accountWalletsMetadata, + ], + (accountTree, accountGroupsMetadata, accountWalletsMetadata) => ({ + accountTree: accountTree ?? EMPTY_ACCOUNT_TREE, + accountGroupsMetadata: accountGroupsMetadata ?? EMPTY_OBJECT, + accountWalletsMetadata: accountWalletsMetadata ?? EMPTY_OBJECT, + }), +); +``` + +### Rule: Use Granular Input Selectors + +**DO:** + +- Create granular input selectors for composition +- Build complex selectors from simple ones + +**DON'T:** + +- Access state directly with broad selectors (can't be composed efficiently) + +**Example - WRONG:** + +```typescript +const selectExpensiveComputation = createSelector( + (state) => state.metamask, // Too broad + (metamask) => { + // Expensive computation using many properties + return metamask.tokens + .filter((t) => t.balance > 0) + .map((t) => ({ ...t, computed: expensiveTransform(t) })) + .sort((a, b) => b.balance - a.balance); + }, +); +``` + +**Example - CORRECT:** + +```typescript +const selectTokens = (state) => state.metamask.tokens; +const selectTokenBalances = (state) => state.metamask.tokenBalances; + +const selectExpensiveComputation = createSelector( + [selectTokens, selectTokenBalances], + (tokens, balances) => { + // Only recomputes when tokens or balances change + return tokens + .filter((t) => balances[t.address] > 0) + .map((t) => ({ ...t, computed: expensiveTransform(t) })) + .sort((a, b) => balances[b.address] - balances[a.address]); + }, +); +``` + +### Rule: Use createDeepEqualSelector Sparingly + +**DO:** + +- Use createSelector by default +- Use createDeepEqualSelector only when inputs keep the same reference but nested values change +- Document why you chose createDeepEqualSelector + +**DON'T:** + +- Use createDeepEqualSelector for all selectors (isEqual runs on every evaluation, expensive for large payloads) + +**Context:** updateMetamaskState applies background patches to Redux using Immer. Immer guarantees structural sharing: only the objects along the mutated path receive new references, while untouched branches retain their identity. + +**When to use createSelector:** + +- Works best when input selectors point directly at the branch that changes +- Most selectors can rely on reference changes produced by reducers + +**When to use createDeepEqualSelector:** + +- When patches touch other controllers but Redux still replaces the parent object you depend on +- When rebuilding complex aggregates (sorting, merging, normalizing) that always produce fresh structures but semantic contents often stay the same + +**Example - CORRECT: createSelector (most cases)** + +```typescript +export const getInternalAccountByAddress = createSelector( + (state) => state.metamask.internalAccounts.accounts, + (_state, address: string) => address, + (accounts, address) => { + return Object.values(accounts).find((account) => + isEqualCaseInsensitive(account.address, address), + ); + }, +); +``` + +**Example - CORRECT: createDeepEqualSelector (when needed)** + +```typescript +export const getWalletsWithAccounts = createDeepEqualSelector( + getMetaMaskAccountsOrdered, + getAccountTree, + getOrderedConnectedAccountsForActiveTab, + getSelectedInternalAccount, + getPinnedAccountsList, + getHiddenAccountsList, + ( + internalAccounts, + accountTree, + connectedAccounts, + selectedAccount, + pinnedAccounts, + hiddenAccounts, + ) => { + return createConsolidatedWallets( + internalAccounts, + accountTree, + connectedAccounts, + selectedAccount, + pinnedAccounts, + hiddenAccounts, + (groupAccounts) => groupAccounts, + ); + }, +); +``` + +**Guard rails:** + +- If a deep selector becomes hot, profile it with React DevTools before shipping +- Document why you chose createDeepEqualSelector so future contributors can revisit the trade-off + +### Rule: Combine Related Selectors into One Memoized Selector + +**DO:** + +- Combine multiple useSelector calls into a single memoized selector +- Reduce redundant subscriptions and re-renders + +**DON'T:** + +- Call multiple selectors in sequence (each creates separate subscription) + +**Example - WRONG:** + +```typescript +const { + activeQuote, + isQuoteGoingToRefresh, + isLoading: isQuoteLoading, +} = useSelector(getBridgeQuotes); +const currency = useSelector(getCurrentCurrency); +const { insufficientBal } = useSelector(getQuoteRequest); +const fromChain = useSelector(getFromChain); +// ... 11+ more selectors +``` + +**Example - CORRECT:** + +```typescript +const selectBridgeQuoteCardView = createSelector( + [ + getBridgeQuotes, + getCurrentCurrency, + getQuoteRequest, + getFromChain, + getIntlLocale, + getIsStxEnabled, + getFromToken, + getToToken, + getSlippage, + getIsSolanaSwap, + getIsToOrFromNonEvm, + getPriceImpactThresholds, + ], + ( + bridgeQuotes, + currency, + quoteRequest, + fromChain, + locale, + isStxEnabled, + fromToken, + toToken, + slippage, + isSolanaSwap, + isToOrFromNonEvm, + priceImpactThresholds, + ) => ({ + activeQuote: bridgeQuotes.activeQuote, + isQuoteGoingToRefresh: bridgeQuotes.isQuoteGoingToRefresh, + isQuoteLoading: bridgeQuotes.isLoading, + currency, + insufficientBal: quoteRequest.insufficientBal, + fromChain, + locale, + isStxEnabled, + fromToken, + toToken, + slippage, + isSolanaSwap, + isToOrFromNonEvm, + priceImpactThresholds, + }), +); + +const MultichainBridgeQuoteCard = () => { + const { + activeQuote, + isQuoteGoingToRefresh, + isQuoteLoading, + currency, + insufficientBal, + fromChain, + locale, + isStxEnabled, + fromToken, + toToken, + slippage, + isSolanaSwap, + isToOrFromNonEvm, + priceImpactThresholds, + } = useSelector(selectBridgeQuoteCardView); +}; +``` + +**Benefits:** + +- Only one subscription; component rerenders once per state change instead of once per selector +- Shared memoization ensures combined output only changes when at least one dependency does +- Centralizes domain-specific shaping logic in selector layer + +### Rule: Avoid Inline Selector Functions in useSelector + +**DO:** + +- Extract selector functions to memoized selectors +- Use useCallback for selector functions if needed + +**DON'T:** + +- Create selector functions inline in useSelector (creates new reference every render) + +**Example - WRONG:** + +```typescript +const Connections = () => { + const subjectMetadata = useSelector((state) => { + return getConnectedSitesList(state); + }); + + const connectedAccountGroups = useSelector((state) => { + if (!showConnectionStatus || permittedAddresses.length === 0) { + return []; + } + return getAccountGroupsByAddress(state, permittedAddresses); + }); +}; +``` + +**Example - CORRECT:** + +```typescript +// Option 1: Extract to memoized selector (preferred) +const selectConnectedAccountGroups = createSelector( + [ + (state) => state, + (_state, showConnectionStatus: boolean) => showConnectionStatus, + (_state, _showConnectionStatus, permittedAddresses: string[]) => + permittedAddresses, + ], + (state, showConnectionStatus, permittedAddresses) => { + if (!showConnectionStatus || permittedAddresses.length === 0) { + return []; + } + return getAccountGroupsByAddress(state, permittedAddresses); + }, +); + +const Connections = () => { + const subjectMetadata = useSelector(getConnectedSitesList); + const connectedAccountGroups = useSelector((state) => + selectConnectedAccountGroups( + state, + showConnectionStatus, + permittedAddresses, + ), + ); +}; + +// Option 2: Use useCallback for selector function +const Connections = () => { + const selectConnectedGroups = useCallback( + (state) => { + if (!showConnectionStatus || permittedAddresses.length === 0) { + return []; + } + return getAccountGroupsByAddress(state, permittedAddresses); + }, + [showConnectionStatus, permittedAddresses], + ); + + const connectedAccountGroups = useSelector(selectConnectedGroups); +}; +``` + +### Rule: Avoid Multiple useSelector Calls for Same State Slice + +**DO:** + +- Select entire slice once or create single memoized selector + +**DON'T:** + +- Create multiple useSelector calls for the same state slice (creates unnecessary subscriptions) + +**Example - WRONG:** + +```typescript +const Routes = () => { + const alertOpen = useAppSelector((state) => state.appState.alertOpen); + const alertMessage = useAppSelector((state) => state.appState.alertMessage); + const isLoading = useAppSelector((state) => state.appState.isLoading); + const loadingMessage = useAppSelector( + (state) => state.appState.loadingMessage, + ); + // ... 20+ more selectors from same slice +}; +``` + +**Example - CORRECT:** + +```typescript +// Option 1: Select entire slice once +const Routes = () => { + const appState = useAppSelector((state) => state.appState); + const { alertOpen, alertMessage, isLoading, loadingMessage } = appState; +}; + +// Option 2: Create single memoized selector +const selectAppState = (state) => state.appState; +const selectAppStateSlice = createSelector([selectAppState], (appState) => ({ + alertOpen: appState.alertOpen, + alertMessage: appState.alertMessage, + isLoading: appState.isLoading, + loadingMessage: appState.loadingMessage, + // ... other properties +})); + +const Routes = () => { + const appStateSlice = useAppSelector(selectAppStateSlice); +}; +``` + +### Rule: Avoid Inefficient Use of Object.values() and Object.keys() in Selectors + +**DO:** + +- Store arrays alongside objects in state if frequently accessed +- Properly memoize object-to-array conversion +- Use createDeepEqualSelector for deeply nested structures +- Normalize state structure to avoid conversions + +**DON'T:** + +- Use Object.values() or Object.keys() in selectors without proper memoization (creates new array references on every call) + +**Why:** When state is stored as objects keyed by ID, selectors frequently use Object.values() to convert to arrays. This creates new array references on every selector evaluation, even when underlying data hasn't changed, causing unnecessary re-renders. + +**Example - WRONG:** + +```typescript +export const getInternalAccounts = createSelector( + (state: AccountsState) => + Object.values(state.metamask.internalAccounts.accounts), // New array every time! + (accounts) => accounts, // Identity function doesn't help +); +``` + +**Solution 1: Store Arrays Alongside Objects** + +```typescript +interface AccountsState { + accounts: { + byId: Record; + allIds: string[]; // Maintained alongside byId + }; +} + +// Selector uses pre-computed array +const selectAllAccounts = createSelector( + (state) => state.accounts.allIds, + (state) => state.accounts.byId, + (allIds, byId) => allIds.map((id) => byId[id]), +); +``` + +**Solution 2: Proper Memoization** + +```typescript +// Base selector returns the object +const selectAccountsObject = (state: AccountsState) => + state.metamask.internalAccounts.accounts; + +// Memoized conversion selector +export const getInternalAccounts = createSelector( + selectAccountsObject, + (accountsObject) => { + // Only creates array when accountsObject reference changes + return Object.values(accountsObject); + }, +); +``` + +**Solution 3: Normalize State Structure** + +```typescript +// Before: Nested objects +{ + metamask: { + allNfts: { + [account]: { + [chainId]: Nft[] + } + } + } +} + +// After: Normalized with indexes +{ + metamask: { + nfts: { + byId: { [nftId]: Nft }, + allIds: string[], + byAccountId: { [accountId]: string[] }, + byChainId: { [chainId]: string[] }, + } + } +} + +// Selector uses indexes instead of Object.values() +const selectNftsByAccount = createSelector( + (state, accountId) => state.metamask.nfts.byAccountId[accountId] ?? [], + (state) => state.metamask.nfts.byId, + (nftIds, nftsById) => nftIds.map((id) => nftsById[id]), +); +``` + +### Rule: Avoid Deep Property Access in Selectors + +**DO:** + +- Use granular input selectors for each level +- Compose selectors from base selectors + +**DON'T:** + +- Access deeply nested properties directly (fragile to state structure changes) + +**Example - WRONG:** + +```typescript +const selectGroupAccounts = createSelector( + (state, walletId, groupId) => + state.metamask.accountTree.wallets[walletId]?.groups[groupId]?.accounts ?? + [], + (accounts) => accounts, +); +``` + +**Example - CORRECT:** + +```typescript +// Base selectors for each level +const selectAccountTree = (state) => state.metamask.accountTree; +const selectWallet = createSelector( + [selectAccountTree, (_, walletId) => walletId], + (accountTree, walletId) => accountTree.wallets[walletId], +); +const selectGroup = createSelector( + [selectWallet, (_, __, groupId) => groupId], + (wallet, groupId) => wallet?.groups[groupId], +); + +// Composed selector +const selectGroupAccounts = createSelector( + [selectGroup], + (group) => group?.accounts ?? [], +); +``` + +### Rule: Avoid Repeated Object Traversal in Selectors + +**DO:** + +- Use shared base selector and composition +- Build derived selectors from base selectors + +**DON'T:** + +- Traverse the same nested object structure independently in multiple selectors + +**Example - WRONG:** + +```typescript +const selectAllWallets = createSelector( + (state) => state.metamask.accountTree.wallets, + (wallets) => Object.values(wallets), +); + +const selectAllGroups = createSelector( + (state) => state.metamask.accountTree.wallets, + (wallets) => { + return Object.values(wallets).flatMap((wallet) => + Object.values(wallet.groups), + ); + }, +); + +const selectAllAccounts = createSelector( + (state) => state.metamask.accountTree.wallets, + (wallets) => { + return Object.values(wallets).flatMap((wallet) => + Object.values(wallet.groups).flatMap((group) => group.accounts), + ); + }, +); +``` + +**Example - CORRECT:** + +```typescript +// Single traversal, multiple derived selectors +const selectWalletsObject = (state) => state.metamask.accountTree.wallets; + +const selectAllWallets = createSelector([selectWalletsObject], (wallets) => + Object.values(wallets), +); + +const selectAllGroups = createSelector([selectAllWallets], (wallets) => + wallets.flatMap((wallet) => Object.values(wallet.groups)), +); + +const selectAllAccounts = createSelector([selectAllGroups], (groups) => + groups.flatMap((group) => group.accounts), +); +``` + +### Rule: Avoid Selectors That Reorganize Nested State + +**DO:** + +- Store data in the needed format if both formats are needed frequently +- Normalize state to avoid reorganization + +**DON'T:** + +- Reorganize nested state structures on every selector call (expensive, creates new object references) + +**Example - WRONG:** + +```typescript +export const getNftContractsByAddressByChain = createSelector( + getNftContractsByChainByAccount, + (nftContractsByChainByAccount) => { + // Expensive reorganization + const userAccounts = Object.keys(nftContractsByChainByAccount); + const allNftContracts = userAccounts + .map((account) => + Object.keys(nftContractsByChainByAccount[account]).map((chainId) => + nftContractsByChainByAccount[account][chainId].map((contract) => ({ + ...contract, + chainId, + })), + ), + ) + .flat() + .flat(); + + return allNftContracts.reduce( + (acc, contract) => { + const { chainId, ...data } = contract; + const chainIdContracts = acc[chainId] ?? {}; + acc[chainId] = chainIdContracts; + chainIdContracts[data.address.toLowerCase()] = data; + return acc; + }, + {} as { [chainId: string]: { [address: string]: NftContract } }, + ); + }, +); +``` + +**Example - CORRECT:** + +```typescript +// Option 1: Store in both formats if both are needed frequently +interface NftState { + byAccountByChain: { [account]: { [chainId]: NftContract[] } }; + byChainByAddress: { [chainId]: { [address]: NftContract } }; // Pre-computed +} + +// Option 2: Normalize to avoid reorganization +interface NftState { + contracts: { + byId: { [contractId]: NftContract }; + byAccountId: { [accountId]: string[] }; + byChainId: { [chainId]: string[] }; + byAddress: { [address]: string[] }; + }; +} +``` + +### Rule: Avoid Filtering/Searching Through Nested Objects + +**DO:** + +- Maintain lookup indexes in state +- Use O(1) lookups instead of O(n) searches + +**DON'T:** + +- Use Object.values().find() or Object.values().filter() to search (O(n), creates temporary arrays) + +**Example - WRONG:** + +```typescript +export const getInternalAccountByAddress = createSelector( + (state) => state.metamask.internalAccounts.accounts, + (_, address) => address, + (accounts, address) => { + return Object.values(accounts).find((account) => + isEqualCaseInsensitive(account.address, address), + ); + }, +); +``` + +**Example - CORRECT:** + +```typescript +// State includes address-to-ID mapping +interface AccountsState { + accounts: { + byId: Record; + byAddress: Record; // address -> accountId + }; +} + +const selectAccountByAddress = createSelector( + (state, address) => + state.metamask.internalAccounts.accounts.byAddress[address.toLowerCase()], + (state) => state.metamask.internalAccounts.accounts.byId, + (accountId, accountsById) => + accountId ? accountsById[accountId] : undefined, +); +``` diff --git a/domains/performance/skills/perf-state-management/skill.md b/domains/performance/skills/perf-state-management/skill.md new file mode 100644 index 0000000..f571dcc --- /dev/null +++ b/domains/performance/skills/perf-state-management/skill.md @@ -0,0 +1,4 @@ +--- +name: perf-state-management +description: Redux and state management optimization +--- diff --git a/domains/perps/knowledge/architecture.md b/domains/perps/knowledge/architecture.md new file mode 100644 index 0000000..56f7ac8 --- /dev/null +++ b/domains/perps/knowledge/architecture.md @@ -0,0 +1,677 @@ +--- +name: architecture +domain: perps +description: Perps architecture overview: layers, hooks, components, data flow +--- + +# Perps Architecture + +## Overview + +The Perps feature enables perpetual futures trading in MetaMask Mobile. This document provides a high-level architectural overview of the codebase structure, key patterns, and references to detailed documentation. + +**Locations**: + +- **Controller**: `app/controllers/perps/` — business logic, providers, services, types (portable, no mobile-specific imports) +- **UI**: `app/components/UI/Perps/` — React components, hooks, views, contexts + +## Quick Navigation + +- **[Connection Architecture](./perps-connection-architecture.md)** - Connection lifecycle, reconnection logic, WebSocket management +- **[Screen Documentation](./perps-screens.md)** - Detailed view documentation +- **[Sentry Integration](./perps-sentry-reference.md)** - Error tracking and monitoring +- **[MetaMetrics Events](./perps-metametrics-reference.md)** - Analytics events +- **[Protocol Documentation](./hyperliquid/)** - HyperLiquid protocol specifics + +## Layer Architecture + +The Perps system uses a layered architecture where each layer has clear responsibilities: + +```mermaid +graph TD + UI[UI Components] -->|consume| Hooks[React Hooks] + Hooks -->|subscribe to| Streams[Stream Manager] + Streams -->|coordinate with| Connection[Connection Manager] + Connection -->|orchestrates| Controller[Perps Controller] + Controller -->|manages| Provider[Protocol Provider] + Provider -->|communicates with| Protocol[HyperLiquid API] + + Controller -->|stores data in| Redux[Redux State] + Hooks -->|read from| Redux + + style Streams fill:#e1f5ff + style Connection fill:#e1f5ff +``` + +### Layer Responsibilities + +| Layer | Purpose | Examples | +| ---------------------- | ------------------------------------------------- | -------------------------------------------------- | +| **UI Components** | Presentational components, user interactions | PerpsOrderView, PerpsMarketList, PerpsPositionCard | +| **React Hooks** | Data access, business logic, state management | usePerpsTrading, usePerpsMarkets, useLivePrices | +| **Stream Manager** | WebSocket subscription management, real-time data | PerpsStreamManager, component-level throttling | +| **Connection Manager** | Connection lifecycle, reconnection orchestration | PerpsConnectionManager (singleton) | +| **Perps Controller** | Business logic, provider management, Redux state | PerpsController (Redux controller) | +| **Protocol Provider** | Exchange-specific API implementation | HyperLiquidProvider (REST + WebSocket) | + +**See [perps-connection-architecture.md](./perps-connection-architecture.md) for detailed connection flow.** + +## Directory Structure + +### Controller (`app/controllers/perps/`) + +The controller is isolated from mobile-specific code and published as `@metamask/perps-controller`. +See [ADR-042](https://github.com/MetaMask/core/blob/main/docs/adr/ADR-042-perps-controller-location.md) for the architectural decision. + +``` +app/controllers/perps/ +├── PerpsController.ts - Main controller (state, lifecycle, provider mgmt) +├── index.ts - Package entry point +├── perpsErrorCodes.ts - Error code definitions +├── selectors.ts - Redux state selectors +├── providers/ - Protocol-specific implementations +│ ├── HyperLiquidProvider.ts +│ ├── MYXProvider.ts +│ └── AggregatedPerpsProvider.ts +├── services/ - Business logic services +│ ├── AccountService.ts +│ ├── TradingService.ts +│ ├── MarketDataService.ts +│ ├── EligibilityService.ts +│ ├── DepositService.ts +│ ├── DataLakeService.ts +│ ├── RewardsIntegrationService.ts +│ ├── FeatureFlagConfigurationService.ts +│ ├── HyperLiquidClientService.ts +│ ├── HyperLiquidSubscriptionService.ts +│ ├── HyperLiquidWalletService.ts +│ ├── MYXClientService.ts +│ ├── TradingReadinessCache.ts +│ └── ServiceContext.ts +├── routing/ - Provider routing logic +├── aggregation/ - Multi-provider aggregation +├── types/ - TypeScript type definitions +├── constants/ - Configuration values +└── utils/ - Pure utility functions +``` + +### UI (`app/components/UI/Perps/`) + +``` +app/components/UI/Perps/ +├── components/ - Reusable UI components +├── Views/ - Main screen-level components +├── hooks/ - React hooks for data access and logic +│ └── stream/ - WebSocket subscription hooks (real-time data) +├── providers/ - React context providers +├── selectors/ - Redux selectors by domain +├── contexts/ - React contexts +├── styles/ - Shared style utilities +├── Debug/ - Developer tools +├── animations/ - Rive animation files +└── __mocks__/ - Test mocks and fixtures +``` + +### Components + +Reusable UI components organized by feature: + +- **Display Components**: LivePriceDisplay, PerpsAmountDisplay, PerpsBadge, PerpsProgressBar, PerpsLoader +- **Form Components**: PerpsSlider, PerpsOrderTypeBottomSheet, PerpsLeverageBottomSheet, PerpsLimitPriceBottomSheet +- **Card Components**: PerpsCard, PerpsPositionCard, PerpsOpenOrderCard, PerpsMarketStatisticsCard +- **List Components**: PerpsMarketList, PerpsRecentActivityList, PerpsWatchlistMarkets +- **Modal Components**: PerpsCancelAllOrdersModal, PerpsCloseAllPositionsModal, PerpsGTMModal +- **Header Components**: PerpsHomeHeader, PerpsMarketHeader, PerpsOrderHeader, PerpsTabControlBar +- **Navigation**: PerpsNavigationCard, PerpsMarketTabs +- **Tooltips**: PerpsBottomSheetTooltip (with content registry), PerpsNotificationTooltip +- **Charts**: TradingViewChart, PerpsCandlestickChartIntervalSelector, FundingCountdown +- **Developer Tools**: PerpsDeveloperOptionsSection + +### Views + +Main screen-level components representing full pages: + +- **PerpsTabView** - Main tab container with navigation +- **PerpsHomeView** - Landing/dashboard screen +- **PerpsMarketListView** - Market browser with search/filters +- **PerpsMarketDetailsView** - Individual market with chart +- **PerpsOrderView** - Order entry form +- **PerpsPositionsView** - Active positions list +- **PerpsClosePositionView** - Single position close flow +- **PerpsCloseAllPositionsView** - Close all positions flow +- **PerpsCancelAllOrdersView** - Cancel all orders flow +- **PerpsTPSLView** - Take profit/stop loss management +- **PerpsTransactionsView** - Transaction history +- **PerpsWithdrawView** - Withdrawal flow +- **PerpsHeroCardView** - Hero/banner cards +- **PerpsEmptyState** - Empty state screens +- **PerpsRedirect** - Routing/redirect logic +- **HIP3DebugView** - Developer debug interface + +**See [perps-screens.md](./perps-screens.md) for detailed view documentation.** + +### Hooks + +React hooks organized by category: + +#### Controller Access + +- `usePerpsTrading` - Trading operations (place/cancel/close) +- `usePerpsDeposit` - Deposit flow +- `usePerpsDepositQuote` - Deposit quotes +- `usePerpsMarkets` - Market data +- `usePerpsNetwork` - Network configuration +- `usePerpsWithdrawQuote` - Withdrawal quotes + +#### State Management + +- `usePerpsAccount` - Redux account state +- `usePerpsConnection` - Connection provider context +- `usePerpsPositions` - Position list +- `usePerpsNetworkConfig` - Network state +- `usePerpsOpenOrders` - Open orders list + +#### Live Data (Stream Architecture) + +- `useLivePrices` - Real-time prices with component-level throttling +- `usePerpsLiveAccount` - Account state updates +- `usePerpsLiveFills` - Order fill notifications +- `usePerpsLiveOrders` - Order updates +- `usePerpsLivePositions` - Position updates +- `usePerpsTopOfBook` - Top-of-book data +- `usePerpsPositionData` - Position data aggregation + +#### Calculations + +- `usePerpsLiquidationPrice` - Liquidation price calculation +- `usePerpsOrderFees` - Fee calculation +- `useMinimumOrderAmount` - Minimum order calculation +- `usePerpsMarketData` - Market-specific data +- `usePerpsMarketStats` - Market statistics +- `usePerpsFunding` - Funding rate data + +#### Validation + +- `usePerpsOrderValidation` - Order validation (protocol + UI rules) +- `usePerpsClosePositionValidation` - Close validation +- `useWithdrawValidation` - Withdrawal validation + +#### Form Management + +- `usePerpsOrderForm` - Order form state +- `usePerpsOrderExecution` - Order execution flow +- `usePerpsClosePosition` - Close position flow +- `usePerpsTPSLForm` - TP/SL form management +- `usePerpsTPSLUpdate` - TP/SL updates + +#### UI Utilities + +- `useColorPulseAnimation` - Price change animations +- `useBalanceComparison` - Balance comparison +- `useHasExistingPosition` - Position existence check +- `useStableArray` - Array reference stability +- `usePerpsNavigation` - Navigation utilities +- `usePerpsToasts` - Toast notifications + +#### Assets/Tokens + +- `usePerpsAssetsMetadata` - Asset metadata +- `usePerpsPaymentTokens` - Payment tokens +- `useWithdrawTokens` - Withdrawal tokens + +#### Monitoring & Tracking + +- `usePerpsEventTracking` - Analytics events +- `usePerpsDataMonitor` - Data monitoring +- `usePerpsMeasurement` - Performance measurement +- `usePerpsDepositStatus` - Deposit status tracking +- `usePerpsWithdrawStatus` - Withdrawal status tracking + +### Controller (`app/controllers/perps/`) + +Business logic and Redux state management. Isolated from mobile-specific code — uses `PerpsPlatformDependencies` for dependency injection of platform-specific services (logging, metrics, feature flags, etc.). + +- **PerpsController** (`app/controllers/perps/PerpsController.ts`) - Main controller managing providers, orders, positions, market data +- **HyperLiquidProvider** (`app/controllers/perps/providers/HyperLiquidProvider.ts`) - HyperLiquid protocol implementation +- **MYXProvider** (`app/controllers/perps/providers/MYXProvider.ts`) - MYX protocol implementation +- **AggregatedPerpsProvider** (`app/controllers/perps/providers/AggregatedPerpsProvider.ts`) - Multi-protocol aggregation via `ProviderRouter` +- **Selectors** (`app/controllers/perps/selectors.ts`) - Redux state selectors +- **Error Codes** (`app/controllers/perps/perpsErrorCodes.ts`) - Error code definitions + +### Services (`app/controllers/perps/services/`) + +Business logic services instantiated with platform dependencies: + +- **AccountService** - Account state, balances, withdrawals +- **TradingService** - Order placement, cancellation, position management +- **MarketDataService** - Market info, candles, funding rates +- **EligibilityService** - User eligibility and geo-blocking +- **DepositService** - Deposit flow with transaction confirmation +- **DataLakeService** - Historical data queries +- **RewardsIntegrationService** - Rewards program integration +- **FeatureFlagConfigurationService** - Remote feature flag configuration (HIP-3, geo-blocking) +- **HyperLiquidClientService** - HTTP client for HyperLiquid REST API +- **HyperLiquidSubscriptionService** - WebSocket subscription management +- **HyperLiquidWalletService** - Wallet operations and signing +- **MYXClientService** - HTTP client for MYX protocol +- **TradingReadinessCache** - Cached trading readiness state +- **PerpsConnectionManager** (`app/components/UI/Perps/services/`) - Connection lifecycle orchestration (mobile-specific singleton, stays in UI layer) + +### Providers + +React context providers: + +- **PerpsAlwaysOnProvider** - Top-level always-on lifecycle manager (mounted at Wallet root); single caller of connect/disconnect on the PerpsConnectionManager singleton +- **PerpsConnectionProvider** - Connection state and methods for UI; all instances use `manageLifecycle={false}` — lifecycle is delegated to PerpsAlwaysOnProvider +- **PerpsStreamManager** - WebSocket stream management with caching +- **PerpsOrderContext** - Order form context + +### Utils + +Pure utility functions organized by domain: + +- **Calculations**: orderCalculations, positionCalculations, pnlCalculations +- **Formatting**: formatUtils, amountConversion, textUtils +- **Validation**: hyperLiquidValidation, tpslValidation +- **Transforms**: marketDataTransform, transactionTransforms, arbitrumWithdrawalTransforms +- **Market Utils**: marketUtils, marketHours, sortMarkets +- **Error Handling**: perpsErrorHandler, translatePerpsError +- **Protocol**: hyperLiquidAdapter, hyperLiquidOrderBookProcessor +- **Blockchain**: idUtils, tokenIconUtils + +## Key Patterns + +### Validation Flow + +Protocol validation (provider) → UI validation (hook) → Display errors (component) + +```typescript +// Provider validates protocol rules +provider.validateOrder(order) // throws if invalid + +// Hook adds UI-specific rules +usePerpsOrderValidation(orderParams) // returns { isValid, errors } + +// Component displays errors +{errors.amount && {errors.amount}} +``` + +### Data Flow + +Controller → Redux Store → Hooks → Components + +```typescript +// Controller fetches and stores +await controller.getAccountState() // updates Redux + +// Hook reads from Redux +const account = usePerpsAccount() // subscribes to Redux + +// Component renders +{account.balance} +``` + +### Real-time Updates + +WebSocket → Stream Manager → Hooks → Components + +```typescript +// Stream Manager maintains single WebSocket connection +streamManager.subscribeToPrices(['BTC', 'ETH']) + +// Hook throttles updates at component level +const prices = useLivePrices({ + symbols: ['BTC', 'ETH'], + throttleMs: 2000, // 2s updates +}) + +// Component renders with throttled data +{prices.BTC?.price} +``` + +**See [perps-connection-architecture.md](./perps-connection-architecture.md) for WebSocket architecture details.** + +### Background Preloading + +Market data and user data are preloaded in the background before the user opens Perps, enabling instant rendering of all sections on the home screen. + +#### Preload Pipeline + +| Step | Method | What It Does | +| ----------------- | ---------------------------------------------------- | ------------------------------------------------------------ | +| 1. Trigger | `startMarketDataPreload()` (`PerpsAlwaysOnProvider`) | Starts immediate fetch + 5-min periodic refresh | +| 2. Market data | `performMarketDataPreload()` | Fetches market data via standalone REST → `cachedMarketData` | +| 3. User data | `performUserDataPreload()` | Fetches positions, orders, account state → cached fields | +| 4. Cache guard | `PRELOAD_GUARD_MS` (30s) | Debounce to prevent rapid re-fetches | +| 5. Account change | State-change handler | Clears user data cache, re-preloads | + +#### Cache-Seeded Hook Initialization + +Hooks use lazy `useState` initializers to read cached data from the controller, so the first render already has data instead of showing an empty skeleton. + +| Utility | Purpose | +| ---------------------------- | ---------------------------------------------------- | +| `hasPreloadedData(field)` | Returns `true` if controller cache field is non-null | +| `getPreloadedData(field)` | Returns cached value or `null` | + +Cache freshness is managed by the controller's 5-minute preload cycle, not by the hooks — there is no client-side TTL. + +#### What the User Sees + +| Timing | Content | +| ------------ | ------------------------------------------------------------------------------------ | +| **Instant** | Market lists, positions, orders, and account balance populated from cached REST data | +| **~1-2s** | Live WebSocket data replaces cache with real-time updates | +| **On error** | `PerpsConnectionErrorView` renders (unchanged behavior) | + +**See [perps-connection-architecture.md](./perps-connection-architecture.md) for detailed preloading architecture.** + +### Form Management + +Component input → Hook state → Validation → Controller action + +```typescript +// Component captures input + + +// Hook manages form state +const { amount, setAmount, errors } = usePerpsOrderForm() + +// Hook validates +const validation = usePerpsOrderValidation({ amount, ... }) + +// Hook executes when valid +if (validation.isValid) { + await controller.placeOrder(params) +} +``` + +### Standalone Mode (Lightweight Queries) + +For discovery use cases that need perps data without full initialization: + +```typescript +// Check if perps market exists for an asset (usePerpsMarketForAsset hook) +const markets = await perpsController.getMarkets({ + symbols: ['ETH'], + standalone: true, +}); + +// Query positions for any address without WebSocket, wallet setup, etc. +const positions = await perpsController.getPositions({ + standalone: true, + userAddress: '0x...', +}); + +// Check if user has perps funds (for discovery banners) +const accountState = await perpsController.getAccountState({ + standalone: true, + userAddress: '0x...', +}); +``` + +**Supported methods:** `getMarkets`, `getPositions`, `getAccountState` + +**When to use:** + +- Spot token detail pages checking for perps market availability (see `usePerpsMarketForAsset`) +- Token detail pages showing perps positions +- Discovery banners checking if user has perps funds +- Portfolio analytics without entering perps context + +**How it works:** + +1. Bypasses `getActiveProvider()` check (works even when controller is not initialized) +2. Creates standalone HTTP client via `createStandaloneInfoClient` (see `utils/standaloneInfoClient.ts`) +3. No WebSocket, wallet, or account setup required +4. HIP-3 multi-DEX aggregation supported (positions and account state) + +**Limitations:** + +- No TP/SL data on positions (would require additional API calls) +- No spot balance aggregation on account state +- No real-time updates (HTTP only, no WebSocket) + +### Cache Invalidation + +Standalone queries use client-side caching for performance (e.g., 30s TTL for positions). +The `PerpsCacheInvalidator` service provides loosely-coupled cache invalidation when +data changes in the perps environment: + +**Hook side (consumers):** + +```typescript +import { PerpsCacheInvalidator } from '../services/PerpsCacheInvalidator'; + +// Subscribe to invalidation events +useEffect(() => { + const unsubPositions = PerpsCacheInvalidator.subscribe('positions', () => { + clearMyCache(); + refetch(); + }); + const unsubAccount = PerpsCacheInvalidator.subscribe('accountState', () => { + clearMyCache(); + refetch(); + }); + return () => { + unsubPositions(); + unsubAccount(); + }; +}, []); +``` + +**Service side (producers):** + +```typescript +// After successful position change (TradingService) +PerpsCacheInvalidator.invalidate('positions'); +PerpsCacheInvalidator.invalidate('accountState'); + +// After successful withdrawal (AccountService) +PerpsCacheInvalidator.invalidate('accountState'); +``` + +**Cache types:** + +- `positions` - Position data caches (invalidated on order placement, position close) +- `accountState` - Account balance/state caches (invalidated on trades, withdrawals) +- `markets` - Market data caches (rarely changes) + +This pattern allows token detail pages to show accurate position status even after +the user closes positions in the perps environment, without polling or WebSocket overhead. + +## Stream Architecture + +**Single WebSocket connections shared across all components with component-level debouncing.** + +### Benefits + +- **90% fewer WebSocket connections** - One subscription per data type (not per component) +- **No subscription interference** - Each component controls its own update rate +- **Component-level control** - Different throttle rates for different views +- **Instant first render** - Pre-warmed connections provide cached data immediately +- **Zero parent re-renders** - Updates go directly to subscribers + +### How It Works + +1. **PerpsConnectionManager** pre-warms critical subscriptions on connection +2. **PerpsStreamManager** maintains single WebSocket subscriptions with reference counting +3. **Stream Hooks** provide component-level throttling: + +```typescript +// Order view: stable prices (10s throttle) +const prices = useLivePrices({ symbols: ['BTC'], throttleMs: 10000 }); + +// Market list: responsive updates (2s throttle) +const prices = useLivePrices({ symbols: allSymbols, throttleMs: 2000 }); + +// Charts: near real-time (100ms throttle) +const prices = useLivePrices({ symbols: ['BTC'], throttleMs: 100 }); +``` + +4. **Shared cache** ensures instant data availability for all subscribers + +**See [perps-connection-architecture.md](./perps-connection-architecture.md) for detailed stream architecture.** + +## Quick Reference + +| Need | Use Hook | Use Component | +| -------------- | -------------------------------------------- | ------------------------- | +| Place order | `usePerpsTrading` + `usePerpsOrderExecution` | PerpsOrderView | +| Validate order | `usePerpsOrderValidation` | - | +| Get prices | `useLivePrices` | LivePriceDisplay | +| Manage form | `usePerpsOrderForm` | - | +| Calculate fees | `usePerpsOrderFees` | PerpsFeesDisplay | +| Check position | `useHasExistingPosition` | - | +| Close position | `usePerpsClosePosition` + validation | PerpsClosePositionView | +| Get account | `usePerpsAccount` | - | +| Deposit funds | `usePerpsDeposit` | PerpsMarketBalanceActions | +| Withdraw funds | `usePerpsWithdrawQuote` + validation | PerpsWithdrawView | +| Show market | - | PerpsMarketDetailsView | +| List markets | `usePerpsMarkets` | PerpsMarketListView | + +## Error Handling + +Perps uses a multi-layered error handling approach: + +1. **Provider Layer** - Protocol-specific errors, logs to Sentry +2. **Controller Layer** - Business logic errors, updates Redux, logs to Sentry +3. **Manager Layer** - Connection errors, sets local state, logs to DevLogger +4. **Hook Layer** - Exposes errors to UI +5. **Component Layer** - Displays errors to user + +**See [perps-sentry-reference.md](./perps-sentry-reference.md) for error tracking details.** + +## Analytics + +All user interactions are tracked via MetaMetrics events: + +- Trading actions (orders, closes, cancels) +- Market interactions (views, searches, filters) +- Connection events (connect, disconnect, errors) +- Deposit/withdrawal flows + +**See [perps-metametrics-reference.md](./perps-metametrics-reference.md) for complete event catalog.** + +## Development Guidelines + +### Adding a New Hook + +1. Determine category (Controller Access, State Management, Live Data, etc.) +2. Follow naming convention: `usePerps[Feature][Action]` +3. Keep single responsibility +4. Add comprehensive tests +5. Document in this file + +### Adding a New Component + +1. Create in appropriate subdirectory under `components/` +2. Include `.styles.ts` file for styles +3. Add tests in `__tests__/` subdirectory +4. Export from component directory's `index.ts` +5. Use existing shared components where possible + +### Adding a New View + +1. Create in `Views/` directory +2. Follow naming: `Perps[Feature]View` +3. Use hooks for data access (not direct controller calls) +4. Add to navigation in `routes/index.tsx` +5. Document in [perps-screens.md](./perps-screens.md) + +### Before Committing + +```bash +# Format code +yarn prettier --write 'app/components/UI/Perps/**/*.{ts,tsx}' + +# Check for errors +yarn eslint app/components/UI/Perps/**/*.{ts,tsx} + +# Run tests +yarn jest app/components/UI/Perps/ --no-coverage +``` + +## Testing + +- **Test Coverage**: ~95% across hooks, components, and utilities +- **Test Location**: Co-located `__tests__/` directories or `.test.ts` files +- **Mock System**: Centralized mocks in `__mocks__/` directory + +Key testing utilities: + +- `perpsHooksMocks.ts` - Mock hooks +- `perpsComponentMocks.ts` - Mock components +- `providerMocks.ts` - Mock providers +- `streamHooksMocks.ts` - Mock stream hooks + +## Code Quality + +The codebase maintains high quality standards: + +- **Test Coverage**: ~95% across hooks, components, and utilities +- **Architecture**: Tight cohesion with 59% of files used only internally +- **Patterns**: Consistent use of hooks, components, and utilities +- **Documentation**: Comprehensive inline and external documentation + +## Protocol Integration + +Multi-protocol architecture with provider abstraction: + +### HyperLiquid (primary) + +- **REST API** - Account queries, order placement, market data +- **WebSocket** - Real-time prices, order fills, position updates +- **Wallet Integration** - Ethereum signing for orders + +### MYX (feature-flagged) + +- MYX protocol support via `MYXProvider` and `MYXClientService` +- Enabled via `perpsMyxProviderEnabled` remote feature flag with version gating +- When enabled alongside HyperLiquid, uses `AggregatedPerpsProvider` with `ProviderRouter` + +### Multi-Protocol Architecture + +- **`ProviderRouter`** (`routing/`) - Routes operations to the correct provider based on market +- **`AggregatedPerpsProvider`** - Wraps multiple providers behind the `PerpsProvider` interface +- **`SubscriptionMultiplexer`** - Merges WebSocket subscriptions from multiple providers + +**See [hyperliquid/](./hyperliquid/) directory for HyperLiquid-specific documentation.** + +## Migration Notes + +### HIP-3 Upgrade (Nov 2024) + +Major protocol upgrade with webData3 migration: + +- Single WebSocket connection for positions + orders +- Improved performance and reliability +- See HIP3DebugView for debugging tools + +### Stream Architecture (Oct 2024) + +Migrated from per-component subscriptions to shared streams: + +- Old: `usePerpsPrices` (deprecated) +- New: `useLivePrices` with component-level throttling +- 90% reduction in WebSocket connections + +### Always-On Connection Architecture (Mar 2026) + +Migrated from per-section `PerpsConnectionProvider` lifecycle management to a single top-level `PerpsAlwaysOnProvider`: + +- **Old**: Multiple `PerpsConnectionProvider` instances in Homepage, PerpsTabView, ActivityView, TrendingView, ExploreSearchScreen, and UrlAutocomplete each called `connect()`/`disconnect()`. Reference-count edge cases caused intermittent bugs (positions not showing, 24h values missing) after long app backgrounding. +- **New**: Single `PerpsAlwaysOnProvider` at `Wallet/index.tsx` owns the entire lifecycle. All `PerpsConnectionProvider` instances use `manageLifecycle={false}` — they provide React context only. +- **Result**: `connectionRefCount` in `PerpsConnectionManager` stays exactly 1; no more reference-count races. Skeleton correctly shows on reconnect via `isConnecting` flag ORed into loading states in `usePerpsHomeData`. + +## Additional Resources + +- **[Perps Screens](./perps-screens.md)** - Detailed view documentation +- **[Connection Architecture](./perps-connection-architecture.md)** - Connection management deep dive +- **[Sentry Integration](./perps-sentry-reference.md)** - Error tracking +- **[MetaMetrics Events](./perps-metametrics-reference.md)** - Analytics events +- **[HyperLiquid Docs](./hyperliquid/)** - Protocol documentation + +## Questions? + +For architecture questions or contributions, refer to the specific documentation linked above or consult the team. diff --git a/domains/perps/knowledge/caching-architecture.md b/domains/perps/knowledge/caching-architecture.md new file mode 100644 index 0000000..78323b0 --- /dev/null +++ b/domains/perps/knowledge/caching-architecture.md @@ -0,0 +1,188 @@ +--- +name: caching-architecture +domain: perps +description: Four-tier caching: real-time, session, disk, preload +--- + +# Perps Caching Architecture + +## Overview + +Trading UX must feel instant. Users expect sub-second rendering of positions, orders, and prices — any loading skeleton on the Perps home screen feels like a broken app. Caching bridges the gap between slow REST APIs, WebSocket warmup latency, and the instant UI that traders demand. + +**Historical context**: The current architecture has a legacy REST preload layer that was built when WebSocket connections only existed while the Perps tab was visible. Now that `PerpsAlwaysOnProvider` keeps WS connected from wallet mount, parts of this preload infrastructure are redundant and candidates for removal. + +## How It Works Today + +Four tiers, from freshest to stalest: + +| Tier | What | Lifetime | Examples | +| ---------------- | --------------------------------- | --------------- | --------------------------------------------------------- | +| Real-time | WebSocket via StreamManager | While connected | Prices, positions, fills, orders, account balance | +| Session | Provider + TradingReadinessCache | App lifetime | DEX discovery, signing state, spot metadata, fee rates | +| Disk | MMKV via StreamManager/Controller | Across restarts | Market list, positions/orders for cold-start hydration | +| Preload (legacy) | REST via Controller | 5-min refresh | Market list for home screen, positions before WS connects | + +The Disk tier was added to eliminate the 1-2s loading skeleton on cold start. `PerpsController` persists preload snapshots to MMKV immediately after successful REST preloads, and `PerpsStreamManager` refreshes those snapshots from live channel data once WS is warm. On next launch, the controller hydrates in-memory caches from MMKV before React hooks read them, so the UI renders last-known structural data instantly. Volatile price fields are stripped and show `$---` until WS delivers fresh values. + +The Preload tier exists because WS wasn't always-on historically. With `PerpsAlwaysOnProvider`, it's partially redundant — WS data arrives within 1-2 seconds of app launch, making the 5-min REST cycle a safety net rather than a primary data source. + +## Data Flow: Cold Start to Live Data + +What happens when the app launches: + +1. **App launches** — `PerpsController` hydrates MMKV disk cache into `cachedMarketDataByProvider` during construction, then `startMarketDataPreload()` fires from `PerpsAlwaysOnProvider` +2. **UI hooks mount** — `getPreloadedData()` reads controller cache (now populated from disk) — instant render with structural data, prices show `$---` +3. **PerpsAlwaysOnProvider mounts** — WS connects — stream channels prewarm via `preloadSubscriptions()` +4. **WS data arrives** — overrides disk-hydrated data with live prices, positions, orders +5. **Controller + StreamManager persist to disk** — preload writes seed MMKV immediately, then live channel snapshots refresh MMKV on the normal throttled cadence + +The key coupling point: `MarketDataChannel.getCachedData()` falls back to the controller's REST cache. With disk hydration, this fallback returns MMKV-hydrated data instantly on cold start — no loading skeleton. + +## Per-Layer Details + +### Real-time Layer (Hooks + StreamManager + ConnectionManager) + +**UI Hooks** don't own caches — they read from stream channels first, then fall back to the controller's preloaded REST data via `getPreloadedData()`. + +**PerpsStreamManager** maintains 9 WebSocket channels: + +| Channel | Cache key | Scope | What it stores | +| ------------ | ----------------- | ---------- | ------------------------ | +| `prices` | `priceCache` Map | Global | Per-symbol price updates | +| `positions` | `'positions'` | Account | User positions array | +| `orders` | `'orders'` | Account | Open orders array | +| `account` | `'account'` | Account | Account state (balance) | +| `fills` | `'fills'` | Account | Recent fills (max 100) | +| `marketData` | `'markets'` | Global | Market data array | +| `oiCaps` | `'oiCaps'` | Global | OI cap strings | +| `topOfBook` | `cachedTopOfBook` | Per-symbol | Best bid/ask/spread | +| `candles` | (external class) | Per-symbol | Candle data | + +All 9 channels support `pause()`/`resume()` — pausing blocks emission to React subscribers while keeping the WebSocket alive. Used during brief operations to prevent UI flicker. + +**PerpsConnectionManager** doesn't own caches — it orchestrates clearing of stream channels during lifecycle events. See [Cache Clearing Matrix](#cache-clearing-matrix). + +### Session Layer: Provider Instance Caches + +| Cache | What it stores | Scope | Cleared on disconnect? | +| ----------------------------- | ---------------------------------- | ------- | ----------------------- | +| `#cachedValidatedDexs` | Feature-flag-filtered DEX names | Global | No (intentional) | +| `#cachedAllPerpDexs` | Raw `perpDexs()` API objects | Global | No (intentional) | +| `#perpDexsCache` | Extended DEX data with fee scales | Session | Yes | +| `#dexDiscoveryComplete` | Boolean gate for retry logic | Session | Yes (reset to false) | +| `#cachedMetaByDex` | Per-DEX meta responses | Session | Yes | +| `#cachedSpotMeta` | Spot metadata (USDC token info) | Session | Yes | +| `#cachedMarketDataWithPrices` | Last known-good market snapshot | Global | No | +| `#symbolToAssetId` | Symbol-to-asset-ID mapping | Session | Rebuilt on init | +| `#maxLeverageCache` | Per-asset max leverage (TTL-based) | Session | No (TTL-based eviction) | +| `#userFeeCache` | Per-user fee rates (TTL-based) | Account | No (TTL-based eviction) | +| `#referralCheckCache` | Referral state per user | Account | Yes | +| `#builderFeeCheckCache` | Builder fee approval per user | Account | Yes | + +**Critical note**: `#cachedValidatedDexs` and `#cachedAllPerpDexs` are two views of the same `perpDexs()` API response. The [P1 dual-cache desync bug](postmortems/2026-03-24-hip3-asset-id-cache-poisoning.md) was caused by a code path writing one without the other. + +### Session Layer: TradingReadinessCache (Global Singleton) + +| Cache | What it stores | Key format | Cleared on disconnect? | +| --------------- | -------------------------------------- | ---------------------------- | ---------------------- | +| Signing state | DEX abstraction, builder fee, referral | `network:userAddress` | Never (intentional) | +| In-flight locks | Concurrent signing operation guards | `opType:network:userAddress` | Self-clearing | + +This is the most important "survives everything" cache. Providers are recreated on account/network changes, which resets all instance-level caches. TradingReadinessCache persists as a global singleton specifically to remember that a hardware wallet user already approved DEX abstraction — without it, every reconnect would trigger another QR code scan. + +### Disk Layer: MMKV Cold-Start Cache + +MMKV snapshots are written by both `PerpsController` preload and `PerpsStreamManager` live channels, then hydrated into controller state by `PerpsController.#hydrateCacheFromDiskSync()` on startup. + +| MMKV key | What it stores | Written by | Read by | +| ---------------------------- | -------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | --------------------------------------------- | +| `PERPS_DISK_CACHE_MARKETS` | One market snapshot entry or `{ entries: [...] }` for multi-provider | `PerpsController.#persistMarketSnapshotsToDisk()` and `PerpsStreamManager.persistMarketDataToDisk()` | `PerpsController.#hydrateCacheFromDiskSync()` | +| `PERPS_DISK_CACHE_USER_DATA` | One user snapshot entry or `{ entries: [...] }` for multi-provider | `PerpsController.#persistUserSnapshotsToDisk()` and `PerpsStreamManager.persistUserDataToDisk()` | `PerpsController.#hydrateCacheFromDiskSync()` | + +**Write behavior**: StreamManager writes are throttled to once per 30 seconds (`PERPS_DISK_CACHE_THROTTLE_MS`). Controller writes happen after each successful preload refresh and are naturally bounded by the preload cadence / `#preloadGuardMs`. All disk writes are best-effort and must never block rendering. + +**Read path**: `#hydrateCacheFromDiskSync()` runs during `PerpsController` construction so the in-memory caches are populated before React hooks read them. `startMarketDataPreload()` then performs the first REST refresh. Market data prices are stripped to `$---` / `--` / `--%` placeholders since disk data may be hours old. User data is only hydrated if the stored address matches the current account. + +**`skipTTL` option**: `getCachedMarketDataForActiveProvider({ skipTTL: true })` and `getCachedUserDataForActiveProvider({ skipTTL: true })` bypass the normal TTL checks. Used by `getPreloadedData()` so disk-hydrated structural data renders regardless of age — the UI shows stale-but-present data instead of a loading skeleton. + +**Invalidation**: See [Cache Clearing Matrix](#cache-clearing-matrix). + +### Preload Layer: PerpsController (Legacy — candidate for removal) + +| Cache | What it stores | Key format | Cleared on disconnect? | +| ---------------------------- | -------------------------------- | -------------------- | ---------------------- | +| `cachedMarketDataByProvider` | Market list from REST | `providerId:network` | No (preserved) | +| `cachedUserDataByProvider` | Positions, orders, account state | `providerId:network` | No (preserved) | + +**Staleness & debounce constants**: + +| Constant | Value | Purpose | +| -------------------- | ------------- | -------------------------------------------------------- | +| `#preloadRefreshMs` | 5 min | Periodic `setInterval` refresh | +| `#preloadGuardMs` | 30 s | Write debounce — skip fetch if entry is fresher than 30s | +| Market data read TTL | 300 s (5 min) | Stale cutoff for consumer reads | +| User data read TTL | 60 s | Stale cutoff for consumer reads | + +**Why this is legacy**: `startMarketDataPreload()` is now started from `PerpsAlwaysOnProvider` at the wallet root, so it runs regardless of whether the wallet is rendering the classic tabs flow or the homepage-sections flow. The user data preload already has a WS-connected guard that skips it when WS is streaming (mostly dormant). The market data preload still runs every 5 min via REST even when WS is streaming — redundant but harmless. Network switches don't clear these caches — different networks use different keys (`hyperliquid:mainnet` vs `hyperliquid:testnet`). + +## Cache Clearing Matrix + +| Trigger | Provider caches | Controller preload | Stream channels | Disk cache (MMKV) | TradingReadinessCache | +| ------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------- | -------------------------------------------- | --------------------------------------------- | --------------------- | +| `disconnect()` (grace period expires) | Meta, spot, fees, perpDexsCache cleared. DEX discovery (`#cachedValidatedDexs`, `#cachedAllPerpDexs`) **NOT** cleared. | Preserved | All 9 cleared | Preserved | Preserved | +| Account switch | Provider recreated (all instance caches reset) | `cachedUserDataByProvider` cleared if address changed. Market data preserved. | All 9 cleared immediately (before reconnect) | User data key deleted. Market data preserved. | Preserved | +| Network/provider switch | Provider recreated | Scoped by `providerId:network` key | All 9 cleared immediately | Both keys deleted | Preserved | +| App → background | Nothing (20s grace period) | Nothing | Nothing (WS stays alive) | Preserved | Preserved | +| App → foreground | `performActualDisconnection()` runs full clear, then fresh connect | Nothing | All 9 cleared, then re-prewarmed | Preserved (stale data OK, WS overlays) | Preserved | +| User retry (force) | Provider recreated | Nothing | All 9 cleared | Preserved | Preserved | + +**Double-clear on account/network switch**: Stream channels are cleared twice — once immediately in the Redux store subscriber (prevents stale data flash), and again inside `performReconnection()` (ensures clean state for new subscriptions). + +## Simplification Roadmap + +### 1. Disk-backed cold-start cache — IMPLEMENTED + +`PerpsController` and `PerpsStreamManager` both persist MMKV snapshots, and `PerpsController.#hydrateCacheFromDiskSync()` hydrates them on startup. This eliminates the cold-start loading skeleton: the last-known market list renders immediately with `$---` placeholder prices until fresh values arrive. + +**Latest validation**: iOS simulator `mm-3` measured `3908ms` for the first clean-cache PerpsHome load, `0ms` for same-session reopen, and after `yarn a:reload` the controller hydrated `286` markets in `9ms` with the first post-reload PerpsHome market data at `0ms`. + +See [Disk Layer](#disk-layer-mmkv-cold-start-cache) above for full details. + +### 2. Remove REST preload timer (simplification) + +**Problem**: 5-min REST polling is redundant when WS is always connected via `PerpsAlwaysOnProvider`. + +**Proposal**: Remove `startMarketDataPreload()` interval. Keep a single REST fetch on first mount as a fallback before WS connects. With disk cache (proposal 1), even that may be unnecessary. + +**Dependencies**: Requires disk cache (proposal 1) to fill the cold-start gap. + +**What to remove**: + +- `#preloadRefreshMs` interval +- `#preloadGuardMs` debounce +- `#performMarketDataPreload` periodic calls +- Keep `#performUserDataPreload` as a one-shot fallback (already guarded by WS check) + +### 3. Dual-cache unification in provider (bug prevention) + +**Problem**: `#cachedValidatedDexs` and `#cachedAllPerpDexs` must stay in sync because `#buildAssetMapping()` reads both. Three independent code paths call the same `perpDexs()` API and write to separate caches: + +| Path | Cache written | Purpose | +| ------------------------------- | --------------------------------------------- | ------------------- | +| `#fetchValidatedDexsInternal()` | `#cachedValidatedDexs` + `#cachedAllPerpDexs` | Init discovery | +| `#getStandaloneValidatedDexs()` | Same two caches | Pre-WebSocket reads | +| `#getCachedPerpDexs()` | `#perpDexsCache` | Fee calculation | + +**Proposal**: Replace all three caches with a single `#dexDiscoveryState` object that atomically stores `raw`, `validated`, and `extended` views. One object assignment, one source of truth — eliminates the desync risk by construction. + +**Postmortem**: [2026-03-24-hip3-asset-id-cache-poisoning.md](postmortems/2026-03-24-hip3-asset-id-cache-poisoning.md) + +## Rules for Adding New Caches + +1. **Pick the right layer**: Provider for API data, Controller for preload, StreamManager for real-time, TradingReadinessCache for signing state. +2. **Decide scope**: Global (market data) vs account-specific (positions, signing state). +3. **If account-specific**: Must be cleared on account switch. Verify your cache is covered by the ConnectionManager's clearing sequence or the controller's account-change handler. +4. **If two caches derive from the same source**: Unify into a single object or ensure atomic writes. Never let independent code paths write subsets of related caches. +5. **Choose a freshness tier**: Real-time (WS), background (REST with TTL), or session (app lifetime). Don't mix — a cache that's sometimes WS-fed and sometimes REST-fed creates confusing staleness semantics. +6. **Document in this file**: Add your cache to the appropriate per-layer inventory table. diff --git a/domains/perps/knowledge/connection-architecture.md b/domains/perps/knowledge/connection-architecture.md new file mode 100644 index 0000000..b02635a --- /dev/null +++ b/domains/perps/knowledge/connection-architecture.md @@ -0,0 +1,555 @@ +--- +name: connection-architecture +domain: perps +description: Connection lifecycle, WebSocket management, stream channels, cache clearing +--- + +# Perps Connection Architecture + +## Overview + +The Perps connection system uses a layered architecture where each layer has clear responsibilities and ownership boundaries. + +Connection lifecycle is managed by a single top-level `PerpsAlwaysOnProvider` mounted at the wallet root. Per-section `PerpsConnectionProvider` instances provide React context to consumers but do **not** manage connect/disconnect — that responsibility belongs exclusively to `PerpsAlwaysOnProvider`. + +## Layer Stack + +```mermaid +graph TD + AOProv[PerpsAlwaysOnProvider
Wallet root - always on] -->|calls connect/disconnect| Manager[PerpsConnectionManager] + UI[UI Components] -->|uses| Hook[usePerpsConnection] + Hook -->|reads context from| Provider[PerpsConnectionProvider
manageLifecycle=false] + Provider -.polls state from.-> Manager + Manager -->|orchestrates| Controller[PerpsController] + Controller -->|manages| HP[HyperLiquidProvider] + HP -->|REST API| API[HyperLiquid API] + HP -->|WebSocket| WS[WebSocket Subscriptions] + + Controller -.stores data in.-> Redux[Redux State] +``` + +## Layer Responsibilities + +### Hook Layer: usePerpsConnection + +**What it is**: React hook that provides access to connection state and methods for UI components + +**Owns**: + +- Nothing - it's a pure accessor hook + +**Responsibilities**: + +- Read connection context from PerpsConnectionProvider +- Provide type-safe access to connection state and methods +- Throw error if used outside of Provider + +**Does NOT**: + +- Store any state +- Perform any logic +- Know about Manager, Controller, or Provider implementation + +**Key API**: Returns `{ isConnected, isConnecting, isInitialized, error, connect, disconnect, reconnectWithNewContext, resetError }` + +**Usage**: Primary API for all UI components to interact with the connection system + +--- + +### Lifecycle Layer: PerpsAlwaysOnProvider + +**What it is**: Top-level React component mounted once at the wallet root that owns the entire WebSocket connection lifecycle + +**File**: `app/components/UI/Perps/providers/PerpsAlwaysOnProvider.tsx` + +**Mounted at**: `app/components/Views/Wallet/index.tsx` — wraps `ErrorBoundary` and all wallet content + +**Owns**: + +- Single `AppState` listener for foreground/background transitions +- The only caller of `PerpsConnectionManager.connect()` and `PerpsConnectionManager.disconnect()` + +**Responsibilities**: + +- Call `connect()` on mount (when `isPerpsEnabled`) +- Call `disconnect()` when app goes to background (triggers 20s grace period in Manager) +- Call `ensureConnected()` when app returns to foreground (forces disconnect + fresh reconnect after stabilization delay) +- Call `disconnect()` on unmount + +**Does NOT**: + +- Provide React context (no `createContext`) +- Know about individual screens or tab visibility +- Manage stream subscriptions + +**Result**: `connectionRefCount` in `PerpsConnectionManager` stays exactly 1 throughout the app lifetime, eliminating all reference-count edge cases from multiple simultaneous providers. + +--- + +### UI Layer: PerpsConnectionProvider + +**What it is**: React Context provider that exposes connection state and methods to UI components + +**Owns**: + +- Local React state (polled from Manager every 100ms) +- Polling interval for state synchronization +- UI-level error handling decisions (show error screen vs content) +- Retry attempt tracking + +**Responsibilities**: + +- Translate singleton Manager state into React state +- Provide stable callback functions to UI (`connect`, `disconnect`, `reconnectWithNewContext`, `resetError`) +- Decide when to show error screen vs content +- Handle E2E mode with mock state + +**Does NOT**: + +- Manage connection lifecycle when `manageLifecycle={false}` (the default for all section-level instances) +- Know about app state or background/foreground transitions +- Handle race conditions or reconnection logic + +**Exposes**: Connection context via `PerpsConnectionContext` that `usePerpsConnection` hook reads from + +**`manageLifecycle` prop**: + +- `true` (default, used only by the perps stack navigator internally for historical reasons): passes `isVisible` to `usePerpsConnectionLifecycle` — **not used in practice since `PerpsAlwaysOnProvider` is the single lifecycle owner** +- `false`: suppresses all connect/disconnect calls; provider acts as context source only + +All current `PerpsConnectionProvider` instances use `manageLifecycle={false}` — lifecycle is owned exclusively by `PerpsAlwaysOnProvider`. + +--- + +### Manager Layer: PerpsConnectionManager (Singleton) + +**What it is**: Orchestrator that coordinates connection lifecycle and manages connection state + +**Owns**: + +- Connection state (`isConnected`, `isConnecting`, `isInitialized`, `error`) +- Race condition guards (`initPromise`, `pendingReconnectPromise`) +- Grace period timer (`CONNECTION_GRACE_PERIOD_MS` = 20s delay before disconnect) +- Connection timeout management (30s limit for connection attempts) +- Reference counting (tracks active provider instances) +- Stream manager caches (via PerpsStreamManager singleton - separate channels for prices, orders, positions, account state; provides instant cached data to subscribers; supports pause/resume for race condition prevention) +- Redux store subscription for account/network change monitoring + +**Responsibilities**: + +- Coordinate connect/disconnect based on provider reference counting +- Monitor Redux for account and network changes, trigger reconnection automatically +- Handle force flag logic (cancel pending operations vs wait, including timeout timers) +- Implement grace period (20s) to prevent flickering disconnects +- Enforce connection timeout (30s) to prevent indefinite hanging +- Clear stream caches during reconnection +- Delegate actual provider initialization to Controller +- Validate connection with WebSocket health checks via `provider.ping()` (replaces blocking HTTP calls) + +**Does NOT**: + +- Create or manage provider instances +- Know about specific exchange protocols +- Update Redux state +- Handle WebSocket connections directly + +**Key Methods**: + +- `connect()` - Initialize connection if first provider instance +- `disconnect()` - Disconnect if last provider instance (after grace period) +- `reconnectWithNewContext(options?: ReconnectOptions)` - Coordinate full reconnection with optional force flag + +--- + +## Subscription Warmup Process + +After controller initialization, the ConnectionManager pre-warms WebSocket subscriptions to enable instant UI rendering. + +### Warmup Sequence + +``` +PerpsConnectionManager.connect() + └── preloadSubscriptions() + ├── positions.prewarm() → webData3 subscription + ├── orders.prewarm() → webData3 subscription + ├── account.prewarm() → webData3 subscription + ├── marketData.prewarm() → assetCtxs subscription + ├── oiCaps.prewarm() → OI caps data + ├── fills.prewarm() → Fill notifications + └── prices.prewarm() → allMids for all markets (async) +``` + +### Channels Pre-warmed + +| Channel | Data | Subscription | +| ---------- | ------------------------ | ------------------ | +| positions | User positions | webData3 | +| orders | Open orders | webData3 | +| account | Account balance | webData3 | +| marketData | Funding, OI, mark prices | assetCtxs per DEX | +| oiCaps | Open interest caps | OI caps | +| fills | Trade fills | Fill notifications | +| prices | All market prices | allMids per DEX | + +### Benefits + +- **Instant data**: UI components receive cached data immediately on mount +- **Single subscription**: Components share pre-warmed subscriptions via reference counting +- **No throttle**: Pre-warm uses throttleMs=0 for fastest cache population + +### Cleanup + +Pre-warm subscriptions are cleaned up during: + +- `disconnect()` - When last provider unmounts +- `reconnectWithNewContext()` - Before reinitializing +- Cache clearing - When account/network changes + +### Stream Channel Initialization Guards + +Stream channel `connect()` methods include safety guards to prevent subscribing before the controller is ready. Each user-data channel (orders, positions, account, fills, OI caps) checks two conditions before calling the controller's subscribe method: + +1. **`isCurrentlyReinitializing()`** — If the controller is mid-reinitialization (e.g., account switch), defer with `ReconnectionCleanupDelayMs` (500ms) timeout +2. **`getConnectionState().isInitialized`** — If the controller hasn't completed `init()` yet, defer with 200ms timeout and retry + +This prevents `CLIENT_NOT_INITIALIZED` errors that would occur if channels subscribe before the controller has an active provider. Without this guard, the controller returns a no-op unsubscribe function, the channel stores it as its active subscription, and no WebSocket callback ever fires — causing `isInitialLoading` to stay `true` forever (infinite skeleton). + +``` +connect() flow: + ┌─ wsSubscription exists? → return (already connected) + ├─ isCurrentlyReinitializing? → deferConnect(500ms) + ├─ !isInitialized? → deferConnect(200ms) + └─ reset retry counter, proceed with subscription +``` + +**Safety**: Deferred retries abort if subscribers become empty (component unmounted) or after 150 attempts (~30s). This prevents orphaned retry loops. + +**Channels with this guard**: `OrderStreamChannel`, `PositionStreamChannel`, `AccountStreamChannel`, `FillStreamChannel`, `OICapStreamChannel` + +--- + +## Background Data Preloading + +The Perps system preloads market data and user data in the background before the user navigates to the Perps tab, enabling instant rendering of market lists, positions, orders, and account state without loading skeletons. + +### How It Works + +1. **`startMarketDataPreload()`** is started by `PerpsAlwaysOnProvider` at the wallet root to fetch market data via REST and cache it in the controller state +2. **`performUserDataPreload()`** is called after market data preload completes, fetching positions, open orders, and account state via lightweight standalone REST calls +3. **`MarketDataChannel`** in the stream manager reads cached market data on mount, providing instant market data to subscribers +4. **`OrderStreamChannel`**, **`PositionStreamChannel`**, and **`AccountStreamChannel`** read cached user data on `connect()`, providing instant display before WebSocket data arrives +5. **PerpsHomeView renders immediately** — markets, positions, orders, and account state populate from the cache, then update with live WebSocket data + +> **Two-tier cache**: Stream channels check their WebSocket cache first (populated by `prewarm()`). If empty, hooks fall back to the controller's preloaded REST cache via `getPreloadedData()`. Data is available on first render even before WebSocket connection is attempted. + +### Cached User Data + +The controller stores preloaded user data in transient (non-persisted) state fields: + +| Field | Type | Description | +| --------------------------- | --------------------------- | ----------------------------------------------------------------------------------------------------------------- | +| `cachedMarketData` | `PerpsMarketData[] \| null` | Preloaded market data from REST | +| `cachedMarketDataTimestamp` | `number` | Timestamp of last market data preload | +| `cachedUserDataByProvider` | `Record` | Per-provider cached user data (positions, orders, accountState, timestamp, address) keyed by `providerId:network` | + +User data cache is automatically cleared when: + +- The selected account changes (all entries cleared since all provider data is stale for new account) + +Network and testnet toggle do NOT clear the cache — different network keys prevent collisions. + +### Hook Behavior When Not Connected + +The stream hooks used by PerpsHomeView gracefully handle the not-yet-connected state: + +| Hook | Behavior | Cache Mechanism | Impact | +| ----------------------- | ---------------------------------------- | ------------------------------------------------------ | ------------------------------------------------------ | +| `usePerpsLivePositions` | Reads controller cache, then WebSocket | `getPreloadedData('cachedPositions')` in `useState` | Positions visible immediately | +| `usePerpsLiveOrders` | Reads controller cache, then WebSocket | `getPreloadedData('cachedOrders')` in `useState` | Orders visible immediately | +| `usePerpsLiveFills` | Stays in `isInitialLoading: true` | None (starts as `[]`) | Skeleton shown until WS data arrives | +| `usePerpsLiveAccount` | Reads controller cache, then WebSocket | `getPreloadedData('cachedAccountState')` in `useState` | Balance visible immediately | +| `usePerpsMarkets` | Reads controller cache | `hasPreloadedData('cachedMarketData')` in `useState` | Renders immediately with real cached data | +| `usePerpsPrices` | Skips subscription when `!isInitialized` | None | Static cached prices shown, live prices once connected | + +> **How this works**: Stream channels defer their WebSocket subscriptions until `PerpsConnectionManager.getConnectionState().isInitialized` is `true`, retrying every 200ms (max 150 attempts). This prevents doomed subscriptions that would permanently block data delivery. See [Stream Channel Initialization Guards](#stream-channel-initialization-guards) above. + +--- + +### Controller Layer: PerpsController (Redux) + +**What it is**: Redux controller that manages provider instances and exposes data methods + +**Owns**: + +- Provider instances (`Map`) +- Redux state (account state, orders, positions, market data) +- Initialization flags (`isInitialized`, `isReinitializing`) +- Network configuration (testnet vs mainnet) + +**Responsibilities**: + +- Create and destroy provider instances +- Disconnect old providers and create new ones during reinitialization +- Expose data access methods (`getAccountState`, `placeOrder`, etc.) +- Update Redux state based on provider data +- Handle provider-level errors and log to Sentry + +**Does NOT**: + +- Handle reconnection orchestration (Manager's job) +- Know about force flags or pending operations +- Manage UI state or React lifecycle +- Implement grace periods or reference counting + +**Key Methods**: + +- `initializeProviders()` - Disconnect old providers, create new ones +- `disconnect()` - Disconnect provider and reset initialization state +- `getAccountState()`, `placeOrder()`, etc. - Data access methods + +--- + +### Provider Layer: HyperLiquidProvider + +**What it is**: Exchange-specific implementation of the provider interface + +**Owns**: + +- REST API clients (InfoClient for queries, ExchangeClient for trading) +- WebSocket connection for real-time subscriptions +- Exchange-specific API request formatting +- Protocol-specific message handling +- Subscription management + +**Responsibilities**: + +- Make REST API calls for trading operations (place/cancel/edit orders) +- Make REST API calls for data queries (account state, positions, market info) +- Establish and maintain WebSocket connection for real-time subscriptions +- Provide `ping()` for WebSocket health checks (5s timeout) - used by Manager for connection validation +- Format requests according to exchange protocol +- Parse responses and normalize data +- Handle exchange-specific errors +- Manage subscriptions (prices, order fills, position updates) + +**Does NOT**: + +- Know about Redux or React +- Handle reconnection logic +- Implement grace periods or timeouts +- Manage multiple provider instances + +**Communication Methods**: + +- **REST API**: Account queries, order placement/cancellation, balance checks, market data +- **WebSocket**: Real-time price updates, order fill notifications, position changes, health checks + +**Key Methods**: + +- `connect()` - Initialize REST clients and WebSocket connection +- `disconnect()` - Close WebSocket and cleanup clients +- `ping()` - WebSocket health check to validate connection responsiveness +- `placeOrder()`, `cancelOrder()` - Trading via REST API +- `getAccountState()`, `getPositions()` - Data queries via REST API +- `subscribeToPrices()`, `subscribeToOrders()` - Real-time updates via WebSocket + +--- + +## Design Principles + +- **Manager Orchestrates, Controller Provides Primitives**: Manager coordinates "when" and "why" to reconnect; Controller provides "how" to initialize/disconnect providers +- **Provider is Exchange-Agnostic Interface**: Controller doesn't know about HyperLiquid specifics; easy to add new providers +- **UI Layer is Presentation Only**: Provider (React) polls state from Manager (singleton); no business logic in React components +- **Clear Ownership Boundaries**: Each layer owns specific concerns; no cross-layer state management; dependencies flow downward only + +--- + +## Key Methods by Layer + +### Manager Layer Methods + +| Method | Signature | Purpose | +| --------------------------- | ----------------------------------------------- | ----------------------------------------------------- | +| `connect()` | `() => Promise` | Initialize connection if first provider | +| `disconnect()` | `() => Promise` | Disconnect if last provider (with grace period) | +| `ensureConnected()` | `() => Promise` | Foreground return: force disconnect + fresh reconnect | +| `reconnectWithNewContext()` | `(options?: ReconnectOptions) => Promise` | Coordinate full reconnection | +| `getConnectionState()` | `() => ConnectionState` | Get current connection state (for polling) | +| `resetError()` | `() => void` | Clear error state | + +### Controller Layer Methods + +| Method | Signature | Purpose | +| ----------------------- | ----------------------------- | ------------------------------------ | +| `initializeProviders()` | `() => Promise` | Disconnect old, create new providers | +| `disconnect()` | `() => Promise` | Disconnect provider, reset flags | +| `getAccountState()` | `() => Promise` | Fetch account data | +| `placeOrder()` | `(order) => Promise` | Submit order | + +### Provider Layer Methods (React) + +| Method | Signature | Purpose | +| --------------------------- | ----------------------------- | -------------------- | +| `connect()` | `() => Promise` | Delegates to Manager | +| `disconnect()` | `() => Promise` | Delegates to Manager | +| `reconnectWithNewContext()` | `(options?) => Promise` | Delegates to Manager | +| `resetError()` | `() => void` | Delegates to Manager | + +--- + +## ReconnectOptions + +```typescript +interface ReconnectOptions { + force?: boolean; // default: false +} +``` + +**Only used at Manager layer** - Controls pending operation handling: + +- `force: false` (default): Waits for pending operations → safe for automatic reconnects +- `force: true`: Cancels pending operations AND clears connection timeout timer → user-initiated retry + +**Why Controller doesn't need it**: Manager calls `initializeProviders()` directly which always does full reinitialization with provider disconnect/recreate. + +**Additional Effects of force: true**: + +- Cancels grace period immediately +- Clears connection timeout timer +- Clears all pending promises: `initPromise`, `pendingReconnectPromise` + +--- + +## Flow Scenarios + +### User Retry (force: true) + +**Why each layer is involved**: + +- **UI**: User clicks retry button → Provider.reconnectWithNewContext({ force: true }) +- **Provider (React)**: Delegates to Manager singleton +- **Manager**: Cancels pending promises, clears caches, calls Controller.initializeProviders() +- **Controller**: Disconnects old provider, creates new one +- **Provider (Exchange)**: Closes WebSocket, establishes new connection + +```mermaid +sequenceDiagram + participant UI as UI Component + participant RP as React Provider + participant M as Manager + participant C as Controller + participant P as Exchange Provider + participant W as WebSocket + + UI->>RP: onClick retry + RP->>M: reconnectWithNewContext({force: true}) + M->>M: Cancel pending promises + M->>M: Clear stream caches + M->>C: initializeProviders() + C->>P: disconnect() + P->>W: close() + C->>C: Create new provider + C->>P: (implicit connect via getAccountState) + P->>W: establish connection + W-->>UI: Connected +``` + +### Account Switch (force: false) + +**Why each layer is involved**: + +- **UI**: Account changed → Lifecycle hook calls reconnect +- **Provider (React)**: Delegates to Manager +- **Manager**: Waits for pending operations, then reinitializes +- **Controller**: Disconnects old provider (old account), creates new one +- **Provider (Exchange)**: Closes WebSocket, establishes new connection with new account context + +```mermaid +sequenceDiagram + participant UI as UI Component + participant RP as React Provider + participant M as Manager + participant C as Controller + participant P as Exchange Provider + + UI->>RP: Account changed + RP->>M: reconnectWithNewContext() + M->>M: Wait for pending operations + M->>M: Clear stream caches + M->>C: initializeProviders() + C->>P: disconnect old provider + C->>C: create new provider + P-->>UI: Connected with new account +``` + +--- + +## Race Condition Guards + +Each layer protects against its own concurrency concerns: + +| Guard | Location | Purpose | Why This Layer | +| ------------------------- | ---------- | --------------------------------- | ---------------------------------- | +| `initPromise` | Manager | Prevents concurrent connect() | Manager owns connection lifecycle | +| `pendingReconnectPromise` | Manager | Prevents concurrent reconnects | Manager coordinates reconnection | +| `isReinitializing` | Controller | Prevents concurrent provider init | Controller owns provider instances | + +### Rapid Account Switch Protection + +**Scenario**: User rapidly switches Account A → B → C + +The Manager's `pendingReconnectPromise` ensures only one reconnection happens at a time: + +1. **A → B**: Triggers `reconnectWithNewContext()` → creates `pendingReconnectPromise` +2. **B → C** (during B reconnection): Calls `reconnectWithNewContext()` again +3. **Manager**: Returns existing `pendingReconnectPromise` (doesn't start new reconnection) +4. **When B completes**: Promise is cleared, state is updated +5. **C change detected**: New reconnection starts with fresh promise + +**Result**: The final account (C) will be correctly connected because: + +- Each reconnection fetches account address fresh at execution time +- Redux subscription in Manager detects every account change and queues reconnection +- `pendingReconnectPromise` serializes reconnections to prevent race conditions +- The last account change always triggers a reconnection after previous one completes +- Stream caches are cleared immediately on account change to prevent old data from showing + +--- + +## When to Use What + +| Scenario | Method | Options | Which Layer Decides | Notes | +| ----------------- | --------------------------- | ----------------- | ------------------------------------------ | --------------------------------------------- | +| Initial load | `connect()` | - | Manager (via Provider hook) | Uses WebSocket ping for validation | +| User retry button | `reconnectWithNewContext()` | `{ force: true }` | UI → Provider → Manager | Cancels pending operations + timeout timer | +| Account switch | `reconnectWithNewContext()` | default | Manager (automatic via Redux subscription) | Clears caches immediately before reconnection | +| Network switch | `reconnectWithNewContext()` | default | Manager (automatic via Redux subscription) | Same as account switch | +| App background | `disconnect()` | - | PerpsAlwaysOnProvider → Manager | Grace period (20s) before actual disconnect | +| App foreground | `ensureConnected()` | - | PerpsAlwaysOnProvider → Manager | Forces disconnect + reconnect after delay | + +--- + +## Error Handling by Layer + +Each layer handles errors at its own level: + +1. **Provider Layer (Exchange)**: + - Catches WebSocket errors, logs to Sentry + - Returns error state to Controller + +2. **Controller Layer**: + - Catches provider errors, logs to Sentry + - Updates Redux error state + - Throws to Manager + +3. **Manager Layer**: + - Catches Controller errors, logs to DevLogger + - Sets local error state + - Does NOT throw (prevents crash) + +4. **Provider Layer (React)**: + - Catches Manager errors, logs to Sentry + - Polls error state from Manager + - Decides UI presentation (error screen vs retry button) + +**All layers update state regardless of error to keep UI in sync.** diff --git a/domains/perps/knowledge/feature-flags.md b/domains/perps/knowledge/feature-flags.md new file mode 100644 index 0000000..ecf892e --- /dev/null +++ b/domains/perps/knowledge/feature-flags.md @@ -0,0 +1,282 @@ +--- +name: feature-flags +domain: perps +description: Feature flag framework: LaunchDarkly flags, version gating, selectors +--- + +# Perps Feature Flags Framework + +## Overview + +Framework for controlling Perps feature availability through LaunchDarkly with local fallback support. Supports version-gated rollouts and gradual feature releases. + +**Key Design Principles:** + +- LaunchDarkly is the single source of truth for feature enablement +- Version-gated flags ensure features only activate on compatible app versions +- Local environment variables provide development/testing fallback +- Graceful degradation when LaunchDarkly is unavailable + +## Architecture + +```mermaid +graph TD + LD[LaunchDarkly Remote] -->|JSON flag| RFC[RemoteFeatureFlagController] + RFC -->|stores in| Redux[Redux State] + Redux -->|read by| Selector[Feature Flag Selector] + Selector -->|boolean| Component[UI Component] + + ENV[Environment Variable] -->|fallback| Selector + + style LD fill:#e1f5ff + style RFC fill:#fff3e0 + style Selector fill:#f3e5f5 + style Component fill:#e8f5e9 +``` + +## Flag Types + +### Version-Gated Boolean Flags + +Used for feature on/off toggles with version requirements. + +**Interface:** + +```typescript +interface VersionGatedFeatureFlag { + enabled: boolean; + minimumVersion: string; +} +``` + +**Example LaunchDarkly JSON:** + +```json +{ + "enabled": true, + "minimumVersion": "7.60.0" +} +``` + +**Behavior:** + +- `enabled: true` + version >= `minimumVersion` = feature ON +- `enabled: true` + version < `minimumVersion` = feature OFF +- `enabled: false` = feature OFF (regardless of version) +- Invalid/missing flag = fallback to local environment variable + +### String Flags (for A/B Tests) + +See [Perps A/B Testing Framework](./perps-ab-testing.md) for variant-based flags. + +--- + +## Implementation Guide + +### Adding a New Feature Flag + +#### 1. Define the Selector + +**File:** `app/components/UI/Perps/selectors/featureFlags/index.ts` + +```typescript +/** + * Selector for My Feature flag + * Controls visibility of My Feature in the UI + * + * @returns boolean - true if feature should be shown, false otherwise + */ +export const selectMyFeatureEnabledFlag = createSelector( + selectRemoteFeatureFlags, + (remoteFeatureFlags) => { + // Choose default behavior: + // - Use `=== 'true'` for disabled by default (must explicitly enable) + // - Use `!== 'false'` for enabled by default (must explicitly disable) + const localFlag = process.env.MM_PERPS_MY_FEATURE_ENABLED === 'true'; + const remoteFlag = + remoteFeatureFlags?.perpsMyFeatureEnabled as unknown as VersionGatedFeatureFlag; + + // Remote takes precedence, fallback to local + return validatedVersionGatedFeatureFlag(remoteFlag) ?? localFlag; + }, +); +``` + +**Default Behavior Options:** + +| Pattern | Default | Use Case | +| ------------- | -------- | ----------------------------------------------------- | +| `=== 'true'` | Disabled | New experimental features | +| `!== 'false'` | Enabled | Features that should be on unless explicitly disabled | + +#### 2. Add Mock Flag + +**File:** `app/components/UI/Perps/mocks/remoteFeatureFlagMocks.ts` + +```typescript +export const mockedPerpsFeatureFlagsEnabledState: Record< + string, + VersionGatedFeatureFlag +> = { + // ... existing flags ... + perpsMyFeatureEnabled: mockEnabledPerpsLDFlag, +}; +``` + +#### 3. Add Environment Variable + +**File:** `.js.env.example` + +```bash +export MM_PERPS_MY_FEATURE_ENABLED="true" +``` + +#### 4. Use in Component + +```typescript +import { useSelector } from 'react-redux'; +import { selectMyFeatureEnabledFlag } from '../../selectors/featureFlags'; + +const MyComponent = () => { + const isMyFeatureEnabled = useSelector(selectMyFeatureEnabledFlag); + + if (!isMyFeatureEnabled) { + return null; // or alternative UI + } + + return ; +}; +``` + +#### 5. Add Unit Tests + +**File:** `app/components/UI/Perps/selectors/featureFlags/index.test.ts` + +Follow existing test patterns covering: + +- Default behavior (when env var not set) +- Remote flag takes precedence over local +- Version gating validation +- Fallback to local when remote is invalid/unavailable + +--- + +## Available Flags Reference + +### Version-Gated Boolean Flags + +| Redux Property | LaunchDarkly Key | Env Variable | Default | Purpose | +| -------------------------------------------------- | -------------------------------------------------------- | ---------------------------------------------- | ------- | --------------------------------------------------------------------------------------- | +| `perpsPerpTradingEnabled` | `perps-perp-trading-enabled` | `MM_PERPS_ENABLED` | false | Main Perps feature toggle | +| `perpsPerpTradingServiceInterruptionBannerEnabled` | `perps-perp-trading-service-interruption-banner-enabled` | `MM_PERPS_SERVICE_INTERRUPTION_BANNER_ENABLED` | false | Service disruption banner | +| `perpsPerpGtmOnboardingModalEnabled` | `perps-perp-gtm-onboarding-modal-enabled` | `MM_PERPS_GTM_MODAL_ENABLED` | false | GTM onboarding modal | +| `perpsOrderBookEnabled` | `perps-order-book-enabled` | `MM_PERPS_ORDER_BOOK_ENABLED` | false | Order Book feature | +| `perpsFeedbackEnabled` | `perps-feedback-enabled` | `MM_PERPS_FEEDBACK_ENABLED` | false | Feedback button on home | +| `perpsDefaultPayTokenWhenNoBalanceEnabled` | `perps-default-pay-token-when-no-balance-enabled` | — | true | Default pay token when no perps balance + Add funds CTA on market details (remote only) | + +### A/B Test Flags + +| Redux Property | LaunchDarkly Key | Variants | Purpose | +| ------------------------ | --------------------------- | ----------------------- | -------------------------------- | +| `perpsAbtestButtonColor` | `perps-abtest-button-color` | `control`, `monochrome` | Button color A/B test (TAT-1937) | + +### Configuration Flags + +These flags are managed via `FeatureFlagConfigurationService` and control runtime configuration: + +| Redux Property | LaunchDarkly Key | Env Variable | Purpose | +| --------------------------------------- | --------------------------------------------- | ---------------------------------------------- | ----------------------------------------------------------------------------------------- | +| `perpsHip3Enabled` | `perps-hip3-enabled` | `MM_PERPS_HIP3_ENABLED` | HIP-3 markets master switch | +| `perpsHip3AllowlistMarkets` | `perps-hip3-allowlist-markets` | `MM_PERPS_HIP3_ALLOWLIST_MARKETS` | HIP-3 market allowlist | +| `perpsHip3BlocklistMarkets` | `perps-hip3-blocklist-markets` | `MM_PERPS_HIP3_BLOCKLIST_MARKETS` | HIP-3 market blocklist | +| `perpsPayWithAnyTokenAllowlistAssets` | `perps-pay-with-any-token-allowlist-assets` | `MM_PERPS_PAY_WITH_ANY_TOKEN_ALLOWLIST_ASSETS` | Pay-with modal token allowlist (`chainId.address`, comma-separated; env overrides remote) | +| `perpsPerpTradingGeoBlockedCountriesV2` | `perps-perp-trading-geo-blocked-countries-v2` | `MM_PERPS_BLOCKED_REGIONS` | Geo-blocking regions list | + +> **Note:** `perpsPerpTradingGeoBlockedCountries` (without V2) is deprecated. Use the V2 variant. + +--- + +## LaunchDarkly Configuration + +### Naming Convention + +| Format | Example | +| ------------------------- | -------------------------- | +| LaunchDarkly (kebab-case) | `perps-order-book-enabled` | +| Redux state (camelCase) | `perpsOrderBookEnabled` | + +### Flag Structure (JSON type) + +```json +{ + "variations": [ + { + "name": "Enabled", + "value": { + "enabled": true, + "minimumVersion": "7.60.0" + } + }, + { + "name": "Disabled", + "value": { + "enabled": false, + "minimumVersion": "0.0.0" + } + } + ], + "offVariation": 1, + "fallthrough": { + "variation": 0 + } +} +``` + +### Version Gating + +The `minimumVersion` field ensures features only activate on compatible app versions: + +- **Format:** Semantic version string (e.g., `"7.60.0"`) +- **Comparison:** Uses `compare-versions` library with `>=` operator +- **Use case:** Prevent feature activation on older app versions that lack required code + +--- + +## Troubleshooting + +### Flag Not Taking Effect + +1. **Check Redux state:** Verify flag exists in `RemoteFeatureFlagController.remoteFeatureFlags` +2. **Check version:** Ensure app version meets `minimumVersion` requirement +3. **Check selector:** Verify selector is imported and used correctly + +### Version Gating Not Working + +1. **Verify `minimumVersion` format:** Must be valid semver string +2. **Check app version:** `getVersion()` from `react-native-device-info` +3. **Check comparison:** Uses `>=` operator + +### Local Flag Not Overriding + +1. **Restart Metro bundler** after changing `.js.env` +2. **Check override setting:** `isRemoteFeatureFlagOverrideActivated` must be true +3. **Verify spelling:** Environment variable names are case-sensitive + +--- + +## Related Files + +- **Selectors:** `app/components/UI/Perps/selectors/featureFlags/index.ts` +- **Mocks:** `app/components/UI/Perps/mocks/remoteFeatureFlagMocks.ts` +- **Tests:** `app/components/UI/Perps/selectors/featureFlags/index.test.ts` +- **Version validation:** `app/util/remoteFeatureFlag/index.ts` +- **Controller init:** `app/core/Engine/controllers/remote-feature-flag-controller-init.ts` +- **Configuration service:** `app/controllers/perps/services/FeatureFlagConfigurationService.ts` + +--- + +## Related Documentation + +- [Perps A/B Testing Framework](./perps-ab-testing.md) +- [Perps Connection Architecture](./perps-connection-architecture.md) +- [Perps MetaMetrics Reference](./perps-metametrics-reference.md) diff --git a/domains/perps/knowledge/formatting-rules.md b/domains/perps/knowledge/formatting-rules.md new file mode 100644 index 0000000..b252d4c --- /dev/null +++ b/domains/perps/knowledge/formatting-rules.md @@ -0,0 +1,51 @@ +--- +name: formatting-rules +domain: perps +description: Decimal and significant-digit formatting rules for perps price display +--- + +# Formatting Rules + +## Significant Digits + +A significant digit contributes to precision, excluding leading zeros. + +| Rule | Example | Sig Digs | +|---|---|---| +| Non-zero digits always significant | 123.45 | 5 | +| Zeros between non-zeros significant | 1002 | 4 | +| Leading zeros not significant | 0.00123 | 3 | +| Trailing zeros significant with decimal | 1.230 | 4 | + +## General Rules + +- Max 6 decimals (matches Hyperliquid). Never exceed. +- Hide trailing zeros: `1.230` -> `1.23`, `1.000` -> `1` +- Apply sig digs, cap decimals at 6 +- Sig digs by absolute value range: + - `> $100,000`: 6 sig digs + - `$100,000 > x > $0.01`: 5 sig digs + - `< $0.01`: 4 sig digs + +## Decimal Display by Price Range (FiatRangeConfig) + +| Range | Min Dec | Max Dec | Sig Digs | Example Input | Output | +|---|---|---|---|---|---| +| \|v\| > 10,000 | 0 | 0 | 5 (6 if >100k) | 12345.67 | 12346 | +| \|v\| > 1,000 | 0 | 1 | 5 | 1234.56 | 1234.6 | +| \|v\| > 100 | 0 | 2 | 5 | 123.456 | 123.46 | +| \|v\| > 10 | 0 | 4 | 5 | 12.34567 | 12.346 | +| \|v\| <= 10 | 2 | 6 | 5 (4 if <0.01) | 1.3445555 | 1.3446 | +| \|v\| <= 10 | 2 | 6 | 5 | 0.333333 | 0.33333 | +| \|v\| <= 10 | 2 | 6 | 4 | 0.004236 | 0.004236 | +| \|v\| <= 10 | 2 | 6 | 4 | 0.0000006 | 0.000001 | +| \|v\| <= 10 | 2 | 6 | 4 | 0.0000004 | 0 | + +## Platform Status + +| Platform | Implementation | Status | +|---|---|---| +| Mobile | `formatPerpsFiat` in `app/components/UI/Perps/utils/formatUtils.ts` | Correct -- adaptive sig-dig by range | +| Extension | `formatCurrencyWithMinThreshold`, `formatNumber({min:2,max:2})`, `.toFixed(2)` | Wrong -- no sig-dig logic | + +**Rule**: Do NOT add more `.toFixed(2)` on extension. Use `formatCurrencyWithMinThreshold` as interim. Target: mobile's `formatPerpsFiat` behavior. diff --git a/domains/perps/knowledge/mobile-extension-map.md b/domains/perps/knowledge/mobile-extension-map.md new file mode 100644 index 0000000..5279d44 --- /dev/null +++ b/domains/perps/knowledge/mobile-extension-map.md @@ -0,0 +1,117 @@ +--- +name: mobile-extension-map +domain: perps +description: Screen, hook, formatting, and testID mapping between mobile and extension perps codebases +--- + +# Mobile-Extension Mapping + +Mobile is source of truth. Extension was built after mobile without rigorous comparison. + +## Screen/Route Mapping + +| Mobile Screen | Extension Equivalent | Extension Route | Status | +|---|---|---|---| +| PerpsHomeView | PerpsView (`ui/pages/perps/`) | `/perps` | Diverged name | +| PerpsMarketListView | market-list/index | `/perps/market-list` | Diverged name | +| PerpsMarketDetailsView | perps-market-detail-page | `/perps/market/:symbol` | Equivalent | +| PerpsOrderView | perps-order-entry-page | `/perps/trade/:symbol` | Diverged name | +| PerpsPositionsView | perps-positions-orders | inline in `/perps` | Inline, not page | +| PerpsClosePositionView | close-position-modal | modal | Modal equivalent | +| PerpsCloseAllPositionsView | -- | -- | **MISSING** | +| PerpsCancelAllOrdersView | -- | -- | **MISSING** | +| PerpsTPSLView | auto-close-section | inline | Modal equivalent | +| PerpsAdjustMarginView | edit-margin-modal | modal | Modal equivalent | +| PerpsTransactionsView | perps-activity-page | `/perps/activity` | Equivalent | +| PerpsWithdrawView | -- | -- | **MISSING** | +| PerpsOrderBookView | -- | -- | **MISSING** | +| PerpsOrderDetailsView | -- | -- | **MISSING** | + +## Hook Mapping + +Mobile: ~94 hooks. Extension: ~15. Extension consolidates heavily. + +### Stream hooks (both channel-based) + +Both: `usePerpsLivePositions`, `usePerpsLiveOrders`, `usePerpsLiveAccount`, `usePerpsLivePrices`, `usePerpsLiveCandles`, `usePerpsLiveOrderBook`, `usePerpsTopOfBook`, `usePerpsLiveFills` + +Extension adds: `usePerpsLiveMarketData`, `usePerpsStreamManager`, `usePerpsViewActive`, `usePerpsChannel` + +### Form/trade hooks (major consolidation on extension) + +| Mobile | Extension | +|---|---| +| `usePerpsOrderForm` + `usePerpsOrderFees` + `usePerpsOrderValidation` + `usePerpsOrderExecution` | `usePerpsOrderForm` (single) | +| `usePerpsClosePosition` + `usePerpsClosePositionValidation` | Inline in `close-position-modal.tsx` | +| `usePerpsTPSLForm` + `usePerpsTPSLUpdate` | Inline in `auto-close-section.tsx` | +| `usePerpsMarginAdjustment` + `usePerpsAdjustMarginData` | `usePerpsMarginCalculations` | + +### Missing on extension + +`usePerpsNavigation`, `usePerpsRewards`, `usePerpsSearch`, `usePerpsSorting`, `usePerpsProvider`, `usePerpsWithdrawStatus`, `usePerpsCloseAllPositions`, `usePerpsCancelAllOrders`, `usePerpsOrderBookGrouping`, `usePerpsFirstTimeUser` + +## Formatting Divergence + +See `formatting-rules` knowledge file for full rules. + +| Platform | Formatter | Behavior | +|---|---|---| +| Mobile | `formatPerpsFiat` | Adaptive sig-dig by price range | +| Extension | `formatCurrencyWithMinThreshold` | Generic, no sig-dig | +| Extension | `formatNumber({min:2,max:2})` | Always 2 decimals | +| Extension | `.toFixed(2)` | Hardcoded 2 decimals | + +**Files with hardcoded formatting (extension):** +- `ui/components/app/perps/utils/transactionTransforms.ts` -- `.toFixed(2)` x7 +- `ui/components/app/perps/order-entry/components/auto-close-section/` -- `{min:2, max:2}` +- `ui/components/app/perps/order-entry/components/limit-price-input/` -- `{min:2, max:2}` +- `ui/components/app/perps/edit-margin/edit-margin-modal-content.tsx` -- `.toFixed(2)` +- `ui/components/app/perps/reverse-position/reverse-position-modal.tsx` -- `.toFixed(2)` +- `ui/hooks/perps/usePerpsOrderForm.ts` -- `formatCurrencyWithMinThreshold` x6 + +## TestID Mapping + +Convention: mobile = PascalCase selectors, extension = kebab-case strings. + +| Concept | Mobile | Extension | +|---|---|---| +| Position card | `PerpsPositionCardSelectorsIDs.CARD` | `position-card-{symbol}` | +| Order card | -- | `order-card-{orderId}` | +| Balance | `PerpsMarketBalanceActionsSelectorsIDs.BALANCE_VALUE` | `perps-balance-dropdown-balance` | +| Order submit | `PerpsOrderViewSelectorsIDs.*` | `order-entry-submit-button` | +| Direction tabs | -- | `direction-tab-long` / `direction-tab-short` | +| TP price input | `PerpsTPSLViewSelectorsIDs.TAKE_PROFIT_PRICE_INPUT` | `tp-price-input` | +| Market item | `PerpsMarketRowItemSelectorsIDs.ROW_ITEM` | `explore-crypto-{symbol}` | +| Close modal | `PerpsClosePositionViewSelectorsIDs.*` | `perps-close-position-modal` | + +## Duplicated Utilities + +Identical or near-identical between codebases: + +| Function | Shareable? | +|---|---| +| `getDisplayName` / `getDisplaySymbol` | YES -- already in controller | +| `getPositionDirection` | YES | +| `formatOrderType` / `formatStatus` | YES | +| `filterMarketsByQuery` | YES | +| `isHip3Market` / `isCryptoMarket` | YES | +| `groupTransactionsByDate` | Near-identical | + +**Rule**: When modifying any of these on extension, check the mobile equivalent first. + +## Key File Paths + +**Mobile:** +- Screens: `app/components/UI/Perps/Views/` +- Hooks: `app/components/UI/Perps/hooks/` +- Utils: `app/components/UI/Perps/utils/` +- TestIDs: `app/components/UI/Perps/Perps.testIds.ts` +- Controller: `app/controllers/perps/` + +**Extension:** +- Components: `ui/components/app/perps/` +- Pages: `ui/pages/perps/` +- Hooks: `ui/hooks/perps/` +- Utils: `ui/components/app/perps/utils.ts` +- Transforms: `ui/components/app/perps/utils/transactionTransforms.ts` +- Stream bridge: `app/scripts/controllers/perps/perps-stream-bridge.ts` diff --git a/domains/perps/knowledge/review-antipatterns.md b/domains/perps/knowledge/review-antipatterns.md new file mode 100644 index 0000000..d026645 --- /dev/null +++ b/domains/perps/knowledge/review-antipatterns.md @@ -0,0 +1,107 @@ +--- +name: review-antipatterns +domain: perps +description: Anti-patterns to watch for when reviewing perps code +--- + +# Perps Domain Anti-Patterns + +> Patterns to watch for when reviewing perps-related code. Generic code quality is handled by standard review. + +## Controller Portability (Core Sync) + +The controller at `app/controllers/perps/` is published as `@metamask/perps-controller` and synced to `core` monorepo via `scripts/perps/validate-core-sync.sh`. It must remain platform-agnostic — no mobile-specific imports. + +- **Mobile import in controller** — `react-native`, `Engine`, `Sentry`, `DevLogger` imported directly in `app/controllers/perps/`. All platform services must flow through `PerpsPlatformDependencies` (DI). The sync script checks for these but a PR could introduce them. +- **Direct controller import from app code** — app files importing `from '../../controllers/perps/...'` instead of `from '@metamask/perps-controller'`. ESLint rule exists but may be suppressed. +- **`__DEV__` in controller code** — must not appear in controller files. Core replaces it with `false` during sync. If new code adds `__DEV__` checks, sync breaks. +- **New dependency not in DI interface** — controller code reaching outside its boundary (e.g., importing a hook, React context, or mobile utility). Everything the controller needs must come through `infrastructure: PerpsPlatformDependencies` constructor param. +- **Breaking the publisher contract** — changing PerpsController's public API (state shape, method signatures, event names) without considering extension consumers. Controller is a publisher — mobile and extension both consume it. + +## Magic Strings, Magic Numbers & Placeholder Values + +Constants live in `app/controllers/perps/constants/perpsConfig.ts` (controller-portable) and `app/components/UI/Perps/constants/perpsConfig.ts` (UI-only). PRs must use these — not inline literals. + +- **Defaulting to `0` when data is unavailable** — the most common mistake. When price/percentage/data hasn't loaded yet, use the placeholder constants, NOT `0`, `$0`, or `0%`: + - `PERPS_CONSTANTS.FallbackPriceDisplay` (`'$---'`) — price not yet loaded + - `PERPS_CONSTANTS.FallbackPercentageDisplay` (`'--%'`) — percentage not yet loaded + - `PERPS_CONSTANTS.FallbackDataDisplay` (`'--'`) — generic data not yet loaded + - `PERPS_CONSTANTS.ZeroAmountDisplay` (`'$0'`) / `ZeroAmountDetailedDisplay` (`'$0.00'`) — ONLY for actual confirmed zero values (e.g., no volume), never for "loading" or "unavailable" + - Defaulting to `0` hides loading states, makes bugs invisible, and can mislead users into thinking their balance/PnL is actually zero. +- **Inline timeout/delay values** — hardcoded `5000`, `10000`, `300` instead of `PERPS_CONSTANTS.WebsocketTimeout`, `PERPS_CONSTANTS.ConnectionTimeoutMs`, `PERFORMANCE_CONFIG.ValidationDebounceMs`, etc. Every timing constant has a named export. +- **Hardcoded slippage** — using `0.03` or `300` instead of `ORDER_SLIPPAGE_CONFIG.DefaultMarketSlippageBps`, `DefaultTpslSlippageBps`, `DefaultLimitSlippageBps`. +- **Hardcoded leverage fallback** — using `3` or `50` instead of `PERPS_CONSTANTS.DefaultMaxLeverage` or `MARGIN_ADJUSTMENT_CONFIG.FallbackMaxLeverage`. +- **Hardcoded precision** — using `6`, `2`, `5` for decimal places instead of `DECIMAL_PRECISION_CONFIG.MaxPriceDecimals`, `MaxSignificantFigures`, `FallbackSizeDecimals`, or `CLOSE_POSITION_CONFIG.UsdDecimalPlaces`. +- **Hardcoded API URLs** — inline `'https://perps.api...'` instead of `DATA_LAKE_API_CONFIG.OrdersEndpoint`. +- **Hardcoded provider name** — `'hyperliquid'` string instead of `PROVIDER_CONFIG.DefaultProvider`. +- **Hardcoded validation thresholds** — `20` for high leverage warning, `0.1` for price deviation, instead of `VALIDATION_THRESHOLDS.HighLeverageWarning`, `VALIDATION_THRESHOLDS.PriceDeviation`. +- **Hardcoded cache durations** — inline `5 * 60 * 1000` instead of `PERFORMANCE_CONFIG.MarketDataCacheDurationMs`, `FeeDiscountCacheDurationMs`, etc. + +## Protocol Abstraction + +All provider access must go through `AggregatedPerpsProvider` → `ProviderRouter`. HyperLiquid is primary, MYX is feature-flagged. + +- **Hardcoded provider** — uses HyperLiquid or MYX APIs directly instead of going through `AggregatedPerpsProvider` / `ProviderRouter`. All operations must route through the abstraction. +- **Provider-specific branching in UI** — `if (provider === 'hyperliquid')` in components or hooks. Provider differences must be normalized in the aggregation layer, not leaked to the view. +- **Provider-specific error handling** — catches errors from one provider but not others. All providers must have consistent error boundaries via the aggregated layer. +- **Hardcoded market symbols** — string literals `"BTC"` or `"ETH"` instead of market config constants. Breaks when new markets or providers are added. +- **Hardcoded decimals/precision** — using provider-native decimal formats without normalization. HyperLiquid and MYX use different precision for prices, sizes, and leverage. Must go through `MarketDataFormatters` (DI). + +## MetaMetrics Events + +8 consolidated events with typed constants. Reference: `docs/perps/perps-metametrics-reference.md`. + +- **Magic string event properties** — using `'status'`, `'asset'`, `'direction'` instead of `PERPS_EVENT_PROPERTY.STATUS`, `PERPS_EVENT_PROPERTY.ASSET`, etc. from `@metamask/perps-controller`. +- **Magic string event values** — using `'executed'`, `'long'`, `'market'` instead of `PERPS_EVENT_VALUE.STATUS.EXECUTED`, `PERPS_EVENT_VALUE.DIRECTION.LONG`, `PERPS_EVENT_VALUE.ORDER_TYPE.MARKET`. +- **New event instead of property** — creating a 9th event when the change should be a new `screen_type`, `interaction_type`, or `action_type` value on an existing event. The 8-event model is intentional (Segment cost optimization). +- **Missing `source` on screen view** — `PERPS_SCREEN_VIEWED` without `source` property loses navigation flow tracking. Source = current screen, not earlier in the chain. +- **Hardcoded source in reusable component** — reusable components (`PerpsMarketTypeSection`, `PerpsWatchlistMarkets`, `PerpsCard`) must receive `source` as a prop from the parent screen, not set it implicitly. +- **New screen/view without tracking** — adding a new view without `PERPS_SCREEN_VIEWED` event + `usePerpsMeasurement` Sentry trace. +- **Missing `completion_duration` on transaction events** — all transaction events (`PERPS_TRADE_TRANSACTION`, `PERPS_POSITION_CLOSE_TRANSACTION`, etc.) require duration tracking. + +## Sentry Tracing + +38+ traces for performance monitoring. Reference: `docs/perps/perps-sentry-reference.md`. + +- **New screen without `usePerpsMeasurement`** — every new view needs a Sentry performance trace with appropriate `conditions` for when data is loaded. +- **Missing error context** — `Logger.error()` calls without `{ feature: 'perps', context: 'ClassName.method', provider, network }`. Sentry filtering depends on these fields. +- **Missing `ensureError()` wrapper** — catching errors without `ensureError(error)` before passing to `Logger.error()`. Non-Error objects crash Sentry reporting. +- **New trace without TraceName enum** — hardcoded trace name strings instead of adding to `TraceName` enum in `app/util/trace.ts`. +- **Missing `endTrace` in finally block** — `trace()` started but `endTrace()` not in a `finally` block. Orphaned traces leak in Sentry. + +## Connection & WebSocket Architecture + +Single `PerpsAlwaysOnProvider` at wallet root owns lifecycle. All `PerpsConnectionProvider` instances use `manageLifecycle={false}`. + +- **New `PerpsConnectionProvider` with lifecycle** — adding a `PerpsConnectionProvider` without `manageLifecycle={false}` creates reference-count bugs. Only `PerpsAlwaysOnProvider` manages connect/disconnect. +- **Unthrottled WS → setState** — every WS tick triggers state update. Must use `useLivePrices` with appropriate `throttleMs` (100ms for charts, 2s for lists, 10s for order forms). +- **Per-component WS subscription** — creating a new WebSocket connection per component instead of using `PerpsStreamManager` shared subscriptions with reference counting. +- **WS subscription leak** — subscribing on mount without unsubscribing on unmount or market switch. `PerpsStreamManager` handles ref counting but custom subscriptions must clean up. +- **Stale data after async gap** — reading position/order state, awaiting something, then using the stale read. WS updates change state between awaits. Re-read after async boundaries. +- **Missing cache invalidation** — after trade/withdrawal/position change, not calling `PerpsCacheInvalidator.invalidate()` for affected cache types (`positions`, `accountState`). Standalone queries on token detail pages show stale data. + +## Data Flow & State + +Controller → Redux → Hooks → Components. Standalone mode for lightweight queries without full init. + +- **Direct controller call from component** — components calling `PerpsController.method()` directly instead of going through hooks (`usePerpsTrading`, `usePerpsAccount`, etc.). +- **Missing `accountState` check** — accessing positions/orders/balances without verifying accountState is loaded. Causes undefined errors on first load or account switch. +- **Stale position after close** — position in UI after close because local state not cleared or WS update not processed. Must refresh via `PerpsCacheInvalidator`. +- **Preload data not seeded** — new hook not using `getPreloadedData()` lazy initializer. First render shows skeleton instead of cached data from the 5-minute preload cycle. +- **Order state race** — submitting order and immediately reading order state. WS confirmation hasn't arrived. Use transaction receipt or poll with backoff. +- **Leverage/validation bypass** — allowing values outside market's `maxLeverage` or skipping pre-trade checks (balance, market open, position limit). + +## Trade Flow + +- **Pre-trade checks missing** — submitting trade without verifying: sufficient balance, market open, position limit, leverage within bounds, slippage tolerance set. +- **Post-trade state not refreshed** — after trade confirmation, not triggering refresh of balances, positions, orders. User sees stale data until next WS tick. +- **Missing slippage in order params** — creating order without slippage tolerance, or hardcoding slippage instead of user preference. + +## Agentic Testability (testIDs) + +PRs that touch UI components must include testIDs so agentic recipes and E2E tests can navigate and assert on the app without manual interaction. + +- **Missing testID on interactive elements** — any `TextInput`, `Pressable`, `Button`, or touchable in a new or modified component without a `testID` prop. Agentic recipes use `app-state.sh press ` and `eval_sync` fiber-walk queries to interact with and assert on UI. If the element has no testID, the recipe cannot press it or read its value — the fix is untestable agentically. +- **testID not in `Perps.testIds.ts`** — testIDs defined as inline strings instead of exported constants from `app/components/UI/Perps/Perps.testIds.ts`. All testIDs must be centralized so recipes can reference them by constant name. +- **testID missing from the element that holds the value** — adding testID to a wrapper View instead of the `TextInput` or Text that actually contains the value. CDP fiber-walk reads `value` from the React element with the matching testID — the testID must be on the element that owns the state. +- **TP/SL price inputs without testID** — the trigger price `TextInput` components in `PerpsTPSLView` (and similar order-form screens) frequently lack testIDs, making it impossible to assert the accepted decimal precision agentically. Any PR touching these screens must add `testID` to both the Take Profit and Stop Loss price inputs. diff --git a/domains/perps/knowledge/screens.md b/domains/perps/knowledge/screens.md new file mode 100644 index 0000000..8d76fd8 --- /dev/null +++ b/domains/perps/knowledge/screens.md @@ -0,0 +1,988 @@ +--- +name: screens +domain: perps +description: Complete reference for all perps screens, hooks, data flow, and navigation +--- + +# Perps Screens & Views Documentation + +Complete architectural reference for all 17 Perps screens in MetaMask Mobile. + +## Table of Contents + +1. [PerpsTabView](#perpstabview) - Main container +2. [PerpsHomeView](#perpshomeview) - Landing screen +3. [PerpsMarketListView](#perpsmarketlistview) - Market browser +4. [PerpsMarketDetailsView](#perpsmarketdetailsview) - Market detail +5. [PerpsOrderView](#perpsorderview) - Order entry +6. [PerpsPositionsView](#perpspositionsview) - Positions list +7. [PerpsClosePositionView](#perpsclosepositio nview) - Close position +8. [PerpsAdjustMarginView](#perpsadjustmarginview) - Adjust margin +9. [PerpsCloseAllPositionsView](#perpsclosealpositionsview) - Close all +10. [PerpsCancelAllOrdersView](#perpcancelallordersview) - Cancel all +11. [PerpsTPSLView](#perpstpslview) - TP/SL management +12. [PerpsTransactionsView](#perpstransactionsview) - Transaction history +13. [PerpsWithdrawView](#perpswithdrawview) - Withdrawal +14. [PerpsHeroCardView](#perpsherocardview) - Hero cards +15. [PerpsEmptyState](#perpsemptystate) - Empty states +16. [PerpsRedirect](#perpsredirect) - Routing logic +17. [HIP3DebugView](#hip3debugview) - Debug tools + +--- + +## PerpsTabView + +**Location:** `app/components/UI/Perps/Views/PerpsTabView/PerpsTabView.tsx` + +### Purpose & User Journey + +Main container view for Perps trading interface. Orchestrates all Perps screens within a tab-based structure. Acts as the root component when user selects Perps from main wallet tabs. + +### Key Components Used + +- `PerpsNavigation` - React Navigation stack navigator configuration +- Screen components (dynamically rendered based on active route) + +### Hooks Consumed + +- None directly (orchestration level) + +### Data Flow + +- Receives navigation props from parent (Wallet component) +- Routes all Perps navigation through React Navigation stack +- No Redux state mutations + +### Navigation + +- Entry point: User taps "Perps" tab in wallet +- Destinations: All other Perps screens (HomeView, MarketDetails, OrderView, etc.) +- Exit: User switches to different wallet tab + +--- + +## PerpsHomeView + +**Location:** `app/components/UI/Perps/Views/PerpsHomeView/PerpsHomeView.tsx` + +### Purpose & User Journey + +Landing screen for Perps trading. Displays aggregated trading overview including positions, open orders, watchlist markets, and recent activity. Single entry point to all trading actions. + +### Key Components Used + +| Component | Purpose | Location | +| ---------------------------- | --------------------------------------- | ------------------------------------- | +| `PerpsMarketBalanceActions` | Balance & deposit section | `components/` | +| `PerpsCard` | Featured trading card | `components/` | +| `PerpsWatchlistMarkets` | User watchlist | `components/PerpsWatchlistMarkets/` | +| `PerpsMarketTypeSection` | Market categories (Crypto/Stocks/Forex) | `components/` | +| `PerpsRecentActivityList` | Recent trades & orders | `components/PerpsRecentActivityList/` | +| `PerpsHomeHeader` | Header with balance display | `components/` | +| `PerpsCloseAllPositionsView` | Modal: Close all positions | `Views/PerpsCloseAllPositionsView/` | +| `PerpsCancelAllOrdersView` | Modal: Cancel all orders | `Views/PerpsCancelAllOrdersView/` | + +### Hooks Consumed + +| Hook | Purpose | +| ----------------------- | -------------------------------------------- | +| `usePerpsHomeData` | Fetches positions, orders, markets, activity | +| `usePerpsNavigation` | Centralized navigation routing | +| `usePerpsMeasurement` | Performance tracking | +| `usePerpsEventTracking` | Analytics events | + +### Data Flow + +``` +Redux + WebSocket (via usePerpsHomeData) + ↓ +Positions, Orders, Markets (real-time) + ↓ +PerpsHomeView renders sections + ↓ +User navigates to detail screens or executes close-all/cancel-all +``` + +### Navigation + +- **From:** Perps tab selection from wallet +- **To:** + - PerpsMarketDetailsView (tap market) + - PerpsOrderView (new trade) + - PerpsCloseAllPositionsView (modal) + - PerpsCancelAllOrdersView (modal) +- **Analytics:** Tracks screen view with source (main button or deep link) + +--- + +## PerpsMarketListView + +**Location:** `app/components/UI/Perps/Views/PerpsMarketListView/PerpsMarketListView.tsx` + +### Purpose & User Journey + +Browsable market list with search, sorting, filtering by market type. User discovers new markets and filters by asset class (Crypto/Stocks/Commodities/Forex). + +### Key Components Used + +| Component | Purpose | +| -------------------------------------------- | ----------------------------------- | +| `PerpsMarketList` | Virtualized market list (FlashList) | +| `PerpsMarketFiltersBar` | Asset type filter tabs | +| `PerpsMarketSortFieldBottomSheet` | Sort options modal | +| `PerpsStocksCommoditiesBottomSheet` | Sub-filter for stocks/commodities | +| `PerpsMarketListHeader` | Header with search | +| `PerpsMarketBalanceActions` | Balance section | +| `PerpsMarketListView.PerpsMarketRowSkeleton` | Loading skeleton | + +### Hooks Consumed + +| Hook | Purpose | +| ------------------------ | ------------------------------------------- | +| `usePerpsMarketListView` | All market filtering, sorting, search logic | +| `usePerpsMeasurement` | Performance tracking | +| `usePerpsEventTracking` | Analytics | +| `usePerpsNavigation` | Navigation to market details | + +### Data Flow + +``` +usePerpsMarketListView hook: + ├─ Fetches all markets + ├─ Filters by: search, type (crypto/stocks/forex), favorites + ├─ Sorts by: price change, volume, interest + └─ Returns: filteredMarkets[], marketCounts + +User interactions: + ├─ Search → real-time filter + ├─ Sort → reorder list + ├─ Type filter → category filter + └─ Tap market → navigate to PerpsMarketDetailsView +``` + +### Navigation + +- **From:** PerpsHomeView, back buttons +- **To:** PerpsMarketDetailsView (tap market row) +- **Modal dialogs:** Sort/filter options + +--- + +## PerpsMarketDetailsView + +**Location:** `app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.tsx` + +### Purpose & User Journey + +Detailed market view with TradingView chart, market stats, and trading interface. User analyzes price action and executes trades for a single market. + +### Key Components Used + +| Component | Purpose | +| ------------------------------- | ---------------------------------- | +| `PerpsMarketHeader` | Title, price, 24h change | +| `TradingViewChart` | Chart with multiple timeframes | +| `PerpsCandlePeriodSelector` | Candle period (1m, 5m, 1h, 4h, 1d) | +| `PerpsMarketTabs` | Info/Orders/Positions tabs | +| `PerpsNavigationCard` | Quick action buttons | +| `PerpsOICapWarning` | OI capacity warning | +| `PerpsMarketHoursBanner` | Trading hours status | +| `PerpsMarketBalanceActions` | Balance info | +| `PerpsFlipPositionConfirmSheet` | Flip position confirmation modal | + +### Hooks Consumed + +| Hook | Purpose | +| ------------------------------------------- | ------------------------------- | +| `usePerpsPositionData` | Fetch position for this market | +| `usePerpsMarketStats` | Market statistics (funding, OI) | +| `useHasExistingPosition` | Check if user has position | +| `usePerpsOICap` | OI cap checking | +| `usePerpsDataMonitor` | Data consistency monitoring | +| `usePerpsMeasurement` | Performance tracking | +| `usePerpsLiveOrders`, `usePerpsLiveAccount` | Real-time updates | + +### Data Flow + +``` +Route params: { market: PerpsMarketData } + ↓ +usePerpsMarketStats → Statistics +usePerpsPositionData → Existing position +usePerpsDataMonitor → Data consistency + ↓ +Render: Chart + Stats + Tabs + ↓ +User actions: + ├─ Trade → PerpsOrderView + ├─ Manage position → PerpsClosePositionView or PerpsTPSLView + └─ View orders → Market orders tab +``` + +### Navigation + +- **From:** PerpsMarketListView (tap market) +- **To:** + - PerpsOrderView (new order button) + - PerpsClosePositionView (close existing) + - PerpsTPSLView (TP/SL settings) + +--- + +## PerpsOrderView + +**Location:** `app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.tsx` + +### Purpose & User Journey + +Order placement interface. User specifies trade parameters: direction (long/short), amount (USD or size), leverage, and optional limit price. Final review before execution. + +### Key Components Used + +| Component | Purpose | +| ---------------------------- | ---------------------------------- | +| `PerpsOrderHeader` | Market info (asset, price, change) | +| `PerpsAmountDisplay` | USD amount display/input | +| `PerpsSlider` | Leverage/amount slider | +| `PerpsFeesDisplay` | Estimated fees breakdown | +| `PerpsLimitPriceBottomSheet` | Limit price input modal | + +### Hooks Consumed + +| Hook | Purpose | +| ----------------------- | --------------------- | +| `usePerpsOrderForm` | Form state management | +| `usePerpsOrderFees` | Fee calculation | +| `usePerpsRewards` | Rewards & discounts | +| `usePerpsValidation` | Form validation | +| `usePerpsLivePrices` | Real-time price feed | +| `usePerpsMeasurement` | Performance tracking | +| `usePerpsEventTracking` | Analytics | + +### Data Flow + +``` +Route params: + ├─ market: PerpsMarketData + ├─ orderType: 'market' | 'limit' + └─ initialLeverage?: number + +Form state: + ├─ amount (USD) + ├─ leverage + ├─ orderType + ├─ limitPrice (if limit order) + └─ direction (long/short) + +usePerpsOrderFees: + ├─ Calculates trading fee + ├─ Applies fee discount + └─ Shows rewards + +User action: + ├─ Adjust amount → slider or keypad + ├─ Set leverage → numeric input + ├─ Set limit price → modal + └─ Confirm → Execute order +``` + +### Navigation + +- **From:** PerpsMarketDetailsView (Trade button) +- **To:** PerpsTPSLView (optional, after order placed) +- **Back:** Returns to market details + +--- + +## PerpsPositionsView + +**Location:** `app/components/UI/Perps/Views/PerpsPositionsView/PerpsPositionsView.tsx` + +### Purpose & User Journey + +List of all open positions. User views position details, total P&L, and can initiate close or TP/SL updates. + +### Key Components Used + +| Component | Purpose | +| ------------------- | --------------------------- | +| `PerpsPositionCard` | Individual position card | +| Utility functions | PnL calculation, formatting | + +### Hooks Consumed + +| Hook | Purpose | +| ----------------------- | ----------------------------- | +| `usePerpsLivePositions` | Fetch all positions real-time | +| `usePerpsLiveAccount` | Account state (margin, etc.) | + +### Data Flow + +``` +usePerpsLivePositions (WebSocket): + └─ Returns: positions[], isInitialLoading + +Calculate: + ├─ Total unrealized P&L + ├─ Total margin used + └─ Position count + +Render: + ├─ Positions list + ├─ Total P&L summary + └─ Per-position action buttons +``` + +### Navigation + +- **From:** Perps tab or PerpsHomeView +- **To:** + - PerpsClosePositionView (close position) + - PerpsTPSLView (set TP/SL) + - PerpsMarketDetailsView (view market) + +--- + +## PerpsClosePositionView + +**Location:** `app/components/UI/Perps/Views/PerpsClosePositionView/PerpsClosePositionView.tsx` + +### Purpose & User Journey + +Interface to close existing position (fully or partially). User specifies close amount/percentage and optional limit price. Shows estimated fees and receive amount. + +### Key Components Used + +| Component | Purpose | +| ---------------------------- | -------------------------------- | +| `PerpsOrderHeader` | Position info | +| `PerpsAmountDisplay` | Close amount display | +| `PerpsSlider` | Close percentage slider | +| `PerpsCloseSummary` | Fee and receive amount breakdown | +| `PerpsLimitPriceBottomSheet` | Limit price for limit orders | + +### Hooks Consumed + +| Hook | Purpose | +| --------------------------------- | ------------------------ | +| `usePerpsClosePosition` | Close position execution | +| `usePerpsClosePositionValidation` | Validation logic | +| `usePerpsOrderFees` | Fee calculation | +| `usePerpsRewards` | Rewards calculation | +| `usePerpsLivePrices` | Real-time prices | +| `usePerpsMeasurement` | Performance tracking | + +### Data Flow + +``` +Route params: { position: Position } + +State: + ├─ closePercentage (0-100) + ├─ closeAmountUSD (for keypad input) + ├─ orderType ('market' | 'limit') + └─ limitPrice (optional) + +Calculations: + ├─ closeAmount = position.size * (closePercentage / 100) + ├─ closingValue = positionValue * (closePercentage / 100) + ├─ effectivePnL = calculated based on effective price + └─ receiveAmount = margin + pnl - fees + +User action: Confirm → handleClosePosition() +``` + +### Navigation + +- **From:** PerpsPositionsView or PerpsMarketDetailsView +- **To:** PerpsMarketDetailsView (after close) +- **Modal:** Limit price bottom sheet + +--- + +## PerpsCloseAllPositionsView + +**Location:** `app/components/UI/Perps/Views/PerpsCloseAllPositionsView/PerpsCloseAllPositionsView.tsx` + +### Purpose & User Journey + +Modal/bottom sheet to close all open positions at once. Shows summary of total margin, P&L, and fees. Confirms user intent before mass execution. + +### Key Components Used + +| Component | Purpose | +| ------------------- | --------------------- | +| `BottomSheet` | Modal container | +| `PerpsCloseSummary` | Fee breakdown summary | + +### Hooks Consumed + +| Hook | Purpose | +| ------------------------------ | ---------------------- | +| `usePerpsLivePositions` | Fetch all positions | +| `usePerpsLivePrice` | Price data for calc | +| `usePerpsCloseAllCalculations` | Aggregate calculations | +| `usePerpsCloseAllPositions` | Execution hook | +| `usePerpsToasts` | Success/error feedback | +| `usePerpsEventTracking` | Analytics | + +### Data Flow + +``` +Mount: + ├─ Fetch positions + ├─ Fetch prices + └─ Calculate aggregates + +Calculations (usePerpsCloseAllCalculations): + ├─ totalMargin + ├─ totalPnl + ├─ totalFees + ├─ feeDiscounts + └─ rewards + +User action: Confirm → usePerpsCloseAllPositions() → Loop through and close all +``` + +### Navigation + +- **From:** PerpsHomeView (modal action) or navigation stack +- **To:** Back to PerpsHomeView (modal close) +- **Integration:** Can be embedded as external sheet ref or standalone route + +--- + +## PerpsCancelAllOrdersView + +**Location:** `app/components/UI/Perps/Views/PerpsCancelAllOrdersView/PerpsCancelAllOrdersView.tsx` + +### Purpose & User Journey + +Modal to cancel all pending orders at once. Shows list count and confirmation. Useful for clearing market without closing positions. + +### Key Components Used + +| Component | Purpose | +| ------------- | --------------- | +| `BottomSheet` | Modal container | + +### Hooks Consumed + +| Hook | Purpose | +| ------------------------- | --------------------------------- | +| `usePerpsLiveOrders` | Fetch all orders (excludes TP/SL) | +| `usePerpsCancelAllOrders` | Execution hook | +| `usePerpsToasts` | Feedback | +| `usePerpsEventTracking` | Analytics | + +### Data Flow + +``` +Mount: + ├─ Fetch orders (hideTpSl: true) + └─ Show count + +User action: Confirm → Loop through and cancel all + +Result: + ├─ Show success toast + ├─ Close modal + └─ Refresh orders +``` + +### Navigation + +- **From:** PerpsHomeView (modal action) +- **To:** Back to PerpsHomeView +- **Pattern:** Similar to PerpsCloseAllPositionsView + +--- + +## PerpsTPSLView + +**Location:** `app/components/UI/Perps/Views/PerpsTPSLView/PerpsTPSLView.tsx` + +### Purpose & User Journey + +Full-screen editor for Take Profit and Stop Loss price levels. Supports entry by price or percentage (ROE). Shows expected profit/loss. Used for new orders or position management. + +### Key Components Used + +| Component | Purpose | +| -------------------------------------------- | ------------------------ | +| `Keypad` | Numeric input for prices | +| Utility: `formatPerpsFiat`, `PRICE_RANGES_*` | Display formatting | + +### Hooks Consumed + +| Hook | Purpose | +| -------------------------- | --------------------------- | +| `usePerpsTPSLForm` | All form state & validation | +| `usePerpsLivePrices` | Real-time market price | +| `usePerpsLiquidationPrice` | Calculate liquidation level | +| `usePerpsEventTracking` | Analytics | + +### Data Flow + +``` +Route params: + ├─ asset (market) + ├─ direction (long/short) + ├─ position (optional) + ├─ leverage + ├─ orderType ('market' | 'limit') + └─ onConfirm callback + +Form state (usePerpsTPSLForm): + ├─ takeProfitPrice & percentage + ├─ stopLossPrice & percentage + ├─ validation errors + └─ expected P&L + +Pricing: + ├─ Use live price if available + ├─ Fall back to entry price for existing position + └─ Use limit price for limit orders + +User action: Confirm → onConfirm(tpPrice, slPrice, trackingData) +``` + +### Navigation + +- **From:** PerpsOrderView or PerpsMarketDetailsView +- **To:** Previous screen (back navigation) +- **Full screen:** SafeAreaView-based navigation + +--- + +## PerpsAdjustMarginView + +**Location:** `app/components/UI/Perps/Views/PerpsAdjustMarginView/PerpsAdjustMarginView.tsx` + +### Purpose & User Journey + +Unified view for adjusting position margin (add or remove). Mode parameter determines behavior: add mode increases margin to reduce leverage; remove mode decreases margin to free collateral. Slider-based selection with live impact preview and risk warnings for remove mode. + +### Key Components Used + +| Component | Purpose | +| ------------------ | ------------------ | +| `Slider` | Amount selector | +| `PerpsOrderHeader` | Asset info & price | + +### Hooks Consumed + +| Hook | Purpose | +| -------------------------- | ------------------------------------- | +| `usePerpsMarginAdjustment` | Unified margin adjustment with toasts | +| `usePerpsLiveAccount` | Available balance (add mode) | +| `usePerpsMarkets` | Max leverage (remove mode) | +| `usePerpsLivePrices` | Current market price | +| `usePerpsMeasurement` | Performance tracking with mode tag | + +### Data Flow + +``` +Route params: { position, mode: 'add' | 'remove' } +Add mode: availableBalance → maxAmount +Remove mode: calculateMaxRemovableMargin() → maxAmount +User slides → Preview new margin/leverage/liq price +Remove mode: assessMarginRemovalRisk() → risk level (safe/warning/danger) +Confirm → handleAddMargin() or handleRemoveMargin() +``` + +### Navigation + +- **From:** PerpsMarketDetailsView (position card → Adjust Margin action sheet → mode selection) +- **To:** Navigates back on success +- **Full screen:** SafeAreaView-based + +--- + +## PerpsTransactionsView + +**Location:** `app/components/UI/Perps/Views/PerpsTransactionsView/PerpsTransactionsView.tsx` + +### Purpose & User Journey + +Historical transaction log with filterable tabs: Trades, Orders, Funding, Deposits/Withdrawals. Pull-to-refresh supported. User reviews trading history. + +### Key Components Used + +| Component | Purpose | +| --------------------------- | ----------------------------------- | +| `FlashList` | Virtualized list (high performance) | +| `PerpsTransactionItem` | Individual transaction card | +| `PerpsTransactionsSkeleton` | Loading state | +| Tab buttons | Filter by transaction type | + +### Hooks Consumed + +| Hook | Purpose | +| ---------------------------- | ---------------------- | +| `usePerpsTransactionHistory` | Fetch all transactions | +| `usePerpsConnection` | Connection state | +| `usePerpsMeasurement` | Performance tracking | + +### Data Flow + +``` +usePerpsTransactionHistory: + └─ Fetch: trades, orders, funding, deposits/withdrawals + +Grouping: + ├─ Group by date + └─ Flatten for FlashList + +Filtering: + ├─ User selects tab (Trades/Orders/Funding/Deposits) + └─ Filter transactions by type + +Navigation: + ├─ Tap trade → PerpsPositionTransactionView + ├─ Tap order → PerpsOrderTransactionView + ├─ Tap funding → PerpsFundingTransactionView + └─ Deposits show inline (no detail view) +``` + +### Navigation + +- **From:** Perps tab or PerpsHomeView +- **To:** Transaction detail views (type-specific) +- **Pull-to-refresh:** Reloads all transaction data + +--- + +## PerpsWithdrawView + +**Location:** `app/components/UI/Perps/Views/PerpsWithdrawView/PerpsWithdrawView.tsx` + +### Purpose & User Journey + +Withdrawal flow to move USDC from Perps account back to mainchain wallet. User enters amount, sees fees, and confirms. Immediate navigation on confirm. + +### Key Components Used + +| Component | Purpose | +| ------------------------- | ------------------ | +| `Keypad` | Numeric input | +| `AvatarToken` | USDC token display | +| `Badge` | Network badge | +| `PerpsBottomSheetTooltip` | Info tooltips | +| `KeyValueRow` | Fee/time display | + +### Hooks Consumed + +| Hook | Purpose | +| ----------------------- | --------------------------- | +| `usePerpsLiveAccount` | Get available balance | +| `usePerpsWithdrawQuote` | Fee calculation | +| `useWithdrawValidation` | Validation (min/max) | +| `useWithdrawTokens` | Get destination token/chain | +| `usePerpsEventTracking` | Analytics | +| `usePerpsMeasurement` | Performance | + +### Data Flow + +``` +Mount: + ├─ Fetch account balance + ├─ Fetch destination token (USDC on Arbitrum) + └─ Display available balance + +User input: + ├─ Enter amount via keypad + ├─ Or tap 10/25/50/Max percentage + └─ Validation: min $10, max available + +Confirm: + ├─ Call controller.withdraw() + ├─ Navigate back immediately + └─ Async execution with toast feedback + +Result: + ├─ Success/error toast + └─ Balance update via WebSocket +``` + +### Navigation + +- **From:** PerpsHomeView (deposit button) +- **To:** Back to PerpsHomeView (immediate) +- **Modal state:** Percentage buttons disappear when amount entered + +--- + +## PerpsHeroCardView + +**Location:** `app/components/UI/Perps/Views/PerpsHeroCardView/PerpsHeroCardView.tsx` + +### Purpose & User Journey + +Celebratory card carousel for profitable positions. User can swipe through 4 themed cards, customize with optional referral code, and share to social media. + +### Key Components Used + +| Component | Purpose | +| ------------------------ | --------------------- | +| `ScrollableTabView` | Card carousel (swipe) | +| `react-native-view-shot` | Capture card image | +| `react-native-share` | Share to social apps | +| `RewardsReferralCodeTag` | Referral code display | +| `PerpsTokenLogo` | Market asset logo | + +### Hooks Consumed + +| Hook | Purpose | +| ------------------------------------ | ------------------------ | +| `usePerpsEventTracking` | Share analytics | +| `usePerpsToasts` | Share feedback | +| Redux selector: `selectReferralCode` | Get user's referral code | + +### Data Flow + +``` +Route params: { position: Position, marketPrice?: string } + +Data used: + ├─ position.unrealizedPnl (ROE calculation) + ├─ position.leverage + ├─ position.entryPrice + ├─ marketPrice (for mark price display) + └─ position.coin (asset symbol) + +Carousel: + ├─ 4 PNL character images + ├─ Swipe to change + └─ Dots indicator + +Share: + ├─ Capture current card as image + ├─ Include referral code if available + ├─ Send via Share sheet + └─ Track success/failure +``` + +### Navigation + +- **From:** PerpsHomeView (position share button) +- **To:** Share sheet or back to home +- **Analytics:** Track card view, share attempts + +--- + +## PerpsEmptyState + +**Location:** `app/components/UI/Perps/Views/PerpsEmptyState/PerpsEmptyState.tsx` + +### Purpose & User Journey + +Reusable empty state component shown when no positions exist. Encourages user to start trading. + +### Key Components Used + +| Component | Purpose | +| --------------- | ------------------------- | +| `TabEmptyState` | Base empty state layout | +| Image assets | Theme-aware illustrations | + +### Hooks Consumed + +| Hook | Purpose | +| ------------------- | --------------------- | +| `useAssetFromTheme` | Theme-specific images | +| `useTailwind` | Styling | + +### Data Flow + +``` +Props: { onActionPress?, testID? } + +Render: + ├─ Theme image (light/dark) + ├─ "Start trading" message + └─ CTA button (optional) + +Action: + └─ onActionPress() → Navigate to market list +``` + +### Navigation + +- **From:** PerpsPositionsView or PerpsHomeView (when empty) +- **To:** PerpsMarketListView (action button) + +--- + +## PerpsRedirect + +**Location:** `app/components/UI/Perps/Views/PerpsRedirect.tsx` + +### Purpose & User Journey + +Initialization route that connects to Perps controller, initializes WebSocket, and redirects to home. User never sees this screen in normal flow (only during initialization). + +### Key Components Used + +| Component | Purpose | +| ------------- | ----------------------------- | +| `PerpsLoader` | Full-screen loading indicator | + +### Hooks Consumed + +| Hook | Purpose | +| -------------------- | ------------------------ | +| `usePerpsConnection` | Monitor connection state | + +### Data Flow + +``` +Mount: + ├─ Check if connected & initialized + ├─ If not: show loader + └─ If yes: redirect to home + +Redirect: + ├─ Navigate to Routes.WALLET.HOME + ├─ Wait for navigation complete (delay needed) + ├─ setParams to select perps tab + └─ Tab selection triggers PerpsTabView +``` + +### Navigation + +- **From:** Deep link or initial Perps tab selection +- **To:** PerpsHomeView (or PerpsTabView container) +- **Status messages:** "Initializing Perps" → "Connecting" → redirect + +--- + +## HIP3DebugView + +**Location:** `app/components/UI/Perps/Debug/HIP3DebugView.tsx` + +### Purpose & User Journey + +Development-only debug interface for testing HyperLiquid HIP-3 multi-DEX feature. Tests DEX selection, market loading, transfers between DEXs, and order placement with auto-transfer. + +### Key Components Used + +| Component | Purpose | +| -------------------------------------------- | -------------------- | +| `DevLogger` | Debug output console | +| Native UI: buttons, text, activity indicator | Basic controls | + +### Hooks Consumed + +None directly (uses direct provider calls) + +### Data Flow + +``` +Provider access: + ├─ Engine.context.PerpsController.getActiveProvider() + └─ Cast to HyperLiquidProvider + +Test workflows: + 1. Load available DEXs + 2. Load markets for selected DEX + 3. Check account balances per DEX + 4. Manual transfer to/from DEX + 5. Test order with auto-transfer + 6. Test close with auto-transfer back + +Output: DevLogger console (accessible via DevLogger UI) +``` + +### Features + +| Feature | Purpose | Input | +| --------------------- | ----------------------------------------------- | ---------------------------------- | +| DEX Selector | Choose which DEX to test | Dropdown from available HIP-3 DEXs | +| Market Selector | Choose market on DEX | Dropdown filtered by selected DEX | +| Balance Check | View aggregated balances | Button (logs to console) | +| Manual Transfer → DEX | Transfer $10 from main to selected DEX | Button | +| Manual Transfer ← DEX | Transfer all from selected DEX back to main | Button (reset) | +| Place Order | $11 order with auto-transfer if needed | Button | +| Close Position | Close first position on DEX, auto-transfer back | Button | + +### Navigation + +- **From:** Developer menu or deep link (dev builds only) +- **To:** Only accessible in `__DEV__` mode +- **Visibility:** Returns "Debug tools unavailable" in production builds + +--- + +## Transaction Detail Views + +Also included in PerpsTransactionsView folder (referenced from main transactions view): + +### PerpsFundingTransactionView + +Shows detailed funding rate transaction with cumulative funding data. + +### PerpsOrderTransactionView + +Shows order details: status (pending/filled/canceled), price, size, fees. + +### PerpsPositionTransactionView + +Shows position trade details: entry price, P&L realized, fees paid. + +--- + +## Architecture Summary + +### Data Layer + +All views consume real-time data via: + +1. **WebSocket streams** (via hooks): + - `usePerpsLivePrices` - Price updates + - `usePerpsLivePositions` - Position updates + - `usePerpsLiveOrders` - Order updates + - `usePerpsLiveAccount` - Balance updates + +2. **Controller methods** (async): + - `usePerpsOrderFees` - Fee calculations + - `usePerpsMarketData` - Market metadata + - `usePerpsTransactionHistory` - Historical data + +3. **Redux** (selectors): + - User preferences + - Cached state + - Referral code + +### Navigation Pattern + +``` +Wallet Tab (PerpsTabView) + ↓ +PerpsHomeView (entry point) + ├→ PerpsMarketListView (browse) + │ └→ PerpsMarketDetailsView (view market) + │ ├→ PerpsOrderView (trade) + │ └→ PerpsClosePositionView (close) + ├→ PerpsPositionsView (manage) + │ ├→ PerpsClosePositionView + │ └→ PerpsTPSLView (TP/SL) + ├→ PerpsTransactionsView (history) + │ └→ Detail views (trade/order/funding) + ├→ PerpsWithdrawView (withdraw) + └→ PerpsHeroCardView (share card) +``` + +### Performance Patterns + +- **Throttled prices:** 1000ms for close position, 500ms for TP/SL +- **Virtualized lists:** FlashList in PerpsTransactionsView +- **Lazy loading:** Markets load on demand in market list +- **Performance tracking:** usePerpsMeasurement hook tracks screen load times + +### State Management + +- **Ephemeral:** Form inputs, UI state (focused input, etc.) +- **Cached:** Market data, transaction history +- **Real-time:** Prices, positions, orders, balances +- **Persisted:** User preferences (chart candle period, etc.) diff --git a/domains/perps/knowledge/shared-package-analysis.md b/domains/perps/knowledge/shared-package-analysis.md new file mode 100644 index 0000000..3fd573b --- /dev/null +++ b/domains/perps/knowledge/shared-package-analysis.md @@ -0,0 +1,78 @@ +--- +name: shared-package-analysis +domain: perps +description: What can be extracted into @metamask/perps-controller to eliminate duplication +--- + +# Shared Package Analysis + +Companion to `mobile-extension-map`. Tracks what's shared, what should be, and what can't be. + +## Already Shared via @metamask/perps-controller + +**Utils (20)**: `significantFigures`, `orderValidation`, `orderCalculations`, `marketDataTransform`, `sortMarkets`, `marketUtils`, `accountUtils`, `errorUtils`, `hyperLiquidAdapter`, `hyperLiquidOrderBookProcessor`, `hyperLiquidValidation`, `myxAdapter`, `standaloneInfoClient`, `stringParseUtils`, `idUtils`, `rewardsUtils`, `transferData`, `wait` + +**Services (14)**: `AccountService`, `TradingService`, `MarketDataService`, `DepositService`, `EligibilityService`, `HyperLiquidClientService`, `HyperLiquidSubscriptionService`, `HyperLiquidWalletService`, `MYXClientService`, `MYXWalletService`, `RewardsIntegrationService`, `TradingReadinessCache`, `DataLakeService`, `FeatureFlagConfigurationService` + +## Priority 1 -- Move to Controller (pure TS, no React deps) + +| Utility | Mobile Path | What It Does | +|---|---|---| +| `pnlCalculations.ts` | `UI/Perps/utils/` | P&L math, ROE | +| `positionCalculations.ts` | `UI/Perps/utils/` | Liquidation price, position value | +| `marginUtils.ts` | `UI/Perps/utils/` | Risk assessment, margin math | +| `orderUtils.ts` | `UI/Perps/utils/` | Order price resolution, trigger validation | +| `marketHours.ts` | `UI/Perps/utils/` | Market hours logic | +| `orderBookGrouping.ts` | `UI/Perps/utils/` | Order book aggregation | +| `tpslValidation.ts` | `UI/Perps/utils/` | TP/SL validation | +| `amountConversion.ts` | `UI/Perps/utils/` | USD <-> size conversions | + +Once in controller, extension imports directly instead of reimplementing. + +## Priority 2 -- Exact Duplicates to Consolidate + +| Function | Mobile | Extension | Identical? | +|---|---|---|---| +| `getDisplayName`/`getDisplaySymbol` | controller `marketUtils.ts` | `ui/components/app/perps/utils.ts` | YES | +| `getPositionDirection` | `UI/Perps/utils/` | `ui/components/app/perps/utils.ts` | YES | +| `formatOrderType`/`formatStatus` | `UI/Perps/utils/` | `ui/components/app/perps/utils.ts` | YES | +| `filterMarketsByQuery` | `UI/Perps/utils/filterAndSortMarkets.ts` | `ui/components/app/perps/utils.ts` | YES | +| `isHip3Market`/`isCryptoMarket` | `UI/Perps/utils/` | `ui/components/app/perps/utils.ts` | YES | +| `groupTransactionsByDate` | `UI/Perps/utils/transactionTransforms.ts` | `ui/components/app/perps/utils/transactionTransforms.ts` | Near-identical | + +## Priority 3 -- Formatting Abstraction + +Extract pure formatting logic into controller: + +``` +Controller exports: + PRICE_RANGES_CONFIG (range thresholds + sig dig rules) + calculateDisplayDecimals(value, config) -> { decimals, sigDigs } + roundToDisplayPrecision(value, config) -> number + +Platform layer: + formatPerpsFiat(value, opts) -> calls calculateDisplayDecimals + locale formatter +``` + +Extension currently uses `.toFixed(2)` and `formatNumber({min:2, max:2})` everywhere -- both wrong for low-value and high-precision assets. + +## Can't Share (platform-bound) + +| File | Reason | +|---|---| +| `formatUtils.ts` | i18n dependency (only pure logic extractable) | +| `translatePerpsError.ts` | i18n strings | +| `buttonColors.ts` | React Native color system | +| Color utilities | Platform-specific color enums | +| `tokenIconUtils.ts` | Mobile-specific image handling | + +## Sync Mechanism + +Existing: `validate-core-sync.sh` syncs `app/controllers/perps/` to Core `packages/perps-controller/src/`. + +**Steps for each Priority 1 item:** +1. Move from `app/components/UI/Perps/utils/X.ts` to `app/controllers/perps/utils/X.ts` +2. Update mobile imports +3. Run `validate-core-sync.sh` +4. Extension imports from `@metamask/perps-controller` +5. Delete extension's duplicate diff --git a/domains/perps/skills/fix-perps-bug/repos/metamask-extension.md b/domains/perps/skills/fix-perps-bug/repos/metamask-extension.md new file mode 100644 index 0000000..4fad112 --- /dev/null +++ b/domains/perps/skills/fix-perps-bug/repos/metamask-extension.md @@ -0,0 +1,64 @@ +--- +repo: metamask-extension +parent: fix-perps-bug +--- + +## File Paths + +| Area | Path | +|---|---| +| Components | `ui/components/app/perps/` | +| Pages | `ui/pages/perps/` | +| Hooks | `ui/hooks/perps/` | +| Utils | `ui/components/app/perps/utils.ts` | +| Transforms | `ui/components/app/perps/utils/transactionTransforms.ts` | +| Stream bridge | `app/scripts/controllers/perps/perps-stream-bridge.ts` | +| Routes | `ui/helpers/constants/routes.ts` | + +## TestIDs + +Convention: kebab-case strings, inline in JSX. + +| Element | TestID | +|---|---| +| Position card | `position-card-{symbol}` | +| Order card | `order-card-{orderId}` | +| Balance | `perps-balance-dropdown-balance` | +| Submit order | `order-entry-submit-button` | +| Direction tabs | `direction-tab-long` / `direction-tab-short` | +| TP price | `tp-price-input` | +| Market row | `explore-crypto-{symbol}` | +| Close modal | `perps-close-position-modal` | + +## Formatting + +Known hotspots with incorrect formatting: + +``` +ui/components/app/perps/utils/transactionTransforms.ts -- .toFixed(2) x7 +ui/components/app/perps/order-entry/components/auto-close-section/ -- {min:2, max:2} +ui/components/app/perps/order-entry/components/limit-price-input/ -- {min:2, max:2} +ui/components/app/perps/edit-margin/edit-margin-modal-content.tsx -- .toFixed(2) +ui/components/app/perps/reverse-position/reverse-position-modal.tsx -- .toFixed(2) +ui/hooks/perps/usePerpsOrderForm.ts -- formatCurrencyWithMinThreshold x6 +``` + +- Search for `.toFixed(2)`, `formatNumber({min:2, max:2})`, `formatCurrencyWithMinThreshold` in affected files +- Do NOT add more `.toFixed(2)` -- use `formatCurrencyWithMinThreshold` as interim +- Target behavior: mobile's `formatPerpsFiat` adaptive sig-dig rules + +## Validation + +1. `yarn lint` -- no lint errors +2. `yarn test:unit` -- run tests for affected files +3. `yarn build` -- TypeScript compiles +4. Manual: load extension, navigate to perps, verify the fix in the browser +5. E2E: check if existing perps E2E tests cover the affected flow + +## Architectural Notes + +- Order form is a single `usePerpsOrderForm` hook (mobile splits into 4) +- TP/SL is inline in `auto-close-section.tsx` (mobile has separate view) +- Close position is a modal, not a page +- `usePerpsStreamManager` controls all live data subscriptions +- Missing screens: close-all, cancel-all, withdraw, order book, order details diff --git a/domains/perps/skills/fix-perps-bug/repos/metamask-mobile.md b/domains/perps/skills/fix-perps-bug/repos/metamask-mobile.md new file mode 100644 index 0000000..557fac4 --- /dev/null +++ b/domains/perps/skills/fix-perps-bug/repos/metamask-mobile.md @@ -0,0 +1,51 @@ +--- +repo: metamask-mobile +parent: fix-perps-bug +--- + +## File Paths + +| Area | Path | +|---|---| +| Views | `app/components/UI/Perps/Views/` | +| Hooks | `app/components/UI/Perps/hooks/` | +| Utils | `app/components/UI/Perps/utils/` | +| TestIDs | `app/components/UI/Perps/Perps.testIds.ts` | +| Controller | `app/controllers/perps/` | +| Docs | `docs/perps/` (35 docs) | + +## TestIDs + +Convention: PascalCase selector objects in `Perps.testIds.ts`. + +| Element | Selector | +|---|---| +| Position card | `PerpsPositionCardSelectorsIDs.CARD` | +| Balance | `PerpsMarketBalanceActionsSelectorsIDs.BALANCE_VALUE` | +| Order submit | `PerpsOrderViewSelectorsIDs.*` | +| TP price | `PerpsTPSLViewSelectorsIDs.TAKE_PROFIT_PRICE_INPUT` | +| Market row | `PerpsMarketRowItemSelectorsIDs.ROW_ITEM` | +| Close modal | `PerpsClosePositionViewSelectorsIDs.*` | + +## Formatting + +- Primary formatter: `formatPerpsFiat` in `app/components/UI/Perps/utils/formatUtils.ts` +- Implements adaptive sig-dig rules (see formatting-rules knowledge) +- Has i18n dependency for locale-specific number formatting +- 33KB file -- check the specific range handler for your value range + +## Validation + +1. `yarn lint` -- no lint errors in affected files +2. `yarn test --testPathPattern=Perps` -- run perps unit tests +3. `yarn build:ios` or `yarn build:android` +4. Detox E2E: `yarn test:e2e:ios --testNamePattern="perps"` (if perps E2E tests exist) +5. Manual: run on simulator, navigate to perps, verify the fix + +## Architectural Notes + +- Hooks are fine-grained: order form splits into `usePerpsOrderForm` + `usePerpsOrderFees` + `usePerpsOrderValidation` + `usePerpsOrderExecution` +- Each view is a separate screen with its own navigation route +- TP/SL is a separate view (`PerpsTPSLView`), not inline +- Close-all and cancel-all have dedicated views +- Controller changes sync to Core via `validate-core-sync.sh` diff --git a/domains/perps/skills/fix-perps-bug/skill.md b/domains/perps/skills/fix-perps-bug/skill.md new file mode 100644 index 0000000..e37ae01 --- /dev/null +++ b/domains/perps/skills/fix-perps-bug/skill.md @@ -0,0 +1,51 @@ +--- +name: fix-perps-bug +description: Debug and fix perps feature bugs +maturity: stable +--- + +# Fix Perps Bug + +## When To Use + +- Bug report involving perps UI (positions, orders, trading, margins, TP/SL) +- Formatting/decimal display issues in perps screens +- Stream data not updating (prices, positions, orders) +- Order submission or validation failures + +## Workflow + +1. **Identify affected screen.** Map the bug to a screen using the mobile-extension-map. Check if the screen exists on both repos or is missing on one. + +2. **Locate the code.** + - Find the component/hook for that screen (see file paths in repo-specific section) + - Check if the bug involves a duplicated utility (see shared-package-analysis) -- if so, check both codebases + +3. **Check formatting.** If the bug involves number display: + - Read formatting-rules knowledge + - Fix must follow the sig-dig rules, not hardcode decimals + +4. **Check stream hooks.** If the bug involves stale/missing data: + - Verify the channel subscription is active and data transforms are correct + +5. **Fix the bug.** + - Make the minimal change + - If fixing a duplicated utility, check the other repo's equivalent + - If the fix belongs in `@metamask/perps-controller`, fix there (not in UI layer) + +6. **Write tests.** + - Unit test the fix + - If formatting: test against the sig-dig table in formatting-rules + +7. **Validate.** + - Run repo-specific validation (see below) + - Confirm the fix doesn't break the other repo's equivalent screen + +## Common Pitfalls + +| Pitfall | Rule | +|---|---| +| Adding `.toFixed(2)` on extension | Use `formatCurrencyWithMinThreshold` as interim, target `formatPerpsFiat` behavior | +| Fixing a util that exists in both repos | Check the other repo's copy too | +| Ignoring missing screens on extension | Flag as known gap, don't create stub implementations | +| Hardcoding testIDs | Use the repo's convention (PascalCase selectors on mobile, kebab-case on extension) | diff --git a/domains/perps/skills/review-perps-pr/repos/metamask-extension.md b/domains/perps/skills/review-perps-pr/repos/metamask-extension.md new file mode 100644 index 0000000..4e363fb --- /dev/null +++ b/domains/perps/skills/review-perps-pr/repos/metamask-extension.md @@ -0,0 +1,39 @@ +--- +repo: metamask-extension +parent: review-perps-pr +--- + +## File Paths + +| Area | Path | +|---|---| +| Components | `ui/components/app/perps/` | +| Pages | `ui/pages/perps/` | +| Hooks | `ui/hooks/perps/` | +| Utils | `ui/components/app/perps/utils.ts` | +| Transforms | `ui/components/app/perps/utils/transactionTransforms.ts` | +| Stream bridge | `app/scripts/controllers/perps/perps-stream-bridge.ts` | +| Routes | `ui/helpers/constants/routes.ts` | + +## Review Focus Areas + +**Formatting hotspots** — these files have known incorrect formatting. PRs touching them should fix, not add more: + +``` +ui/components/app/perps/utils/transactionTransforms.ts -- .toFixed(2) x7 +ui/components/app/perps/order-entry/components/auto-close-section/ -- {min:2, max:2} +ui/components/app/perps/order-entry/components/limit-price-input/ -- {min:2, max:2} +ui/components/app/perps/edit-margin/edit-margin-modal-content.tsx -- .toFixed(2) +ui/components/app/perps/reverse-position/reverse-position-modal.tsx -- .toFixed(2) +ui/hooks/perps/usePerpsOrderForm.ts -- formatCurrencyWithMinThreshold x6 +``` + +**Hook consolidation** — extension merges several mobile hooks into one. When reviewing hook changes, check that the consolidated hook still covers all cases the separate mobile hooks handle. + +**Missing screens** — close-all, cancel-all, withdraw, order book, order details. Don't block PRs for these, but note if a PR introduces partial implementations that conflict with future full implementations. + +## Validation + +1. `yarn lint` — no lint errors in affected files +2. `yarn test:unit` — run tests for affected files +3. `yarn build` — TypeScript compiles diff --git a/domains/perps/skills/review-perps-pr/repos/metamask-mobile.md b/domains/perps/skills/review-perps-pr/repos/metamask-mobile.md new file mode 100644 index 0000000..bc7ffdd --- /dev/null +++ b/domains/perps/skills/review-perps-pr/repos/metamask-mobile.md @@ -0,0 +1,31 @@ +--- +repo: metamask-mobile +parent: review-perps-pr +--- + +## File Paths + +| Area | Path | +|---|---| +| Views | `app/components/UI/Perps/Views/` | +| Hooks | `app/components/UI/Perps/hooks/` | +| Utils | `app/components/UI/Perps/utils/` | +| TestIDs | `app/components/UI/Perps/Perps.testIds.ts` | +| Controller | `app/controllers/perps/` | +| Docs | `docs/perps/` | + +## Review Focus Areas + +**Formatting** — mobile is source of truth. `formatPerpsFiat` in `utils/formatUtils.ts` implements the correct sig-dig rules. PRs should use it, not introduce new formatters. + +**Controller changes** — any modification to `app/controllers/perps/` syncs to Core via `validate-core-sync.sh`. Verify the sync script still passes. Changes here affect extension too. + +**Hook granularity** — mobile uses fine-grained hooks (e.g., order form splits into 4 hooks). PRs adding new hooks should follow this pattern, not consolidate into monolithic hooks. + +**Priority 1 candidates** — if a PR modifies a utility listed in shared-package-analysis Priority 1, flag the opportunity to move it to the controller instead of modifying it in the UI layer. + +## Validation + +1. `yarn lint` — no lint errors in affected files +2. `yarn test --testPathPattern=Perps` — run perps unit tests +3. `yarn build:ios` or `yarn build:android` diff --git a/domains/perps/skills/review-perps-pr/skill.md b/domains/perps/skills/review-perps-pr/skill.md new file mode 100644 index 0000000..38f6a72 --- /dev/null +++ b/domains/perps/skills/review-perps-pr/skill.md @@ -0,0 +1,5 @@ +--- +name: review-perps-pr +description: Review perps PRs with cross-repo awareness +maturity: stable +--- diff --git a/domains/pr-workflow/skills/create-pr/repos/metamask-mobile.md b/domains/pr-workflow/skills/create-pr/repos/metamask-mobile.md new file mode 100644 index 0000000..ff8ec70 --- /dev/null +++ b/domains/pr-workflow/skills/create-pr/repos/metamask-mobile.md @@ -0,0 +1,99 @@ +--- +repo: metamask-mobile +parent: create-pr +--- + + +# PR Create + +## Workflow + +### 1. Precondition checks + +```bash +git rev-parse --abbrev-ref HEAD +git status --porcelain +git rev-parse --verify origin/ +``` + +- On `main` --> abort: "Cannot create a PR from main" +- Dirty working tree --> abort: "Commit all changes before creating a PR" +- Branch not on origin --> use the `AskQuestion` tool to ask whether to push, with options "Yes, push" and "No, abort" (default: **No, abort**). Never push without explicit user consent. + +### 2. Generate a PR description + +Generate a PR description for the current branch (see `pr-description` skill if available). + +### 3. Create the PR + identify code owners (in parallel) + +Run these two steps concurrently since they are independent: + +**3a. Create the PR** + +**Prefer `gh` CLI:** + +```bash +gh pr create \ + --title "" \ + --body "" \ + --base main \ + --assignee @me \ + --draft +``` + +**Fallback to GitHub MCP** (`create_pull_request` tool from `user-github` server): + +```json +{ + "server": "user-github", + "toolName": "create_pull_request", + "arguments": { + "owner": "MetaMask", + "repo": "metamask-mobile", + "title": "", + "body": "", + "head": "", + "base": "main", + "draft": true + } +} +``` + +**3b. Identify code owners** + +Identify code owners and their Slack handles for the changed files (see `pr-codeowners` skill if available). This only depends on the diff, not the PR itself. + +### 4. Output + +- Print the PR URL +- Provide a Slack review request message in a fenced code block for easy copy + +If code owners were found: + +``` +PR ready for review: + + +cc @ @ +``` + +If no code owners (or only the author's own team): + +``` +PR ready for review: + + +``` + +Tell the user they can paste it in `#metamask-mobile-dev` in the "Mobile PRs that need review" thread of the day. + +### 5. Offer to add PR to review queue + +Use the `AskQuestion` tool to ask whether to add the PR to the [PR review queue](https://github.com/orgs/MetaMask/projects/64/views/1), with options "Yes" and "No" (default: **Yes**). If yes, invoke the `pr-review-queue` skill if available. + +## Rules + +- Always create as **draft** per repo guidelines ("Submit as Draft initially for CI") +- Always assign to `@me` +- Target `main` branch +- Team label is handled automatically by CI (`add-team-label.yml`) on PR open diff --git a/domains/pr-workflow/skills/create-pr/skill.md b/domains/pr-workflow/skills/create-pr/skill.md new file mode 100644 index 0000000..60339c4 --- /dev/null +++ b/domains/pr-workflow/skills/create-pr/skill.md @@ -0,0 +1,4 @@ +--- +name: create-pr +description: Create GitHub pull request +--- diff --git a/domains/pr-workflow/skills/pr-changelog/repos/metamask-mobile.md b/domains/pr-workflow/skills/pr-changelog/repos/metamask-mobile.md new file mode 100644 index 0000000..adf628d --- /dev/null +++ b/domains/pr-workflow/skills/pr-changelog/repos/metamask-mobile.md @@ -0,0 +1,57 @@ +--- +repo: metamask-mobile +parent: pr-changelog +--- + + +# PR Changelog + +## Format + +``` +CHANGELOG entry: +``` + +or + +``` +CHANGELOG entry: null +``` + +## Decision + +**User-facing change?** (new feature, bug fix, UI change, behavior change visible to end users) + +- Yes --> write a past-tense summary: `Added...`, `Fixed...`, `Updated...`, `Removed...` +- No --> write `null` (refactors, tests, CI, internal tooling, dev-only changes) + +## Rules + +- CI validates this line exists and is non-empty (`.github/scripts/check-template-and-add-labels.ts`) +- The line must contain `CHANGELOG entry:` followed by a non-empty value (leading whitespace is tolerated by CI) +- Alternative bypass: adding the `no-changelog` label skips the check entirely +- One entry per PR, even if multiple things changed -- summarize the primary user-facing impact + +## Steps + +1. Read the diff: `git diff main...HEAD` +2. Determine if any change is user-facing +3. If yes, write a concise past-tense summary of the primary impact +4. If no, write `null` + +## Examples + +**User-facing:** + +``` +CHANGELOG entry: Added dark mode toggle to settings screen +CHANGELOG entry: Fixed token balance not updating after swap +CHANGELOG entry: Updated network selector to show custom networks first +CHANGELOG entry: Removed deprecated fiat on-ramp provider +``` + +**Not user-facing:** + +``` +CHANGELOG entry: null +``` diff --git a/domains/pr-workflow/skills/pr-changelog/skill.md b/domains/pr-workflow/skills/pr-changelog/skill.md new file mode 100644 index 0000000..14df5fc --- /dev/null +++ b/domains/pr-workflow/skills/pr-changelog/skill.md @@ -0,0 +1,4 @@ +--- +name: pr-changelog +description: Generate CHANGELOG entry +--- diff --git a/domains/pr-workflow/skills/pr-codeowners/repos/metamask-mobile.md b/domains/pr-workflow/skills/pr-codeowners/repos/metamask-mobile.md new file mode 100644 index 0000000..077662c --- /dev/null +++ b/domains/pr-workflow/skills/pr-codeowners/repos/metamask-mobile.md @@ -0,0 +1,53 @@ +--- +repo: metamask-mobile +parent: pr-codeowners +--- + + +# PR Code Owners + +## Steps + +1. Get changed files: + + ```bash + git diff --name-only main...HEAD + ``` + +2. Read `.github/CODEOWNERS` and match each changed file against the patterns to collect unique `@MetaMask/` owners + +3. Map each owner to a Slack group handle using the lookup table below + +4. If an owner is not in the table, fall back to `@metamask-mobile-platform` and warn the user about the unmapped team + +## Code owner to Slack handle lookup + +| CODEOWNERS team | Slack group handle | +| ----------------------- | ---------------------------- | +| perps | mm-perps-engineering-team | +| confirmations | metamask-confirmations-team | +| metamask-earn | earn-dev-team | +| mobile-core-ux | mobile-core-ux | +| accounts-engineers | accounts-team-devs | +| swaps-engineers | swaps-engineers | +| metamask-assets | assets-dev-team | +| card | card-dev-team | +| notifications | notifications-dev-team | +| mobile-platform | metamask-mobile-platform | +| web3auth | onboarding-dev | +| wallet-integrations | wallet-integrations-team | +| wallet-api-platform | wallet-integrations-team | +| ramp | ramp-team | +| predict | predict-team | +| rewards | rewards-team | +| design-system-engineers | metamask-design-system-team | +| core-platform | core-platform-team | +| supply-chain | mm-supply-chain-reviewers | +| mobile-admins | metamask-mobile-platform | +| transactions | mm-transactions-stx-core-dev | +| delegation | delegators | +| qa | metamask-qa-team | + +## Output + +List of unique `{ team, slackHandle }` pairs for all code owners of the changed files. diff --git a/domains/pr-workflow/skills/pr-codeowners/skill.md b/domains/pr-workflow/skills/pr-codeowners/skill.md new file mode 100644 index 0000000..6843f57 --- /dev/null +++ b/domains/pr-workflow/skills/pr-codeowners/skill.md @@ -0,0 +1,4 @@ +--- +name: pr-codeowners +description: Identify code owners from CODEOWNERS +--- diff --git a/domains/pr-workflow/skills/pr-description/repos/metamask-mobile.md b/domains/pr-workflow/skills/pr-description/repos/metamask-mobile.md new file mode 100644 index 0000000..959cf87 --- /dev/null +++ b/domains/pr-workflow/skills/pr-description/repos/metamask-mobile.md @@ -0,0 +1,68 @@ +--- +repo: metamask-mobile +parent: pr-description +--- + + +# PR Description + +## Workflow + +1. **Collect context** + + ```bash + git rev-parse --abbrev-ref HEAD + git diff main...HEAD + git log main..HEAD --oneline + ``` + +2. **Generate a PR title** in conventional commit format (see `pr-title` skill if available) + +3. **Find related GitHub issues** from branch name, commit messages, or keyword search (see `pr-issue-search` skill if available) + +4. **Write description** -- analyze the diff to answer: (1) what is the reason for the change? (2) what is the improvement/solution? + +5. **Generate a changelog entry** for the PR (see `pr-changelog` skill if available) + +6. **Generate Gherkin manual testing steps** for the changed features (see `pr-manual-testing` skill if available) + +7. **Verify author checklist items** -- run the `pr-readiness-check` skill (if available) to warn about missing tests, missing JSDoc, or guideline violations. Do not block — continue generating the description regardless of findings. + +8. **Assemble output** -- read `.github/pull-request-template.md` for the full template body, then fill all sections + +## Template Sections + +CI validates that all 7 section titles are present **exactly** as written below (`.github/scripts/shared/template.ts`). Missing or altered titles cause the `invalid-pull-request-template` label. + +| Section title (exact string) | Guidance | +| ------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `## **Description**` | Concise what/why. Keep HTML comments from template as-is. | +| `## **Changelog**` | `CHANGELOG entry: ` or `CHANGELOG entry: null`. Line must exist and be non-empty -- CI enforces this. | +| `## **Related issues**` | `Fixes: #NUMBER` or `Refs: #NUMBER`. Leave `Fixes:` empty if none found. | +| `## **Manual testing steps**` | Gherkin code block. If no useful manual test exists (e.g. automation-only, unit tests suffice), write `N/A`. | +| `## **Screenshots/Recordings**` | Keep Before/After subsections. Write `N/A` in each subsection when not applicable instead of HTML comments. | +| `## **Pre-merge author checklist**` | Include all checklist items from the template. **Check all boxes** (`- [x]`): checking means the author actively considered the item and takes responsibility, even if it doesn't apply to this PR. | +| `## **Pre-merge reviewer checklist**` | Include all checklist items from the template. Leave boxes unchecked — these are for the reviewer. | + +## Output + +Write the result to `.agent/[branch-name].PR-desc.md`. Create the `.agent/` directory if it does not exist. Sanitize the branch name first by replacing `/` with `-` so names like `feat/MCWP-392` become `feat-MCWP-392` (avoids creating nested directories). + +Structure: + +```markdown +# PR Title + + + + + +``` + +Preserve all HTML comments from the original template as guidance for the PR author. + +Append at the very end of the file: + +```markdown + +``` diff --git a/domains/pr-workflow/skills/pr-description/skill.md b/domains/pr-workflow/skills/pr-description/skill.md new file mode 100644 index 0000000..0930231 --- /dev/null +++ b/domains/pr-workflow/skills/pr-description/skill.md @@ -0,0 +1,4 @@ +--- +name: pr-description +description: Generate PR description +--- diff --git a/domains/pr-workflow/skills/pr-guidelines/repos/metamask-extension.md b/domains/pr-workflow/skills/pr-guidelines/repos/metamask-extension.md new file mode 100644 index 0000000..cc7eec9 --- /dev/null +++ b/domains/pr-workflow/skills/pr-guidelines/repos/metamask-extension.md @@ -0,0 +1,479 @@ +--- +repo: metamask-extension +parent: pr-guidelines +--- + + +Reference: [MetaMask Pull Request Guidelines](https://github.com/MetaMask/contributor-docs/blob/main/docs/pull-requests.md) + +# MetaMask Extension - Pull Request Guidelines + +## Creating Pull Requests + +### Write Comprehensive Descriptions + +- **ALWAYS fill out the pull request description** +- Explain not only WHAT was changed, but WHY +- Write for people outside your team and for future readers +- Save reviewers' time by providing context directly in the PR + +#### Answer These Questions: + +1. **What is the context behind your changes?** + - Share domain-specific information + - Set the stage for reviewers + - Link to issues, but also summarize the context + +2. **What is the purpose of your changes?** + - What's insufficient about the current state? + - What's the user story? + - What problem are you solving? + +3. **What is your solution?** + - How do your changes satisfy the need? + - Are there non-obvious changes to explain? + - If UI changes, include screenshots or videos + +#### Description Template: + +```markdown +## Context + +[Explain the background, domain-specific information, and any relevant history] + +## Problem + +[Describe what's insufficient about current behavior or what user need exists] + +## Solution + +[Explain your approach and how it addresses the problem] + +## Implementation Notes + +[Call out any non-obvious changes, complex logic, or design decisions] + +## Screenshots/Videos (if applicable) + +[Add visual documentation for UI changes] +``` + +#### Examples of Good Descriptions: + +- https://github.com/MetaMask/metamask-extension/pull/19970 +- https://github.com/MetaMask/metamask-extension/pull/18629 +- https://github.com/MetaMask/metamask-mobile/pull/6677 +- https://github.com/MetaMask/snaps/pull/1708 +- https://github.com/MetaMask/metamask-extension/pull/21370 +- https://github.com/MetaMask/metamask-mobile/pull/9450 + +### Write Good Commit Messages + +- **Apply the same guidelines to commit messages as PR descriptions** +- If you create a PR from a single commit, GitHub copies the commit message to the PR description +- Focus on good commit messages BEFORE pushing = less work later +- Good commit messages are visible in Git history, not just GitHub + +#### Resources for Commit Messages: + +- ["Explain the context" - GitHub Blog](https://github.blog/2022-06-30-write-better-commits-build-better-projects/#explain-the-context) +- ["My favourite Git commit" by David Thompson](https://dhwthompson.com/2019/my-favourite-git-commit) +- ["How to Write a Git Commit Message"](https://commit.style) + +### Add PR Comments + +- **Use PR comments to call attention to specific changes** +- Keep PR description succinct, add details in comments +- Open a review on GitHub and comment on your own PR at key locations +- Explain non-obvious implementation details inline + +Example: + +``` +💡 This refactoring was necessary because the previous approach didn't +handle edge cases when the user switches networks mid-transaction. +``` + +Reference: ["Leaving Comments on My Own Pull Requests" by Hector Castro](https://hector.dev/2021/02/24/leaving-comments-on-my-own-pull-requests/) + +### Create Smaller Pull Requests + +- **Large PRs are extremely painful to review** +- Small PRs reduce conflicts and make commit history clearer +- Break tasks into smaller pieces ahead of time +- Decompose changes into separate focused PRs + +#### Guidelines: + +- Each PR should focus on a single purpose +- If using "and" in the PR title, consider splitting it +- Plan out code changes and identify natural separation points +- Prototype if needed to understand how to decompose the work + +Example: + +``` +❌ WRONG: Large unfocused PR +"Add token management and update network handling and refactor state" + +✅ CORRECT: Focused PRs +PR 1: "Add token validation logic" +PR 2: "Implement token addition UI" +PR 3: "Add token removal functionality" +``` + +## Reviewing Pull Requests + +### Be Compassionate + +- **Assume good intent on the part of the author** +- Be mindful of constraints, tradeoffs, or priorities +- Consider invisible context that may have guided the PR +- Respect the author's work and effort + +### Go Beyond Surface Level + +- Standards and best practices are important BUT +- **Focus on whether the solution solves the underlying user story soundly** +- Evaluate the approach, not just code style +- Consider edge cases and long-term maintainability + +### Be Curious, Not Curt + +- **Open dialogue instead of forcing ideas** +- Use questions and suggestions, not commands + +Examples: + +``` +❌ WRONG: Commanding +"Delete this comment" +"Rename this variable" +"Change this approach" + +✅ CORRECT: Collaborative +"I'm worried that this approach would..." +"I'm wondering if it makes sense to..." +"Should we consider...?" +"What do you think about...?" +"Is it worth it to...?" +"Did you mean to...?" +``` + +### Show, Don't Tell + +- **Use GitHub's suggestion feature** +- Helps authors understand and incorporate ideas quickly +- Prevents extended back-and-forth discussions + +Example: + +````markdown +```suggestion +const activeAccounts = accounts.filter(account => account.isActive); +``` +```` + +### Highlight Non-Blocking Comments + +- **Use prefixes for lower-importance suggestions** +- Communicate that suggestions are optional +- Common prefixes: `Nit:`, `Nitpick:`, `Optional:` + +Example: + +``` +Nit: Jest has a `jest.mocked` function you could use here instead of +`jest.MockFunction`. That should let you clean this up a bit if you wanted. +``` + +### Let Go of Your Code + +- **No two people think exactly alike** +- Different approaches can both be valid +- If reviewing changes to your own code, stay objective +- State your position with context, but respect author's decision +- The author has the right to make the final call + +### Praise Good Work + +- **Acknowledge great work when you see it** +- Positive feedback motivates and builds team culture +- Call out clever solutions, thorough testing, or clear documentation + +Example: + +``` +✨ This is a really elegant solution! The error handling here is +particularly well thought out. +``` + +### Take Criticism Offline + +- **Fundamental disagreements may need a conversation** +- Video calls can resolve differences more quickly +- Prevents public heated discussions +- Protects everyone's reputation +- More nuanced communication possible + +### Use "Request Changes" Sparingly + +- **"Request changes" blocks merging until resolved** +- Places an X next to your review +- Use only when changes truly cannot be merged +- Always explain your reasoning clearly + +When to use: + +- Security vulnerabilities +- Breaking changes without migration +- Fundamentally flawed approach +- Missing critical functionality + +When NOT to use: + +- Style preferences +- Minor improvements +- Non-blocking suggestions +- Nitpicks + +## Receiving Feedback + +### Be Open to Other Perspectives + +- **Different approaches may be motivated by different values** +- Others' perspectives may reveal blind spots +- Consider context and constraints you may not have +- Feedback is about improving the code, not criticizing you + +### Assume Positive Intent + +- **Handle brusque or unclear comments gracefully** +- Don't take negative tone personally +- Ask for clarification if needed +- Focus on the technical content + +Example response: + +``` +Thanks for the feedback! Could you help me understand what specific +concerns you have about this approach? I'd like to address them properly. +``` + +### Point to Updates + +- **Link to commits that address feedback** +- Helps reviewers check your work efficiently +- Allows discussions to reach resolution +- Find commits in the "Conversation" view + +Example: + +``` +Good catch! Updated in abc1234. +``` + +Tips: + +- Copy commit ID from Conversation view +- GitHub will auto-link when you paste +- Can also type commit ID directly in comment + +### Employ Alternate Communication + +- **Sense tension? Reach out directly** +- Offer a video call to talk through concerns +- Prevents misunderstandings from escalating +- Builds stronger team relationships + +## Maintaining Pull Requests + +### Communicate Takeovers + +- **If taking over someone else's PR, let them know** +- Avoid surprises +- Provide context for why you're taking over +- Credit original author appropriately + +Example: + +``` +@original-author I need to move forward with this work to unblock other +features. I'll be taking over this PR and will make sure to preserve your +commits and credit your work. Thanks for getting this started! +``` + +### Rebase with Caution + +- **Once you receive comments, avoid rebasing or amending history** +- Push each new change as a new commit instead + +#### Why Avoid Rebasing Active PRs: + +1. **Preserves timeline order in Conversation view** + - Reviewers revisit PRs multiple times + - Timeline helps them catch up on changes + - Rebasing moves all commits to the end + - Makes it hard to locate new changes + +2. **Prevents conversations from being marked outdated** + - Rebasing re-creates commits + - GitHub thinks old commits are outdated + - Active conversations get buried + - Reviewers may ignore "outdated" discussions + +3. **Smoother workflow for co-authors** + - Rewriting history causes pull errors for others + - Forces others to reset their branches + - Can lead to frustration and confusion + +#### When Rebasing Is Acceptable: + +- Rebase on a recent commit (not base branch) +- Minimize range of affected commits +- Choose commit after last conversation entry +- **Always inform reviewers and collaborators** + +Example: + +``` +⚠️ I needed to rebase this PR to fix a merge conflict. All reviewers +have been notified. Please re-review if needed. +``` + +## Merging Pull Requests + +### Clean Up the Commit Message + +- **Review the commit message before clicking "Squash & Merge"** +- Default message varies by repository configuration +- May contain: PR description, commit message, or concatenated commits + +#### Guidelines: + +1. **Ensure message describes the change well** +2. **Replace commit lists with PR description if clearer** +3. **DO NOT modify the commit title format** + - Must be: `Pull request title (#number)` + - Automated scripts depend on this format + - Edit PR title before merging if needed + +Example: + +``` +❌ WRONG: Modified format +Add token validation + +This PR adds token validation... + +✅ CORRECT: Preserve format +Add token validation (#12345) + +This PR adds token validation... +``` + +## Best Practices Checklist + +### Before Creating a PR: + +- [ ] Code is complete and tested +- [ ] All tests pass locally +- [ ] Code follows style guidelines +- [ ] No console.logs or debug code +- [ ] Branch is up to date with base branch +- [ ] Commit messages are clear and descriptive + +### When Creating a PR: + +- [ ] PR title is clear and descriptive +- [ ] Description answers: context, problem, solution +- [ ] Screenshots/videos included for UI changes +- [ ] Linked to relevant issues +- [ ] Added self-review comments for complex changes +- [ ] PR is as small as reasonably possible +- [ ] Single focused purpose (no "and" in title) + +### When Reviewing a PR: + +- [ ] Read description and understand context +- [ ] Assume good intent +- [ ] Focus on solving user story, not just style +- [ ] Use questions, not commands +- [ ] Use suggestion feature for code changes +- [ ] Mark non-blocking comments with "Nit:" +- [ ] Praise good work +- [ ] Use "Request changes" only when necessary +- [ ] Consider offline discussion if needed + +### When Receiving Feedback: + +- [ ] Assume positive intent +- [ ] Be open to other perspectives +- [ ] Link to commits that address feedback +- [ ] Ask for clarification when needed +- [ ] Consider video call if tension arises + +### When Maintaining a PR: + +- [ ] Avoid rebasing after receiving comments +- [ ] Push new changes as new commits +- [ ] Communicate if taking over someone's PR +- [ ] Keep reviewers informed of major changes +- [ ] Respond to all feedback (even if just acknowledging) + +### When Merging a PR: + +- [ ] All conversations resolved or acknowledged +- [ ] All required approvals received +- [ ] CI/CD checks passing +- [ ] Commit message reviewed and cleaned up +- [ ] Title format preserved: `Title (#number)` +- [ ] Final message accurately describes change + +## Communication Examples + +### Requesting Review: + +``` +@reviewer This PR adds token validation logic. I'd especially appreciate +feedback on the approach in `validateToken()` (lines 45-78) as I had to +make some tradeoffs there. +``` + +### Responding to Feedback: + +``` +Great point about edge cases! I've added additional validation and tests +for those scenarios in commit abc1234. Let me know if this addresses your +concerns. +``` + +### Explaining Complex Changes: + +``` +💡 The reason for this seemingly complex approach is that we need to +maintain backwards compatibility with the old API while supporting the +new format. This will be simplified in the next major version. +``` + +### Suggesting Improvements: + +``` +This looks good! One thought: have you considered using memoization here? +It might improve performance for large token lists. Not blocking though, +just something to think about. +``` + +### Taking Work Offline: + +``` +I have some thoughts about the overall architecture here that might be +better discussed in person. Would you be available for a quick call +tomorrow to talk through some alternatives? +``` + +## References + +- [MetaMask Pull Request Guidelines](https://github.com/MetaMask/contributor-docs/blob/main/docs/pull-requests.md) +- [GitHub - Write Better Commits](https://github.blog/2022-06-30-write-better-commits-build-better-projects/) +- [How to Write a Git Commit Message](https://commit.style) diff --git a/domains/pr-workflow/skills/pr-guidelines/repos/metamask-mobile.md b/domains/pr-workflow/skills/pr-guidelines/repos/metamask-mobile.md new file mode 100644 index 0000000..ef139a3 --- /dev/null +++ b/domains/pr-workflow/skills/pr-guidelines/repos/metamask-mobile.md @@ -0,0 +1,128 @@ +--- +repo: metamask-mobile +parent: pr-guidelines +--- + + +# MetaMask Mobile Pull Request Guidelines + +Follow these rules when creating PRs to ensure consistent quality and smooth reviews. + +## Core Principles + +Small focused PRs • Clear titles/descriptions • Complete template • Correct labels • Branch naming + +## 1. PR Title Requirements + +**Format**: `[optional scope]: ` (Conventional Commits) + +**Types**: feat|fix|docs|style|refactor|test|chore|perf|ci|build|revert + +**Examples**: + +| ✅ Good | ❌ Bad | +|---------|--------| +| `feat: add NFT gallery to collectibles tab` | `Add some stuff` | +| `fix: resolve wallet connection timeout on cold start` | `fixing bug` | +| `refactor: extract transaction formatter into utility` | `refactor code` | +| `test: add e2e spec for onboarding SRP import flow` | `tests` | +| `docs: update contributing guide with design-system usage` | `update docs` | +| `chore: bump react-native to 0.74.5` | `upgrade rn` | + +**Rules**: Use imperative mode • Keep concise (<72 chars) • Be specific + +## 2. PR Template Compliance + +Use `.github/pull-request-template.md` - fill ALL sections: + +- **Description**: What changed and why (context, constraints, trade-offs) +- **Changelog**: User-facing summary in past participle (`Added`, `Fixed`) OR `CHANGELOG entry: null` +- **Related issues**: `Fixes: #NUMBER`, `Closes: #NUMBER`, or `Refs: #NUMBER` +- **Manual testing**: test cases desciption using Gherkin syntax (see sample in template) +- **Screenshots/Recordings**: Required for UI changes (before/after) +- **Pre-merge checklist**: Ensure all items checked, even if thye don't apply, it shows that you thought about the specific item. + +## 3. Required Labels and Assignment + +**PR Assignment**: Assignee is the one who is in charge of making the PR move forward to merge. It's usually the author, but can be delegated. + +**Team Label** (REQUIRED - merge will be blocked if missing): +- Must have one: `team-*` label OR `external-contributor` + +**Blocking Labels** (these WILL prevent merge): +- `needs-qa` - QA validation required +- `need-ux-ds-review` - UX/Design System review needed +- `blocked` - Blocked by external dependency +- `stale` - PR is stale and needs attention +- `DO-NOT-MERGE` - Explicit block + +**DO**: +- ✅ Assign to person responsible for moving PR forward (usually author) +- ✅ Add team label (required for merge) +- ✅ Remove blocking labels when resolved + +**DON'T**: +- ❌ Leave unassigned +- ❌ Use deprecated labels + +## 4. Branch Naming + +**Format**: `/_` (include issue number when applicable) + +**Examples**: +- `fix/1234_wallet-connection-issue` - with issue number +- `feat/5678_add-nft-gallery` - with issue number +- `chore/update-linting-config` - no issue (maintenance work) +- `refactor/migrate-util-to-ts` - no issue (internal improvement) + +**Rules**: Lowercase • Kebab-case • Include issue number when fixing/implementing • Match PR type • Keep description short + +## 5. PR Best Practices + +- Submit as **Draft** initially for CI +- Mark "Ready for review" only after: + - Manually tested by yourself first + - Assigned to yourself + - Template complete + - Lint/tests/type-checks pass + - Screenshots attached (if UI) + - Labels applied +- Target `main` branch +- Keep focused (single feature/fix) +- Include tests (unit/Component-view/e2e) +- Update TSDoc when relevant + +**⚠️ Force Push Policy**: +- **DO NOT use force push** (`git push --force`) once first review is done +- Force push breaks GitHub's "changes since last review" feature +- Reviewers must re-review entire PR instead of just new changes +- If you need to clean history, do it BEFORE requesting first review +- After first review: use regular commits, squash on merge if needed + +**DO**: Assign yourself • Keep diff small • Include testing steps • Update docs/changelog • Regular push after reviews + +**DON'T**: Leave unassigned • Skip template • Push unrelated changes • Force push after first review + +## GitHub CLI Example + +```bash +# Create PR with auto team detection +gh pr create \ + --title "feat: add NFT gallery to collectibles tab" \ + --assignee @me \ + --draft +``` + +## Enforcement + +- PRs must use template with all sections complete +- PRs must be assigned +- Titles must follow Conventional Commits +- Blocking labels must be resolved before asking for review +- Non-compliant PRs converted to Draft with comment + +## References + +- Conventional Commits: https://www.conventionalcommits.org/ +- PR Template: `.github/pull-request-template.md` +- E2E framework: `tests/` diff --git a/domains/pr-workflow/skills/pr-guidelines/skill.md b/domains/pr-workflow/skills/pr-guidelines/skill.md new file mode 100644 index 0000000..93bdfca --- /dev/null +++ b/domains/pr-workflow/skills/pr-guidelines/skill.md @@ -0,0 +1,4 @@ +--- +name: pr-guidelines +description: Pull request creation and review guidelines +--- diff --git a/domains/pr-workflow/skills/pr-issue-search/repos/metamask-mobile.md b/domains/pr-workflow/skills/pr-issue-search/repos/metamask-mobile.md new file mode 100644 index 0000000..36299c5 --- /dev/null +++ b/domains/pr-workflow/skills/pr-issue-search/repos/metamask-mobile.md @@ -0,0 +1,104 @@ +--- +repo: metamask-mobile +parent: pr-issue-search +--- + + +# PR Issue Search + +## Strategy + +Follow these steps in order. Stop as soon as you have issue numbers. + +### Step 1: Extract from branch name + +```bash +git rev-parse --abbrev-ref HEAD +``` + +Branch naming conventions (check both): + +**GitHub issue number**: `/_` + +- `fix/1234_wallet-connection-issue` --> `#1234` +- `feat/5678_add-nft-gallery` --> `#5678` + +**Jira ticket ID**: `/_` + +- `feat/MCWP-392_pr_desc_skills` --> Jira ticket `MCWP-392` +- `fix/MOB-1234_fix-crash` --> Jira ticket `MOB-1234` + +Pattern: after the `/` prefix, look for either a bare number (`\d+`) for GitHub issues or an alphanumeric project key (`[A-Z]+-\d+`) for Jira tickets. + +- `chore/update-linting-config` --> no issue or ticket, continue to Step 2 + +### Step 2: Extract from commit messages + +```bash +git log main..HEAD --oneline +``` + +Look for `#NUMBER` references (GitHub issues) and `[A-Z]+-\d+` patterns (Jira tickets) in commit subjects. Collect all unique references. + +### Step 3: Search by keywords + +Only if Steps 1-2 found no issue numbers. + +Build keywords from the branch name segments and commit subjects (strip type prefix, split on hyphens/underscores). + +**Prefer `gh` CLI** (no permission prompt required): + +```bash +gh search issues --repo MetaMask/metamask-mobile "" --limit 5 +``` + +**Fallback to GitHub MCP** if `gh` is unavailable: + +Use the `search_issues` tool from the `user-github` MCP server: + +```json +{ + "server": "user-github", + "toolName": "search_issues", + "arguments": { + "query": "", + "owner": "MetaMask", + "repo": "metamask-mobile", + "perPage": 5 + } +} +``` + +Review results and pick the most relevant issue(s). + +## Output Format + +- `Fixes: #NUMBER` -- use when the PR fully resolves the issue (closes on merge) +- `Refs: #NUMBER` -- use when the PR partially addresses or is related to the issue + +If multiple issues are found: + +``` +Fixes: #1234 +Refs: #5678 +``` + +If no issues are found, leave the section as: + +``` +Fixes: +``` + +## Examples + +**Branch with GitHub issue number:** +Branch `fix/9012_token-balance-stale` --> `Fixes: #9012` + +**Branch with Jira ticket ID:** +Branch `feat/MCWP-392_pr_desc_skills` --> `Refs: MCWP-392` (Jira tickets go in `Refs:`, not `Fixes:`, since GitHub cannot auto-close Jira tickets) + +**Commit with reference:** +Commit message `implement caching for token prices (#3456)` --> `Refs: #3456` + +**Keyword search:** +Branch `feat/bridge-fee-estimation` --> search "bridge fee estimation" --> find issue #7890 "Bridge: show estimated fees before confirmation" --> `Fixes: #7890` diff --git a/domains/pr-workflow/skills/pr-issue-search/skill.md b/domains/pr-workflow/skills/pr-issue-search/skill.md new file mode 100644 index 0000000..3ad09b8 --- /dev/null +++ b/domains/pr-workflow/skills/pr-issue-search/skill.md @@ -0,0 +1,4 @@ +--- +name: pr-issue-search +description: Find related GitHub issues +--- diff --git a/domains/pr-workflow/skills/pr-manual-testing/repos/metamask-mobile.md b/domains/pr-workflow/skills/pr-manual-testing/repos/metamask-mobile.md new file mode 100644 index 0000000..91e56e5 --- /dev/null +++ b/domains/pr-workflow/skills/pr-manual-testing/repos/metamask-mobile.md @@ -0,0 +1,109 @@ +--- +repo: metamask-mobile +parent: pr-manual-testing +--- + + +# PR Manual Testing + +## Format + +Write scenarios inside a fenced code block with `gherkin` language tag. + +**Keywords**: `Feature`, `Background`, `Scenario`, `Given`, `When`, `Then`, `And` + +- `Feature` -- name of the feature being tested +- `Background` -- shared preconditions across all scenarios (optional, use when multiple scenarios share setup) +- `Scenario` -- one distinct user flow to verify +- `Given` -- initial state / preconditions +- `When` -- user action +- `Then` -- expected outcome +- `And` -- continuation of the previous keyword + +Use **data tables** (`| col | col |`) when verifying multiple inputs or list entries. + +## Steps + +1. Read the diff: `git diff main...HEAD` +2. Identify the changed features and user-facing behaviors +3. Write scenarios covering the primary happy path and critical edge cases +4. Use multiple `Scenario` blocks when changes affect distinct behaviors + +## Template + +```gherkin +Feature: [feature name derived from changes] + + Background: + Given I am logged into MetaMask Mobile + + Scenario: [describe the user flow being verified] + Given [initial app state] + + When user [performs action] + Then [expected outcome] +``` + +## Examples + +**Simple single scenario:** + +```gherkin +Feature: Token balance refresh + + Scenario: user pulls to refresh token balances + Given I am on the Wallet home screen + And I have tokens in my portfolio + + When user pulls down on the token list + Then the token balances should update + And a loading indicator should appear briefly +``` + +**Multiple scenarios with background:** + +```gherkin +Feature: Network selector migration to design system + + Background: + Given I am logged into MetaMask Mobile + And I have multiple networks configured + + Scenario: user switches network from wallet screen + Given I am on the Wallet home screen + + When user taps the network selector + Then I should see the network list bottom sheet + And the current network should be highlighted + + When user selects "Linea" + Then the network should switch to "Linea" + And the wallet should display Linea balances + + Scenario: user dismisses network selector + Given I am on the Wallet home screen + + When user taps the network selector + And user taps outside the bottom sheet + Then the network selector should close + And the network should remain unchanged +``` + +**Scenario with data table:** + +```gherkin +Feature: Address book validation + + Scenario: user enters invalid addresses + Given I am on the Send screen + + When user enters the following invalid addresses: + | Input | Reason | + | 0x123 | Too short | + | not-an-address | Invalid format | + Then the "Next" button should remain disabled +``` + +## Reference + +For richer Gherkin patterns (tags, complex data tables, network-specific scenarios), see `app/features/SampleFeature/e2e/sample-scenarios.feature`. diff --git a/domains/pr-workflow/skills/pr-manual-testing/skill.md b/domains/pr-workflow/skills/pr-manual-testing/skill.md new file mode 100644 index 0000000..0e15368 --- /dev/null +++ b/domains/pr-workflow/skills/pr-manual-testing/skill.md @@ -0,0 +1,4 @@ +--- +name: pr-manual-testing +description: Generate manual testing steps +--- diff --git a/domains/pr-workflow/skills/pr-readiness-check/repos/metamask-mobile.md b/domains/pr-workflow/skills/pr-readiness-check/repos/metamask-mobile.md new file mode 100644 index 0000000..f1e9a37 --- /dev/null +++ b/domains/pr-workflow/skills/pr-readiness-check/repos/metamask-mobile.md @@ -0,0 +1,56 @@ +--- +repo: metamask-mobile +parent: pr-readiness-check +--- + + +# PR Readiness Check + +Scan the current branch diff for common issues that could be flagged during PR review. This is a **non-blocking** check — always report findings as warnings and let the user decide what to act on. + +## Steps + +1. **Collect the diff** + + ```bash + git diff main...HEAD --name-only + git diff main...HEAD + ``` + +2. **Check for missing tests** + + For each new or modified source file with non-trivial logic changes (not just config, docs, or styles), check whether a corresponding test file was added or updated. + + Heuristic: a source file `Foo.ts(x)` should have a matching `Foo.test.ts(x)` (or `Foo.view.test.tsx` for components). Warn if: + - New exported functions/components have no corresponding test file changes + - Existing test files were not updated despite significant logic changes in the source + +3. **Check for missing JSDoc** + + Scan new exported functions, types, and components in the diff. Warn if any lack JSDoc comments. + +4. **Check for guideline violations** + + Look for obvious violations of `.github/guidelines/CODING_GUIDELINES.md` and `.cursor/rules/` patterns in the changed lines: + - `any` type usage in TypeScript + - `StyleSheet.create()` in new code + - Raw `View` or `Text` imports from `react-native` instead of design system `Box`/`Text` + - `import tw from 'twrnc'` instead of `useTailwind()` hook + - `npx` usage in scripts + +## Output + +Print each finding as a warning line: + +``` +⚠ No tests detected for new logic in `app/core/Foo.ts` +⚠ Missing JSDoc on exported function `calculateFee` in `app/util/fees.ts` +⚠ `any` type used in `app/components/Bar.tsx:42` +⚠ `StyleSheet.create()` found in new file `app/components/Baz/Baz.tsx` +``` + +If no issues found, confirm: + +``` +✅ No readiness issues detected +``` diff --git a/domains/pr-workflow/skills/pr-readiness-check/skill.md b/domains/pr-workflow/skills/pr-readiness-check/skill.md new file mode 100644 index 0000000..ecd68ab --- /dev/null +++ b/domains/pr-workflow/skills/pr-readiness-check/skill.md @@ -0,0 +1,4 @@ +--- +name: pr-readiness-check +description: Check branch for missing tests and guidelines +--- diff --git a/domains/pr-workflow/skills/pr-review-queue/repos/metamask-mobile.md b/domains/pr-workflow/skills/pr-review-queue/repos/metamask-mobile.md new file mode 100644 index 0000000..4c82cc1 --- /dev/null +++ b/domains/pr-workflow/skills/pr-review-queue/repos/metamask-mobile.md @@ -0,0 +1,97 @@ +--- +repo: metamask-mobile +parent: pr-review-queue +--- + + +# PR Review Queue + +Add a pull request to the [MetaMask PR review queue](https://github.com/orgs/MetaMask/projects/64/views/1) project board. + +## Input + +- **PR URL or PR number** (required) +- **Priority** (ask user, default: "Priority 3" — see step 1 below for fetching options dynamically) + +## Method + +### 1. Fetch project fields (Priority options + field IDs) + +Priority options can change — always fetch them dynamically: + +```bash +gh project field-list 64 --owner MetaMask --format json +``` + +From the JSON response: + +- Find the field with `"name": "Priority"` → extract its `id` (the Priority field ID) and its `options` array (each with `id` and `name`). +- Find the field with `"name": "Comment"` → extract its `id` (the Comment field ID). + +Use the `AskQuestion` tool to present the Priority options and ask the user to pick one. Default to the option whose name contains "Priority 3" if the user does not specify. + +### 2. Add PR to the project + +```bash +gh project item-add 64 --owner MetaMask --url --format json --jq '.id' +``` + +This returns the **item node ID** needed for field updates. + +### 3. Get project node ID + +```bash +gh project view 64 --owner MetaMask --format json --jq '.id' +``` + +### 4. Set Priority field + +Use the Priority field ID and selected option ID obtained in step 1. + +```bash +gh api graphql -f query=' + mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $optionId: String!) { + updateProjectV2ItemFieldValue(input: { + projectId: $projectId, + itemId: $itemId, + fieldId: $fieldId, + value: { singleSelectOptionId: $optionId } + }) { projectV2Item { id } } + } +' -f projectId="" \ + -f itemId="" \ + -f fieldId="" \ + -f optionId="" +``` + +### 5. Set Comment field with current date + +Use the Comment field ID obtained in step 1. + +Automatically compute today's date in short month + day format (e.g. "Feb 21", "Mar 4", "Jan 10") — do NOT ask the user for this value. + +```bash +DATE_COMMENT=$(date +"%b %-d") + +gh api graphql -f query=' + mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $comment: String!) { + updateProjectV2ItemFieldValue(input: { + projectId: $projectId, + itemId: $itemId, + fieldId: $fieldId, + value: { text: $comment } + }) { projectV2Item { id } } + } +' -f projectId="" \ + -f itemId="" \ + -f fieldId="" \ + -f comment="$DATE_COMMENT" +``` + +## Output + +Confirm the PR was added to the review queue with: + +- A link to the project board +- The priority that was set +- The date comment that was added diff --git a/domains/pr-workflow/skills/pr-review-queue/skill.md b/domains/pr-workflow/skills/pr-review-queue/skill.md new file mode 100644 index 0000000..45763fa --- /dev/null +++ b/domains/pr-workflow/skills/pr-review-queue/skill.md @@ -0,0 +1,4 @@ +--- +name: pr-review-queue +description: Add PR to review queue project board +--- diff --git a/domains/pr-workflow/skills/pr-title/repos/metamask-mobile.md b/domains/pr-workflow/skills/pr-title/repos/metamask-mobile.md new file mode 100644 index 0000000..37418ec --- /dev/null +++ b/domains/pr-workflow/skills/pr-title/repos/metamask-mobile.md @@ -0,0 +1,73 @@ +--- +repo: metamask-mobile +parent: pr-title +--- + + +# PR Title + +## Format + +``` +[optional scope]: +``` + +- Max 72 characters total +- Imperative mood ("add", not "added" or "adds") +- No ticket/issue numbers (those belong in the PR description) +- Lowercase type and scope + +## Types + +| Type | When to use | +| ---------- | -------------------------------------------- | +| `feat` | New feature or enhancement | +| `fix` | Bug fix | +| `refactor` | Code restructuring without behavior change | +| `chore` | Maintenance (dependencies, configs, scripts) | +| `test` | Adding or updating tests | +| `docs` | Documentation changes | +| `perf` | Performance improvements | +| `style` | Code style/formatting (no logic change) | +| `ci` | CI/CD pipeline changes | +| `build` | Build system or external dependency changes | +| `revert` | Reverting a previous commit | + +## Scope + +Optional. Infer from the primary folder or feature area being modified. + +Infer the scope from the primary feature area or directory being modified. Any meaningful area in the repo is a valid scope. + +**Common scopes** (non-exhaustive): `analytics`, `transactions`, `wallet`, `ui`, `network`, `settings`, `permissions`, `tokens`, `nfts`, `swaps`, `bridge`, `staking`, `onboarding`, `confirmations`, `agents`, `e2e`, `deps`, `ramp`, `earn`, `perps`, `predict`, `notifications`, `accounts` + +**Avoid**: Generic scopes like `app`, `src`, `components` that don't convey meaning. + +**When to omit**: If changes span multiple unrelated areas, omit scope entirely. + +## Steps + +1. Get the branch name: `git rev-parse --abbrev-ref HEAD` +2. Get the full diff: `git diff main...HEAD` +3. Determine the **type** from the nature of the changes +4. Infer a **scope** from the primary folder/feature being modified (omit if ambiguous) +5. Write a concise **description** in imperative mood + +## Examples + +``` +feat(predict): add market details view +fix(transactions): resolve memory leak in controller +refactor(analytics): migrate rewards tracking to useAnalytics hook +chore(deps): update @metamask/controller-utils to v5.0.0 +test(onboarding): add e2e spec for SRP import flow +docs(staking): update pooled staking integration guide +perf(wallet): lazy-load token list on scroll +style(ui): apply design system tokens to network selector +ci: add conventional commit validation to PR checks +revert: undo NFT gallery feature flag removal +``` + +## CI Validation + +PR titles are validated by the `amannn/action-semantic-pull-request` GitHub Action (`.github/workflows/pr-title-linter.yml`). Titles that don't follow Conventional Commits format will fail CI. diff --git a/domains/pr-workflow/skills/pr-title/skill.md b/domains/pr-workflow/skills/pr-title/skill.md new file mode 100644 index 0000000..0e73bc5 --- /dev/null +++ b/domains/pr-workflow/skills/pr-title/skill.md @@ -0,0 +1,4 @@ +--- +name: pr-title +description: Generate conventional commit PR title +--- diff --git a/domains/swaps/skills/add-non-evm-network/repos/metamask-extension.md b/domains/swaps/skills/add-non-evm-network/repos/metamask-extension.md new file mode 100644 index 0000000..26e0be8 --- /dev/null +++ b/domains/swaps/skills/add-non-evm-network/repos/metamask-extension.md @@ -0,0 +1,17 @@ +--- +repo: metamask-extension +parent: add-non-evm-network +--- + + +# Add Non-EVM Swaps/Bridge Network + +`docs/add-non-evm-swaps-bridge-network.md` is the single source of truth. + +Follow `docs/add-non-evm-swaps-bridge-network.md` section `Agent Execution Standard (SSOT)` for: + +- prerequisites +- reference implementations +- implementation checklist +- rollout and validation requirements +- required response sections diff --git a/domains/swaps/skills/add-non-evm-network/skill.md b/domains/swaps/skills/add-non-evm-network/skill.md new file mode 100644 index 0000000..ab26c00 --- /dev/null +++ b/domains/swaps/skills/add-non-evm-network/skill.md @@ -0,0 +1,4 @@ +--- +name: add-non-evm-network +description: Add non-EVM network support for Swaps/Bridge +--- diff --git a/domains/testing/skills/ab-testing/repos/metamask-extension.md b/domains/testing/skills/ab-testing/repos/metamask-extension.md new file mode 100644 index 0000000..d6a0672 --- /dev/null +++ b/domains/testing/skills/ab-testing/repos/metamask-extension.md @@ -0,0 +1,112 @@ +--- +repo: metamask-extension +parent: ab-testing +--- + + +# A/B Testing Implementation + +Canonical workflow for implementing and reviewing MetaMask Extension A/B +tests. + +Do not use this skill for general analytics work that does not involve A/B +test flags, `useABTest`, `active_ab_tests`, or related tests/docs. + +## Required Response Sections + +1. `Implementation Checklist` +2. `Files To Modify` +3. `Analytics Payload Changes` +4. `Tests To Run` +5. `Compliance Check Result` + +## Agent Execution Standard + +For implementation or review tasks, follow this workflow: + +1. Run discovery before edits. + +```bash +rg -n "useABTest\\(|active_ab_tests|ab_tests|Abtest|feature-flag-registry|RemoteFeatureFlagController" app shared ui test docs +rg -n "Experiment Viewed|ExperimentViewed" app shared ui +``` + +2. Confirm the intended experiment shape. + - Use a threshold-array remote flag value for production defaults. + - Keep reused variants or metadata centralized in a config module when + multiple files need the same definitions. + - Use the same experiment key format as mobile: + `{teamName}{ticketId}Abtest{TestName}`. +3. Implement the assignment logic correctly. + - Prefer `useABTest(flagKey, variants)` and keep a `control` variant in + the variants object. + - Use `variantName` and `isActive` from the hook for business-event + instrumentation. + - If assignment is missing, invalid, or unresolved, the hook falls back + to `control` and `isActive: false`. +4. Implement analytics correctly. + - Rely on `useABTest` for the automatic `Experiment Viewed` exposure + event. + - Add `active_ab_tests` only to business events, and only when the + assignment is active. + - Never add new `ab_tests:` payloads. If a legacy touchpoint cannot be + migrated in the same change, keep the line annotated with + `LEGACY_AB_TEST_ALLOWED` and explain why. +5. Use the canonical event payload shapes. + +```typescript +const experiment = useABTest('swapsSWAPS4135AbtestNumpadQuickAmounts', { + control: { buttons: [25, 50, 75, 'MAX'] }, + treatment: { buttons: [50, 75, 90, 'MAX'] }, +}); + +const activeABTests = experiment.isActive + ? [ + { + key: 'swapsSWAPS4135AbtestNumpadQuickAmounts', + value: experiment.variantName, + }, + ] + : undefined; +``` + +6. Update tests and fixtures when behavior or flag plumbing changes. + - Register every new remote A/B test flag in + `test/e2e/feature-flags/feature-flag-registry.ts` with the production + default threshold-array JSON value. + - Use test overrides such as `manifestFlags.remoteFeatureFlags` or + `FixtureBuilder.withRemoteFeatureFlags(...)` when a test needs + deterministic assignment. + - If the change is copy-only or config-only, you may skip new tests with a brief rationale. +7. Run the A/B compliance checker using the repository's current supported + invocation and report the result. + - Checker path: + `.agents/skills/ab-testing-implementation/scripts/check-ab-testing-compliance.ts` + +```bash +# Current pre-commit / local implementation example +node --import tsx .agents/skills/ab-testing-implementation/scripts/check-ab-testing-compliance.ts --staged + +# Current review-mode / explicit file set example +node --import tsx .agents/skills/ab-testing-implementation/scripts/check-ab-testing-compliance.ts --files app/path/to/file.ts,test/path/to/file.spec.ts --base origin/main +``` + +## Review Checklist + +- Confirm `useABTest` always has a `control` variant. +- Confirm `Experiment Viewed` is not emitted manually when `useABTest` is in + use. +- Confirm business events use `active_ab_tests` rather than `ab_tests`. +- Confirm E2E flag registration and local test overrides remain production-accurate. +- Confirm the compliance checker result is included in the final response. + +## Related Files + +- `ui/hooks/useABTest.ts` +- `ui/hooks/useABTest.test.ts` +- `ui/selectors/remote-feature-flags.ts` +- `shared/constants/metametrics.ts` +- `test/e2e/feature-flags/feature-flag-registry.ts` + +Use `docs/ab-testing.md` only when you need deeper background, additional +examples, FAQ answers, or local override guidance beyond this workflow. diff --git a/domains/testing/skills/ab-testing/repos/metamask-mobile.md b/domains/testing/skills/ab-testing/repos/metamask-mobile.md new file mode 100644 index 0000000..5939111 --- /dev/null +++ b/domains/testing/skills/ab-testing/repos/metamask-mobile.md @@ -0,0 +1,23 @@ +--- +repo: metamask-mobile +parent: ab-testing +--- + + +# A/B Testing Implementation + +`docs/ab-testing.md` is the single source of truth. + +Follow `docs/ab-testing.md` section `Agent Execution Standard (SSOT)` for: + +- workflow +- analytics rules +- risk-based testing policy +- required response sections +- compliance command + +Run and report: + +```bash +bash .agents/skills/ab-testing-implementation/scripts/check-ab-testing-compliance.sh --staged +``` diff --git a/domains/testing/skills/ab-testing/skill.md b/domains/testing/skills/ab-testing/skill.md new file mode 100644 index 0000000..a32ea87 --- /dev/null +++ b/domains/testing/skills/ab-testing/skill.md @@ -0,0 +1,4 @@ +--- +name: ab-testing +description: A/B testing implementation +--- diff --git a/domains/testing/skills/component-view-test/references/navigation-mocking.md b/domains/testing/skills/component-view-test/references/navigation-mocking.md new file mode 100644 index 0000000..f52312f --- /dev/null +++ b/domains/testing/skills/component-view-test/references/navigation-mocking.md @@ -0,0 +1,195 @@ +# Navigation & Mocking + +Reference: [SKILL.md](../SKILL.md) · [Writing Tests](writing-tests.md) · [Reference](reference.md) + +--- + +## Navigation Testing + +### How `renderScreenWithRoutes` works + +`renderScreenWithRoutes(EntryComponent, entryRoute, routesArray, options)` — entry screen, its route name, an array of **1 to N** routes, and options (e.g. `{ state }`). Each route is `{ name: Routes.X }` or `{ name: Routes.X, Component: RealComponent }`. + +When navigation hits a registered route, the framework renders an element with `` testID=`route-${routeName}` `` so you can assert with ``await findByTestId(`route-${Routes.X}`)``. If you passed `Component`, the real component is shown instead. Use only `{ name }` when you just need to assert navigation; use `Component` when the test interacts with the destination screen. For cross-screen journeys, use a renderer that registers all reachable routes with `Component`. + +```typescript +// tests/component-view/renderers/myFeature.ts +export function renderMyFeatureWithRoutes(options = {}) { + const state = initialStateMyFeature(options).build(); + + return renderScreenWithRoutes( + MyFeatureHome as unknown as React.ComponentType, + { name: Routes.MY_FEATURE.HOME }, + [ + { + name: Routes.MY_FEATURE.DETAIL, + Component: MyFeatureDetail as unknown as React.ComponentType, + }, + { + name: Routes.MY_FEATURE.FILTER_MODAL, + Component: MyFeatureFilter as unknown as React.ComponentType, + }, + { + name: Routes.MY_FEATURE.ASSET, + Component: AssetDetails as unknown as React.ComponentType, + }, + ], + { state }, + ); +} +``` + +### Cross-screen journey test + +The most valuable navigation tests follow a **complete user journey across multiple screens**: + +```typescript +// ✅ User navigates from feed → full list view, then applies a network filter — +// the list updates with chain-specific data. +// +// Key techniques in this pattern: +// +// 1. DYNAMIC MOCK — the mock responds differently based on the params the component +// passes. This proves the component sends the correct filter params to the API, +// not just that the UI reacts to props. +// +// 2. PAIRED ASSERTIONS — after selecting the filter, assert BOTH sides: +// - Positive: new items appear (the filtered set) +// - Negative: old items are gone (queryByTestId(...).not.toBeOnTheScreen()) +// Asserting only one side does not prove the list actually changed. +it('displays only BNB tokens when BNB Chain network filter is selected', async () => { + // Dynamic mock: returns different data based on the chainIds param + getMyFeatureDataMock.mockImplementation(async (params) => { + if (params?.chainIds?.length === 1 && params.chainIds[0] === 'eip155:56') { + return mockBnbChainToken; // BNB-specific dataset + } + return mockTokensData; // default multi-chain dataset + }); + + const { findByTestId, findByText, getByTestId, queryByTestId } = + renderMyFeatureWithRoutes(); + + // Screen 1: wait for feed to load (confirms default data is visible) + await waitFor(() => + expect( + getByTestId(MyFeatureSelectorsIDs.FEED_SCROLL_VIEW), + ).toBeOnTheScreen(), + ); + + // Navigate to full list view + await userEvent.press(getByTestId('section-header-view-all')); + await waitFor(() => + expect(getByTestId('full-list-header')).toBeOnTheScreen(), + ); + + // Confirm a non-BNB token is visible before filtering + expect( + await findByTestId('token-row-eip155:1/erc20:0xAAA...'), + ).toBeOnTheScreen(); + + // Open network filter modal and select BNB Chain + await userEvent.press(getByTestId('all-networks-button')); + await waitFor(() => expect(getByTestId('close-button')).toBeOnTheScreen()); + await userEvent.press(await findByText('BNB Chain')); + + // ✅ Positive assertions — BNB token appears with all its fields + const bnbRow = await findByTestId('token-row-eip155:56/erc20:0xBTC000...'); + expect(within(bnbRow).getByText('Bitcoin BNB')).toBeOnTheScreen(); + expect(within(bnbRow).getByText(/\$44,500/)).toBeOnTheScreen(); + expect(within(bnbRow).getByText(/-1\.8/)).toBeOnTheScreen(); + + // ✅ Negative assertions — previous chain tokens are gone (proves the list changed, + // not just that new items were added on top) + expect( + queryByTestId('token-row-eip155:1/erc20:0xAAA...'), + ).not.toBeOnTheScreen(); + expect( + queryByTestId('token-row-eip155:1/erc20:0xBBB...'), + ).not.toBeOnTheScreen(); + expect( + queryByTestId('token-row-eip155:1/erc20:0xCCC...'), + ).not.toBeOnTheScreen(); +}); +``` + +### `userEvent` vs `fireEvent` + +For interactions that involve realistic user behavior (typing, pressing with focus), prefer `userEvent` over `fireEvent`: + +```typescript +import { fireEvent, userEvent } from '@testing-library/react-native'; + +// ✅ userEvent — simulates full event sequence including focus, pointer events +await userEvent.press(getByTestId('button')); +await userEvent.type(getByTestId('search-input'), 'ethereum'); + +// fireEvent — lower-level, useful when userEvent isn't available or for non-user events +fireEvent.press(getByTestId('button')); +fireEvent.changeText(getByTestId('input'), 'value'); +``` + +Route names live in `app/constants/navigation/Routes.ts`. + +--- + +## External Service / API Mocking + +Some views call **external HTTP APIs** (e.g. `fetch()` to a REST endpoint). Those requests cannot be driven through Redux state. The framework provides an **api-mocking** layer using [nock](https://github.com/nock/nock) so tests intercept HTTP at the network level **without** using `jest.mock` on service modules (which would violate the “only Engine and allowed native mocks” rule). + +### Preferred pattern — nock (api-mocking folder) + +All HTTP API mocks for component view tests live under `tests/component-view/api-mocking/`. Each feature has one file (e.g. `trending.ts`) that exports: + +- Mock response data (e.g. `mockTrendingTokensData`) +- A **setup** function (e.g. `setupTrendingApiFetchMock(responseData?, customReply?)`) that uses nock to intercept the endpoint +- A **clear** function (e.g. `clearTrendingApiMocks()`) to call in `afterEach` + +Shared nock lifecycle helpers (`clearAllNockMocks`, `disableNetConnect`, `teardownNock`) are in `api-mocking/nockHelpers.ts`. To **add a new API mock** for another view, add a file `api-mocking/.ts` following the pattern in `api-mocking/trending.ts` (mock data, `setupXxxApiMock`, `clearXxxApiMocks` using `nockHelpers`), and call setup/clear in the view test’s `beforeEach`/`afterEach`. + +**Example (trending):** + +```typescript +import { + setupTrendingApiFetchMock, + clearTrendingApiMocks, + mockTrendingTokensData, + mockBnbChainToken, +} from '../../../../tests/component-view/api-mocking/trending'; + +beforeEach(() => { + setupTrendingApiFetchMock(mockTrendingTokensData); +}); +afterEach(() => { + clearTrendingApiMocks(); +}); + +it('user sees trending tokens section with mocked data', async () => { + const { findByText, queryByTestId } = renderTrendingViewWithRoutes(); + await waitFor(async () => { + expect(await findByText('Ethereum')).toBeOnTheScreen(); + }); + // assert rows with assertTrendingTokenRowsVisibility(...) +}); + +it('displays only BNB tokens when BNB Chain network filter is selected', async () => { + setupTrendingApiFetchMock(mockTrendingTokensData, (uri) => { + const url = new URL(uri, 'https://token.api.cx.metamask.io'); + const chainIdsParam = url.searchParams.get('chainIds') ?? ''; + const chainIds = chainIdsParam.split(',').map((s) => s.trim()); + if (chainIds.length === 1 && chainIds[0] === 'eip155:56') { + return mockBnbChainToken; + } + return mockTrendingTokensData; + }); + const { getByTestId, findByText, queryByTestId } = + renderTrendingViewWithRoutes(); + // ... navigate to full view, open network filter, select BNB Chain + // assert visible: [BNB], missing: [ETH, BTC, UNI] +}); +``` + +### Fallback — jest.mock on the service module (antipattern) + +When a view calls an external **function** (not `fetch`) from a package and that function cannot be replaced by nock (e.g. no HTTP), you may mock the module in a file under `tests/component-view/mocks/` and use setup/clear helpers. This requires an `eslint-disable` and is a **known antipattern**; prefer moving the integration to an HTTP API and using api-mocking, or drive data through Engine/Redux when possible. + +> ⚠️ Only Engine and allowed native modules should be mocked in `*.view.test.*` files. Mocking a service module directly bypasses the ESLint guard. Always link to a tracking issue and plan to migrate to nock (api-mocking) or Engine/Redux. diff --git a/domains/testing/skills/component-view-test/references/reference.md b/domains/testing/skills/component-view-test/references/reference.md new file mode 100644 index 0000000..0c69d15 --- /dev/null +++ b/domains/testing/skills/component-view-test/references/reference.md @@ -0,0 +1,211 @@ +# Running Tests, Self-Review, and Diagnosing Failures + +Use this reference when you need to **run** component view tests, **self-review** after tests pass, or **diagnose and fix** failures. It also covers assertion patterns, deterministic fiat, and What NOT to Do. + +Reference: [SKILL.md](../SKILL.md) · [Writing Tests](writing-tests.md) · [Navigation & Mocking](navigation-mocking.md) + +--- + +## Table of contents + +- [Deterministic Fiat Assertions](#deterministic-fiat-assertions) +- [Run the Tests](#run-the-tests) +- [Self-Review Checklist](#self-review-checklist) +- [Diagnosing Failures](#diagnosing-failures) +- [Assertion Patterns](#assertion-patterns) +- [What NOT to Do](#what-not-to-do) +- [Quick Reference](#quick-reference) + +--- + +## Deterministic Fiat Assertions + +Pass `deterministicFiat: true` whenever a test asserts exact currency values. This injects stable exchange rates: + +```typescript +const { getByText } = renderBridgeView({ + deterministicFiat: true, + overrides: { bridge: { sourceAmount: '1' } }, +}); +expect(getByText('$2,000.00')).toBeOnTheScreen(); +``` + +--- + +## Run the Tests + +**Always use `jest.config.view.js`** — the default Jest config does not apply the component view test rules. + +```bash +# Run a single file +yarn jest -c jest.config.view.js app/components/UI/Bridge/Views/BridgeView/BridgeView.view.test.tsx --runInBand --silent --coverage=false + +# Run a specific test by name +yarn jest -c jest.config.view.js -t "renders the source token" --runInBand --silent --coverage=false + +# Watch mode +yarn jest -c jest.config.view.js --watch + +# Coverage for a feature folder (use this, not --coverage directly — avoids OOM) +yarn test:view:coverage:folder app/components/UI/MyFeature +``` + +--- + +## Self-Review Checklist + +Before declaring the task done, go through this checklist for every test written or modified. If any item fails, fix it and re-run. + +| # | Check | What to do if it fails | +| --- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------- | +| 1 | **No render scenarios** — every test has at least one `fireEvent`, `waitFor`/`findBy`, `store.dispatch`, or Engine spy | Rewrite the test to add a user interaction or system reaction | +| 2 | **No selector mocking** — no `(useSelector as jest.Mock).mockImplementation(...)` anywhere in the file | Remove; drive behavior through state overrides instead | +| 3 | **No fake timers** — no `jest.useFakeTimers()`, `jest.advanceTimersByTime()`, or `jest.useRealTimers()` | Remove fake timers; use `waitFor` / `findBy` for async flows | +| 4 | **Data-completeness test exists** — if the view loads data asynchronously (API, Engine polling), there is one test that waits for the load and validates all fields of all items in the full base mock using `within()` per row | Add the data-completeness test | +| 5 | **Filter/segmentation tests have paired assertions** — every test that selects a filter or changes a network asserts both what appears (`findByTestId`) AND what disappears (`queryByTestId(...).not.toBeOnTheScreen()`) for each item from the previous set | Add the missing negative assertions | +| 6 | **No raw strings in `getByTestId` / `findByTestId` / `queryByTestId`** — all test IDs reference constants from the component's `ComponentName.testIds.ts` | Create or update the testIds file; replace raw strings with constants | +| 7 | **Any `jest.mock` for non-Engine modules is flagged** — if a service module is mocked directly, the `eslint-disable` comment is present and a tracking issue is linked | Add the comment and issue link | +| 8 | **AAA formatting** — blank lines between the Arrange, Act, and Assert blocks in every test | Add the blank line separators | +| 9 | **Import order** — `mocks.ts` is first; remaining order follows project ESLint rules | Ensure `mocks.ts` is the very first import; reorder the rest as needed | + +--- + +## Diagnosing Failures + +### Identify the error type first + +| Error pattern | Likely cause | Fix | +| ------------------------------------------------ | -------------------------------------------------- | ----------------------------------------------------------------- | +| `jest.mock is not allowed in *.view.test.*` | Arbitrary `jest.mock` added to test | Remove it; drive via state instead | +| `Unable to find an element with testID: xxx` | State not providing needed data, or element hidden | Add the relevant state via overrides or check rendering condition | +| `Cannot read property 'X' of undefined` | Preset missing a required state slice | Add `.withMinimalXController()` or override in preset | +| `Warning: An update was not wrapped in act(...)` | Async state update not awaited | Use `await waitFor(...)` | +| `No QueryClient set` | Missing provider — not in Engine mock | Add to mocks.ts or wrap with QueryClientProvider in renderer | +| Flakey number assertions | Non-deterministic exchange rates | Add `deterministicFiat: true` | +| Test passes locally, fails in CI | Time-sensitive assertions | Use `waitFor` not inline assertions after interactions | + +### Inspect what's rendered + +```typescript +// Add temporarily inside the test +const { debug } = renderBridgeView(); +debug(); // prints full component tree +``` + +### Check that state data reaches the component + +Add a `console.log` in the component temporarily, or use `debug()` to confirm the Redux state is wired correctly before writing assertions. + +### Check stale presets + +When a controller's state shape changes (e.g. a new required field added to `BridgeController`), the preset becomes stale. Compare the component's actual selector usage against what the preset provides. + +--- + +## Assertion Patterns + +```typescript +// Presence / absence +expect(getByText('Label')).toBeOnTheScreen(); +expect(queryByText('Label')).not.toBeOnTheScreen(); + +// Enabled / disabled state +expect(getByTestId('cta-button')).toBeEnabled(); +expect(getByTestId('cta-button')).toBeDisabled(); + +// After interaction +fireEvent.press(getByTestId('some-button')); +await waitFor(() => expect(getByText('Result')).toBeOnTheScreen()); + +// Navigation assertion +await findByTestId(`route-${Routes.SOME_SCREEN}`); + +// findByTestId 3rd-arg timeout (NOT 2nd arg) +await findByTestId('my-element', {}, { timeout: 3000 }); + +// Within a subtree — scope queries to avoid false positives when the same text or +// testID appears in multiple list items (e.g., every row shows a "price" label). +// Use within(rowElement) to constrain the query to a single row. +import { within } from '@testing-library/react-native'; +const card = getByTestId(MyViewSelectorsIDs.TOKEN_CARD_ETH); +expect(within(card).getByText('ETH')).toBeOnTheScreen(); +expect(within(card).getByText('$2,000.00')).toBeOnTheScreen(); +``` + +--- + +## What NOT to Do + +```typescript +// ❌ Render scenario — no interaction, no system reaction, just static visibility +it('renders input areas and hides confirm button without tokens or amount', () => { + const { getByTestId, queryByTestId } = renderBridgeView({ overrides: { ... } }); + expect(getByTestId(SOURCE_AREA)).toBeOnTheScreen(); // render check + expect(getByTestId(DEST_AREA)).toBeOnTheScreen(); // render check + expect(queryByTestId(CONFIRM_BUTTON)).toBeNull(); // render check +}); +// More assertions does NOT make it a better test if they're all static. +// ✅ Instead: drive the test through a user interaction, Redux action, or Engine spy + +// ❌ Arbitrary mock — blocked by ESLint and runtime guard +jest.mock('../../some/hook', () => ({ useMyHook: jest.fn() })); + +// ❌ Mocking a selector +(useSelector as jest.Mock).mockImplementation(...); + +// ❌ Fake timers +jest.useFakeTimers(); + +// ❌ Snapshot assertion +expect(wrapper).toMatchSnapshot(); + +// ❌ Rebuilding the whole state from scratch +renderComponentViewScreen(MyView, { name: 'X' }, { + state: { engine: { backgroundState: { /* 200 lines */ } } }, +}); +// ✅ Instead: use a preset + minimal overrides + +// ❌ Raw string literal in getByTestId / findByTestId / queryByTestId +getByTestId('my-view-scroll-view'); +queryByTestId('confirm-button'); + +// ✅ Use the constant from the component's testIds file +import { MyViewSelectorsIDs } from './MyView.testIds'; +getByTestId(MyViewSelectorsIDs.SCROLL_VIEW); +queryByTestId(MyViewSelectorsIDs.CONFIRM_BUTTON); + +// If the testIds file does not exist yet, create it first: +// export const MyViewSelectorsIDs = { +// SCROLL_VIEW: 'my-view-scroll-view', +// CONFIRM_BUTTON: 'my-view-confirm-button', +// } as const; +``` + +--- + +## Quick Reference + +```bash +# Run component view tests +yarn jest -c jest.config.view.js --runInBand --silent --coverage=false + +# Coverage for a feature folder +yarn test:view:coverage:folder app/components/UI/MyFeature + +# Lint check +yarn eslint +``` + +**Key locations:** + +| What | Where | +| ------------------------------ | -------------------------------------------------------------- | +| Engine + native mocks | `tests/component-view/mocks.ts` | +| render, renderScreenWithRoutes | `tests/component-view/render.tsx` | +| StateFixtureBuilder | `tests/component-view/stateFixture.ts` | +| HTTP API mocks (nock) | `tests/component-view/api-mocking/` (per-feature) | +| Feature renderers (per view) | `tests/component-view/renderers/` (e.g. bridge, wallet) | +| Feature presets (per view) | `tests/component-view/presets/` (e.g. bridge, wallet) | +| DeepPartial type | `app/util/test/renderWithProvider` | +| Routes | `app/constants/navigation/Routes.ts` | +| Skill + rules | `.agents/skills/component-view-test/` (SKILL.md + references/) | diff --git a/domains/testing/skills/component-view-test/references/writing-tests.md b/domains/testing/skills/component-view-test/references/writing-tests.md new file mode 100644 index 0000000..c003dfa --- /dev/null +++ b/domains/testing/skills/component-view-test/references/writing-tests.md @@ -0,0 +1,521 @@ +# Writing Tests + +Reference: [SKILL.md](../SKILL.md) · [Navigation & Mocking](navigation-mocking.md) · [Reference](reference.md) + +--- + +## Read Before Writing + +Before writing any test, read: + +- The component file under test +- Any existing `*.view.test.tsx` for the same component +- The relevant preset(s) in `tests/component-view/presets/` +- The relevant renderer(s) in `tests/component-view/renderers/` +- If the view calls an external HTTP API: `tests/component-view/api-mocking/` and any existing `api-mocking/.ts` for that API (see navigation-mocking.md, External Service / API Mocking) + +--- + +## Enumerate Use Cases + +**Do this before writing a single test line.** Build a candidate list scoped and deduplicated against existing tests. + +### 1. List user-facing actions + +Ask: "What can a user **do** on this screen?" — type/paste input, press a button, select from a list, scroll/refresh, open/dismiss a modal, navigate to a sub-screen, wait for async data, long-press/swipe, toggle a setting. + +### 2. Map each action to a valid test pattern + +| User action / system event | Valid pattern | +| ----------------------------------------------------- | ----------------------------------------------------- | +| Presses button → UI changes | `fireEvent.press` → `waitFor` | +| Types input → value appears | `userEvent.type` or `fireEvent.changeText` → `findBy` | +| Selects item → navigates | `userEvent.press` → route probe | +| Redux action dispatched → Engine called | `store.dispatch` + `act` → Engine spy | +| Async data arrives → list renders | `findBy` / `waitFor` | +| User triggers action → API called with correct params | interaction → spy assertion | +| Chained user journey → end state visible | Multiple `fireEvent` → final `findBy` | + +Drop anything that only produces a render scenario: "The screen shows X when state is Y", "Button is disabled without input", "Token name appears in header". + +### 3. Deduplicate against existing tests + +Read `ComponentName.view.test.tsx` (if it exists) and remove any candidate already covered. + +### 4. Run coverage and prioritize + +```bash +yarn test:view:coverage:folder app/components/UI/MyFeature +``` + +Focus on low branch coverage. Prioritize candidates that cover the most uncovered paths. Proceed directly to writing. + +--- + +## Write a New Test File + +### File naming + +``` +ComponentName.view.test.tsx ← always *.view.test.tsx +``` + +### What makes a good test + +A good test is driven by **user interaction or a meaningful business condition** — not by what is statically visible after render. If your test has no `fireEvent`, no `act`, no `waitFor`, and no Engine spy, ask yourself: am I just checking the initial render? If yes, it's a render scenario and it's an antipattern. + +Antipattern examples are in [`reference.md — What NOT to Do`](reference.md#what-not-to-do). **Good tests are interaction-driven or verify a meaningful business rule:** + +```typescript +// ✅ User types on keypad → fiat value reacts in real time +it('types 9.5 with keypad and displays $19,000.00 fiat value', async () => { + const { getByTestId, getByText, findByText, findByDisplayValue } = + defaultBridgeWithTokens({ + bridge: { + sourceAmount: '0', + sourceToken: ETH_SOURCE, + destToken: undefined, + }, + }); + + await waitFor(() => + expect( + getByTestId(BuildQuoteSelectors.KEYPAD_DELETE_BUTTON), + ).toBeOnTheScreen(), + ); + + fireEvent.press(getByText('9')); + fireEvent.press(getByText('.')); + fireEvent.press(getByText('5')); + + expect(await findByDisplayValue('9.5')).toBeOnTheScreen(); + expect(await findByText('$19,000.00')).toBeOnTheScreen(); +}); + +// ✅ Redux dispatch → Engine called with correct params (proves the wiring, not just the UI) +it('calls quote API with custom slippage when user has set 5% and quote is requested', async () => { + const updateQuoteSpy = jest.spyOn( + Engine.context.BridgeController, + 'updateBridgeQuoteRequestParams', + ); + const { store } = defaultBridgeWithTokens({ + bridge: { selectedDestChainId: '0x1' }, + }); + updateQuoteSpy.mockClear(); + + act(() => { + store.dispatch(setSlippage('5')); + }); + + await waitFor( + () => { + expect(updateQuoteSpy).toHaveBeenCalledWith( + expect.objectContaining({ slippage: 5 }), + expect.anything(), + ); + }, + { timeout: 1000 }, + ); + + updateQuoteSpy.mockRestore(); +}); + +// ✅ Async data completeness — waits for API mock to resolve, then validates every +// field of every item. Valid because data arrival is async (findBy / waitFor). +// One of these per view — proves the full data pipeline end-to-end. +it('user sees all items with complete data after async load', async () => { + const { findByText, findByTestId } = renderMyFeatureWithRoutes(); + + // Wait for the first item to confirm data has loaded + await waitFor(async () => { + expect(await findByText('Token A')).toBeOnTheScreen(); + }); + + // Validate all fields of each item in the base mock dataset + const tokenARow = await findByTestId('token-row-item-eip155:1/erc20:0xAAA'); + const tokenAScope = within(tokenARow); + expect(tokenAScope.getByText('Token A')).toBeOnTheScreen(); + expect(tokenAScope.getByText(/\+5\.2/)).toBeOnTheScreen(); // % change + expect(tokenAScope.getByText(/\$/)).toBeOnTheScreen(); // price + + const tokenBRow = await findByTestId('token-row-item-eip155:1/erc20:0xBBB'); + const tokenBScope = within(tokenBRow); + expect(tokenBScope.getByText('Token B')).toBeOnTheScreen(); + expect(tokenBScope.getByText(/-1\.8/)).toBeOnTheScreen(); + expect(tokenBScope.getByText(/\$/)).toBeOnTheScreen(); +}); + +// ✅ User navigates to a new screen — proves the navigation wiring end-to-end. +// When you only need to confirm navigation occurred (not render the destination screen), +// omit the Component key. The framework renders a probe element with +// testID=`route-${routeName}` automatically when navigation arrives at that route. +it('navigates to dest token selector on press', async () => { + const state = initialStateBridge() + .withOverrides({ bridge: { sourceToken: ETH_SOURCE } }) + .build(); + const { findByTestId, findByText } = renderScreenWithRoutes( + BridgeView as unknown as React.ComponentType, + { name: Routes.BRIDGE.ROOT }, + [{ name: Routes.BRIDGE.TOKEN_SELECTOR }], + { state }, + ); + + fireEvent.press(await findByText('Swap to')); + + await findByTestId(`route-${Routes.BRIDGE.TOKEN_SELECTOR}`); +}); +``` + +### Local helper pattern + +For test files where most tests share a common baseline, extract a local helper instead of repeating the same overrides: + +```typescript +// Define the baseline once — each test only overrides its delta from here +const DEFAULT_BRIDGE = { + sourceToken: ETH_SOURCE, + destToken: USDC_DEST, + sourceAmount: '1', +}; + +const defaultBridgeWithTokens = (overrides?: Record) => { + const { bridge: bridgeOverrides, ...rest } = overrides ?? {}; + return renderBridgeView({ + deterministicFiat: true, + overrides: { + bridge: { + ...DEFAULT_BRIDGE, + ...(bridgeOverrides as Record), + }, + ...rest, + } as unknown as DeepPartial, + }); +}; +``` + +Then each test only specifies its delta from this baseline. + +### describe / it and platform (iOS + Android) + +Import from `tests/component-view/platform`. All helpers accept an optional **filter** (3rd arg): `'ios'` | `'android'` | `['ios','android']` | `{ only: 'ios' }` | `{ skip: ['android'] }`. Env: `TEST_OS=ios` or `TEST_OS=android` to run only one OS. + +| Helper | Use | +| ----------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | +| `describeForPlatforms(name, define, filter?)` | One describe per OS. Inside, `define({ os })`; use `it()` or `itForPlatforms()` — each runs once per that OS. | +| `itForPlatforms(name, (ctx) => {}, filter?)` | One `it` per OS. Callback receives `{ os }`. | +| `itOnlyForPlatforms(name, fn, filter?)` | Same as `itForPlatforms` but registers `it.only`. | +| `itEach(table)(name, (row) => {}, filter?)` | One `it` per table row × per OS. Use `$key` in name to interpolate row fields. | +| `describeEach(table)(name, (row) => { it('...', () => {}); }, filter?)` | One describe per row × per OS. Use `$key` in name. | +| `getTargetPlatforms(filter?)` | Returns `['ios','android']` (or filtered list) for custom loops. | + +Example — `itEach` (each case runs on iOS and Android): + +```typescript +import { itEach } from '../../../../../../tests/component-view/platform'; + +const cases = [ + { name: 'renders empty', amount: '0' }, + { name: 'displays fiat', amount: '1' }, +]; +itEach(cases)('$name', ({ amount }) => { + const { findByDisplayValue } = renderDefault({ + bridge: { sourceAmount: amount }, + }); + expect(findByDisplayValue(amount)).toBeOnTheScreen(); +}); +``` + +Jest modifiers (`it.only`, `it.skip`, `describe.only`, `describe.skip`) work as usual inside these blocks. + +### Minimal template + +```typescript +import '../../../../../../tests/component-view/mocks'; +import { renderMyFeatureView } from '../../../../../../tests/component-view/renderers/myFeature'; +import { + describeForPlatforms, + itForPlatforms, +} from '../../../util/test/platform'; +import { act, fireEvent, waitFor, within } from '@testing-library/react-native'; +import { MyViewSelectorsIDs } from './MyView.testIds'; // ← always import from the component's testIds file +import type { DeepPartial } from '../../../../../util/test/renderWithProvider'; +import type { RootState } from '../../../../../reducers'; + +// Local helper — encapsulates the common baseline, each test only overrides its delta +// Define the baseline before the helper +const DEFAULT_MY_FEATURE = { + sourceToken: ETH_SOURCE, + destToken: USDC_DEST, + sourceAmount: '1', +}; + +const renderDefault = (overrides?: Record) => { + const { myFeature: featureOverrides, ...rest } = overrides ?? {}; + return renderMyFeatureView({ + deterministicFiat: true, + overrides: { + myFeature: { + ...DEFAULT_MY_FEATURE, + ...(featureOverrides as Record), + }, + ...rest, + } as unknown as DeepPartial, + }); +}; + +describeForPlatforms('MyView', () => { + // ✅ User interaction → UI reacts + it('types an amount with the keypad and updates the fiat display', async () => { + const { getByTestId, getByText, findByDisplayValue, findByText } = + renderDefault({ + bridge: { + sourceAmount: '0', + sourceToken: ETH_SOURCE, + destToken: USDC_DEST, + }, + }); + + await waitFor(() => + expect( + getByTestId(MyViewSelectorsIDs.KEYPAD_DELETE_BUTTON), + ).toBeOnTheScreen(), + ); + + fireEvent.press(getByText('1')); + fireEvent.press(getByText('0')); + + expect(await findByDisplayValue('10')).toBeOnTheScreen(); + expect(await findByText('$20,000.00')).toBeOnTheScreen(); + }); + + // ✅ Redux dispatch → Engine method called with correct params + it('calls updateBridgeQuoteRequestParams with the selected dest chain when chain changes', async () => { + const updateQuoteSpy = jest.spyOn( + Engine.context.BridgeController, + 'updateBridgeQuoteRequestParams', + ); + const { store } = renderDefault({ + bridge: { sourceToken: ETH_SOURCE, sourceAmount: '1' }, + }); + updateQuoteSpy.mockClear(); + + act(() => { + store.dispatch(setDestChain('0xa')); + }); + + await waitFor(() => { + expect(updateQuoteSpy).toHaveBeenCalledWith( + expect.objectContaining({ destChainId: '0xa' }), + expect.anything(), + ); + }); + + updateQuoteSpy.mockRestore(); + }); + + // ✅ User press → navigates to a new screen + it('opens the destination token selector when the dest token area is tapped', async () => { + const state = initialStateMyFeature() + .withOverrides({ bridge: { sourceToken: ETH_SOURCE } }) + .build(); + const { findByText, findByTestId } = renderScreenWithRoutes( + MyView as unknown as React.ComponentType, + { name: Routes.MY_FEATURE }, + [{ name: Routes.MY_FEATURE_TOKEN_SELECTOR }], + { state }, + ); + + fireEvent.press(await findByText('Swap to')); + + await findByTestId(`route-${Routes.MY_FEATURE_TOKEN_SELECTOR}`); + }); +}); +``` + +### Import order + +`tests/component-view/mocks` **must be the very first import** — it installs Engine and native mocks before anything else loads. For remaining imports follow project ESLint rules: renderer → platform helpers → testIds constants → `@testing-library/react-native` → other. + +--- + +## Choose the Right Renderer and Preset + +### Use an existing renderer when available + +| View area | Renderer | Preset | +| -------------- | ----------------------------------------------------------- | --------------------------- | +| Bridge | `renderBridgeView` | `initialStateBridge` | +| Wallet | `renderWalletView` | `initialStateWallet` | +| Trending | `renderTrendingView` | `initialStateTrending` | +| Wallet Actions | `renderWalletActionsView` | `initialStateWalletActions` | +| Perps | `renderPerpsView` | `initialStatePerps` | +| Predict | `renderPredictFeedView` / `renderPredictFeedViewWithRoutes` | `initialStatePredict` | + +### Passing state overrides + +Always start from a preset, then narrow down with minimal overrides: + +```typescript +// Good — minimal delta from preset +renderBridgeView({ + deterministicFiat: true, + overrides: { + bridge: { sourceAmount: '1' }, + }, +}); + +// Good — complex override via engine background state when needed +renderBridgeView({ + overrides: { + engine: { + backgroundState: { + BridgeController: { + state: { quotesLastFetched: 0 }, + }, + }, + }, + }, +}); +``` + +### Bridge: enabling the confirm CTA + +To enable the Bridge confirm CTA (requires a valid quote), use the `withBridgeRecommendedQuoteEvmSimple` helper on the state fixture — it's the easiest path: + +```typescript +const state = initialStateBridge() + .withBridgeRecommendedQuoteEvmSimple({ sourceAmount: '1' }) + .build(); +``` + +Alternatively, set these fields manually in `engine.backgroundState.BridgeController`: + +- `quotes: [recommendedQuote]` +- `recommendedQuote: recommendedQuote` +- `quotesLastFetched: Date.now()` +- `quotesLoadingStatus: 'SUCCEEDED'` + +Also ensure remote feature flags enable Bridge for the target chain(s) via `RemoteFeatureFlagController.remoteFeatureFlags.bridgeConfigV2`. + +--- + +### When no renderer exists for the view yet + +Create one. Pattern (copy from `tests/component-view/renderers/bridge.ts`): + +```typescript +// tests/component-view/renderers/myFeature.ts +import '../mocks'; +import React from 'react'; +import type { DeepPartial } from '../../../app/util/test/renderWithProvider'; +import type { RootState } from '../../../app/reducers'; +import { renderComponentViewScreen } from '../render'; +import Routes from '../../../app/constants/navigation/Routes'; +import MyView from '../../../app/components/Views/MyFeature'; +import { initialStateMyFeature } from '../presets/myFeature'; + +interface RenderMyFeatureOptions { + overrides?: DeepPartial; + deterministicFiat?: boolean; +} + +export function renderMyFeatureView( + options: RenderMyFeatureOptions = {}, +): ReturnType { + const { overrides, deterministicFiat } = options; + const builder = initialStateMyFeature({ deterministicFiat }); + if (overrides) builder.withOverrides(overrides); + const state = builder.build(); + return renderComponentViewScreen( + MyView as unknown as React.ComponentType, + { name: Routes.MY_FEATURE }, + { state }, + ); +} +``` + +### When the view uses React Query (`@tanstack/react-query`) + +If the view (or any child component) calls `useQuery` / `useMutation`, wrap the component in a `QueryClientProvider` inside the renderer — otherwise tests throw `No QueryClient set`: + +```typescript +// tests/component-view/renderers/myFeature.tsx +import React from 'react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { renderComponentViewScreen } from '../render'; +import { initialStateMyFeature } from '../presets/myFeature'; +import MyView from '../../../app/components/UI/MyFeature/MyView'; +import Routes from '../../../app/constants/navigation/Routes'; + +export function renderMyFeatureView(options = {}) { + const state = initialStateMyFeature(options).build(); + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + + return renderComponentViewScreen( + () => ( + + + + ), + { name: Routes.MY_FEATURE }, + { state }, + ); +} +``` + +Key points: + +- Set `retry: false` so failed queries surface immediately in tests without retry delays +- Create a **new `QueryClient` per call** to avoid state leaking between tests + +### When the view reads route params (`initialParams`) + +`renderComponentViewScreen` and `renderScreenWithRoutes` accept a 4th `initialParams` argument for views that read params from the route (e.g. via `useRoute().params`): + +```typescript +// For a view that expects { marketId: string } as route params +renderComponentViewScreen( + MyDetailView as unknown as React.ComponentType, + { name: Routes.MY_FEATURE.DETAIL }, + { state }, + { marketId: 'market-abc-123' }, // ← initialParams as 4th argument +); +``` + +In tests using `renderScreenWithRoutes`, pass `initialParams` in the route object: + +```typescript +renderScreenWithRoutes( + MyDetailView as unknown as React.ComponentType, + { name: Routes.MY_FEATURE.DETAIL, params: { marketId: 'market-abc-123' } }, + [], + { state }, +); +``` + +If the component crashes with `Cannot read properties of undefined (reading 'marketId')`, the view is reading a required route param — pass it via `initialParams` or `params`. + +--- + +And the matching preset (`tests/component-view/presets/myFeature.ts`): + +```typescript +import { createStateFixture } from '../stateFixture'; + +export const initialStateMyFeature = (options?: { + deterministicFiat?: boolean; +}) => { + const builder = createStateFixture() + .withMinimalAccounts() + .withMinimalMainnetNetwork() + .withMinimalKeyringController() + .withRemoteFeatureFlags({}); + + if (options?.deterministicFiat) { + builder.withOverrides({ + /* currency rate overrides */ + }); + } + return builder; +}; +``` diff --git a/domains/testing/skills/component-view-test/repos/metamask-mobile.md b/domains/testing/skills/component-view-test/repos/metamask-mobile.md new file mode 100644 index 0000000..5df383e --- /dev/null +++ b/domains/testing/skills/component-view-test/repos/metamask-mobile.md @@ -0,0 +1,128 @@ +--- +repo: metamask-mobile +parent: component-view-test +--- + + +# Component View Test Agent + +**Goal**: Create, update, and fix component view tests (`*.view.test.tsx`) in the MetaMask Mobile codebase using the `tests/component-view/` framework. + +Use this skill whenever you need to: + +- Write a new component view test file +- Update tests after a component or preset has changed +- Diagnose and fix a failing component view test + +Your job is to figure out whether the user needs to **write a new test**, **fix a failing test**, or **update tests after a component/preset change**, then follow the corresponding path and open the relevant reference when that path indicates. + +**Decision tree — which reference to use:** + +``` +Task → What do you need? +├─ Write new test or update after change +│ → Read component + existing tests +│ → Open references/writing-tests.md (use cases, coverage, renderer/preset, file structure) +│ → If test needs navigation: also open references/navigation-mocking.md +│ → After writing: run tests, then open references/reference.md for self-review +│ +├─ Fix failing test +│ → Run: yarn jest -c jest.config.view.js --runInBand --silent --coverage=false +│ → Identify error type → Open references/reference.md (Diagnosing Failures) +│ +└─ Run tests or self-review after tests pass + → Open references/reference.md (Run the Tests, Self-Review Checklist) +``` + +Do not read the full reference files until the decision tree or workflow sends you there. + + +## What Are Component View Tests? + +Component view tests are **integration-level** tests that test views through real Redux state — no mocked hooks or selectors. They live alongside the component as `ComponentName.view.test.tsx` and use a dedicated framework in `tests/component-view/`. + +Key constraint: **only Engine and allowed native modules may be mocked** (enforced at runtime by `app/util/test/testSetupView.js` and by ESLint override in `.eslintrc.js` for `**/*.view.test.*`). + + +## The Framework at a Glance + +``` +tests/component-view/ +├── mocks.ts ← Engine + native mocks (import this first, always) +├── render.tsx ← renderComponentViewScreen, renderScreenWithRoutes +├── stateFixture.ts ← StateFixtureBuilder (createStateFixture) +├── platform.ts ← describeForPlatforms, itForPlatforms (run per iOS/Android) +├── api-mocking/ ← HTTP API mocks (nock) — extensible, one file per feature +├── presets/ ← initialState() builders — one file per feature area +└── renderers/ ← renderView() functions — one file per feature area +``` + + +## Workflow (summary) + +- **Write new test**: Read component and existing tests → list use cases and map to test patterns → check coverage and deduplicate → use or create renderer/preset → write test (use `renderScreenWithRoutes` if asserting navigation). Every test must have at least one of: `fireEvent`, `waitFor`/`findBy`, `store.dispatch`/`act`, or Engine spy (no render-only scenarios). Run tests, then run the self-review checklist in `references/reference.md`. +- **Fix failing test**: Run with `jest.config.view.js` → identify error type from the table in `references/reference.md` (Diagnosing Failures) → apply the fix (remove disallowed mock, add state override, add preset, wrap in `waitFor`, add `deterministicFiat`, etc.) → re-run. +- **Update after change**: Same as write — review existing tests, extend preset/renderer if needed, update tests, run and self-review. + +For full detail (use cases, coverage, presets, route probes, self-review checklist, failure table), use the reference files when the decision tree sends you there. + + +## Run the tests + +Always use `jest.config.view.js` — the default Jest config does not apply component view test rules. + +**Run tests (no coverage):** + +```bash +yarn jest -c jest.config.view.js --runInBand --silent --coverage=false +``` + +Example: `yarn jest -c jest.config.view.js app/components/UI/Bridge/Views/BridgeView/BridgeView.view.test.tsx --runInBand --silent --coverage=false` + +**Coverage for a feature folder** (use this instead of `--coverage` to avoid OOM): + +```bash +yarn test:view:coverage:folder app/components/UI/MyFeature +``` + +For run-by-name, watch mode, or other options, see `references/reference.md` (Run the Tests). + + +## Golden Rules (Enforced) + +1. **Only mock Engine and allowed native modules** — no arbitrary `jest.mock()` in `*.view.test.*` files. Allowed: + - `../../app/core/Engine` + - `../../app/core/Engine/Engine` + - `react-native-device-info` + - (these are already handled by `tests/component-view/mocks.ts`) + +2. **Drive all behavior through Redux state** — no mocking of hooks or selectors. Provide data via state overrides. + +3. **Reuse presets and renderers** — never rebuild the full state manually from scratch. + +4. **No fake timers** — never use `jest.useFakeTimers()`, `jest.advanceTimersByTime()`, or `jest.useRealTimers()`. + +5. **Test behavior, not snapshots** — use `toBeOnTheScreen()`, `not.toBeOnTheScreen()`, interaction assertions. + +6. **Follow AAA** — Arrange → Act → Assert, blank lines between each section. One test = one user journey or business outcome; multiple chained actions in a single test are fine. + +7. **No render scenarios** — every test must have at least one of: `fireEvent`, `waitFor`/`findBy`, `store.dispatch`/`act`, or an Engine spy. Static visibility checks are not tests. See [`references/writing-tests.md`](references/writing-tests.md) for examples. + +8. **Use selector ID constants, never raw strings** — every `getByTestId` / `findByTestId` / `queryByTestId` must reference a constant from `ComponentName.testIds.ts`. Create the file if it does not exist. + +9. **Every view with async data needs one data-completeness test** — wait for the load and validate all significant fields of all items in the base mock using `within()` per row. One per independent async data flow. + +10. **Filter / segmentation tests must assert both sides** — after selecting a filter, assert both what appears (positive `findByTestId`) and what disappears (negative `queryByTestId(...).not.toBeOnTheScreen()`). + + +## Reference files (when to use) + +Documentation is split by **action**. Open only the reference that matches what you are doing. + +| Action | File | When to open it | +| ----------------------------------------------- | ---------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | +| **Writing or updating view tests** | [`references/writing-tests.md`](references/writing-tests.md) | New test file, new or updated preset/renderer. Read before writing, use cases and coverage, file structure, renderers, presets, route params. | +| **Testing navigation** | [`references/navigation-mocking.md`](references/navigation-mocking.md) | Route probes, single nav push, multi-screen renderer, cross-screen journey, external/API mocking. | +| **Running tests, self-review, fixing failures** | [`references/reference.md`](references/reference.md) | Run the Tests, Self-Review Checklist, Diagnosing Failures, assertion patterns, Deterministic Fiat Assertions, What NOT to Do, Quick Reference. | + +**Where self-review and What NOT to Do live:** Both are in `references/reference.md`. Self-review is the checklist you run after tests pass. What NOT to Do is the antipatterns section in the same file. Keeping them there means when you run tests or fix failures you have run commands, the checklist, the failure table, and the antipatterns in one place — open that reference for any run/fix/review task. diff --git a/domains/testing/skills/component-view-test/skill.md b/domains/testing/skills/component-view-test/skill.md new file mode 100644 index 0000000..213dc5c --- /dev/null +++ b/domains/testing/skills/component-view-test/skill.md @@ -0,0 +1,4 @@ +--- +name: component-view-test +description: Component view testing +--- diff --git a/domains/testing/skills/e2e-flakiness-patterns/repos/metamask-extension.md b/domains/testing/skills/e2e-flakiness-patterns/repos/metamask-extension.md new file mode 100644 index 0000000..795ee19 --- /dev/null +++ b/domains/testing/skills/e2e-flakiness-patterns/repos/metamask-extension.md @@ -0,0 +1,1092 @@ +--- +repo: metamask-extension +parent: e2e-flakiness-patterns +--- + + +Reference: [Extension CI Flakiness - Google Doc](https://docs.google.com/document/d/1oXd5d1X7j14lHLjaRCWjEh3uhndrXQ_46lBuZ9SAu6M/edit?tab=t.0) + +**See also:** + +- [E2E Testing Guidelines](../e2e-testing-guidelines/RULE.md) - General E2E test patterns and page object conventions +- [Unit Testing Guidelines](../unit-testing-guidelines/RULE.md) - Unit test patterns and Jest best practices + +# Extension CI Flakiness Patterns + +> **How this document relates to E2E Testing Guidelines:** +> The [E2E Testing Guidelines](../e2e-testing-guidelines/RULE.md) is a _prescriptive_ guide covering how to write good E2E tests (page objects, structure, waiting strategies, mocking conventions). This document is a _diagnostic_ reference — a catalog of specific real-world flakiness bugs encountered in CI, each with root cause analysis and concrete before/after fixes. Use the guidelines when **writing new tests**; use this document when **debugging or fixing flaky tests**. + + +## Table of Contents + +- [E2E Anti-Patterns Quick Reference](#e2e-anti-patterns-quick-reference) +- [E2E Flakiness Categories](#e2e-flakiness-categories) + - [Race Conditions on Driver/Helpers Functions](#race-conditions-on-driverhelpers-functions) + - [Taking Unnecessary Steps](#taking-unnecessary-steps) + - [Missing or Incorrect Use of Mocks](#missing-or-incorrect-use-of-mocks) + - [Removing URL/host entries to the live server allowlist](#removing-urlhost-entries-to-the-live-server-allowlist) + - [Race Conditions on Gas / Balance / Navigation values on Screen](#race-conditions-on-gas--balance--navigation-values-on-screen) + - [Confirmation Popups / Modals](#confirmation-popups--modals) + - [Incorrect Testing Conditions](#incorrect-testing-conditions) + - [Race Conditions with Assertions within the Test Body Steps](#race-conditions-with-assertions-within-the-test-body-steps) + - [Race Conditions with Windows](#race-conditions-with-windows) + - [Race Conditions with React Re-renders](#race-conditions-with-react-re-renders) + - [Actions that Take Time](#actions-that-take-time) + - [Errors in the testing dapp](#errors-in-the-testing-dapp) + - [Not using driver methods](#not-using-driver-methods) +- [Unit Test Flakiness Categories](#unit-test-flakiness-categories) +- [Flakiness on Other CI Jobs](#flakiness-on-other-ci-jobs) + + +## E2E Anti-Patterns Quick Reference + +These are the most critical anti-patterns to avoid. Each links to a detailed section with concrete examples below. + +1. **Asserting element values without waiting for them** — use `waitForSelector` with `text` instead of `findElement` + `getText` + `assert`. See [Race Conditions with Assertions](#race-conditions-with-assertions-within-the-test-body-steps). + +2. **Asserting `isDisplayed()` after finding an element** — the element can update between find and assert (e.g., tx status changes), throwing a stale element error. `waitForSelector` already guarantees the element is displayed. + +3. **Using `element.click()` instead of `driver.clickElement()`** — native `.click()` has no stale-element retry. Always use the driver wrapper. See [Not using driver methods](#not-using-driver-methods). + +4. **Going to live sites** instead of using mocks — tests should never depend on external services. See [Missing or Incorrect Use of Mocks](#missing-or-incorrect-use-of-mocks). + +5. **Adding `driver.delay()` instead of waiting for conditions** — use `waitForSelector`, `clickElementAndWaitToDisappear`, or `waitUntil` instead of arbitrary delays. See [Confirmation Popups / Modals](#confirmation-popups--modals). + +6. **Importing from another `.spec` file** — this causes that spec's tests to run too, leading to timeouts. Move shared functions to helper files. See [Actions that Take Time](#actions-that-take-time). + +7. **Looking for transient elements** (e.g., "Pending" status) — skip transient states and wait for the final state. See [Race Conditions with Assertions](#race-conditions-with-assertions-within-the-test-body-steps). + + +## E2E Flakiness Categories + +### Race Conditions on Driver/Helpers Functions + +- **Page object methods should use driver.clickElement() instead of raw element.click()** + ❌ Incorrect: + + ```javascript + async clickCloseButton(rawLocator) { + const element = await this.findClickableElement(rawLocator); + await element.click(); + } + ``` + + ✅ Correct: + + ```javascript + async clickCloseButton(rawLocator) { + await this.driver.clickElement(rawLocator); + } + ``` + +- **Always wait for the expected number of window handles before proceeding — asserting immediately can fail if windows haven't opened yet** + ❌ Incorrect: + + ```javascript + const currentWindowHandles = await driver.getAllWindowHandles(); + assert.equal(currentWindowHandles, 3); + ``` + + ✅ Correct: + + ```javascript + const expectedWindowHandles = 3; + await driver.waitUntilXWindowHandles(expectedWindowHandles); + ``` + +- **Window title can be undefined if the page hasn't loaded — use `switchToWindowWithTitle` which waits for the title** + ❌ Incorrect: + + ```javascript + const title = await driver.getTitle(); + assert.equal(title, 'MetaMask'); + ``` + + ✅ Correct: + + ```javascript + await driver.switchToWindowWithTitle('MetaMask'); + ``` + +- **Click the most specific child element, not the parent — parent elements with refreshing children cause stale element errors** + ❌ Incorrect: + + ```javascript + // Clicking a broad parent container — inner elements may refresh and cause stale errors + await this.driver.clickElement(this.tokenListItem); + ``` + + ✅ Correct: + + ```javascript + // Target the most specific child element using its selector + await this.driver.clickElement(this.tokenName); + ``` + +- **Don't assert element count immediately — elements may still be rendering. Wait for the expected element instead** + ❌ Incorrect: + ```javascript + const accounts = await driver.findElements(this.accountListItem); + assert.equal(accounts.length, 5); + ``` + ✅ Correct: + ```javascript + await accountListPage.checkNumberOfAvailableAccounts(5); + ``` + + +### Taking Unnecessary Steps + +> **General rule:** Use fixtures (`FixtureBuilderV2`) to set up test state (network, tokens, settings, accounts) instead of performing UI actions. This is faster, more reliable, and avoids race conditions during setup. Also avoid unnecessary browser refreshes, scrolls, and delays. + +- **Performing UI actions that can be set via fixtures (settings, network, tokens, contracts, etc.)** + ❌ Incorrect: + + ```javascript + await login(driver); + await headerNavbar.openSettingsMenu(); + await settingsPage.toggleShowNonce(); + await settingsPage.goBack(); + ``` + + ✅ Correct: + + ```javascript + fixtures: new FixtureBuilderV2() + .withPreferencesController({ useNonceField: true }) + .build(), + async ({ driver }) => { + await login(driver); + ``` + + This pattern applies broadly — use `.withSelectedNetwork(NETWORK_CLIENT_ID.MAINNET)` instead of switching network via UI, `withTokensControllerERC20()` instead of importing tokens through modals, and pre-deploy contracts via fixture (`smartContract: [...]`) instead of deploying through the dapp UI. + +- **Avoid unnecessary browser refreshes — they can cause unexpected navigation (e.g., landing on a Confirmation screen for unapproved transactions)** + ❌ Incorrect: + + ```javascript + await driver.switchToWindowWithTitle(WINDOW_TITLES.ExtensionInFullScreenView); + await driver.executeScript(`window.location.reload()`); + await driver.switchToWindowWithTitle(WINDOW_TITLES.ExtensionInFullScreenView); + ``` + + ✅ Correct: + + ```javascript + await driver.switchToWindowWithTitle(WINDOW_TITLES.ExtensionInFullScreenView); + await homePage.goToActivityList(); + ``` + +- **No hardcoded delays — use wait-for-conditions instead** + ❌ Incorrect: + + ```javascript + await driver.delay(5000); + await driver.clickElement(selector); + ``` + + ✅ Correct: + + ```javascript + await driver.waitForSelector(selector); + await driver.clickElement(selector); + ``` + +- **No `scrollToElement` — use `clickElement`, as scrolling is already implicit** + ❌ Incorrect: + ```javascript + await driver.scrollToElement(selector); + await driver.clickElement(selector); + ``` + ✅ Correct: + ```javascript + await driver.clickElement(selector); + ``` + + +### Missing or Incorrect Use of Mocks + +- **Mock eth_balance with 0 on Mainnet — a non-zero balance triggers polling for subsequent accounts, blocking other requests** + ❌ Incorrect: + + ```javascript + json: { + jsonrpc: '2.0', + id: '1111111111111111', + result: '0x1', // Non-zero balance triggers unexpected polling + }, + ``` + + ✅ Correct: + + ```javascript + json: { + jsonrpc: '2.0', + id: '1111111111111111', + result: '0x0', // Zero balance — correct for fresh onboarding wallet + }, + ``` + +- **Ensure mock values match expected defaults — mismatches cause race conditions where test outcome depends on polling timing** + ❌ Incorrect: + + ```javascript + // Mock returns stale/wrong value that conflicts with default state + .forGet('/v2/chains') + .thenJson(200, { chains: [{ chainId: '0x1', isActive: false }] }); + ``` + + ✅ Correct: + + ```javascript + // Mock must match what the app expects — mismatches cause race conditions + .forGet('/v2/chains') + .thenJson(200, { chains: [{ chainId: '0x1', isActive: true }] }); + ``` + +- **Don't include dynamic fields (like `id`) in mock request matching — they change per request and prevent the mock from matching** + ❌ Incorrect: + + ```javascript + export const MALFORMED_TRANSACTION_REQUEST_MOCK = { + request: { + id: '21', // Including 'id' causes exact-match to fail since id is dynamic + jsonrpc: '2.0', + method: 'infura_simulateTransactions', + params: [ + /* ... */ + ], + }, + }; + + await server + .forPost(TX_SENTINEL_URL) + .withJsonBody(request) + .thenJson(200, response); + ``` + + ✅ Correct: + + ```javascript + export const MALFORMED_TRANSACTION_REQUEST_MOCK = { + request: { + // 'id' removed — it's dynamic and should not be part of the match + jsonrpc: '2.0', + method: 'infura_simulateTransactions', + params: [ + /* ... */ + ], + }, + }; + + await server + .forPost(TX_SENTINEL_URL) + .withJsonBodyIncluding(request) + .thenJson(200, response); + ``` + +- **Use hex chainId format in mock URLs — passing integer chainId causes the mock to not match, failing validation and assertions** + ❌ Incorrect: + + ```javascript + // chainId passed as integer — URL becomes /validate/1 + await server + .forPost(`${SECURITY_ALERTS_PROD_API_BASE_URL}/validate/${chainId}`) + .thenCallback(() => ({ statusCode: 200 })); + ``` + + ✅ Correct: + + ```javascript + // chainId must be hex string — URL becomes /validate/0x1 + await server + .forPost( + `${SECURITY_ALERTS_PROD_API_BASE_URL}/validate/0x${chainId.toString(16)}`, + ) + .thenCallback(() => ({ statusCode: 200 })); + ``` + +- **Register default mocks before custom mocks to avoid default mocks overriding custom ones** + ❌ Incorrect: + ```javascript + // Custom mock registered BEFORE the default mock — default overwrites it + await server + .forPost(SOLANA_URL) + .thenJson(200, { result: { value: 500000000 } }); + await setupDefaultMocks(server); // includes its own Solana mock + ``` + ✅ Correct: + ```javascript + // Register default mocks FIRST, then override with custom mock + await setupDefaultMocks(server); + await server + .forPost(SOLANA_URL) + .thenJson(200, { result: { value: 500000000 } }); + ``` + + +### Removing URL/host entries to the live server allowlist + +Tests should never depend on live servers. Remove live server URLs from the allowlist and add corresponding mocks instead. + +❌ Incorrect: + +```javascript +// Allowlisting a live URL so the test can reach it +const LIVE_SERVER_ALLOWLIST = [ + 'https://token.api.cx.metamask.io', + 'https://gas.api.cx.metamask.io', +]; +``` + +✅ Correct: + +```javascript +// Remove from allowlist and add a mock instead +await server + .forGet('https://token.api.cx.metamask.io/tokens/1') + .thenJson(200, MOCK_TOKEN_LIST); +``` + + +### Race Conditions on Gas / Balance / Navigation values on Screen + +- **Wait for balance to load before starting the Send flow — if balance is not loaded, gas is calculated as 0 and the Confirmation screen is blocked** + ❌ Incorrect: + + ```javascript + async ({ driver, contractRegistry }) => { + const contractAddress = await contractRegistry.getContractAddress(smartContract); + await login(driver); + await homePage.startSendFlow(); + ``` + + ✅ Correct: + + ```javascript + async ({ driver, contractRegistry, localNodes }) => { + const contractAddress = await contractRegistry.getContractAddress(smartContract); + await login(driver, { localNode: localNodes[0] }); + await homePage.checkPageIsLoaded(); + await homePage.startSendFlow(); + ``` + +- **Whenever the token allowance amount changes, gas is recalculated — wait for the new gas value before proceeding to avoid race conditions causing a failed transaction** + ❌ Incorrect: + + ```javascript + driver.waitForSelector({ + css: this.tokenAllowanceAmount, + text: `10 TST`, + }); + ``` + + ✅ Correct: + + ```javascript + await confirmationPage.checkTokenAllowanceAmount('10 TST'); + await confirmationPage.checkGasFeeIsDisplayed(); + ``` + +- **Wait for network data to fully load before running assertions — network properties (isActive, EIP1559 support, etc.) may not be available immediately after login** + ❌ Incorrect: + + ```javascript + async ({ driver, mockedEndpoint }) => { + await driver.navigate(); + await driver.findElement(this.passwordInput); + await driver.executeScript( + ``` + + ✅ Correct: + + ```javascript + async ({ driver, localNodes, mockedEndpoint }) => { + await login(driver, { localNode: localNodes[0] }); + await driver.executeScript( + ``` + +- **Wait for gas to recalculate after switching assets in the Send flow — clicking Continue before gas updates causes transaction failures** + ❌ Incorrect: + + ```javascript + await sendPage.selectAsset('TST'); + await sendPage.fillAmount('10'); + await sendPage.clickContinue(); // gas may still be for the previous asset + ``` + + ✅ Correct: + + ```javascript + await sendPage.selectAsset('TST'); + await sendPage.fillAmount('10'); + await sendPage.checkGasFeeIsLoaded(); + await sendPage.clickContinue(); + ``` + +- **Wait for the transaction total value to load before clicking reject — otherwise the action may fail or apply to incomplete data** + ❌ Incorrect: + + ```javascript + async ({ driver }) => { + await login(driver); + await confirmationPage.clickNextPage(); + ``` + + ✅ Correct: + + ```javascript + async ({ driver }) => { + await login(driver); + await confirmationPage.checkTotalAmountIsLoaded(); + await confirmationPage.clickNextPage(); + ``` + +- **Wait for the signature navigation count to render before queueing the next signature — triggering a new signature before the UI updates causes a race condition** + ❌ Incorrect: + + ```javascript + await switchToDialogAndCheckRejectAll(driver); + + await switchToTestDapp(driver); + await testDapp.clickSignTypedDataV4(); + await switchToDialog(driver); + ``` + + ✅ Correct: + + ```javascript + await switchToDialogAndCheckRejectAll(driver); + await confirmationPage.checkNavigationCount('1 of 2'); + + await switchToTestDapp(driver); + await testDapp.clickSignTypedDataV4(); + await switchToDialog(driver); + await confirmationPage.checkNavigationCount('1 of 3'); + ``` + + +### Confirmation Popups / Modals + +> **General rule:** When clicking a button/modal that should disappear afterward, always use `clickElementAndWaitToDisappear` instead of `clickElement`. The disappearing element can block subsequent clicks if it's still in the DOM. This applies to confirmation popups, "Got it" buttons, import modals, onboarding screens, and any overlay that closes after interaction. + +- **Popup/modal obfuscates subsequent elements (common pattern — applies to "Got it" buttons, import modals, add account popups, onboarding screens, etc.)** + ❌ Incorrect: + + ```javascript + await homePage.clickGotItButton(); + await headerNavbar.openThreeDotMenu(); // popup may still be on top + ``` + + ✅ Correct: + + ```javascript + await homePage.clickGotItButtonAndWaitToDisappear(); + await headerNavbar.openThreeDotMenu(); + ``` + +- **Multi-step flows with disappearing screens (e.g., onboarding carousel)** + ❌ Incorrect: + + ```javascript + await onboardingPage.clickManageDefaultSettings(); + await driver.delay(regularDelayMs); + await onboardingPage.clickGeneral(); + ``` + + ✅ Correct: + + ```javascript + await onboardingPage.clickManageDefaultSettingsAndWaitToDisappear(); + await onboardingPage.clickGeneral(); + ``` + +- **Wait for the connect dialog to close before proceeding — acting before the dialog closes can leave the chainId in an outdated state** + ❌ Incorrect: + + ```javascript + await connectPage.clickConnect(); + + await driver.waitUntilXWindowHandles(4); + await driver.switchToWindowWithTitle(WINDOW_TITLES.Dialog); + ``` + + ✅ Correct: + + ```javascript + await connectPage.clickConnectAndWaitForWindowToClose(); + + await testDapp.check_chainId('0x539'); + ``` + +- **The notification (red dot) appears on top of the menu, blocking clicks on the menu button** + ❌ Incorrect: + + ```javascript + await driver.waitForSelector(this.threeDotMenuButton); + await driver.clickElement(this.threeDotMenuButton); + ``` + + ✅ Correct: + + ```javascript + // Click the notification dot overlay instead of the obscured button + await driver.clickElement(this.notificationDotWrapper); + ``` + +- **When changing language, sometimes the dropdown menu remains open, causing the next click to have no effect** + ❌ Incorrect: + ```javascript + async selectLanguage(languageToSelect: string) { + await this.driver.clickElement(this.selectLanguageField); + await this.driver.clickElement({ text: languageToSelect, tag: 'option' }); + await this.check_noLoadingOverlaySpinner(); + } + ``` + ✅ Correct: + ```javascript + async selectLanguage(languageToSelect: string) { + // Use sendKeys to avoid dropdown staying open after selection + const dropdown = await this.driver.findElement(this.selectLanguageField); + await dropdown.sendKeys(languageToSelect); + await this.check_noLoadingOverlaySpinner(); + } + ``` + + +### Incorrect Testing Conditions + +- **MV3 builds use a Service Worker instead of a background page — navigate to the correct page based on the manifest version** + ❌ Incorrect: + ```javascript + it('the UI and background environments are locked down', async function () { + await driver.navigate(PAGES.BACKGROUND); + await driver.delay(1000); + assert.equal( + await driver.executeScript(lockdownTestScript), + true, + 'The background environment should be locked down.', + ); + }); + ``` + ✅ Correct: + ```javascript + it('the background environment is locked down', async function () { + if (process.env.ENABLE_MV3 === 'false') { + await driver.navigate(PAGES.BACKGROUND); + } else { + // MV3 uses a Service Worker — no background page + await driver.navigate(PAGES.OFFSCREEN); + } + await driver.waitUntil( + async () => + await driver.executeScript('return document.readyState === "complete"'), + { timeout: 5000 }, + ); + assert.equal( + await driver.executeScript(lockdownTestScript), + true, + 'The background environment should be locked down.', + ); + }); + ``` + + +### Race Conditions with Assertions within the Test Body Steps + +> **General rule:** Never use `findElement` + `getText`/`isDisplayed`/`isEnabled` + `assert`. The element can update or re-render between the find and the assertion, causing a stale element error or a false negative. Instead, use `waitForSelector` with `text` to atomically wait for the desired state. This single principle covers most assertion-related flakiness. + +- **Don't assert text after findElement — wait for the text with waitForSelector** + ❌ Incorrect: + + ```javascript + const permissionElement = await driver.findElement(this.permissionLabel); + assert.equal(await permissionElement.getText(), 'View your accounts'); + ``` + + ✅ Correct: + + ```javascript + await driver.waitForSelector({ + css: this.permissionLabel, + text: 'View your accounts', + }); + ``` + +- **Don't assert isDisplayed/isEnabled after waitForSelector — the wait already guarantees it** + ❌ Incorrect: + + ```javascript + const buySellButton = await driver.waitForSelector(this.buySellButton); + assert.equal(await buySellButton.isEnabled(), true); + ``` + + ✅ Correct: + + ```javascript + await driver.findClickableElement(this.buySellButton); + ``` + +- **Rapid input can trigger validation errors from partial values — wait for validation to complete before asserting** + ❌ Incorrect: + + ```javascript + await driver.fill(this.chainIdInput, '10'); + // Error message from partial input ('1') persists even after full value is entered + ``` + + ✅ Correct: + + ```javascript + await driver.fill(this.chainIdInput, '10'); + // Wait for validation to complete before asserting + await driver.waitForSelector({ + css: this.chainIdInput, + text: '10', + }); + await driver.assertElementNotPresent(this.formErrorMessage); + ``` + +- **Don't assert transient states — skip to the final state** + ❌ Incorrect: + + ```javascript + await swapSendPage.submitSwap(); + await swapSendPage.verifyHistoryEntry('Send ETH as TST', 'Pending', '-1 ETH', ''); + await swapSendPage.verifyHistoryEntry('Send ETH as TST', 'Confirmed', ...); + ``` + + ✅ Correct: + + ```javascript + await swapSendPage.submitSwap(); + // Skip 'Pending' — it's transient. Wait directly for 'Confirmed'. + await swapSendPage.verifyHistoryEntry('Send ETH as TST', 'Confirmed', ...); + ``` + +- **Don't assert getCurrentUrl — use waitForUrl instead** + ❌ Incorrect: + ```javascript + await phishingPage.clickReportDetectionProblem(); + const emptyPage = await driver.findElement(this.emptyPageBody); + assert.equal( + await emptyPage.getText(), + `Empty page by ${BlockProvider.PhishFort}`, + ); + assert.equal( + await driver.getCurrentUrl(), + `https://github.com/phishfort/phishfort-lists/issues/new?title=...`, + ); + ``` + ✅ Correct: + ```javascript + await phishingPage.clickReportDetectionProblem(); + await phishingPage.waitForReportUrl({ + url: `https://github.com/phishfort/phishfort-lists/issues/new?title=...`, + }); + ``` + + +### Race Conditions with Windows + +- **Production builds auto-open a MetaMask window — calling `driver.navigate()` creates a duplicate, causing driver actions to target the wrong window** + ❌ Incorrect: + + ```javascript + await driver.navigate(); + await driver.switchToWindowWithTitle('MetaMask'); + ``` + + ✅ Correct: + + ```javascript + // MM auto-opens a window in prod build — don't call navigate() + await driver.waitUntilXWindowHandles(2); + await driver.switchToWindowWithTitle('MetaMask'); + ``` + +- **Re-fetch window handles before switching — handles saved earlier may be stale if windows were opened or closed since** + ❌ Incorrect: + + ```javascript + const windowHandles = await driver.getAllWindowHandles(); + // ... many steps later ... + await driver.switchToWindow(windowHandles[1]); // handle may be stale + ``` + + ✅ Correct: + + ```javascript + // Re-fetch window handles right before switching + const windowHandles = await driver.getAllWindowHandles(); + await driver.switchToWindow(windowHandles[1]); + ``` + +- **Wait for the popup window to close after clicking a dismissing button — switching windows before the popup closes causes race conditions** + ❌ Incorrect: + + ```javascript + await confirmationPage.clickConfirm(); + // immediately try to switch windows + ``` + + ✅ Correct: + + ```javascript + await confirmationPage.clickConfirmAndWaitForWindowToClose(); + // window is confirmed closed, safe to switch + ``` + +- **Wait for the dialog to appear before switching dapps — switching too fast can cause the transaction to use the wrong dapp's network** + ❌ Incorrect: + + ```javascript + await driver.switchToWindowWithTitle(WINDOW_TITLES.TestDApp); + await testDapp.clickSendButton(); + await driver.switchToWindowWithUrl(secondDappUrl); // switches too fast + ``` + + ✅ Correct: + + ```javascript + await driver.switchToWindowWithTitle(WINDOW_TITLES.TestDApp); + await testDapp.clickSendButton(); + await driver.waitUntilXWindowHandles(4); // wait for dialog to appear first + await driver.switchToWindowWithUrl(secondDappUrl); + ``` + +- **Snap cronjob dialogs can auto-close — handle the case where the window is no longer available when trying to interact with it** + ❌ Incorrect: + + ```javascript + await driver.switchToWindowWithTitle(WINDOW_TITLES.Dialog); + await confirmationPage.clickApprove(); + ``` + + ✅ Correct: + + ```javascript + try { + await driver.switchToWindowWithTitle(WINDOW_TITLES.Dialog); + await confirmationPage.clickApprove(); + } catch { + // Dialog may have auto-closed — verify from the extension window instead + await driver.switchToWindowWithTitle( + WINDOW_TITLES.ExtensionInFullScreenView, + ); + } + ``` + +- **Wait for the dialog to close before performing the next action — in request queuing, acting too fast causes race conditions** + ❌ Incorrect: + ```javascript + await connectPage.clickConnect(); + await driver.delay(regularDelayMs); + await driver.waitUntilXWindowHandles(3); + await driver.switchToWindowWithTitle(WINDOW_TITLES.Dialog); + ``` + ✅ Correct: + ```javascript + await connectPage.clickConnectAndWaitForWindowToClose(); + await driver.switchToWindowWithTitle(WINDOW_TITLES.Dialog); + ``` + + +### Race Conditions with React Re-renders + +- **Wait for re-renders to settle before clicking — interacting with elements during a loading state (e.g., after changing language) has no effect** + ❌ Incorrect: + + ```javascript + await driver.waitForSelector(this.threeDotMenuButton); + await driver.clickElement(this.threeDotMenuButton); + ``` + + ✅ Correct: + + ```javascript + // Wait for re-renders to settle before clicking + await driver.assertElementNotPresent(this.loadingOverlay); + await driver.clickElement(this.threeDotMenuButton); + ``` + +- **Wait for dynamic content to load before interacting with nearby elements — re-renders can reset element state (e.g., unchecking a checkbox)** + ❌ Incorrect: + + ```javascript + await driver.clickElement(this.checkboxWrapper); + ``` + + ✅ Correct: + + ```javascript + // Wait for host to render before clicking — re-render resets checkbox state + await snapInsightsPage.checkHostIsDisplayed(); + await snapInsightsPage.clickCheckbox(); + ``` + +- **The Add account modal needs to finish rendering the account list before proceeding with a click action — otherwise the re-render causes the click to be performed outside the popup, closing the modal** + ❌ Incorrect: + + ```javascript + await headerNavbar.openAccountMenu(); + await driver.clickElement(this.addAccountButton); + ``` + + ✅ Correct: + + ```javascript + await headerNavbar.openAccountMenu(); + // Wait until account list is loaded to avoid re-render race condition + await accountListPage.checkAccountIsDisplayed('Account 1'); + await accountListPage.clickAddAccountButton(); + ``` + +- **Wait for animated elements to stop moving before clicking — clicks on moving elements have no effect** + ❌ Incorrect: + + ```javascript + await driver.clickElement(this.categoryBackButton); + await driver.delay(regularDelayMs); + await driver.clickElement(this.privacySettingsBackButton); + ``` + + ✅ Correct: + + ```javascript + await driver.clickElement(this.categoryBackButton); + // Wait until carousel stops animating — clicking while moving has no effect + await driver.waitForElementToStopMoving(this.privacySettingsBackButton); + await driver.clickElement(this.privacySettingsBackButton); + ``` + +- **Use `driver.clickElement()` instead of `element.click()` for elements that may re-render — native `.click()` on a stale element throws an error** + ❌ Incorrect: + ```javascript + const dots = await driver.findElements(this.carouselDot); + await dots[i].click(); + ``` + ✅ Correct: + ```javascript + await carouselPage.clickSlideItem(i); + ``` + + +### Actions that Take Time + +> **General rule:** When an action takes time (API calls, file I/O, state updates, extension restarts), wait for the expected outcome using `driver.wait`, `waitUntil`, or `waitForSelector` instead of assuming it's complete and custom timeouts. + +- **State not ready before acting (chain id, balance, account creation, cookie id, etc.) — wait for the expected state instead of acting immediately** + ❌ Incorrect: + + ```javascript + const homePage = new HomePage(driver); + await homePage.startBridgeFlow(); // chain id / balance may not be loaded yet + ``` + + ✅ Correct: + + ```javascript + await login(driver); + const homePage = new HomePage(driver); + await homePage.startBridgeFlow(); + ``` + +- **Wait between sequential actions that trigger metrics events — firing too fast can cause events to arrive out of order** + ❌ Incorrect: + + ```javascript + await testDapp.clickActionOne(); + await testDapp.clickActionTwo(); + // event for action-two may arrive before action-one + const events = await mockedEndpoint.getSeenRequests(); + assert.equal(events[0].body.event, 'Action One'); + ``` + + ✅ Correct: + + ```javascript + await testDapp.clickActionOne(); + await driver.wait(async () => { + const events = await mockedEndpoint.getSeenRequests(); + return events.length >= 1; + }, 5000); + await testDapp.clickActionTwo(); + ``` + +- **Importing a function from another spec file causes the tests from that spec file to also be run, causing long test runs and possible timeouts** + ❌ Incorrect: + + ```javascript + import { mockedSourcifyTokenSend } from '../confirmations/transactions/erc20-token-send-redesign.spec'; + ``` + + ✅ Correct: + + ```javascript + // Moved shared function to a non-spec helpers file + import { mockedSourcifyTokenSend } from '../confirmations/helpers'; + ``` + +- **On the Swap page with a default token, adding an amount triggers quotes. Changing to a custom token before quotes finalize can load quotes for the previous token swap** + ❌ Incorrect: + + ```javascript + await swapPage.enterAmount('1'); + await swapPage.selectDestinationToken('TST'); // quotes for default token may still be loading + ``` + + ✅ Correct: + + ```javascript + await swapPage.selectDestinationToken('TST'); // select token BEFORE entering amount + await swapPage.enterAmount('1'); + await swapPage.waitForQuotesLoaded(); + ``` + +- **Wait for the extension to fully restart after reloading — interacting before it's ready causes failures** + ❌ Incorrect: + + ```javascript + await driver.executeScript('chrome.runtime.reload()'); + await driver.navigate(); // extension may not be ready + ``` + + ✅ Correct: + + ```javascript + await driver.executeScript('chrome.runtime.reload()'); + await driver.waitUntilXWindowHandles(1); + await driver.navigate(); + await driver.waitForSelector(this.passwordInput); + ``` + +- **Background API requests (auth, profile sync) may not complete before the next action — wait for the request or ignore the benign error** + ❌ Incorrect: + + ```javascript + await login(driver, { localNode: localNodes[0] }); + const homePage = new HomePage(driver); + await homePage.headerNavbar.check_pageIsLoaded(); + await driver.delay(5000); + await homePage.headerNavbar.lockMetaMask(); + ``` + + ✅ Correct (wait for the request): + + ```javascript + await login(driver, { localNode: localNodes[0] }); + await driver.waitUntil( + async () => { + const requests = await mockedEndpoint.getSeenRequests(); + return requests.length > 0; + }, + { interval: 200, timeout: 10000 }, + ); + const homePage = new HomePage(driver); + await homePage.headerNavbar.check_pageIsLoaded(); + await homePage.headerNavbar.lockMetaMask(); + ``` + + ⚠️ As a temporary CI unblock, you can ignore the error — but always open a bug to fix the underlying issue: + + ```javascript + ignoredConsoleErrors: ['unable to proceed, wallet is locked'], + ``` + +- **Triggering several transactions from different dapps without waiting individually can cause transactions to appear in a different order** + ❌ Incorrect: + ```javascript + await dapp1.clickSendButton(); + await dapp2.clickSendButton(); + await dapp3.clickSendButton(); + // Transaction order in activity list may not match submission order + ``` + ✅ Correct: + ```javascript + await dapp1.clickSendButton(); + await driver.waitUntilXWindowHandles(4); + await dapp2.clickSendButton(); + await driver.waitUntilXWindowHandles(5); + await dapp3.clickSendButton(); + ``` + + +### Errors in the testing dapp + +- **Event listeners may not be attached immediately after page load — retry clicks until the expected navigation occurs** + ❌ Incorrect: + ```javascript + await driver.navigate(PHISHING_PAGE_URL); + await driver.clickElement(this.proceedLink); // event listener not yet attached + ``` + ✅ Correct: + ```javascript + await driver.navigate(PHISHING_PAGE_URL); + await driver.waitForSelector(this.proceedLink); + // Retry clicking until the event listener is attached and the navigation succeeds + await driver.waitUntil( + async () => { + await driver.clickElement(this.proceedLink); + const currentUrl = await driver.getCurrentUrl(); + return !currentUrl.includes('phishing'); + }, + { timeout: 5000 }, + ); + ``` + + +### Not using driver methods + +- **Using `element.click()` instead of `clickElement()` can cause race conditions when the element is present but not clickable. The driver function has appropriate guards in place** + ❌ Incorrect: + + ```javascript + const [, tst] = await driver.findElements(this.tokenListButton); + await tst.click(); + + // ... + const body = await driver.findElement(this.emptyPageBody); + assert.equal(await body.getText(), 'Empty page by MetaMask'); + assert.equal( + await driver.getCurrentUrl(), + 'https://etherscan.io/address/0x5CfE73b6021E818B776b421B1c4Db2474086a7e1', + ); + ``` + + ✅ Correct: + + ```javascript + await tokenListPage.clickToken('TST'); + + // ... + await tokenDetailsPage.waitForBlockExplorerUrl( + 'https://etherscan.io/address/0x5CfE73b6021E818B776b421B1c4Db2474086a7e1', + ); + await tokenDetailsPage.checkEmptyPageIsDisplayed('Empty page by MetaMask'); + ``` + + +## Unit Test Flakiness Categories + +- **Ensure all required store properties are explicitly defined in mock state — undefined properties cause intermittent test failures** + ❌ Incorrect: + ```javascript + const store = configureMockStore(middleware)(state); + ``` + ✅ Correct: + ```javascript + const testStore = { + DNS: domainInitialState, + metamask: state.metamask, + snaps: {}, + }; + const store = configureMockStore(middleware)(testStore); + ``` + + +## Flakiness on Other CI Jobs + +- **The lint-lockfile job is flaky as it's under-resourced** — fixed by changing resources from medium to medium-plus + +- **Rate limited by yarnpkg returning 429 Too Many Requests** — makes any job dependent on yarn fail. Solution: add retry logic or cache yarn packages more aggressively. diff --git a/domains/testing/skills/e2e-flakiness-patterns/skill.md b/domains/testing/skills/e2e-flakiness-patterns/skill.md new file mode 100644 index 0000000..4adcae2 --- /dev/null +++ b/domains/testing/skills/e2e-flakiness-patterns/skill.md @@ -0,0 +1,4 @@ +--- +name: e2e-flakiness-patterns +description: Known E2E flakiness patterns and anti-patterns +--- diff --git a/domains/testing/skills/e2e-test/references/mocking.md b/domains/testing/skills/e2e-test/references/mocking.md new file mode 100644 index 0000000..6c10e3a --- /dev/null +++ b/domains/testing/skills/e2e-test/references/mocking.md @@ -0,0 +1,143 @@ +# API & Feature Flag Mocking — Reference + +## How Mocking Works + +All E2E tests run with a proxy mock server. Requests not matched by a mock reach the real network, but the test framework warns you (and will soon enforce this). Always mock external APIs your feature calls. + +## testSpecificMock Pattern + +Pass to `withFixtures` to apply mocks only for that test: + +```typescript +import { Mockttp } from 'mockttp'; +import { setupRemoteFeatureFlagsMock } from '../../api-mocking/helpers/remoteFeatureFlagsHelper'; +import { setupMockRequest } from '../../api-mocking/mockHelpers'; + +const testSpecificMock = async (mockServer: Mockttp) => { + // Feature flags + await setupRemoteFeatureFlagsMock(mockServer, { myFeatureEnabled: true }); + + // GET request + await setupMockRequest(mockServer, { + requestMethod: 'GET', + url: 'https://api.example.com/data', + response: { items: [] }, + responseCode: 200, + }); +}; + +await withFixtures({ fixture: ..., testSpecificMock }, async () => { ... }); +``` + +## Feature Flag Mocking + +```typescript +import { setupRemoteFeatureFlagsMock } from '../../api-mocking/helpers/remoteFeatureFlagsHelper'; + +// Simple boolean flags +await setupRemoteFeatureFlagsMock(mockServer, { + predictTradingEnabled: true, + carouselBanners: false, +}); + +// Nested flags +await setupRemoteFeatureFlagsMock(mockServer, { + bridgeConfig: { support: true, refreshRate: 5000 }, +}); + +// Flask distribution +await setupRemoteFeatureFlagsMock(mockServer, { perpsEnabled: true }, 'flask'); + +// Combine predefined configs +import { confirmationsRedesignedFeatureFlags } from '../../api-mocking/mock-responses/feature-flags-mocks'; +await setupRemoteFeatureFlagsMock( + mockServer, + Object.assign({}, ...confirmationsRedesignedFeatureFlags, { myFlag: true }), +); +``` + +## HTTP Request Mocking + +```typescript +import { + setupMockRequest, + setupMockPostRequest, +} from '../../api-mocking/mockHelpers'; + +// GET +await setupMockRequest(mockServer, { + requestMethod: 'GET', + url: 'https://api.example.com/resource', + response: { data: [] }, + responseCode: 200, +}); + +// POST with body validation +await setupMockPostRequest( + mockServer, + 'https://api.example.com/submit', + { amount: '1000000000000000000' }, // expected request body + { success: true }, // response + { statusCode: 201, ignoreFields: ['timestamp', 'nonce'] }, +); +``` + +## Shared Mock Response Files + +For mocks used across multiple tests, create a file in `tests/api-mocking/mock-responses/`: + +```typescript +// tests/api-mocking/mock-responses/predict-mocks.ts +import { MockApiEndpoint } from '../framework/types'; + +export const PREDICT_MOCKS = { + GET: [ + { + urlEndpoint: 'https://predict.api.metamask.io/markets', + responseCode: 200, + response: { markets: [{ id: 'btc-usd', name: 'BTC above $100k?' }] }, + }, + ] as MockApiEndpoint[], +}; +``` + +Then pass directly to `withFixtures`: + +```typescript +await withFixtures({ fixture: ..., testSpecificMock: PREDICT_MOCKS }, async () => { ... }); +``` + +## Controller-Level Mocking (Advanced) + +Only needed when the feature uses SDKs with complex transport (WebSockets, custom protocols) that can't be intercepted at the HTTP level. + +- Implement a mixin in `tests/controller-mocking/mock-config/` +- See `tests/docs/CONTROLLER_MOCKING.md` for details +- Prefer HTTP-level mocking whenever possible + +## Debugging Unmocked Requests + +Check test output for warnings like: + +``` +⚠️ Unmocked request: GET https://api.example.com/resource +``` + +Add a mock for every such request to ensure test determinism. + +## Features using WebSockets or complex transport + +Some features depend on **WebSockets** or other non-HTTP transport (e.g. Perps/HyperLiquid, real-time data). The HTTP mock server cannot intercept these. The repo uses three patterns: + +1. **WebSocket mocking** — A `LocalWebSocketServer` (`tests/websocket/server.ts`) intercepts production WebSocket connections via URL rewriting in the E2E shim. Protocol-specific mocks handle subscribe/unsubscribe and push notifications. See **`tests/docs/WEBSOCKET_MOCKING.md`** for full usage guide and how to add new services. Example: `tests/websocket/account-activity-mocks.ts` for AccountActivity. +2. **Controller-level mocking** — A mixin under `tests/controller-mocking/mock-config/` replaces provider SDK touchpoints so E2E runs with stable, test-controlled data. Example: `perps-controller-mixin.ts` for HyperLiquid. See **`tests/docs/CONTROLLER_MOCKING.md`** for when and how to use it. +3. **Command queue / test server** — Tests that need to drive the app (e.g. inject state or commands) can use **`CommandQueueServer`** (`tests/framework/fixtures/CommandQueueServer.ts`). Enable it in the fixture with `useCommandQueueServer: true`. Used by Perps specs (e.g. `tests/smoke/perps/perps-add-funds.spec.ts`, `tests/regression/perps/perps-limit-long-fill.spec.ts`). The app consumes the queue in E2E context. + +**When adding support for a new feature that uses WebSockets or similar:** + +- For WebSocket services: add a service config in `tests/websocket/constants.ts` and a protocol mock. See `tests/docs/WEBSOCKET_MOCKING.md`. +- For SDK-level mocking: implement under `tests/controller-mocking/mock-config/`. +- For test-driven commands: extend the command-queue protocol as needed. +- Add or update **tests/specs** that cover the mock infrastructure and the E2E flow. + +Prefer HTTP mocking whenever the feature’s API is plain HTTP; use WebSocket mocking for WS connections; use controller mocking or the command server only when necessary. diff --git a/domains/testing/skills/e2e-test/references/page-objects.md b/domains/testing/skills/e2e-test/references/page-objects.md new file mode 100644 index 0000000..1efa575 --- /dev/null +++ b/domains/testing/skills/e2e-test/references/page-objects.md @@ -0,0 +1,189 @@ +# Page Objects & Selectors — Reference + +## Page Object Location + +``` +tests/page-objects/ +└── / + ├── MyFeatureView.ts ← main screen PO + ├── MyFeatureList.ts ← list/modal PO + └── MyFeatureDetailsView.ts ← detail screen PO +``` + +One class per screen or significant UI component. + +## Full Page Object Template + +```typescript +// tests/page-objects/Predict/PredictMarketList.ts +import Matchers from '../../framework/Matchers'; +import Gestures from '../../framework/Gestures'; +import Assertions from '../../framework/Assertions'; +import { Utilities } from '../../framework'; +import { PredictMarketListSelectorsIDs } from '../../../app/components/UI/Predict/PredictMarketList.testIds'; + +class PredictMarketList { + // --- Getters (element references, never interact directly in spec) --- + + get container() { + return Matchers.getElementByID(PredictMarketListSelectorsIDs.CONTAINER); + } + + get searchInput() { + return Matchers.getElementByID(PredictMarketListSelectorsIDs.SEARCH_INPUT); + } + + get firstCard() { + return Matchers.getElementByID(PredictMarketListSelectorsIDs.FIRST_CARD); + } + + // --- Action methods --- + + async tapFirstCard(): Promise { + await Gestures.tap(this.firstCard, { + description: 'tap first market card', + }); + } + + async typeSearchQuery(query: string): Promise { + await Gestures.typeText(this.searchInput, query, { + description: `type search query: ${query}`, + }); + } + + // --- Assertion methods --- + + async expectContainerVisible(): Promise { + await Assertions.expectElementToBeVisible(this.container, { + description: 'market list container should be visible', + }); + } + + async expectContainerNotVisible(): Promise { + await Assertions.expectElementToNotBeVisible(this.container, { + description: 'market list container should not be visible', + }); + } + + // --- Retry pattern for flaky interactions --- + + async tapFirstCardWithRetry(): Promise { + await Utilities.executeWithRetry( + async () => { + await Gestures.tap(this.firstCard, { + timeout: 2000, + description: 'tap first card', + }); + await Assertions.expectElementToBeVisible(this.firstCard, { + timeout: 2000, + description: 'first card visible', + }); + }, + { timeout: 30000, description: 'tap first card and verify' }, + ); + } +} + +export default new PredictMarketList(); +``` + +## Selector / TestId Conventions + +### Preferred: Co-locate with component + +```typescript +// app/components/UI/Predict/PredictMarketList.testIds.ts +export const PredictMarketListSelectorsIDs = { + CONTAINER: 'predict-market-list-container', + SEARCH_INPUT: 'predict-market-list-search-input', + FIRST_CARD: 'predict-market-list-first-card', + CARD_TITLE: 'predict-market-list-card-title', +} as const; +``` + +Then use in the component: + +```tsx + +``` + +### Legacy: under tests/selectors/ + +```typescript +// tests/selectors/Predict/PredictMarketList.selectors.ts +export const PredictMarketListSelectorsIDs = { + CONTAINER: 'predict-market-list-container', +} as const; +``` + +Use co-location for all new code. + +### Prefer testID on the component; text/label only when needed + +**Prefer adding a `testID` to the component** so the Page Object can use `Matchers.getElementByID()`. Add the `testID` in the app component and define the constant in the co-located `*.testIds.ts` (e.g. `PredictBalanceSelectorsIDs.WITHDRAW_BUTTON`). This keeps selectors stable and independent of copy/locale. + +Use **text** (`getElementByText`) or **label** (`getElementByLabel`) selectors only when adding a testID is not feasible (e.g. third-party or legacy component you cannot change). In that case, define the string in a SelectorsText object in the same `*.testIds.ts` (e.g. from locale) and use it in the Page Object. + +### Activity / transaction list assertions + +To assert that a transaction appears in the Activity list (e.g. after deposit or withdraw): + +- Use **ActivitiesView** (`tests/page-objects/Transactions/ActivitiesView.ts`): `tapOnPredictionsTab()`, then match the activity row by its type label. +- Activity type labels live in **ActivitiesView.testIds.ts** (`ActivitiesViewSelectorsText`, e.g. `PREDICT_DEPOSIT`, `PREDICT_WITHDRAW`). If your transaction type is missing, add it there and a getter in ActivitiesView (e.g. `get predictWithdraw()`), then use `Assertions.expectElementToBeVisible(ActivitiesView.predictWithdraw, { description: '...' })`. + +## Matchers API + +```typescript +// By testID (most common) +Matchers.getElementByID('my-test-id'); + +// By text +Matchers.getByText('Submit'); + +// By label (accessibility) +Matchers.getElementByLabel('Close button'); +``` + +## Gestures API + +```typescript +// Tap +await Gestures.tap(element, { description: 'tap X' }); + +// Tap with stability check (for animated elements) +await Gestures.tap(element, { + checkStability: true, + description: 'tap animated X', +}); + +// Tap disabled element +await Gestures.tap(element, { + checkEnabled: false, + description: 'tap loading button', +}); + +// Type text +await Gestures.typeText(input, 'hello', { description: 'type hello in input' }); + +// Swipe +await Gestures.swipe(element, 'up', 'slow', 0.5, { description: 'swipe up' }); +``` + +## Assertions API + +```typescript +// Visible +await Assertions.expectElementToBeVisible(element, { description: '...' }); + +// Not visible +await Assertions.expectElementToNotBeVisible(element, { description: '...' }); + +// Text present on screen +await Assertions.expectTextDisplayed('some text', { description: '...' }); + +// With custom timeout +await Assertions.expectElementToBeVisible(element, { + description: '...', + timeout: 10000, +}); +``` diff --git a/domains/testing/skills/e2e-test/references/running-tests.md b/domains/testing/skills/e2e-test/references/running-tests.md new file mode 100644 index 0000000..3a2a2fb --- /dev/null +++ b/domains/testing/skills/e2e-test/references/running-tests.md @@ -0,0 +1,123 @@ +# Running & Debugging E2E Tests — Reference + +## Step 1: Verify the Build Exists + +**Always check before running.** The binary path comes from `.detoxrc.js`: + +```bash +# Check default iOS debug build path +ls ios/build/Build/Products/Debug-iphonesimulator/MetaMask.app 2>/dev/null \ + && echo "✅ Build found — ready to run" \ + || echo "❌ Build missing" + +# If PREBUILT_IOS_APP_PATH is set (CI pre-built binary), check that instead +[ -n "$PREBUILT_IOS_APP_PATH" ] && \ + ls "$PREBUILT_IOS_APP_PATH" 2>/dev/null \ + && echo "✅ Pre-built binary found" \ + || echo "❌ PREBUILT_IOS_APP_PATH set but binary not found at: $PREBUILT_IOS_APP_PATH" +``` + +**If the build is missing**, do **not** run the build yourself. Warn the user that a debug build is required (~20-30 min for iOS) and show them the command so they can run it themselves: + +```bash +# iOS debug build (simulator, no device needed) +yarn test:e2e:ios:debug:build + +# Android debug build (requires emulator) +yarn test:e2e:android:debug:build +``` + +> Prefer **iOS** for local runs: simulator builds need no physical device and tests execute with zero manual interaction. + +## Step 2: Run a Specific Spec + +```bash +# iOS — run one spec file (preferred for local runs) +IS_TEST='true' NODE_OPTIONS='--experimental-vm-modules' \ + detox test -c ios.sim.main \ + --testPathPattern="tests/regression/predict/predict-buy-flow.spec.ts" + +# iOS — run a specific test by name +IS_TEST='true' NODE_OPTIONS='--experimental-vm-modules' \ + detox test -c ios.sim.main \ + --testPathPattern="tests/regression/predict/predict-buy-flow.spec.ts" \ + --testNamePattern="opens market details from market list" + +# Android — run one spec file (requires running emulator) +IS_TEST='true' NODE_OPTIONS='--experimental-vm-modules' \ + detox test -c android.emu.main \ + --testPathPattern="tests/regression/predict/predict-buy-flow.spec.ts" +``` + +## Run All Tests for a Feature + +```bash +IS_TEST='true' NODE_OPTIONS='--experimental-vm-modules' \ + detox test -c ios.sim.main \ + --testPathPattern="tests/regression/predict/" +``` + +## Lint & Type Check (Run Before Every Test Execution) + +```bash +# Lint a specific file +yarn lint tests/regression/predict/predict-buy-flow.spec.ts --fix +yarn lint tests/page-objects/Predict/PredictMarketList.ts --fix + +# Lint all new files together +yarn lint tests/regression/predict/ tests/page-objects/Predict/ --fix + +# TypeScript check (whole project) +yarn lint:tsc +``` + +## Common Failures & Fixes + +| Failure | Cause | Fix | +| ----------------------------- | ----------------------------------------- | --------------------------------------------------------------------- | +| `Error: element not found` | Wrong testID string, element not rendered | Check selector constant, verify `testID` in component | +| `Error: element not enabled` | Button disabled or loading state | Add `checkEnabled: false` to the `Gestures.tap` call | +| `Timeout waiting for element` | Element renders but too slowly | Add logger; check feature flag mock; increase `timeout` in Assertions | +| `Animation/stability error` | UI animating when tap is attempted | Add `checkStability: true` to `Gestures.tap` | +| `Unmocked API request` | Network call not intercepted | Add the URL to `testSpecificMock` | +| `Feature flag not enabled` | Feature hidden by flag | Add `setupRemoteFeatureFlagsMock` in `testSpecificMock` | +| `loginToApp timeout` | Onboarding modal or slow load | Ensure `restartDevice: true` in `withFixtures` | + +## Retry for Flaky Interactions + +Use `Utilities.executeWithRetry` for inherently unstable taps (carousels, animated modals): + +```typescript +import { Utilities } from '../../framework'; + +async tapButtonWithRetry(): Promise { + await Utilities.executeWithRetry( + async () => { + await Gestures.tap(this.button, { timeout: 2000, description: 'tap button' }); + await Assertions.expectElementToBeVisible(this.nextScreen, { + timeout: 2000, + description: 'next screen visible', + }); + }, + { + timeout: 30000, + description: 'tap button and verify navigation', + }, + ); +} +``` + +## Debugging Tips + +1. Add `logger.info(...)` calls in the spec to trace execution progress +2. Check `tests/artifacts/` for screenshots and device logs after a run +3. If the simulator is in an unexpected state: `detox reset-lock-file` then rebuild +4. For animation issues: `await device.disableSynchronization()` before the problematic interaction, `await device.enableSynchronization()` after + +## Iteration Loop + +``` +Fix code → yarn lint --fix → yarn lint:tsc → detox test → read failure → fix → repeat +``` + +Never skip the lint step after making changes. TypeScript errors caught early save debugging time. diff --git a/domains/testing/skills/e2e-test/references/writing-tests.md b/domains/testing/skills/e2e-test/references/writing-tests.md new file mode 100644 index 0000000..62bcd62 --- /dev/null +++ b/domains/testing/skills/e2e-test/references/writing-tests.md @@ -0,0 +1,122 @@ +# Writing E2E Tests — Reference + +## Spec File Location + +| Test Type | Directory | Tag | +| ---------- | ------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- | +| Smoke | `tests/smoke//.spec.ts` | `SmokeE2E`, `SmokeSwap`, `SmokeStake`, `SmokeMoney`, `SmokePredictions`, `SmokePerps`, `SmokeConfirmations`, etc. | +| Regression | `tests/regression//.spec.ts` | `RegressionTrade`, `RegressionWallet`, etc. | + +Import tags from `tests/tags.ts`. Check **`tests/tags.js`** for the full list and descriptions. Use the same tag as **existing specs in that feature folder** (e.g. `tests/smoke/swap/` uses `SmokeSwap`, `tests/smoke/stake/` uses `SmokeStake`, `tests/smoke/card/` and `tests/smoke/ramps/` use `SmokeMoney`). + +## Minimal Smoke Spec + +```typescript +import { loginToApp } from '../../flows/wallet.flow'; +import { withFixtures } from '../../framework/fixtures/FixtureHelper'; +import { SmokeE2E } from '../../tags'; +import FixtureBuilder from '../../framework/fixtures/FixtureBuilder'; +import MyFeatureView from '../../page-objects/MyFeature/MyFeatureView'; + +describe(SmokeE2E('My Feature'), () => { + it('lands on feature screen after navigation', async () => { + await withFixtures( + { + fixture: new FixtureBuilder().build(), + restartDevice: true, + }, + async () => { + await loginToApp(); + await MyFeatureView.expectScreenVisible(); + }, + ); + }); +}); +``` + +## Regression Spec with API Mocking + +```typescript +import { Mockttp } from 'mockttp'; +import { loginToApp } from '../../flows/wallet.flow'; +import { withFixtures } from '../../framework/fixtures/FixtureHelper'; +import { RegressionTrade } from '../../tags'; +import FixtureBuilder from '../../framework/fixtures/FixtureBuilder'; +import { setupRemoteFeatureFlagsMock } from '../../api-mocking/helpers/remoteFeatureFlagsHelper'; +import { setupMockRequest } from '../../api-mocking/mockHelpers'; +import MyFeatureList from '../../page-objects/MyFeature/MyFeatureList'; +import MyFeatureDetails from '../../page-objects/MyFeature/MyFeatureDetails'; +import { createLogger, LogLevel } from '../../framework/logger'; + +const logger = createLogger({ name: 'MyFeatureSpec', level: LogLevel.INFO }); + +const testSpecificMock = async (mockServer: Mockttp) => { + await setupRemoteFeatureFlagsMock(mockServer, { myFeatureEnabled: true }); + await setupMockRequest(mockServer, { + requestMethod: 'GET', + url: 'https://api.example.com/my-feature/data', + response: { items: [{ id: '1', name: 'Item 1' }] }, + responseCode: 200, + }); +}; + +describe(RegressionTrade('My Feature Details Flow'), () => { + it('opens details from list', async () => { + await withFixtures( + { + fixture: new FixtureBuilder().build(), + restartDevice: true, + testSpecificMock, + }, + async () => { + logger.info('Starting my feature test'); + await loginToApp(); + await MyFeatureList.tapFirstItem(); + await MyFeatureDetails.expectScreenVisible(); + }, + ); + }); +}); +``` + +## FixtureBuilder Patterns + +```typescript +// Basic (just logged-in wallet) +new FixtureBuilder().build(); + +// With popular networks enabled +new FixtureBuilder().withPopularNetworks().build(); + +// With Ganache local network +new FixtureBuilder().withGanacheNetwork().build(); + +// With dapp connected +new FixtureBuilder().withPermissionControllerConnectedToTestDapp().build(); + +// With specific tokens +new FixtureBuilder().withTokensControllerERC20().build(); + +// With contacts +new FixtureBuilder().withAddressBookControllerContactBob().build(); +``` + +## Mandatory Rules + +- Every spec uses `withFixtures` — no plain `beforeAll` / `afterAll` setup +- `restartDevice: true` for most tests (clean state) +- `loginToApp()` always the first call inside the fixture callback +- Test names: descriptive without 'should' prefix +- All gestures and assertions include `description` +- No direct `element(by.id())` calls in specs +- No `TestHelpers.delay()` or `setTimeout()` + +**Synchronization:** Use `device.disableSynchronization()` only when the test hits **timeouts caused by timers or animations** (e.g. confirmation loading, animated modals). Avoid wrapping entire flows by default. Re-enable with `device.enableSynchronization()` after the problematic section. See running-tests.md for the animation tip. + +## Before submitting + +- [ ] `withFixtures` + correct tag (see table above) +- [ ] Only Page Object methods in spec; no direct selectors +- [ ] Every gesture and assertion has a `description` +- [ ] No `TestHelpers.delay()` or `setTimeout()` +- [ ] `yarn lint ` and `yarn lint:tsc` pass diff --git a/domains/testing/skills/e2e-test/repos/metamask-mobile.md b/domains/testing/skills/e2e-test/repos/metamask-mobile.md new file mode 100644 index 0000000..c136c7b --- /dev/null +++ b/domains/testing/skills/e2e-test/repos/metamask-mobile.md @@ -0,0 +1,92 @@ +--- +repo: metamask-mobile +parent: e2e-test +--- + + +# E2E Test Builder — Skill + +> **One source of truth** for adding Detox E2E tests to MetaMask Mobile. +> Applies to: Claude Code (`.claude/commands/e2e-test.md`), Cursor, Copilot, Windsurf, and other AI agents. + +**Before writing or changing any E2E code:** read this skill once, then open the reference(s) indicated by the decision tree for your task. + +## What This Skill Does + +Guides you through adding a new E2E regression or smoke test end-to-end: + +1. Plans the test (type, location, infrastructure needed) +2. Creates or reuses Page Objects and selectors +3. Writes the spec using the mandatory framework patterns and the **correct tag** (see Golden rule 8; check `tests/tags.js` and existing specs in the feature folder) +4. Runs lint and type checks +5. Executes the test locally via Detox +6. Iterates until the test passes + +Your job is to figure out whether the user needs to **write a new spec**, **fix a failing test**, or **add page objects/selectors**, then follow the corresponding path and open the relevant reference when that path indicates. + +**Decision tree — which reference to use:** + +``` +Task → What do you need? +├─ Write new spec or add test steps +│ → Open references/writing-tests.md (spec structure, templates, FixtureBuilder patterns) +│ → If you need POM/selectors: also open references/page-objects.md +│ → If you need API or feature-flag mocks: also open references/mocking.md +│ → After writing: run lint/tsc, then open references/running-tests.md to run and debug +│ +├─ Create or update Page Objects / selectors +│ → Open references/page-objects.md (POM structure, Matchers, Gestures, Assertions, selector conventions) +│ → When writing the spec: open references/writing-tests.md +│ +├─ Mock API or feature flags +│ → Open references/mocking.md (testSpecificMock, setupRemoteFeatureFlagsMock, setupMockRequest) +│ → When writing the spec: open references/writing-tests.md +│ +├─ MetaMetrics / Segment analytics assertions (`analyticsExpectations` on `withFixtures`) +│ → Open [tests/docs/analytics-e2e.md](../../../tests/docs/analytics-e2e.md) (config shape, teardown order, presets under `tests/helpers/analytics/expectations/`, `runAnalyticsExpectations`) +│ → When wiring a spec: still follow references/writing-tests.md for `withFixtures` usage +│ +└─ Run tests, debug failures, or self-review + → Open references/running-tests.md (build check, detox commands, common failures, retry patterns) +``` + +Do not read the full reference files until the decision tree or workflow sends you there. + + +## 10 Golden Rules + +1. **Always use `withFixtures`** — every spec must be wrapped; no exceptions +2. **Always use Page Object Model** — no `element(by.id())` in spec files +3. **Always import from `tests/framework/index.ts`** — never from individual files +4. **Always add `description`** to every `Gestures.*` and `Assertions.*` call +5. **Never use `TestHelpers.delay()`** — use `Assertions.*` which has auto-retry +6. **Use `FixtureBuilder` for state** — do not set state through UI interactions +7. **Selectors live in `*.testIds.ts`** (co-located) or `tests/selectors/` (legacy) +8. **Tag correctly** — Use the tag that matches your feature and test type. Options include `SmokeE2E`, `SmokeTrade`, `SmokePredictions`, `SmokePerps`, `SmokeConfirmations`, `RegressionTrade`, `RegressionWallet`, etc. Check **`tests/tags.js`** for the full list and descriptions, and **existing specs in the same feature folder** to see which tag they use. +9. **Descriptive test names** — no 'should' prefix (e.g., `'opens market details'`) +10. **Fix lint/tsc before running** — never run with known errors + + +## Workflow Overview + +``` +Step 0 → Understand requirement + choose type (smoke/regression) +Step 1 → Discover / create Page Objects and selectors +Step 2 → Write the spec (withFixtures + POM + correct tag) +Step 3 → Lint + TSC (fix all errors) +Step 4 → Run detox test locally +Step 5 → Iterate (fix → lint → run) until green +``` + + +## Reference files (when to use) + +Documentation is split by **action**. Open only the reference that matches what you are doing. + +| Action | File | When to open it | +| --------------------------------------------- | ------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ | +| **Writing or updating a spec** | [references/writing-tests.md](references/writing-tests.md) | New spec file, spec structure, FixtureBuilder patterns, smoke/regression templates. | +| **Page Objects and selectors** | [references/page-objects.md](references/page-objects.md) | Create or update POM classes, selector/testId conventions, Matchers/Gestures/Assertions API. | +| **API and feature flag mocking** | [references/mocking.md](references/mocking.md) | testSpecificMock, setupRemoteFeatureFlagsMock, setupMockRequest, shared mock files. | +| **MetaMetrics / analytics expectations** | [tests/docs/analytics-e2e.md](../../../tests/docs/analytics-e2e.md) | `analyticsExpectations` on `withFixtures`, declarative checks, presets in `tests/helpers/analytics/expectations/`. | +| **Running tests, debugging, fixing failures** | [references/running-tests.md](references/running-tests.md) | Build check, detox run commands, lint/tsc, common failures table, retry patterns, iteration loop. | diff --git a/domains/testing/skills/e2e-test/skill.md b/domains/testing/skills/e2e-test/skill.md new file mode 100644 index 0000000..ab0eb10 --- /dev/null +++ b/domains/testing/skills/e2e-test/skill.md @@ -0,0 +1,4 @@ +--- +name: e2e-test +description: E2E test writing with Detox +--- diff --git a/domains/testing/skills/e2e-testing/repos/metamask-extension.md b/domains/testing/skills/e2e-testing/repos/metamask-extension.md new file mode 100644 index 0000000..e485c3f --- /dev/null +++ b/domains/testing/skills/e2e-testing/repos/metamask-extension.md @@ -0,0 +1,547 @@ +--- +repo: metamask-extension +parent: e2e-testing +--- + + +Reference: [MetaMask Extension E2E Test Guidelines](https://github.com/MetaMask/contributor-docs/blob/main/docs/testing/e2e/extension-e2e-guidelines.md) + +**See also:** + +- [Test i18n Usage Guidelines](../test-i18n-usage/RULE.md) - For i18n patterns in test assertions +- [Extension CI Flakiness Patterns](../extension-flakiness-patterns/RULE.md) - Known flakiness patterns and anti-patterns to avoid + +# MetaMask Extension E2E Testing Guidelines + +## Core Principles + +1. **Test Coverage is Critical**: Higher coverage creates more confidence and helps identify bugs effectively. +2. **Tests Should Be Reliable**: Tests should consistently produce the same results and be resilient to minor system changes. +3. **Tests Should Provide Fast Feedback**: Optimize for quick execution and clear failure messages. +4. **Tests Should Be Easy to Debug**: When a test fails, it should be clear what functionality is broken. +5. **Tests Should Be Maintainable**: Structure tests for easy maintenance as the application evolves. + +## Test Naming Conventions + +### DO: + +- Use clear, descriptive names that communicate the purpose of the test +- Name tests based on what they verify (e.g., `adds Bob to the address book`) +- Keep names concise but informative + +### DON'T: + +- Use the prefix 'should' (e.g., `should add Bob to the address book`) +- Include multiple behaviors with 'and' in a single test name +- Use vague or generic names + +## Test Organization + +- Organize tests into folders based on features and scenarios +- Each feature team should own one or more folders of tests +- Follow the same organization pattern as the extension team for consistency +- Place tests in logical feature directories: + ``` + e2e/tests/tokens/import/import-erc1155.spec.ts + e2e/tests/settings/clear-activity.spec.ts + e2e/tests/ppom/ppom-blockaid-alert-erc20-approval.spec.ts + ``` + +## Test Atomicity and Coupling + +### When to Isolate Tests: + +- Testing specific functionality of a single component or feature +- When you need to pinpoint exact failure causes +- For basic unit-level behaviors + +### When to Combine Tests: + +- For multi-step user flows that represent real user behavior +- When testing how different parts of the application work together +- When the setup for multiple tests is time-consuming and identical + +### Guidelines: + +- Each test should run with a dedicated browser and mock services +- Use the `withFixtures` function to create test prerequisites and clean up afterward +- Avoid shared mocks and services between tests when possible +- Consider the "fail-fast" philosophy - if an initial step fails, subsequent steps may not need to run + +## Controlling State + +### Best Practices: + +- Control application state programmatically rather than through UI interactions +- Use fixtures to set up test prerequisites instead of UI steps +- Minimize UI interactions to reduce potential breaking points +- Improve test stability by reducing timing and synchronization issues +- Prefer `FixtureBuilderV2` for new or updated specs +- Use legacy `FixtureBuilder` only when a required method is not yet available in `FixtureBuilderV2` + +### Fixture Builder Migration Guidance + +For new test code, use `FixtureBuilderV2` by default. + +`FixtureBuilderV2` currently supports: + +**General controller methods**: + +- `withAccountsController` +- `withAddressBookController` +- `withAppStateController` +- `withCurrencyController` +- `withKeyringController` +- `withMetaMetricsController` +- `withMultichainAssetsRatesController` +- `withMultichainRatesController` +- `withNameController` +- `withNetworkController` +- `withNetworkEnablementController` +- `withNftController` +- `withOnboardingController` +- `withPermissionController` +- `withPreferencesController` +- `withSelectedNetworkController` +- `withTokenBalancesController` +- `withTokenListController` +- `withTokensController` +- `withTransactionController` + +**Custom convenience methods**: + +- `withConversionRateDisabled` +- `withConversionRates` +- `withCurrencyRates` +- `withKeyringControllerAdditionalAccountVault` +- `withKeyringControllerMultiSRP` +- `withKeyringControllerOldVault` +- `withEnabledNetworks` +- `withLedgerAccount` +- `withNetworkControllerDoubleNode` +- `withNetworkControllerTripleNode` +- `withNftControllerERC1155` +- `withNftControllerERC721` +- `withNoNames` +- `withPermissionControllerConnectedToTestDapp` +- `withPreferencesControllerTxSimulationsDisabled` +- `withSelectedNetwork` +- `withSelectedNetworkControllerPerDomain` +- `withShowNativeTokenAsMainBalanceDisabled` +- `withShowNativeTokenAsMainBalanceEnabled` +- `withSmartTransactionsOptedOut` +- `withSnapController` +- `withSnapControllerOnStartLifecycleSnap` +- `withSnapsPrivacyWarningAlreadyShown` +- `withStorageServiceData` +- `withTokensControllerERC20` +- `withTokenListControllerStorageServiceData` +- `withTransactionControllerApprovedTransaction` +- `withTransactionControllerCompletedAndIncomingTransaction` +- `withTransactionControllerCompletedTransaction` +- `withTransactionControllerIncomingTransaction` +- `withTrezorAccount` +- `withUseBasicFunctionalityDisabled` + +If your test only needs these methods (or just `.build()`), prefer `FixtureBuilderV2` instead of the legacy builder. + +### Example: + +```typescript +// GOOD: Use fixture to set up prerequisites +new FixtureBuilderV2() + .withPreferencesController({ useCurrencyRateCheck: false }) + .withPermissionControllerConnectedToTestDapp() + .build(); + +// Then test only the essential steps: +// Login +// Send TST +// Assertion + +// BAD: Building all state through UI +new FixtureBuilderV2().build(); +// Login +// Add Contact +// Open test dapp +// Connect to test dapp +// Deploy TST +// Add TST to wallet +// Send TST +// Assertion +``` + +## Framework Architecture + +### Core Components: + +- **Driver** (`/test/e2e/webdriver/driver.js`) - Custom Selenium WebDriver wrapper providing enhanced element interactions, waiting strategies, and browser management +- **Page Objects** (`/test/e2e/page-objects/pages/`) - Individual page classes following Page Object Model pattern for UI interactions +- **Flow Objects** (`/test/e2e/page-objects/flows/`) - Multi-step user workflow implementations with Page Object Model pattern (login, onboarding, transaction flows) +- **Test Fixtures** - Mock data, network responses, and test state management utilities +- **Helper Functions** (`/test/e2e/helpers.js`) - Common test utilities + +### Key Features: + +- ✅ **Enhanced Element Interactions** - Selenium elements wrapped with enhanced methods (`.fill()`, `.press()`, `.click()`) +- ✅ **Intelligent Waiting** - Built-in waits for element visibility, enabled state, stability, and custom conditions +- ✅ **Click Intercepted Handling** - Automatic retry logic for loading overlays and modal backdrop interference +- ✅ **Multi-Window Support** - Window/tab management with title-based switching and handle tracking +- ✅ **Test Artifacts** - Screenshot capture on failures with DOM snapshots and application state dumps +- ✅ **Cross-Browser** - Chrome and Firefox support with browser-specific optimizations + +## Framework Best Practices + +### Page Object Model (POM) Pattern + +- ALWAYS use the Page Object Model pattern for organizing test code +- Move all element selectors to Page Objects or dedicated selector files +- Access UI elements through Page Object methods, not directly in test specs +- **Sort class members alphabetically**: Variables first (sorted A-Z), then methods (sorted A-Z) for easier navigation + +#### Page Object Structure Example: + +```typescript +import { Driver } from '../../webdriver/driver'; +import { WALLET_PASSWORD } from '../../helpers'; + +class LoginPage { + private driver: Driver; + + // Private selector properties (sorted alphabetically) + private readonly incorrectPasswordMessage = { + css: '[data-testid="unlock-page-help-text"]', + text: 'Password is incorrect. Please try again.', + }; + + private readonly passwordInput = '[data-testid="unlock-password"]'; + + private readonly unlockButton = '[data-testid="unlock-submit"]'; + + private readonly welcomeBackMessage = { + css: '[data-testid="unlock-page-title"]', + text: 'Welcome back', + }; + + constructor(driver: Driver) { + this.driver = driver; + } + + // Public methods (sorted alphabetically) + async checkPageIsLoaded(): Promise { + await this.driver.waitForMultipleSelectors([ + this.welcomeBackMessage, + this.passwordInput, + this.unlockButton, + ]); + console.log('Login page is loaded'); + } + + async loginToHomepage(password: string = WALLET_PASSWORD): Promise { + console.log('Login to homepage'); + await this.driver.fill(this.passwordInput, password); + await this.driver.clickElement(this.unlockButton); + } +} + +export default LoginPage; +``` + +### Complex Flows for Multi-Page Interactions + +For complex user workflows that span multiple pages, create **Flow Objects** that orchestrate interactions between multiple Page Objects. Flows should encapsulate complete user journeys and promote reusability across tests. + +#### When to Create Flows + +- **Multi-step workflows** that involve multiple pages +- **Common user journeys** used across multiple tests +- **Complex business processes** (onboarding, transaction flows, account management) + +#### Flow Structure Example + +```typescript +import { Driver } from '../../webdriver/driver'; +import HomePage from '../pages/home/homepage'; +import OnboardingCompletePage from '../pages/onboarding/onboarding-complete-page'; +import OnboardingPasswordPage from '../pages/onboarding/onboarding-password-page'; +import OnboardingSrpPage from '../pages/onboarding/onboarding-srp-page'; +import SecureWalletPage from '../pages/onboarding/secure-wallet-page'; +import StartOnboardingPage from '../pages/onboarding/start-onboarding-page'; + +export async function performCompleteOnboardingFlow( + driver: Driver, + options: { + seedPhrase: string[]; + password: string; + confirmSeedPhrase?: boolean; + skipMetricsOptIn?: boolean; + }, +): Promise { + console.log('Starting complete onboarding flow with imported wallet'); + + // Step 1: Start onboarding process + const startPage = new StartOnboardingPage(driver); + await startPage.checkPageIsLoaded(); + await startPage.selectImportWallet(); + await startPage.acceptMetaMetricsOptIn(); + + // Step 2: Import existing wallet + const srpPage = new OnboardingSrpPage(driver); + await srpPage.checkPageIsLoaded(); + await srpPage.enterSeedPhrase(options.seedPhrase); + await srpPage.confirmSeedPhrase(); + + // Step 3: Create password + const passwordPage = new OnboardingPasswordPage(driver); + await passwordPage.checkPageIsLoaded(); + await passwordPage.enterPassword(options.password); + await passwordPage.confirmPassword(options.password); + await passwordPage.acceptTermsAndConditions(); + await passwordPage.clickImportWalletButton(); + + // Step 4: Secure wallet (optional SRP confirmation) + const secureWalletPage = new SecureWalletPage(driver); + if (options.confirmSeedPhrase !== false) { + await secureWalletPage.checkPageIsLoaded(); + await secureWalletPage.clickRemindMeLaterButton(); // or completeSRPQuiz() + } + + // Step 5: Complete onboarding + const completePage = new OnboardingCompletePage(driver); + await completePage.checkPageIsLoaded(); + await completePage.clickDoneButton(); + + // Step 6: Verify we reach homepage + const homePage = new HomePage(driver); + await homePage.checkPageIsLoaded(); + await homePage.closeUseNetworkNotificationModal(); // Handle potential modals + + console.log('Completed onboarding flow successfully'); +} +``` + +#### Flow Best Practices + +**Structure & Organization:** + +- Place flows in `/test/e2e/page-objects/flows/` directory +- Use descriptive names ending with `.flow.ts` +- Group related flows (e.g., `onboarding.flow.ts`, `swap.flow.ts`) + +**Implementation Guidelines:** + +- **Orchestrate, don't duplicate**: Flows should call page object methods, not contain UI logic +- **Provide clear parameters**: Use typed options objects for configuration +- **Add comprehensive logging**: Help with debugging when flows fail + +**Flow Testing & Maintenance:** + +- **Parameterize flows**: Make flows configurable for different test scenarios +- **Version flows**: Update flows when UI changes, maintain backward compatibility + +### TypeScript Requirement + +- **ALWAYS write e2e tests in TypeScript (.spec.ts), not JavaScript (.spec.js)** +- Use proper type annotations for page object properties and method parameters + +### Proper Waiting and Assertions + +- NEVER use `driver.delay()` - it creates flaky tests and slows down test execution +- ALWAYS use dynamic wait from the framework: + + ```typescript + // DON'T: + await driver.delay(5000); + + // DO: + await driver.waitForSelector(expectedElement); + ``` + +### Element State Handling + +- **Default behavior**: Driver automatically waits for elements to be present and visible +- **When to use safe clicks**: For elements that may not always be present +- **When to handle states**: Loading overlays, modals, and dynamic content + +```typescript +// Standard click - waits for element to be clickable +await driver.clickElement(button); + +// Safe click - won't fail if element is not found +await driver.clickElementSafe(optionalButton); + +// Wait for specific element state +await driver.waitForSelector(element, { state: 'visible' }); +await driver.waitForSelector(element, { state: 'detached' }); + +// Handle loading states +await driver.waitForElementToStopMoving(animatedElement); +await driver.assertElementNotPresent(loadingOverlay); +``` + +### Prohibited Patterns in Test Specs + +The following patterns are prohibited in test specs: + +1. **Direct Driver Calls in Tests** + + ```typescript + // DON'T: + await driver.clickElement('[data-testid="some-button"]'); + + // DO: + await somePage.clickSomeButton(); + ``` + +2. **Hardcoded Selectors in Tests** + + ```typescript + // DON'T: + await driver.fill('#password-input', 'password123'); + + // DO: + // Define in page object: + private readonly passwordInput = '[data-testid="password-input"]'; + async enterPassword(password: string) { + await this.driver.fill(this.passwordInput, password); + } + ``` + +3. **Direct Element State Checks in Tests** + + ```typescript + // DON'T: + await driver.waitForSelector('.loading-spinner', { state: 'detached' }); + + // DO: + await somePage.waitForLoadingToComplete(); + ``` + +## Handling Flaky Tests + +### Common Issues and Solutions + +#### Element Click Intercepted Errors + +- **Cause**: Loading overlays, modal backdrops, or other elements blocking interactions +- **Solution**: Driver automatically detects and handles these with built-in retry logic + +```typescript +// Driver automatically handles loading overlays and modal backdrops +await this.driver.clickElement(this.confirmButton); + +// Use safe click for elements that may not always be present (won't throw if missing) +await this.driver.clickElementSafe(this.optionalNotificationButton, 2000); + +// For elements that need to disappear after clicking (like confirmation dialog) +await this.driver.clickElementAndWaitToDisappear(this.modalCloseButton); +``` + +#### Element Timing and State Issues + +- **Cause**: Elements not ready for interaction due to loading states or animations +- **Solution**: Use appropriate waiting strategies before interaction + +```typescript +// Wait for multiple elements to be present before proceeding +await this.driver.waitForMultipleSelectors([ + this.usernameField, + this.passwordField, + this.loginButton, +]); + +// Wait for element to stop moving (useful for animated carousels, sliding panels) +await this.driver.waitForElementToStopMoving(this.animatedElement); +await this.driver.clickElement(this.animatedElement); + +// Wait for loading states to complete +await this.driver.assertElementNotPresent(this.loadingSpinner, { + waitAtLeastGuard: 1000, + timeout: 10000, +}); +``` + +#### Network and Data Loading Issues + +- **Cause**: Tests failing due to slow API responses, network timeouts, or external service dependencies +- **Solution**: Use mock responses and controlled data instead of relying on real network calls + +The framework provides **two layers of mocking**: + +**1. Global Mocks** (automatic) - Handle common external dependencies. + +**2. Test-Specific Mocks** (manual) - Override globals with test requirements. + +```typescript +// ✅ PREFERRED: Use test-specific mocks for reliable, fast tests +async function mockTokenPriceApi( + mockServer: Mockttp, +): Promise { + return [ + // Mock token price API to avoid external dependency + await mockServer + .forGet('https://price.api.cx.metamask.io/v3/spot-prices') + .thenCallback(() => ({ + statusCode: 200, + json: { ETH: { usd: 2500 }, BTC: { usd: 45000 } }, + })), + ]; +} + +await withFixtures( + { + fixtures: new FixtureBuilderV2().build(), + title: this.test?.fullTitle(), + testSpecificMock: mockTokenPriceApi, // Layered on top of global mocks + }, + async ({ driver }) => { + // Tests now run with predictable, fast mock data + await loginWithBalanceValidation(driver); + }, +); +``` + +## Deprecated Patterns + +For a complete list of E2E test anti-patterns with regex detection patterns, see [BUGBOT.md](../BUGBOT.md). + +## Code Review Checklist + +Before submitting E2E tests, ensure: + +### Code Quality & Structure + +- [ ] **Tests are written in TypeScript (.spec.ts), not JavaScript (.spec.js)** +- [ ] Page Object pattern used for all UI interactions +- [ ] Element selectors defined in page objects, not in test specs +- [ ] No hardcoded selectors in test files +- [ ] Proper TypeScript type annotations used for variables and method parameters + +### Test Reliability + +- [ ] No usage of `driver.delay()` or `setTimeout()` - use proper waits instead +- [ ] Proper waiting strategies used (waitForSelector, waitForMultipleSelectors) +- [ ] Mock responses used for network calls instead of real API dependencies +- [ ] Use fixtures to set up test state instead of UI interactions +- [ ] Prefer `FixtureBuilderV2` when the required fixture methods are supported +- [ ] Error handling for expected failure scenarios + +## Debugging Failed Tests + +- [ ] Console.log statements added for debugging complex flows +- [ ] Tests work on both Chrome and Firefox browsers +- [ ] Clear, descriptive test names that explain what is being tested +- [ ] Use data-testid attributes for stable element selection +- [ ] Descriptive method names in page objects for better error messages +- [ ] Include enough context in page object methods to understand failures + +**Note**: Screenshots and DOM snapshots are automatically captured on failure for debugging. + +## Maintenance Guidelines + +- Review and update tests when features change +- Delete tests for removed features +- Keep test files focused on specific features +- Extract common setup into fixtures +- Document complex test setups with comments +- Avoid non-extendable logic for specific fixtures - make fixtures reusable diff --git a/domains/testing/skills/e2e-testing/repos/metamask-mobile.md b/domains/testing/skills/e2e-testing/repos/metamask-mobile.md new file mode 100644 index 0000000..ad388d1 --- /dev/null +++ b/domains/testing/skills/e2e-testing/repos/metamask-mobile.md @@ -0,0 +1,325 @@ +--- +repo: metamask-mobile +parent: e2e-testing +--- + +# MetaMask Mobile E2E Testing Guidelines + +## Core Principles + +1. **Test Coverage is Critical**: Higher coverage creates more confidence and helps identify bugs effectively. +2. **Tests Should Be Reliable**: Tests should consistently produce the same results and be resilient to minor system changes. +3. **Tests Should Provide Fast Feedback**: Optimize for quick execution and clear failure messages. +4. **Tests Should Be Easy to Debug**: When a test fails, it should be clear what functionality is broken. +5. **Tests Should Be Maintainable**: Structure tests for easy maintenance as the application evolves. + +## Test Naming Conventions + +### DO: +- Use clear, descriptive names that communicate the purpose of the test +- Name tests based on what they verify (e.g., `adds Bob to the address book`) +- Keep names concise but informative + +### DON'T: +- Use the prefix 'should' (e.g., `should add Bob to the address book`) +- Include multiple behaviors with 'and' in a single test name +- Use vague or generic names + +## Test Organization - MANDATORY + +- Organize tests into folders based on features and scenarios +- Use the a directory that suits the test type (regression|smoke) based on the tag used +- Each feature team should own one or more folders of tests +- Follow the same organization pattern as the extension team for consistency +- Place tests in logical feature directories: + ``` + tests/smoke// + tests/smoke/tokens/import/import-erc1155.spec.ts + tests/regression/wallet/settings/clear-activity.spec.ts + tests/regression/ppom/ppom-blockaid-alert-erc20-approval.spec.ts + ``` + +## Framework Architecture + +### Core Classes: + +- **`Assertions`** - Enhanced assertions with auto-retry and detailed error messages +- **`Gestures`** - Robust user interactions with configurable element state checking +- **`Matchers`** - Type-safe element selectors with flexible options +- **`Utilities`** - Core utilities with specialized element state checking + +### Key Features: + +- ✅ **Auto-retry** - Handles flaky network/UI conditions +- ✅ **Configurable element state checking** - Control visibility, enabled, and stability checks per interaction +- ✅ **Performance optimization** - Stability checking disabled by default for better performance +- ✅ **Better error messages** - Descriptive errors with retry context and timing +- ✅ **Type safety** - Full TypeScript support with IntelliSense + +## Test Atomicity and Coupling + +### When to Isolate Tests: +- Testing specific functionality of a single component or feature +- When you need to pinpoint exact failure causes +- For basic unit-level behaviors + +### When to Combine Tests: +- For multi-step user flows that represent real user behavior +- When testing how different parts of the application work together +- When the setup for multiple tests is time-consuming and identical + +### Guidelines: +- Each test should run with a dedicated browser and mock services +- Use the `withFixtures` function to create test prerequisites and clean up afterward +- Avoid shared mocks and services between tests when possible +- Consider the "fail-fast" philosophy - if an initial step fails, subsequent steps may not need to run + +## Controlling State + +### Best Practices: +- Control application state programmatically rather than through UI interactions +- Use fixtures to set up test prerequisites instead of UI steps +- Minimize UI interactions to reduce potential breaking points +- Improve test stability by reducing timing and synchronization issues + +### Example: +```javascript +// GOOD: Use fixture to set up prerequisites +new FixtureBuilder() + .withAddressBookControllerContactBob() + .withTokensControllerERC20() + .build(); + +// Then test only the essential steps: +// Login +// Send TST +// Assertion + +// BAD: Building all state through UI +new FixtureBuilder().build(); +// Login +// Add Contact +// Open test dapp +// Connect to test dapp +// Deploy TST +// Add TST to wallet +// Send TST +// Assertion +``` + +## Framework Best Practices + +### Page Object Model (POM) Pattern +- ALWAYS use the Page Object Model pattern for organizing test code +- Move all element selectors to Page Objects or dedicated selector files +- When adding one or more testID to a component or view, place it in a dedicated file next to where it is being used with the file extension `.testIds.ts` +- Access UI elements through Page Object methods, not directly in test specs + +#### Page Object Structure Example: +```typescript +import { LoginPageSelectors } from './LoginPage.selectors'; + +class LoginPage { + // Getter pattern for elements + get emailInput() { + return Matchers.getElementByID(LoginPageSelectors.EMAIL_INPUT); + } + get passwordInput() { + return Matchers.getElementByID(LoginPageSelectors.PASSWORD_INPUT); + } + get loginButton() { + return Matchers.getElementByID(LoginPageSelectors.LOGIN_BUTTON); + } + + // Public methods for actions + async login(email: string, password: string): Promise { + await Gestures.typeText(this.emailInput, email, { + description: 'enter email', + }); + await Gestures.typeText(this.passwordInput, password, { + description: 'enter password', + }); + await Gestures.tap(this.loginButton, { description: 'tap login button' }); + } + + // Public methods for verifications + async verifyLoginError(expectedError: string): Promise { + await Assertions.expectTextDisplayed(expectedError, { + description: 'login error should be displayed', + }); + } +} + +export default new LoginPage(); +``` + +### TestIDs location example: +```typescript +// DON'T: +import { MyComponentSelectors } from '../../tests/selectors/Card/RecurringFeeModal.selectors'; + +// DO: +import { MyComponentSelectors } from './MyComponent.testIds'; + +const MyComponent = () => { + return ( + + ) +}; +``` + +### Proper Waiting and Assertions +- NEVER use `TestHelpers.delay()` - it creates flaky tests and slows down test execution +- ALWAYS use proper waiting with Assertions from the framework: + ```javascript + // DON'T: + TestHelpers.delay(1000); + + // DO: + Assertions.expectElementToBeVisible(element, { + description: 'element should be visible' + }); + ``` + +### Framework Imports - MANDATORY +- ALWAYS import framework utilities from `tests/framework/index.ts`, not from individual utility files +- Use the centralized framework exports for consistency and maintainability + +### Element State Checking Configuration +- **Default behavior**: `checkVisibility: true`, `checkEnabled: true`, `checkStability: false` +- **Performance optimization**: Stability checking disabled by default for better performance +- **When to enable stability**: Complex animations, moving screens, carousel components +- **When to disable checks**: Loading states, temporarily disabled elements + +```typescript +// Default: checks visibility + enabled, skips stability +await Gestures.tap(button, { description: 'tap button' }); + +// Enable stability for animated elements +await Gestures.tap(carouselItem, { + checkStability: true, + description: 'tap carousel item', +}); + +// Skip checks for loading/processing elements +await Gestures.tap(processingButton, { + checkVisibility: false, + checkEnabled: false, + description: 'tap processing button', +}); +``` + +### Prohibited Patterns in Test Specs - MANDATORY +The following patterns are prohibited in test specs: + +1. **Direct Element Selection** + ```javascript + // DON'T: + element(by.id('some-id')).tap(); + + // DO: + SomePage.tapOnSomeElement(); + ``` + +2. **Direct By Selectors** + ```javascript + // DON'T: + by.text('Submit'); + + // DO: + // Define in page object: + static get submitButton() { + return Matchers.getByText('Submit'); + } + ``` + +3. **Direct waitFor Calls** + ```javascript + // DON'T: + await waitFor(element).toBeVisible().withTimeout(2000); + + // DO: + await Assertions.expectElementToBeVisible(element); + ``` + +## Handling Flaky Tests + +### Common Issues and Solutions + +#### "Element not enabled" Errors +- **Cause**: Element exists but is not interactive (disabled/loading state) +- **Solution**: Use `checkEnabled: false` to bypass enabled state validation + +```typescript +// Skip enabled check for temporarily disabled elements +await Gestures.tap(loadingButton, { + checkEnabled: false, + description: 'tap button during loading', +}); +``` + +#### "Element moving/animating" Errors +- **Cause**: UI animations interfering with interactions +- **Solution**: Enable stability checking for that specific interaction + +```typescript +await Gestures.tap(animatedButton, { + checkStability: true, // Wait for animations to complete + description: 'tap animated button', +}); +``` + +#### Handling Flaky Navigation/Tap Issues +When elements sometimes don't respond to taps, use a higher-level retry pattern: + +```typescript +async tapOpenAllTabsButton(): Promise { + return Utilities.executeWithRetry( + async () => { + await Gestures.waitAndTap(this.tabsButton, { + timeout: 2000 // Short timeout for individual action + }); + + await Assertions.expectElementToBeVisible(this.tabsNumber, { + timeout: 2000 // Short timeout for verification + }); + }, + { + timeout: 30000, // Longer overall timeout for retries + description: 'tap open all tabs button and verify navigation', + elemDescription: 'Open All Tabs Button', + } + ); +} +``` + +## Code Review Checklist - MANDATORY + +Before submitting E2E tests, ensure: + +- [ ] No usage of `TestHelpers.delay()` or `setTimeout()` +- [ ] All assertions have descriptive `description` parameters +- [ ] All gestures have descriptive `description` parameters +- [ ] Appropriate timeouts for operations (not magic numbers) +- [ ] Page Object pattern used for complex interactions +- [ ] Element selectors defined once and reused +- [ ] Framework configuration used appropriately +- [ ] Error handling for expected failure scenarios +- [ ] Tests work on both iOS and Android platforms + +## Debugging Failed Tests + +- Write tests that provide clear failure messages +- Include enough context in assertions to understand what failed +- Use descriptive selectors that won't break with minor UI changes +- Capture screenshots or logs at failure points when possible +- Use descriptive `description` parameters in all assertions and gestures + +## Maintenance Guidelines + +- Review and update tests when features change +- Delete tests for removed features +- Keep test files focused on specific features +- Extract common setup into helper functions or fixtures +- Document complex test setups with comments +- Avoid non-extendable logic for specific fixtures - make fixtures reusable diff --git a/domains/testing/skills/e2e-testing/skill.md b/domains/testing/skills/e2e-testing/skill.md new file mode 100644 index 0000000..c2cd6c5 --- /dev/null +++ b/domains/testing/skills/e2e-testing/skill.md @@ -0,0 +1,4 @@ +--- +name: e2e-testing +description: E2E testing guidelines +--- diff --git a/domains/testing/skills/performance-testing/reference.md b/domains/testing/skills/performance-testing/reference.md new file mode 100644 index 0000000..5a4291f --- /dev/null +++ b/domains/testing/skills/performance-testing/reference.md @@ -0,0 +1,531 @@ +# E2E Performance Test — Full Reference Guide + +**Goal**: Create Playwright-based E2E performance tests that measure real user flows on real devices with `TimerHelper` and `PerformanceTracker` — no mocking, no shortcuts. + +## Core Principles + +1. **Real Device, Real App, Real Network**: Tests run on BrowserStack devices (or local emulators) against actual app builds — zero mocking +2. **User-Centric Timers**: Every `TimerHelper` description reads from the user's perspective: _"Time since the user clicks X until Y is visible"_ +3. **Platform-Specific Thresholds**: Every timer defines `{ ios: , android: }` thresholds that trigger quality gate validation +4. **One Timer Per Measurable Step**: Break flows into discrete, meaningful timing segments — never lump multiple transitions into one timer +5. **Page Object Pattern**: All UI interactions go through page objects from `tests/page-objects/`, never raw selectors +6. **Performance Tags + Team Ownership**: Every test uses area tags from `tags.performance.js` and a team tag for Sentry routing + +## Step 0: Determine Test Location (MANDATORY) + +Before writing anything, determine where the test file goes: + +| Starting condition | Folder | Project | App build | +| ----------------------------------------------------------- | -------------------------------------------- | ------------------------------------------- | --------------- | +| User already has a wallet (login screen) | `tests/performance/login/` | `browserstack-android` / `browserstack-ios` | Standard build | +| Fresh install (onboarding flow) | `tests/performance/onboarding/` | `android-onboarding` / `ios-onboarding` | **Clean** build | +| Dapp connection / external server needed | `tests/performance/mm-connect/` | `mm-connect-*` projects | Standard build | +| Sub-flows within login (e.g., launch times) | `tests/performance/login/launch-times/` | Same as login | Standard build | +| Sub-flows within onboarding (e.g., cold start after import) | `tests/performance/onboarding/launch-times/` | Same as onboarding | Clean build | +| Predict market flows | `tests/performance/login/predict/` | Same as login | Standard build | + +**Key difference**: Onboarding projects use `BROWSERSTACK_*_CLEAN_APP_URL` (no pre-seeded wallet), while login projects use `BROWSERSTACK_*_APP_URL` (wallet already imported). + +## Workflow + +### Step 1: Understand the User Flow + +Before writing code: + +1. Identify the exact user journey to measure (e.g., "open swap screen and get a quote") +2. Break it into discrete steps, each becoming a `TimerHelper` +3. Identify which page objects are needed for each step +4. Determine if the flow starts from login or onboarding +5. Choose the appropriate performance tag(s) from `tests/tags.performance.js` +6. Identify the owning team for the `{ tag: '@team-name' }` annotation + +### Step 2: Create the Test File + +**File naming**: `.spec.ts` inside the appropriate folder. + +**Login-based test template** (`tests/performance/login/.spec.ts`): + +```typescript +import { test as perfTest } from '../../framework/fixture'; +import TimerHelper from '../../framework/TimerHelper'; +import { loginToAppPlaywright } from '../../flows/wallet.flow'; +import { asPlaywrightElement, PlaywrightAssertions } from '../../framework'; +import WalletView from '../../page-objects/wallet/WalletView'; +import SomeOtherView from '../../page-objects/wallet/SomeOtherView'; +import { + PerformanceLogin, + // Add relevant area tags +} from '../../tags.performance.js'; + +perfTest.describe(`${PerformanceLogin}`, () => { + perfTest( + 'Descriptive test name matching the scenario', + { tag: '@owning-team-name' }, + async ({ currentDeviceDetails, driver, performanceTracker }, testInfo) => { + // 1. Login + await loginToAppPlaywright(); + + // 2. Define timers with user-centric descriptions and platform thresholds + // Third arg is currentDeviceDetails.platform — NOT device + const timer1 = new TimerHelper( + 'Time since the user clicks on X until Y is visible', + { ios: 2000, android: 3000 }, + currentDeviceDetails.platform, + ); + + // 3. Perform action OUTSIDE measure, assertion INSIDE measure + await WalletView.tapSomeButton(); // action (not timed) + await timer1.measure(async () => { + await PlaywrightAssertions.expectElementToBeVisible( + asPlaywrightElement(SomeOtherView.container), + ); + }); + + // 4. Add all timers to the tracker + // DO NOT call performanceTracker.attachToTest() — the fixture handles it automatically + performanceTracker.addTimers(timer1); + }, + ); +}); +``` + +**Onboarding-based test template** (`tests/performance/onboarding/.spec.ts`): + +```typescript +import { test } from '../../framework/fixture'; +import TimerHelper from '../../framework/TimerHelper'; +import { asPlaywrightElement, PlaywrightAssertions } from '../../framework'; +import { dismisspredictionsModalPlaywright } from '../../flows/wallet.flow'; +import { fetchProductionFeatureFlags } from '../feature-flag-helper'; +import OnboardingView from '../../page-objects/Onboarding/OnboardingView'; +import OnboardingSheet from '../../page-objects/Onboarding/OnboardingSheet'; +import ImportWalletView from '../../page-objects/Onboarding/ImportWalletView'; +import CreatePasswordView from '../../page-objects/Onboarding/CreatePasswordView'; +import MetaMetricsOptInView from '../../page-objects/Onboarding/MetaMetricsOptInView'; +import OnboardingSuccessView from '../../page-objects/Onboarding/OnboardingSuccessView'; +import WalletView from '../../page-objects/wallet/WalletView'; +import { PerformanceOnboarding } from '../../tags.performance.js'; + +test.describe(PerformanceOnboarding, () => { + test.setTimeout(240000); // Onboarding flows are longer — extend timeout + + test( + 'Descriptive test name matching the scenario', + { tag: '@owning-team-name' }, + async ({ currentDeviceDetails, driver, performanceTracker }, testInfo) => { + // 1. Define all timers upfront + const timer1 = new TimerHelper( + 'Time since the user clicks on "Have existing wallet" until sheet is visible', + { ios: 1000, android: 1800 }, + currentDeviceDetails.platform, + ); + // ... more timers for each step + + // 2. Execute onboarding flow with measurements + await OnboardingView.tapHaveAnExistingWallet(); + await timer1.measure(async () => { + await PlaywrightAssertions.expectElementToBeVisible( + asPlaywrightElement(OnboardingSheet.importSeedButton), + ); + }); + + // ... continue flow + + // 3. Add all timers and attach + performanceTracker.addTimers(timer1 /* , timer2, timer3, ... */); + }, + ); +}); +``` + +### Step 3: Write Timers + +#### CRITICAL RULE: Actions OUTSIDE measure, Assertions INSIDE measure + +The `measure()` callback must contain **only assertions/wait conditions** — never user actions like taps, types, or swipes. The user action that triggers the transition goes **before** the `measure()` call. This ensures we measure purely the app's response time, not the interaction itself. + +```typescript +// ✅ CORRECT — tap OUTSIDE measure, assertion INSIDE measure +await WalletView.tapSwapButton(); +await timer.measure(() => + PlaywrightAssertions.expectElementToBeVisible( + asPlaywrightElement(BridgeView.container), + ), +); + +// ✅ CORRECT — multiple assertions inside measure are fine +await WalletView.tapOnToken('USDC'); +await timer.measure(async () => { + await PlaywrightAssertions.expectElementToBeVisible( + asPlaywrightElement(TokenOverview.container), + ); + await PlaywrightAssertions.expectElementToBeVisible( + asPlaywrightElement(TokenOverview.sendButton), + ); + await PlaywrightAssertions.expectElementToBeVisibleWithSettle( + asPlaywrightElement(TokenOverview.todaysChange), + { timeout: 10000, settleMs: 500 }, + ); +}); + +// ❌ WRONG — action inside measure pollutes the timing +await timer.measure(async () => { + await WalletView.tapSwapButton(); // ❌ action inside measure + await PlaywrightAssertions.expectElementToBeVisible( + asPlaywrightElement(BridgeView.container), + ); +}); +``` + +#### 3a: Using `timer.measure()` (preferred — simple flows) + +When the action that starts the timer and the wait condition are in sequence: + +```typescript +const timer = new TimerHelper( + 'Time since the user clicks on the "Swap" button until the swap page is loaded', + { ios: 2000, android: 2500 }, + currentDeviceDetails.platform, +); + +// Action BEFORE measure +await WalletView.tapSwapButton(); +// Only assertions INSIDE measure +await timer.measure(() => + PlaywrightAssertions.expectElementToBeVisible( + asPlaywrightElement(BridgeView.container), + ), +); +``` + +#### 3b: Using manual `start()` / `stop()` (cross-context flows — dapp tests) + +When timing spans native ↔ web context switches, use `start()`/`stop()` with `PlaywrightContextHelpers`. The same rule applies: `start()` goes right after the triggering action, and `stop()` goes after the assertion that confirms the result. + +```typescript +import PlaywrightContextHelpers from '../../framework/PlaywrightContextHelpers'; + +const connectTimer = new TimerHelper( + 'Time from tapping Connect to dapp confirming connected state', + { ios: 20000, android: 30000 }, + currentDeviceDetails.platform, +); + +// Switch to web context, action then start — tap triggers the flow, then start timing +await PlaywrightContextHelpers.switchToWebViewContext(DAPP_URL); +await BrowserPlaygroundDapp.tapConnectLegacy(); // action +connectTimer.start(); // start AFTER the action + +// Switch to native context — these are intermediate steps, not measured +await PlaywrightContextHelpers.switchToNativeContext(); +await DappConnectionModal.tapConnectButton(); + +// Switch back to web, stop after assertion +await PlaywrightContextHelpers.switchToWebViewContext(DAPP_URL); +await BrowserPlaygroundDapp.assertConnected(true); // assertion +connectTimer.stop(); // stop AFTER assertion +``` + +#### 3c: Multi-timer flows + +For complex scenarios, define all timers upfront. Each step follows the same pattern: action first, then `measure()` with only assertions. + +```typescript +const timer1 = new TimerHelper( + 'Time since the user clicks on "Create wallet" until sign-up screen is visible', + { ios: 1000, android: 1800 }, + currentDeviceDetails.platform, +); +const timer2 = new TimerHelper( + 'Time since the user clicks "Import SRP" until SRP field is displayed', + { ios: 1000, android: 1500 }, + currentDeviceDetails.platform, +); +const timer3 = new TimerHelper( + 'Time since the user clicks "Continue" until password fields are visible', + { ios: 2500, android: 1800 }, + currentDeviceDetails.platform, +); + +// Step 1: action OUTSIDE, assertion INSIDE +await OnboardingView.tapCreateWallet(); +await timer1.measure(async () => { + await PlaywrightAssertions.expectElementToBeVisible( + asPlaywrightElement(OnboardingSheet.importSeedButton), + ); +}); + +// Step 2: action OUTSIDE, assertion INSIDE +await OnboardingSheet.tapImportSeedButton(); +await timer2.measure(async () => { + await PlaywrightAssertions.expectElementToBeVisible( + asPlaywrightElement(ImportWalletView.title), + ); +}); + +// Step 3: action OUTSIDE, assertion INSIDE +await ImportWalletView.tapContinueButton(); +await timer3.measure(async () => { + await PlaywrightAssertions.expectElementToBeVisible( + asPlaywrightElement(CreatePasswordView.container), + ); +}); + +performanceTracker.addTimers(timer1, timer2, timer3); +``` + +### Step 4: Page Objects + +Page objects in `tests/page-objects/` are static classes — no device assignment needed. Just import and use: + +```typescript +import WalletView from '../../page-objects/wallet/WalletView'; +import TokenOverview from '../../page-objects/wallet/TokenOverview'; +import LoginView from '../../page-objects/wallet/LoginView'; +import TabBarComponent from '../../page-objects/wallet/TabBarComponent'; + +// ✅ CORRECT — just use the static methods directly +await WalletView.tapOnToken('USDC'); +await PlaywrightAssertions.expectElementToBeVisible( + asPlaywrightElement(TokenOverview.container), +); +``` + +Dapp tests have their own page objects under `tests/page-objects/MMConnect/`: + +```typescript +import BrowserPlaygroundDapp from '../../page-objects/MMConnect/BrowserPlaygroundDapp'; +import DappConnectionModal from '../../page-objects/MMConnect/DappConnectionModal'; +import SignModal from '../../page-objects/MMConnect/SignModal'; +import SwitchChainModal from '../../page-objects/MMConnect/SwitchChainModal'; +``` + +### Step 5: Register Timers and Attach Results + +At the end of every test, add timers to the tracker. **Do NOT call `attachToTest` manually** — the fixture teardown calls it automatically. + +```typescript +// Add all timers to the tracker +performanceTracker.addTimers(timer1, timer2, timer3); +// OR for a single timer: +performanceTracker.addTimer(timer1); + +// ❌ DO NOT CALL — the fixture handles this automatically +// await performanceTracker.attachToTest(testInfo); +``` + +The performance fixture automatically: + +- Validates quality gates if any timer has thresholds +- Publishes metrics to Sentry +- Stores session data for video URL retrieval +- Skips retries on quality gate failures (threshold exceeded ≠ flaky test) + +## Performance Tags + +Import from `tests/tags.performance.js`: + +```javascript +import { + PerformanceLogin, // Login/unlock flows + PerformanceOnboarding, // Fresh wallet setup + PerformanceSwaps, // Swap/bridge flows + PerformanceLaunch, // Cold/warm start times + PerformanceAssetLoading, // Token/NFT loading + PerformanceAccountList, // Account list rendering + PerformancePredict, // Prediction markets + PerformancePreps, // Perpetuals trading +} from '../../tags.performance.js'; +``` + +Combine multiple tags in `test.describe()`: + +```typescript +perfTest.describe(`${PerformanceLogin} ${PerformanceLaunch}`, () => { ... }); +perfTest.describe(`${PerformanceLogin} ${PerformanceAssetLoading}`, () => { ... }); +``` + +## Team Tags + +Every test MUST include a team tag for ownership and Sentry routing: + +```typescript +perfTest('Test name', { tag: '@team-name' }, async ({ currentDeviceDetails, driver, performanceTracker }, testInfo) => { ... }); +``` + +Known team tags: + +- `@assets-dev-team` — Token lists, balances, NFTs +- `@swap-bridge-dev-team` — Swap and bridge flows +- `@metamask-mobile-platform` — Platform, launch times +- `@metamask-onboarding-team` — Onboarding, wallet creation +- `@accounts-team` — Account management +- `@team-predict` — Prediction markets +- `@mm-perps-engineering-team` — Perpetuals trading + +## Threshold Guidelines + +Thresholds are in milliseconds. The `TimerHelper` adds a 10% margin automatically. + +| Action type | iOS range | Android range | +| -------------------------------- | ------------- | -------------- | +| Simple screen transition | 500–1500 ms | 600–1800 ms | +| Data loading (API + render) | 1500–5000 ms | 2000–7000 ms | +| Heavy computation (50+ accounts) | 5000–90000 ms | 5000–90000 ms | +| Dapp connection (cross-context) | 8000–20000 ms | 12000–30000 ms | +| Quote/swap execution | 7000–9000 ms | 7000–9000 ms | +| Cold start to screen | 2000–3500 ms | 2000–3500 ms | + +When unsure, start generous and tighten after collecting baseline data. + +## Common Helpers + +### Login flow + +```typescript +import { loginToAppPlaywright } from '../../flows/wallet.flow'; +await loginToAppPlaywright(); // Types password, taps unlock, waits — no args needed +// Optional: specify scenario type for different wallets +await loginToAppPlaywright({ scenarioType: 'login' }); +``` + +### Account selection by device + +```typescript +import { selectAccountByDevice } from '../../flows/wallet.flow'; +await selectAccountByDevice(currentDeviceDetails.deviceName); +// Selects the account mapped in tests/performance/device-matrix.json +``` + +### Onboarding flow + +```typescript +import { onboardingFlowImportSRPPlaywright } from '../../flows/wallet.flow'; +await onboardingFlowImportSRPPlaywright(process.env.TEST_SRP_1); +``` + +### Dismiss modals + +```typescript +import { dismisspredictionsModalPlaywright } from '../../flows/wallet.flow'; +await dismisspredictionsModalPlaywright(); +``` + +### App lifecycle (cold start tests) + +```typescript +import { PlaywrightGestures } from '../../framework'; +await PlaywrightGestures.terminateApp(currentDeviceDetails); +await PlaywrightGestures.activateApp(currentDeviceDetails); +``` + +### Dapp tests (native ↔ web context switching) + +```typescript +import PlaywrightContextHelpers from '../../framework/PlaywrightContextHelpers'; +import { DappServer, DappVariants, TestDapps } from '../../framework'; + +// Switch between contexts +await PlaywrightContextHelpers.switchToNativeContext(); +await PlaywrightContextHelpers.switchToWebViewContext(DAPP_URL); + +// Start a local dapp server +const playgroundServer = new DappServer({ + dappCounter: 0, + rootDirectory: TestDapps[DappVariants.BROWSER_PLAYGROUND].dappPath, + dappVariant: DappVariants.BROWSER_PLAYGROUND, +}); + +perfTest.beforeAll(async () => { + playgroundServer.setServerPort(DAPP_PORT); + await playgroundServer.start(); +}); + +perfTest.afterAll(async () => { + await playgroundServer.stop(); +}); +``` + +### Production feature flags + +```typescript +import { fetchProductionFeatureFlags } from '../feature-flag-helper'; +const flags = await fetchProductionFeatureFlags(); +// Use flags to conditionally skip tests based on feature availability +``` + +## ❌ FORBIDDEN Patterns + +```typescript +// NEVER in E2E performance tests: +jest.mock(...) // No mocking — real device, real app +require(...) // Use ES6 imports only +import { test } from '@playwright/test' // ALWAYS import from framework/fixture (not directly) +as any // Use proper types +// Hardcoded passwords // Use getPasswordForScenario() +// Raw selectors // Use page objects from tests/page-objects/ +// PageObj.device = device // Page objects are static — no device assignment needed +// Timer without threshold // Always provide { ios: X, android: Y } +// Timer without team tag // Always include { tag: '@team-name' } +new TimerHelper('...', {...}, device) // Third arg is currentDeviceDetails.platform, not device +await performanceTracker.attachToTest() // DO NOT call manually — fixture auto-handles teardown +// Actions inside measure() // ONLY assertions/waits go inside measure +``` + +## Checklist Before Submitting + +- [ ] Test file is `.spec.ts` (TypeScript) and in the correct folder (`login/`, `onboarding/`, `mm-connect/`, etc.) +- [ ] Imports `test` from `../../framework/fixture` (NOT from `@playwright/test` directly) +- [ ] Fixture destructures `{ currentDeviceDetails, driver, performanceTracker }` (not `device`) +- [ ] `TimerHelper` third argument is `currentDeviceDetails.platform` (not `device`) +- [ ] Each measurable step has its own `TimerHelper` with platform-specific thresholds +- [ ] Timer descriptions are user-centric: _"Time since the user clicks X until Y is visible"_ +- [ ] `test.describe()` uses performance area tag(s) from `tags.performance.js` +- [ ] Test has `{ tag: '@team-name' }` for ownership +- [ ] All timers are added via `performanceTracker.addTimers()` or `performanceTracker.addTimer()` +- [ ] **`performanceTracker.attachToTest()` is NOT called** — the fixture handles it automatically +- [ ] `test.setTimeout()` is set for long flows (onboarding: 240000ms+) +- [ ] Actions (taps, types, swipes) are OUTSIDE `measure()` — only assertions/waits inside +- [ ] No mocking of any kind +- [ ] No hardcoded passwords (use `getPasswordForScenario()`) +- [ ] Test name is descriptive and matches the scenario being measured +- [ ] Page objects from `tests/page-objects/` used — no `.device = device` assignment + +## Quick Commands + +```bash +# Run a single performance test locally (Android emulator) +yarn playwright test --project android --grep "Test name pattern" + +# Run all login performance tests on BrowserStack +yarn playwright test --project browserstack-android + +# Run all onboarding tests on BrowserStack +yarn playwright test --project android-onboarding + +# Run with specific tag filter +yarn playwright test --grep "@PerformanceLogin" +yarn playwright test --grep "@PerformanceSwaps|@PerformanceOnboarding" + +# View test reports +open tests/reporters/reports +``` + +## References + +- Performance fixture: `tests/framework/fixture/index.ts` +- TimerHelper: `tests/framework/TimerHelper.ts` +- Performance tags: `tests/tags.performance.js` +- Flow utilities: `tests/flows/wallet.flow.ts` +- Feature flag helper: `tests/performance/feature-flag-helper.ts` +- Device matrix: `tests/performance/device-matrix.json` +- Context switching (dapp tests): `tests/framework/PlaywrightContextHelpers.ts` +- Quality gates: `tests/framework/quality-gates/` +- Page objects: `tests/page-objects/` +- Dapp page objects: `tests/page-objects/MMConnect/` +- Config: `tests/playwright.config.ts` +- Example login test: `tests/performance/login/asset-view.spec.ts` +- Example onboarding test: `tests/performance/onboarding/import-wallet.spec.ts` +- Example cold start test: `tests/performance/login/launch-times/cold-start-to-login.spec.ts` +- Example predict test: `tests/performance/login/predict/predict-available-balance.spec.ts` +- Example dapp test: `tests/performance/mm-connect/connection-evm.spec.ts` diff --git a/domains/testing/skills/performance-testing/repos/metamask-mobile.md b/domains/testing/skills/performance-testing/repos/metamask-mobile.md new file mode 100644 index 0000000..e7efd4a --- /dev/null +++ b/domains/testing/skills/performance-testing/repos/metamask-mobile.md @@ -0,0 +1,135 @@ +--- +repo: metamask-mobile +parent: performance-testing +--- + + +# E2E Performance Testing + +For the full reference guide with templates, examples, and decision trees, read [reference.md](reference.md). + +## Core Rules + +1. **Real device, real app, real network** — zero mocking +2. **Actions OUTSIDE `measure()`, assertions INSIDE `measure()`** — measure app response time, not interactions +3. **One `TimerHelper` per measurable step** with platform-specific thresholds `{ ios: , android: }` +4. **User-centric timer descriptions**: _"Time since the user clicks X until Y is visible"_ +5. **Screen Object pattern** — all UI via `wdio/screen-objects/`, never raw selectors +6. **Every screen object** must have `device` assigned before use +7. **Every test** must call `performanceTracker.addTimers()` + `performanceTracker.attachToTest(testInfo)` +8. **Performance + team tags** are mandatory on every test + +## File Location + +| Starting condition | Folder | +| -------------------------------------- | ------------------------------- | +| User already has wallet (login screen) | `tests/performance/login/` | +| Fresh install (onboarding) | `tests/performance/onboarding/` | +| Dapp connection needed | `tests/performance/mm-connect/` | + +## Quick Template (login-based) + +```js +import { test } from '../../framework/fixtures/performance'; +import TimerHelper from '../../framework/TimerHelper'; +import { login } from '../../framework/utils/Flows.js'; +import { PerformanceLogin } from '../../tags.performance.js'; + +test.describe(`${PerformanceLogin}`, () => { + test( + 'Descriptive name', + { tag: '@team-name' }, + async ({ device, performanceTracker }, testInfo) => { + ScreenA.device = device; + ScreenB.device = device; + + await login(device); + + const timer = new TimerHelper( + 'Time since the user clicks X until Y is visible', + { ios: 2000, android: 3000 }, + device, + ); + + await ScreenA.tapButton(); // action OUTSIDE + await timer.measure(() => ScreenB.isVisible()); // assertion INSIDE + + performanceTracker.addTimers(timer); + await performanceTracker.attachToTest(testInfo); + }, + ); +}); +``` + +## Modifying Existing Tests + +When editing an existing test in `tests/performance/`, follow these rules: + +### Thresholds + +- **Never tighten thresholds without baseline data** — run the test 3+ times first to collect real timings +- **Never remove thresholds** — every timer must keep `{ ios, android }` values +- **Document why** in the PR if changing a threshold (e.g., "tightened after 10 runs averaging 1.2s") +- If a test is consistently failing quality gates, **widen the threshold** rather than deleting the timer + +### Adding/Removing Timers + +- When adding a new timer to an existing test, register it in the existing `performanceTracker.addTimers()` call +- When removing a timer, ensure it's also removed from `addTimers()` — orphaned timers cause silent failures +- Never reduce a test to zero timers — if the flow no longer needs measurement, delete the test file + +### Screen Object Changes + +- If a screen object method is renamed or removed, **grep all `tests/performance/`** for usages before changing +- When adding methods to a screen object in `wdio/screen-objects/`, ensure backward compatibility — existing tests must not break +- New screen objects must follow the same `device` getter/setter pattern + +### UI Flow Changes + +- If the app UI changes (new screen, removed step, renamed button), update **all** perf tests that use that flow +- Re-run the affected tests to verify timers still measure the intended transition +- Update timer descriptions if the user-facing flow changed (e.g., button was renamed) + +### Refactoring + +- Extract shared flows into `tests/framework/utils/Flows.js` (e.g., `login`, `importSRPFlow`) +- If multiple tests duplicate the same setup, create a helper that returns `TimerHelper[]` +- Keep `screensSetup(device)` pattern for tests with many screen objects (see `perps-position-management.spec.js`) + +### Code Review Checklist for Modifications + +- [ ] No existing timer was accidentally removed or left unregistered +- [ ] Threshold changes are justified with data +- [ ] `attachToTest(testInfo)` is still called at the end +- [ ] All screen objects still have `device` assigned +- [ ] Timer descriptions still match the actual flow being measured +- [ ] Actions are still OUTSIDE `measure()`, assertions INSIDE + +## Forbidden Patterns + +- `jest.mock(...)` — no mocking +- `import { test } from 'appwright'` — always from `fixtures/performance` +- Actions inside `measure()` — only assertions/waits +- Missing `device` assignment on screen objects +- Timers without thresholds +- Missing team tag +- Hardcoded passwords — use `getPasswordForScenario()` + +## Threshold Ranges + +| Action type | iOS | Android | +| ------------------------------- | ------------- | -------------- | +| Simple screen transition | 500–1500 ms | 600–1800 ms | +| Data loading (API + render) | 1500–5000 ms | 2000–7000 ms | +| Dapp connection (cross-context) | 8000–20000 ms | 12000–30000 ms | +| Quote/swap execution | 7000–9000 ms | 7000–9000 ms | + +## Key References + +- Full guide: [reference.md](reference.md) +- Performance fixture: `tests/framework/fixtures/performance/performance-fixture.ts` +- TimerHelper: `tests/framework/TimerHelper.ts` +- Tags: `tests/tags.performance.js` +- Flows: `tests/framework/utils/Flows.js` +- Screen objects: `wdio/screen-objects/` +- Examples: `tests/performance/login/`, `tests/performance/onboarding/` diff --git a/domains/testing/skills/performance-testing/skill.md b/domains/testing/skills/performance-testing/skill.md new file mode 100644 index 0000000..2f1cb5a --- /dev/null +++ b/domains/testing/skills/performance-testing/skill.md @@ -0,0 +1,4 @@ +--- +name: performance-testing +description: E2E performance testing +--- diff --git a/domains/testing/skills/test-i18n-usage/repos/metamask-extension.md b/domains/testing/skills/test-i18n-usage/repos/metamask-extension.md new file mode 100644 index 0000000..139b656 --- /dev/null +++ b/domains/testing/skills/test-i18n-usage/repos/metamask-extension.md @@ -0,0 +1,232 @@ +--- +repo: metamask-extension +parent: test-i18n-usage +--- + + +Reference: [PR #39859 - Replace hardcoded strings with i18n message references](https://github.com/MetaMask/metamask-extension/pull/39859) + +# Test i18n Usage Guidelines + +## Import i18n Messages from Test Helpers + +### ALWAYS Use enLocale from Test Helpers + +- **Import locale messages from `test/lib/i18n-helpers`**, not directly from `app/_locales/en/messages.json` +- Use the alias `enLocale as messages` for consistency +- This prevents tests from breaking when translations change +- Eliminates need for `eslint-disable-next-line import-x/no-restricted-paths` + +```typescript +✅ CORRECT: +import { enLocale as messages } from '../../../test/lib/i18n-helpers'; + +❌ WRONG: +// eslint-disable-next-line import-x/no-restricted-paths +import enMessages from '../../../app/_locales/en/messages.json'; +``` + +## Use i18n Keys in Test Assertions + +### Reference Locale Messages in Queries + +- **ALWAYS use `messages..message`** instead of hardcoded strings +- Makes tests resilient to translation changes +- Self-documenting - shows which i18n key is being used +- Caught by fitness function test that prevents locale query mismatches + +```typescript +✅ CORRECT: +import { enLocale as messages } from '../../../test/lib/i18n-helpers'; + +it('displays cancel button', () => { + render(); + expect(screen.getByText(messages.cancel.message)).toBeInTheDocument(); +}); + +it('finds button by role and name', () => { + render(); + const button = screen.getByRole('button', { name: messages.confirm.message }); + expect(button).toBeInTheDocument(); +}); + +❌ WRONG: +it('displays cancel button', () => { + render(); + expect(screen.getByText('Cancel')).toBeInTheDocument(); // Hardcoded string +}); + +it('finds button by role and name', () => { + render(); + const button = screen.getByRole('button', { name: 'Confirm' }); // Hardcoded string + expect(button).toBeInTheDocument(); +}); +``` + +## Handle Parameterized i18n Strings + +### Use String Replace for Placeholders + +- **ALWAYS use `.replace()` for parameterized locale strings** +- i18n strings with placeholders like `$1`, `$2` need runtime replacement +- Chain multiple `.replace()` calls for multiple parameters + +```typescript +✅ CORRECT: +import { enLocale as messages } from '../../../test/lib/i18n-helpers'; + +it('displays token with symbol', () => { + render(); + const expectedText = messages.tokenBalance.message.replace('$1', 'DAI'); + expect(screen.getByText(expectedText)).toBeInTheDocument(); +}); + +it('displays message with multiple parameters', () => { + render(); + const expectedText = messages.transferFrom.message + .replace('$1', 'Alice') + .replace('$2', 'Bob'); + expect(screen.getByText(expectedText)).toBeInTheDocument(); +}); + +❌ WRONG: +it('displays token with symbol', () => { + render(); + expect(screen.getByText('Balance: DAI')).toBeInTheDocument(); // Hardcoded +}); + +it('displays message with placeholders', () => { + render(); + // This will fail - placeholders not replaced + expect(screen.getByText(messages.transferFrom.message)).toBeInTheDocument(); +}); +``` + +## Import Testing Library Utilities Directly + +### Import fireEvent from @testing-library/react + +- **Import `fireEvent` directly from `@testing-library/react`** +- Don't import from `test/jest` re-exports +- Apply to all @testing-library utilities (screen, render, waitFor, etc.) + +```typescript +✅ CORRECT: +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; + +it('handles click event', () => { + render(); + fireEvent.click(screen.getByRole('button')); + expect(mockFn).toHaveBeenCalled(); +}); + +❌ WRONG: +import { fireEvent } from '../../../test/jest'; +import { render, screen } from '@testing-library/react'; + +it('handles click event', () => { + render(); + fireEvent.click(screen.getByRole('button')); + expect(mockFn).toHaveBeenCalled(); +}); +``` + +## Fitness Function Protection + +### Quality Gate for Locale Query Mismatches + +A fitness function test (`test/unit-global/locale-query-mismatch.test.ts`) prevents: + +1. **Querying via locale keys when component renders hardcoded text** + - If component has hardcoded JSX text, test shouldn't query via i18n key + +2. **Introducing new hardcoded query strings that match existing locale values** + - New tests must use `messages..message` pattern + - Baseline allowlist exists for legacy exceptions + +If the fitness function fails, either: + +- Fix the test to use i18n references +- Fix the component to use i18n (preferred) +- Add to baseline only if legacy code + +## Complete Example + +```typescript +import { render, screen, fireEvent } from '@testing-library/react'; +import { enLocale as messages } from '../../../test/lib/i18n-helpers'; +import { TokenApproval } from './token-approval'; + +describe('TokenApproval', () => { + const mockProps = { + tokenSymbol: 'USDC', + spenderName: 'Uniswap', + onApprove: jest.fn(), + onReject: jest.fn(), + }; + + it('displays approval message with token and spender', () => { + render(); + + const expectedMessage = messages.approveTokenSpender.message + .replace('$1', 'USDC') + .replace('$2', 'Uniswap'); + + expect(screen.getByText(expectedMessage)).toBeInTheDocument(); + }); + + it('calls onApprove when confirm button is clicked', () => { + render(); + + const confirmButton = screen.getByRole('button', { + name: messages.confirm.message, + }); + + fireEvent.click(confirmButton); + + expect(mockProps.onApprove).toHaveBeenCalledTimes(1); + }); + + it('calls onReject when cancel button is clicked', () => { + render(); + + const cancelButton = screen.getByRole('button', { + name: messages.cancel.message, + }); + + fireEvent.click(cancelButton); + + expect(mockProps.onReject).toHaveBeenCalledTimes(1); + }); + + it('disables confirm button when loading', () => { + render(); + + const confirmButton = screen.getByRole('button', { + name: messages.confirm.message, + }); + + expect(confirmButton).toBeDisabled(); + }); +}); +``` + +## Benefits of This Approach + +1. **Resilient to translation changes** - Tests won't break when copy changes +2. **Self-documenting** - Clear which i18n keys are being tested +3. **Enforced by automation** - Fitness function prevents regressions +4. **Consistent patterns** - All tests follow same i18n approach +5. **Better refactoring** - Can safely update translations without test failures + +## Checklist + +Before submitting tests: + +- [ ] Import `enLocale as messages` from `test/lib/i18n-helpers` +- [ ] No direct imports from `app/_locales/en/messages.json` +- [ ] All string queries use `messages..message` pattern +- [ ] Parameterized strings use `.replace()` for placeholders +- [ ] Import testing utilities directly from `@testing-library/react` +- [ ] No hardcoded English strings in assertions +- [ ] Fitness function test passes diff --git a/domains/testing/skills/test-i18n-usage/skill.md b/domains/testing/skills/test-i18n-usage/skill.md new file mode 100644 index 0000000..5ca8e36 --- /dev/null +++ b/domains/testing/skills/test-i18n-usage/skill.md @@ -0,0 +1,4 @@ +--- +name: test-i18n-usage +description: i18n usage patterns in test files +--- diff --git a/domains/testing/skills/unit-testing/repos/metamask-extension.md b/domains/testing/skills/unit-testing/repos/metamask-extension.md new file mode 100644 index 0000000..1a977af --- /dev/null +++ b/domains/testing/skills/unit-testing/repos/metamask-extension.md @@ -0,0 +1,755 @@ +--- +repo: metamask-extension +parent: unit-testing +--- + + +Reference: [MetaMask Unit Testing Guidelines](https://github.com/MetaMask/contributor-docs/blob/main/docs/testing/unit-testing.md) + +**See also:** [Test i18n Usage Guidelines](../test-i18n-usage/RULE.md) - For i18n patterns in test assertions + +# MetaMask Extension - Cursor Rules + +## Unit Testing Guidelines + +### Testing Framework + +- **ALWAYS use Jest** for unit tests (not Mocha or Tape) +- Leverage Jest's built-in features: module mocks, timer mocks, snapshots, and parallel test execution + +### Test File Organization + +#### File Placement + +- **ALWAYS colocate test files with implementation files** +- Test files should use the `.test.ts` or `.test.tsx` extension +- Place test file in the same directory as the code it tests + +Example: + +``` +✅ CORRECT: +src/ + permission-controller.ts + permission-controller.test.ts + +❌ WRONG: +src/ + permission-controller.ts +test/ + permission-controller.ts +``` + +#### Test Structure + +- **ALWAYS wrap tests for the same function/method in a `describe` block** +- Use nested `describe` blocks to organize tests by method/function name +- Use `describe` blocks with "when..." or "if..." to group tests under scenarios + +Example: + +```typescript +describe('KeyringController', () => { + describe('addAccount', () => { + it('adds a new account to the given keyring', () => { + // ... + }); + }); + + describe('removeAccount', () => { + describe('when the account exists', () => { + it('removes the account from its associated keyring', () => { + // ... + }); + }); + }); +}); +``` + +### Test Descriptions + +#### Writing `it` Statements + +- **NEVER use "should" at the beginning of test names** +- **NEVER repeat the function/method name in the test description** +- Describe the desired behavior in present tense +- Focus on a single aspect of behavior per test + +Examples: + +```typescript +❌ WRONG: +it('should not stop the block tracker', () => {}); +it('addToken', () => {}); + +✅ CORRECT: +it('does not stop the block tracker', () => {}); +it('adds the given token to "tokens" in state', () => {}); +``` + +#### Test Focus + +- **Keep tests focused on one aspect of behavior** +- If using "and" in a test description, consider splitting into multiple tests + +Example: + +```typescript +❌ WRONG: +it('starts the block tracker and returns the block number', () => {}); + +✅ CORRECT: +it('starts the block tracker', () => {}); +it('returns the block number', () => {}); +``` + +### Testing Approach + +#### Private Code + +- **NEVER directly test private code** +- Test private methods/functions through their public interface +- Private code includes: + - Non-exported functions or classes + - Methods starting with `#` (ECMAScript private fields) + - Methods starting with `_` (informal private convention) + - Methods with `private` keyword in TypeScript + - Functions/methods tagged with `@private` JSDoc + +#### Test Phase Organization + +- **Clearly separate the three phases of a test:** + 1. **Arrange**: Set up test data and preconditions + 2. **Act**: Execute the code being tested + 3. **Assert**: Verify the expected behavior +- Use blank lines or comments to highlight the "exercise" (Act) phase +- Consider using comments like `// Arrange`, `// Act`, `// Assert` for complex tests + +#### Test Data + +- **Keep critical data inside the test** +- Don't spread essential test data across multiple variables at file level +- Make the test "story" self-contained and easy to follow +- Inline important data rather than referencing distant constants + +Example: + +```typescript +✅ CORRECT: +it('loads the token list for the selected chain', async () => { + const chainIdInHex = '0x1'; + const tokensByAddress = { + '0xc011a73ee8576fb46f5e1c5751ca3b9fe0af2a6f': { + address: '0xc011a73ee8576fb46f5e1c5751ca3b9fe0af2a6f', + symbol: 'SNX', + decimals: 18, + // ... other properties inline + }, + }; + + // ... test continues with this data +}); +``` + +### Mocking and Test Utilities + +#### Mock Functions + +- **Use Jest's mock functions instead of Sinon** +- Use `jest.fn()` instead of `sinon.stub()` +- Use `jest.spyOn(object, method)` instead of `sinon.spy()` or `sinon.stub()` +- Use `jest.useFakeTimers()` instead of `sinon.useFakeTimers()` + +#### Manual Mocks + +- **AVOID general manual mocks in `__mocks__/` directories** +- Manual mocks in `__mocks__/` are automatically applied to ALL tests +- Only use manual mocks when absolutely necessary and document their impact +- Prefer inline mocks with `jest.mock()` for test-specific behavior + +#### Test Helpers + +- Create helper functions to reduce boilerplate +- Use TypeScript for type-safe test utilities +- Prefer async/await for test helpers that set up controllers or async resources +- Include cleanup/teardown in helper functions (use try/finally) + +Example: + +```typescript +async function withController(...args: WithControllerArgs) { + const controller = new TokensController(options); + + try { + await fn({ controller }); + } finally { + controller.destroy(); + } +} +``` + +### Snapshot Testing + +#### Snapshot Test Naming + +- **NEVER name snapshot tests as "should render correctly" or "renders correctly"** +- **ALWAYS use "render matches snapshot" or similar variants** +- Add context when needed: "render matches snapshot when not enabled" +- Remember: Snapshots only check for changes, NOT correctness + +Examples: + +```typescript +❌ WRONG: +it('should renders correctly', () => {}); +it('renders correctly', () => {}); + +✅ CORRECT: +it('render matches snapshot', () => {}); +it('render matches snapshot when not enabled', () => {}); +it('render matches snapshot with custom props', () => {}); +``` + +### Async Testing Best Practices + +#### Async/Await Usage + +- **ALWAYS use async/await instead of callbacks or done()** +- Return promises from test functions when using async operations +- Never mix done() callbacks with async/await + +Examples: + +```typescript +❌ WRONG: +it('fetches data', (done) => { + fetchData().then((data) => { + expect(data).toBeDefined(); + done(); + }); +}); + +✅ CORRECT: +it('fetches data', async () => { + const data = await fetchData(); + expect(data).toBeDefined(); +}); +``` + +#### Timer Mocking + +- Use `jest.useFakeTimers()` with fake timers for time-dependent code +- Use `jest.advanceTimersByTime()` to control time progression +- Remember to call `jest.runAllTimers()` or `jest.advanceTimersByTime()` before assertions +- Clean up timers with `jest.useRealTimers()` in `afterEach` + +Example: + +```typescript +describe('debounce', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('delays execution by specified time', () => { + const callback = jest.fn(); + const debounced = debounce(callback, 1000); + + debounced(); + expect(callback).not.toHaveBeenCalled(); + + jest.advanceTimersByTime(1000); + expect(callback).toHaveBeenCalledTimes(1); + }); +}); +``` + +#### Error Path Testing + +- **ALWAYS test both success and error paths for async operations** +- Use `expect().rejects.toThrow()` for async error assertions +- Test error recovery and cleanup behavior +- Verify specific error types and messages + +Example: + +```typescript +it('throws an error when the network request fails', async () => { + nock('https://api.example.com') + .get('/data') + .reply(500, { error: 'Internal Server Error' }); + + await expect(fetchData()).rejects.toThrow('Failed to fetch data'); +}); + +it('cleans up resources after an error', async () => { + const cleanup = jest.fn(); + + try { + await operationThatFails(); + } catch (error) { + cleanup(); + } + + expect(cleanup).toHaveBeenCalled(); +}); +``` + +#### Async State Changes + +- Use `waitFor()` or similar patterns for async state changes (React Testing Library) +- Set appropriate test timeouts for long-running operations +- Avoid arbitrary delays with `setTimeout()` in tests + +Example: + +```typescript +it('updates state after async operation', async () => { + render(); + + fireEvent.click(screen.getByRole('button', { name: 'Load Data' })); + + await waitFor(() => { + expect(screen.getByText('Data loaded')).toBeInTheDocument(); + }); +}); +``` + +### Mock Data Management + +#### Test Data Factories + +- Create factory functions for complex test objects +- Use builders for objects with many optional properties +- Keep factories in test files or colocated test-utils (not in `__mocks__/`) +- Prefer minimal valid objects, adding only necessary properties per test + +Example: + +```typescript +// Good: Factory function for creating test data +function createMockTransaction( + overrides: Partial = {}, +): Transaction { + return { + id: '1', + status: 'pending', + from: '0x123', + to: '0x456', + value: '0x0', + gasLimit: '0x5208', + ...overrides, + }; +} + +describe('TransactionController', () => { + it('updates transaction status', () => { + const transaction = createMockTransaction({ + id: '42', + status: 'submitted', + }); + + controller.updateTransaction(transaction); + + expect(controller.state.transactions[0].status).toBe('submitted'); + }); +}); +``` + +#### Builder Pattern for Complex Objects + +- Use builder pattern for objects with many optional fields +- Chain methods for readability +- Provide sensible defaults + +Example: + +```typescript +class TransactionBuilder { + private transaction: Partial = { + status: 'pending', + value: '0x0', + }; + + withId(id: string): this { + this.transaction.id = id; + return this; + } + + withStatus(status: TransactionStatus): this { + this.transaction.status = status; + return this; + } + + build(): Transaction { + return this.transaction as Transaction; + } +} + +it('processes confirmed transactions', () => { + const transaction = new TransactionBuilder() + .withId('1') + .withStatus('confirmed') + .build(); + + // test with transaction +}); +``` + +#### Avoid Shared Mutable State + +- Don't reuse mock objects across tests +- Each test should create its own fresh mock data +- Be careful with module-level constants that are mutated + +Example: + +```typescript +❌ WRONG: +const mockAccount = { address: '0x123', balance: '100' }; + +it('updates balance', () => { + mockAccount.balance = '200'; // Mutates shared state! + expect(getBalance(mockAccount)).toBe('200'); +}); + +it('has initial balance', () => { + expect(getBalance(mockAccount)).toBe('100'); // Will fail! +}); + +✅ CORRECT: +function createMockAccount() { + return { address: '0x123', balance: '100' }; +} + +it('updates balance', () => { + const account = createMockAccount(); + account.balance = '200'; + expect(getBalance(account)).toBe('200'); +}); + +it('has initial balance', () => { + const account = createMockAccount(); + expect(getBalance(account)).toBe('100'); +}); +``` + +### Controller-Specific Testing Patterns + +Reference: [MetaMask Controller Guidelines](https://github.com/MetaMask/core/blob/main/docs/controller-guidelines.md) + +#### Controller Lifecycle Testing + +- **ALWAYS test controller initialization with default state** +- Test controller destruction/cleanup (if `destroy()` method exists) +- Verify state is properly initialized with partial state options +- Test that default state function (`getDefault${ControllerName}State`) returns correct values + +Example: + +```typescript +describe('TokensController', () => { + describe('constructor', () => { + it('initializes with default state when no state is provided', () => { + const controller = new TokensController({ + messenger: getTokensMessenger(), + }); + + expect(controller.state).toStrictEqual(getDefaultTokensControllerState()); + }); + + it('merges provided state with defaults', () => { + const controller = new TokensController({ + messenger: getTokensMessenger(), + state: { tokens: { '0x1': [{ address: '0xabc' }] } }, + }); + + expect(controller.state.tokens).toStrictEqual({ + '0x1': [{ address: '0xabc' }], + }); + // Other default state properties should still be present + }); + }); + + describe('destroy', () => { + it('cleans up subscriptions and timers', () => { + const controller = new TokensController({ + messenger: getTokensMessenger(), + }); + + controller.destroy(); + + // Verify cleanup occurred (e.g., no memory leaks, timers cleared) + }); + }); +}); +``` + +#### State Management Testing + +- **Test state updates through controller methods, not direct state manipulation** +- Verify state changes trigger appropriate events via messenger +- Use `controller.state` to access state (NOT internal properties) +- Test that state metadata is correctly defined (persist, anonymous, usedInUi) + +Example: + +```typescript +describe('TokensController', () => { + describe('addToken', () => { + it('updates state with new token', () => { + const controller = new TokensController({ + messenger: getTokensMessenger(), + }); + + controller.addToken({ + address: '0x123', + symbol: 'DAI', + decimals: 18, + }); + + expect(controller.state.tokens['0x1']).toContainEqual({ + address: '0x123', + symbol: 'DAI', + decimals: 18, + }); + }); + + it('publishes state change event', async () => { + const messenger = getTokensMessenger(); + const stateChangeListener = jest.fn(); + + messenger.subscribe('TokensController:stateChange', stateChangeListener); + + const controller = new TokensController({ messenger }); + controller.addToken({ address: '0x123', symbol: 'DAI', decimals: 18 }); + + expect(stateChangeListener).toHaveBeenCalled(); + }); + }); +}); +``` + +#### Messenger Interaction Testing + +- Mock other controllers using messenger allowedActions +- Test event subscriptions and publications +- Verify messenger calls to other controllers +- Test that controller responds correctly to events from other controllers + +Example: + +```typescript +describe('PreferencesController', () => { + it('updates when AccountsController state changes', () => { + const messenger = getPreferencesMessenger(); + const controller = new PreferencesController({ messenger }); + + // Trigger AccountsController state change + messenger.publish( + 'AccountsController:stateChange', + { + selectedAccount: { address: '0x456' }, + }, + [], + ); + + expect(controller.state.selectedAddress).toBe('0x456'); + }); + + it('calls AccountsController to get accounts', () => { + const messenger = getPreferencesMessenger(); + const getAccountsSpy = jest.spyOn(messenger, 'call'); + + const controller = new PreferencesController({ messenger }); + controller.syncWithAccounts(); + + expect(getAccountsSpy).toHaveBeenCalledWith( + 'AccountsController:getAccounts', + ); + }); +}); +``` + +#### Selector Testing + +- Test selectors as pure functions independently from controllers +- Use actual controller state shape in selector tests +- Test memoization behavior if using `reselect` +- Export selectors under `${controllerName}Selectors` object + +Example: + +```typescript +import { accountsControllerSelectors } from './AccountsController'; + +describe('accountsControllerSelectors', () => { + describe('selectActiveAccounts', () => { + it('returns only active accounts', () => { + const state = { + accounts: [ + { address: '0x1', isActive: true }, + { address: '0x2', isActive: false }, + { address: '0x3', isActive: true }, + ], + }; + + const result = accountsControllerSelectors.selectActiveAccounts(state); + + expect(result).toHaveLength(2); + expect(result[0].address).toBe('0x1'); + expect(result[1].address).toBe('0x3'); + }); + }); +}); +``` + +#### External Dependency Mocking + +- Mock network requests (use `nock` for HTTP) +- Mock blockchain interactions (eth_call, eth_sendTransaction, etc.) +- Mock storage operations +- Mock other controllers via messenger + +Example: + +```typescript +describe('TokensController', () => { + describe('fetchTokens', () => { + it('fetches tokens from API', async () => { + nock('https://token-api.example.com') + .get('/tokens/1') + .reply(200, [{ address: '0x123', symbol: 'DAI', decimals: 18 }]); + + const controller = new TokensController({ + messenger: getTokensMessenger(), + }); + + await controller.fetchTokens(); + + expect(controller.state.tokens['0x1']).toHaveLength(1); + }); + + it('handles API errors gracefully', async () => { + nock('https://token-api.example.com') + .get('/tokens/1') + .reply(500, { error: 'Internal Server Error' }); + + const controller = new TokensController({ + messenger: getTokensMessenger(), + }); + + await expect(controller.fetchTokens()).rejects.toThrow(); + }); + }); +}); +``` + +#### Action Method Testing + +- Test methods as high-level actions, not just state setters +- Verify business logic within action methods +- Test side effects (API calls, event emissions, messenger calls) +- Test validation and error handling + +Example: + +```typescript +describe('AlertsController', () => { + describe('dismissAlert', () => { + it('marks alert as dismissed in state', () => { + const controller = new AlertsController({ + messenger: getAlertsMessenger(), + state: { + alerts: { + alert1: { message: 'Test', isDismissed: false }, + }, + }, + }); + + controller.dismissAlert('alert1'); + + expect(controller.state.alerts.alert1.isDismissed).toBe(true); + }); + + it('throws error if alert does not exist', () => { + const controller = new AlertsController({ + messenger: getAlertsMessenger(), + }); + + expect(() => controller.dismissAlert('nonexistent')).toThrow( + 'Alert not found: nonexistent', + ); + }); + }); +}); +``` + +## General Coding Standards + +### TypeScript + +- Prefer TypeScript over JavaScript for all new code +- Use proper type annotations (avoid `any`) +- Define interfaces for complex objects and function parameters + +### Code Organization + +- Export public interfaces only +- Keep implementation details private +- Use meaningful variable and function names + +### Documentation + +- Add JSDoc comments for public APIs +- Document complex logic inline +- Keep comments up-to-date with code changes + +## Testing Checklist + +Before submitting tests, ensure: + +### Basic Structure + +- [ ] Test file is colocated with implementation +- [ ] Tests are organized with `describe` blocks by method/function +- [ ] Test descriptions use present tense without "should" +- [ ] Tests are focused on single behaviors +- [ ] Private code is tested through public interface +- [ ] Test phases (Arrange, Act, Assert) are clear + +### Mocking and Data + +- [ ] Critical test data is kept inline +- [ ] Jest mocks are used (not Sinon) +- [ ] Manual mocks in `__mocks__/` are avoided +- [ ] Factory functions used for complex test objects +- [ ] No shared mutable state across tests +- [ ] Fresh mock data created per test + +### Async Testing + +- [ ] async/await used (not done() callbacks) +- [ ] Fake timers used for time-dependent code +- [ ] Both success and error paths tested for async operations +- [ ] Appropriate timeouts set for long operations + +### Controller Testing (if applicable) + +- [ ] Controller initialization tested with default state +- [ ] State updates tested through controller methods +- [ ] Messenger interactions verified +- [ ] Controller lifecycle (destroy/cleanup) tested +- [ ] Selectors tested as pure functions +- [ ] External dependencies properly mocked + +### Final Checks + +- [ ] Snapshot tests are named "render matches snapshot" +- [ ] All tests pass and are deterministic +- [ ] Tests run in isolation (can run individually) +- [ ] No console errors or warnings diff --git a/domains/testing/skills/unit-testing/repos/metamask-mobile.md b/domains/testing/skills/unit-testing/repos/metamask-mobile.md new file mode 100644 index 0000000..911773b --- /dev/null +++ b/domains/testing/skills/unit-testing/repos/metamask-mobile.md @@ -0,0 +1,717 @@ +--- +repo: metamask-mobile +parent: unit-testing +--- + + +Reference: [MetaMask Unit Testing Guidelines](https://github.com/MetaMask/contributor-docs/blob/main/docs/testing/unit-testing.md) + +# Unit Testing Guidelines + +## Test Naming Rules + +- **NEVER use "should" in test names** - this is a hard rule with zero exceptions +- **Use action-oriented descriptions** that describe what the code does +- **Be specific about the behavior being tested** +- **AVOID** weasel words like "handle", "manage", or other non-specific action verbs +- **AVOID** subjective outcome words like "successfully", "correctly", "invalid" - instead indicate what the actual result should be +- **BE SPECIFIC** about conditions: use "email without domain" instead of "invalid email" + +```ts +// ❌ WRONG +it('should return fixed timestamp', () => { ... }); +it('should ignore events', () => { ... }); +it('should display error when input is invalid', () => { ... }); +it('handles invalid input correctly', () => { ... }); + +// ✅ CORRECT +it('returns fixed timestamp for privacy events', () => { ... }); +it('ignores events without privacy timestamp property', () => { ... }); +it('displays error when email is missing @ symbol', () => { ... }); +it('returns false for email without domain', () => { ... }); +``` + +## Test Structure and Organization - MANDATORY + +- **EVERY test MUST follow the AAA pattern** (Arrange, Act, Assert) with blank line separation +- **Each test must cover ONE behavior** and be isolated from others +- **Use helper functions** for test data creation +- **Group related tests** in `describe` blocks + +```ts +it('returns false for email without domain', () => { + const input = 'user@'; + + const result = validateEmail(input); + + expect(result).toBe(false); +}); +``` + +**Helper Functions**: + +```ts +const createTestEvent = (overrides = {}) => ({ + type: EventType.TrackEvent, + event: 'Test Event', + timestamp: '2024-01-01T12:00:00.000Z', + ...overrides, +}); +``` + +## Element Selection - PREFER DATA TEST IDs + +- **ALWAYS prefer `testID` props** for selecting elements in tests +- **Use `getByTestId`** as the primary query method for reliable element selection +- **Add `testID` props** to components when writing new code or updating existing code +- **Avoid selecting by text** when the text might change (i18n, copy updates) + +```tsx +// ✅ CORRECT - Use testID for reliable selection +; + +// In test: +const submitButton = screen.getByTestId('submit-button'); +expect(submitButton).toBeOnTheScreen(); + +// ✅ ALSO GOOD - Locale keys (safe from content updates) +const button = screen.getByText(strings('common.submit')); + +// ❌ AVOID - Selecting by hardcoded text content +const button = screen.getByText('Submit'); // Breaks when text changes +const input = screen.getByPlaceholderText('Enter email'); // Fragile +``` + +### CHILD PROP OBJECTS - ALL COMPONENTS SUPPORT THIS + +**ALL `@metamask/design-system-react-native` and `app/component-library` components support child prop objects for passing testIDs to internal elements.** This is a universal design pattern - prefer not to mock these components just to inject testIDs. + +Common child prop object patterns: + +- `closeButtonProps` - for close/dismiss buttons +- `backButtonProps` - for back navigation buttons +- `startAccessoryProps` / `endAccessoryProps` - for accessory elements +- `iconProps` - for icon elements +- `labelProps` - for label text elements +- `inputProps` - for input elements within compound components +- `*Props` - any prop ending in `Props` is likely a child prop object + +```tsx +// ❌ WRONG - Mocking to add testID (141 lines of unnecessary code!) +// The testID capability ALREADY EXISTS via child prop objects! +jest.mock('BottomSheetHeader', () => { + return ({ onClose }) => ( + Close + ); +}); + +// ✅ CORRECT - Use the component's child prop object API + + +// ✅ CORRECT - BottomSheetHeader supports child prop objects + + {title} + + +// In test - no mocking needed! +const closeButton = screen.getByTestId('region-selector-close-button'); +fireEvent.press(closeButton); +``` + +### This Is Universal - No Exceptions + +| Library | testID Support | +| -------------------------------------- | -------------------------------------------------------------- | +| `@metamask/design-system-react-native` | ✅ All components support `testID` prop AND child prop objects | +| `app/component-library/*` | ✅ All components support `testID` prop AND child prop objects | + +**If you need to add a testID to one of these components, check for child prop objects first.** Most components support this functionality. If not available, suggest adjusting the component to support it in another change set. + +### How to Find Child Prop Objects + +1. **Check TypeScript types** - Look at the component's props interface for props ending in `Props` +2. **Check component source** - Search for `Props` suffix patterns +3. **Check Storybook** - Component stories demonstrate these props + +```tsx +// Example: BottomSheetHeader TypeScript interface shows: +// - closeButtonProps?: ButtonIconProps +// - backButtonProps?: ButtonIconProps + +// Example: Design system Button with icon + +``` + +### TestID Naming Conventions + +```tsx +// Use kebab-case for testIDs +testID="settings-screen" +testID="submit-button" +testID="error-message" +testID="token-list-item" + +// Include context for list items +testID={`token-item-${token.symbol}`} +testID={`network-option-${network.chainId}`} +``` + +## Snapshot Testing Policy - MANDATORY + +### The Rule + +- ❌ **`toMatchSnapshot()` is BANNED** — Do not use it. Do not add it. Do not approve it. +- ✅ **`toMatchInlineSnapshot()` is ALLOWED** — Use sparingly, only when the serialized output is the meaningful assertion. + +### Why `toMatchSnapshot()` Is Banned + +External snapshot files (`.snap`) have three critical problems: + +1. **Invisible in review** — The diff lives in a separate `.snap` file that reviewers routinely rubber-stamp. Regressions hide there. +2. **No code ownership** — `CODEOWNERS` explicitly assigns `**/*.snap` to *nobody*, meaning no team is accountable for snapshot correctness. +3. **Brittle by design** — Any style tweak, whitespace change, or unrelated refactor regenerates the snapshot and silently passes CI. + +### Why `toMatchInlineSnapshot()` Is Allowed + +The snapshot string lives directly in the test file, so: +- It appears in the PR diff alongside the code that produces it +- The file's code owner is responsible for reviewing it +- Reviewers can see exactly what changed and why + +### When to Use `toMatchInlineSnapshot()` + +Only use it when the serialized shape of the output **is** the assertion — for example, verifying a complex object structure, a formatted string, or a serialized data payload. Do **not** use it as a lazy substitute for explicit `expect` calls. + +```ts +// ❌ BANNED — writes to an external .snap file +expect(tree).toMatchSnapshot(); +expect(component).toMatchSnapshot(); +expect(result).toMatchSnapshot(); + +// ✅ ALLOWED — snapshot is inline and visible in review +expect(result).toMatchInlineSnapshot(` + { + "chainId": "0x1", + "name": "Ethereum Mainnet", + } +`); + +// ✅ PREFERRED — explicit assertions are always better than snapshots +expect(result.chainId).toBe('0x1'); +expect(result.name).toBe('Ethereum Mainnet'); +``` + +### Migrating Existing `toMatchSnapshot()` Calls + +When you encounter an existing `toMatchSnapshot()` call: +1. **Prefer replacing it** with explicit `expect` assertions targeting the specific values that matter. +2. If the full serialized output is genuinely meaningful, convert to `toMatchInlineSnapshot()`. +3. Delete the corresponding `.snap` file entry once migrated. + +Do **not** leave `toMatchSnapshot()` in place when modifying a test file — migrate it as part of your change. + + +## Assertions - PREFER toBeOnTheScreen + +- **ALWAYS use `toBeOnTheScreen()`** to assert element presence - NOT `toBeTruthy()` or `toBeDefined()` +- **Use specific matchers** that communicate intent clearly +- **Avoid weak matchers** that don't actually verify the expected behavior + +```tsx +// ✅ CORRECT - Clear, specific assertions +expect(screen.getByTestId('submit-button')).toBeOnTheScreen(); +expect(screen.queryByTestId('error-message')).not.toBeOnTheScreen(); +expect(screen.getByTestId('balance-text')).toHaveTextContent('100 ETH'); + +// ❌ WRONG - Weak matchers that don't verify presence properly +expect(screen.getByTestId('submit-button')).toBeTruthy(); // Misleading +expect(screen.getByTestId('submit-button')).toBeDefined(); // Doesn't verify render +expect(element).not.toBeNull(); // Use toBeOnTheScreen() instead +``` + +### Recommended Matchers + +| Instead of | Use | +| ------------------------------------ | --------------------------------------------------------- | +| `toBeTruthy()` for elements | `toBeOnTheScreen()` | +| `toBeDefined()` for elements | `toBeOnTheScreen()` | +| `not.toBeNull()` | `not.toBeOnTheScreen()` or `queryByTestId` returning null | +| `toHaveLength(1)` for single element | `toBeOnTheScreen()` | + +## Mocking Rules - CRITICAL + +### Exception: UI Components and TestIDs + +**Prefer not to mock `@metamask/design-system-react-native` or `app/component-library` components just to inject testIDs.** All these components support testIDs via: + +- Direct `testID` prop on the component +- Child prop objects (`closeButtonProps`, `iconProps`, etc.) for internal elements + +```tsx +// ❌ WRONG: Mocking to inject testID +jest.mock('BottomSheetHeader', () => ({ onClose }) => ( + + Close + +)); + +// ✅ RIGHT: Use child prop objects +; +``` + +See [PR #25548](https://github.com/MetaMask/metamask-mobile/pull/25548) for refactoring example. + +### General Mocking Rules + +- **EVERYTHING not under test MUST be mocked** - no exceptions +- **NO** use of `require` - use ES6 imports only +- **NO** use of `any` type - use proper TypeScript types +- **Mock all external dependencies** including APIs, services, hooks +- **Use realistic mock data** that reflects real usage + +### Theme Mocking Rules + +- **Prefer shared `mockTheme` from `app/util/theme`** instead of hard-coded color literals in tests. +- **Never hardcode design token hex values** in assertions or theme mocks (enforced by `@metamask/design-tokens/color-no-hex`). +- **Avoid local hex color objects** for `useTheme`, `useStyles`, or tailwind mock color functions. +- **If a test only needs a specific theme field**, derive it from `mockTheme` (or spread `mockTheme` and override minimally). + +```ts +// ✅ CORRECT: use shared mockTheme for useTheme mocks +import { mockTheme } from '../../util/theme'; + +jest.mock('../../util/theme', () => ({ + useTheme: () => mockTheme, +})); +``` + +```ts +// ✅ CORRECT: return { styles, theme } for useStyles mocks +import { mockTheme } from '../../util/theme'; + +jest.mock('../../../../../component-library/hooks', () => ({ + useStyles: jest.fn((styleFn, vars) => ({ + styles: styleFn({ theme: mockTheme, vars }), + theme: mockTheme, + })), +})); +``` + +```ts +// ✅ CORRECT: mock useTailwind with the right shape +jest.mock('@metamask/design-system-twrnc-preset', () => ({ + useTailwind: () => ({ + // Most components only need tw.style(...) + style: jest.fn(() => ({})), + }), +})); +``` + +```ts +// ✅ ALSO CORRECT: match real useTailwind() return type (callable function + helpers) +import { mockTheme } from '../../util/theme'; + +jest.mock('@metamask/design-system-twrnc-preset', () => ({ + useTailwind: () => { + const tw = () => ({}); + tw.style = jest.fn(() => ({})); + return tw; + }, +})); +``` + +```ts +// ❌ AVOID: local hex color mocks +const mockColors = { + text: { default: '#000000' }, + background: { default: '#FFFFFF' }, + border: { muted: '#E5E7EB' }, +}; +``` + +```ts +// ✅ CORRECT +import { apiService } from '../services/api'; +jest.mock('../services/api'); +const mockApiService = apiService as jest.Mocked; + +interface MockEvent { + type: EventType; + event: string; + timestamp: string; +} + +// ❌ WRONG +const mockApi = require('../services/api'); // ❌ no require +const mockApi: any = jest.fn(); // ❌ no any type +``` + +## Test Isolation and Focus - MANDATORY + +- **Each test MUST be independent** - no shared state between tests +- **Use `beforeEach` for setup, `afterEach` for cleanup** +- **Reset all mocks between tests** +- **Tests MUST run in any order** +- **Avoid duplicated or polluted tests** +- **Use mocks for all external dependencies** + +```ts +// ✅ CORRECT Test Isolation +describe('MetaMetricsCustomTimestampPlugin', () => { + let plugin: MetaMetricsCustomTimestampPlugin; + + beforeEach(() => { + plugin = new MetaMetricsCustomTimestampPlugin({ + timestampStrategy: 'fixed', + customTimestamp: '1970-01-01T00:00:00.000Z', + }); + jest.clearAllMocks(); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + it('returns fixed timestamp for privacy events', () => { + const event = createTestEvent({ privacyTimestamp: true }); + + const result = plugin.execute(event); + + expect(result.timestamp).toBe('1970-01-01T00:00:00.000Z'); + }); +}); +``` + +## Test Coverage (MANDATORY) + +**EVERY component MUST test:** + +- ✅ **Happy path** - normal expected behavior +- ✅ **Edge cases** - null, undefined, empty values, boundary conditions +- ✅ **Error conditions** - invalid inputs, failure scenarios +- ✅ **Different code paths** - all if/else branches, switch cases +- ✅ **Method chaining** - for builder patterns +- ✅ **Side effects** - property changes, state updates, cleanup + +```ts +// ✅ CORRECT Coverage Example +describe('MetaMetricsCustomTimestampPlugin', () => { + describe('execute', () => { + it('returns fixed timestamp for privacy events', () => { + // Happy path + }); + + it('ignores events without privacy timestamp property', () => { + // Edge case + }); + + it('throws error when strategy is null', () => { + // Error condition + }); + + it('uses event-specific timestamp strategy when provided', () => { + // Different code path + }); + + it('removes privacy properties from event', () => { + // Side effect + }); + }); +}); +``` + +## Parameterized Tests + +- Parameterize tests to cover all values (e.g., enums) with type-safe iteration. + +```ts +it.each(['small', 'medium', 'large'] as const)('renders %s size', (size) => { + expect(renderComponent(size)).toBeOnTheScreen(); +}); +``` + +## Test Determinism + +- **EVERYTHING** not under test must be mocked - no exceptions. +- Avoid brittle tests: do not test internal state or UI snapshots for logic. **`toMatchSnapshot()` is banned** — see Snapshot Testing Policy above. +- Only test public behavior, not implementation details. +- Mock time, randomness, and external systems to ensure consistent results. + +```ts +// Mock all external dependencies +jest.mock('../services/api'); +jest.mock('../utils/date'); +jest.mock('../hooks/useAuth'); + +jest.useFakeTimers(); +jest.setSystemTime(new Date('2024-01-01')); +``` + +- Avoid relying on global state or hardcoded values (e.g., dates) or mock it. + +## Async Testing and act() - CRITICAL + +**ALWAYS use `act()` when testing async operations that trigger React state updates.** + +### When to Use act() + +Use `act()` when you: + +- Call async functions via component props (e.g., `onRefresh`, `onPress` with async handlers) +- Invoke functions that perform state updates asynchronously +- Test pull-to-refresh or other async interactions + +### Symptoms of Missing act() + +Tests fail intermittently with: + +- `TypeError: terminated` +- `SocketError: other side closed` +- Warnings about state updates not being wrapped in act() + +### Examples + +```ts +// ❌ WRONG - Causes flaky tests +it('calls Logger.error when handleOnRefresh fails', async () => { + const mockLoggerError = jest.spyOn(Logger, 'error'); + render(BankDetails); + + // Async function called without act() - causes race condition + screen + .getByTestId('refresh-control-scrollview') + .props.refreshControl.props.onRefresh(); + + await waitFor(() => { + expect(mockLoggerError).toHaveBeenCalled(); + }); +}); + +// ✅ CORRECT - Properly handles async state updates +it('calls Logger.error when handleOnRefresh fails', async () => { + const mockLoggerError = jest.spyOn(Logger, 'error'); + render(BankDetails); + + // Wrap async operation in act() + await act(async () => { + await screen + .getByTestId('refresh-control-scrollview') + .props.refreshControl.props.onRefresh(); + }); + + await waitFor(() => { + expect(mockLoggerError).toHaveBeenCalled(); + }); +}); +``` + +### Common Patterns Requiring act() + +```ts +// RefreshControl callbacks +await act(async () => { + await refreshControl.props.onRefresh(); +}); + +// Async button press handlers +await act(async () => { + await button.props.onPress(); +}); + +// Any async callback that updates state +await act(async () => { + await component.props.onSomeAsyncAction(); +}); +``` + +### Why This Matters + +Without `act()`: + +1. Async function starts executing +2. Test continues and waits only for specific assertion +3. Jest cleanup/termination happens while promises are still pending +4. Results in "terminated" or "other side closed" errors + +With `act()`: + +1. React Testing Library waits for all state updates +2. All promises resolve before test proceeds +3. Clean, deterministic test execution + +# Reviewer Responsibilities + +- Validate that tests fail when the code is broken (test the test). + +```ts +// Break the SuT and make sure this test fails +expect(result).toBe(false); +``` + +- Ensure tests use proper matchers (`toBeOnTheScreen` vs `toBeDefined`). +- **Reject any new `.snap` files or new `toMatchSnapshot()` calls** — these are banned; require the author to use explicit assertions or `toMatchInlineSnapshot()` instead. +- Reject tests with complex names combining multiple logical conditions (AND/OR). + +# Refactoring Support + +- Ensure tests provide safety nets during refactors and logic changes. Run the tests before pushing commits! +- Encourage small, testable components. +- Unit tests must act as documentation for feature expectations. + +# Quality Checklist - MANDATORY + +Before submitting any test file, verify: + +- [ ] **No `toMatchSnapshot()` calls** — BANNED; use explicit assertions or `toMatchInlineSnapshot()` instead +- [ ] **No mocking to inject testIDs** - Use component's built-in testID support +- [ ] **testIDs via child prop objects** - Use `closeButtonProps={{ testID }}` not mocks +- [ ] **No "should" in any test name** +- [ ] **All tests follow AAA pattern** +- [ ] **Each test has one clear purpose** +- [ ] **All code paths are tested** +- [ ] **Edge cases are covered** +- [ ] **Test data is realistic** +- [ ] **Tests are independent** +- [ ] **Assertions use `toBeOnTheScreen()`** - NOT `toBeTruthy()` or `toBeDefined()` +- [ ] **Assertions are specific** +- [ ] **Elements selected by `testID`** - NOT fragile text queries +- [ ] **Test names are descriptive** +- [ ] **No test duplication** +- [ ] **Async operations wrapped in act()** when they trigger state updates + +# Common Mistakes to AVOID - CRITICAL + +- ❌ **Using `toMatchSnapshot()`** — BANNED; it writes opaque `.snap` files with no code owner; use explicit assertions or `toMatchInlineSnapshot()` instead +- ❌ **Mocking to inject testIDs** - Components already support testID (see guidelines above) +- ❌ **Using "should" in test names** - This is the #1 mistake, use action-oriented descriptions +- ❌ **Testing multiple behaviors in one test** - One test, one behavior +- ❌ **Sharing state between tests** - Each test must be independent +- ❌ **Not testing error conditions** - Test both success and failure paths +- ❌ **Using unrealistic test data** - Use data that reflects real usage +- ❌ **Not following AAA pattern** - Always Arrange, Act, Assert +- ❌ **Not testing edge cases** - Test null, undefined, empty values +- ❌ **Using weak matchers** - Use `toBeOnTheScreen()` instead of `toBeTruthy()`/`toBeDefined()` +- ❌ **Selecting elements by text** - Use `testID` props for reliable selection +- ❌ **Not wrapping async state updates in act()** - Causes flaky "terminated" errors + +# Unit tests developement workflow + +- Always run unit tests after making code changes. +- **NEVER** use npm, npx, or other package managers - ONLY use yarn + +## Testing Commands + +### Single File Testing + +```shell +# Use this command for testing a specific file +yarn jest +# Use this command for testing specific test cases +yarn jest -t "" +# Use this command for running all unit tests +yarn test:unit +# Run a specific test file +yarn jest MyComponent.test.tsx +yarn jest utils/helpers.test.ts +``` + +### Coverage Reports + +```shell +# Use this command for coverage reports +yarn test:unit:coverage +``` + +## Workflow Requirements + +- Confirm all tests are passing before commit. +- **Do not add new `toMatchSnapshot()` calls** — this is banned. See Snapshot Testing Policy. +- When modifying a test file that contains `toMatchSnapshot()`, opportunistically migrate those calls to explicit assertions or `toMatchInlineSnapshot()` — do not block a PR solely to force migration, but do not add new ones. +- `toMatchInlineSnapshot()` updates are acceptable but must be reviewed — confirm the new inline snapshot reflects an intentional, expected change. + +# Reference Code Examples + +**Proper AAA**: + +```ts +it('indicates expired milk when past due date', () => { + const today = new Date('2025-06-01'); + const milk = { expiration: new Date('2025-05-30') }; + + const result = isMilkGood(today, milk); + + expect(result).toBe(false); +}); +``` + +## ❌ Banned: `toMatchSnapshot()` + +```ts +// ❌ BANNED — toMatchSnapshot() writes to an external .snap file. +// It has no code owner, hides regressions, and breaks on trivial changes. +it('renders the button', () => { + const { container } = render(); + expect(container).toMatchSnapshot(); // 🚫 BANNED — do not use +}); +``` + +## ✅ Robust UI Assertion + +```ts +it('displays error message when API fails', async () => { + mockApi.failOnce(); + const { findByText } = render(); + + expect(await findByText('Something went wrong')).toBeOnTheScreen(); +}); +``` + +**Test the Test**: + +```ts +it('hides selector when disabled', () => { + const { queryByTestId } = render(); + + expect(queryByTestId('IPFS_GATEWAY_SELECTED')).toBeNull(); + + // Break test: change enabled={false} to enabled={true} and verify test fails +}); +``` + +## Reviewer Responsibilities + +Validate tests fail when code breaks • Ensure proper matchers • **Reject new `toMatchSnapshot()` calls and new `.snap` files** • Reject complex names with AND/OR + +```ts +// OK +it('renders button when enabled'); + +// NOT OK +it('renders and disables button when input is empty or missing required field'); +``` + +## Workflow + +Always run tests after changes • Confirm all pass before commit • **Do not add new `toMatchSnapshot()` calls** — migrate existing ones to explicit assertions or `toMatchInlineSnapshot()` when touching a test file + +**Resources**: [Contributor docs](https://github.com/MetaMask/contributor-docs/blob/main/docs/testing/unit-testing.md) • [Jest Matchers](https://jestjs.io/docs/using-matchers) • [React Native Testing Library](https://testing-library.com/docs/react-native-testing-library/intro/) diff --git a/domains/testing/skills/unit-testing/skill.md b/domains/testing/skills/unit-testing/skill.md new file mode 100644 index 0000000..b11e63f --- /dev/null +++ b/domains/testing/skills/unit-testing/skill.md @@ -0,0 +1,4 @@ +--- +name: unit-testing +description: Unit testing guidelines +--- diff --git a/domains/testing/skills/visual-testing/repos/metamask-extension.md b/domains/testing/skills/visual-testing/repos/metamask-extension.md new file mode 100644 index 0000000..f4b1601 --- /dev/null +++ b/domains/testing/skills/visual-testing/repos/metamask-extension.md @@ -0,0 +1,705 @@ +--- +repo: metamask-extension +parent: visual-testing +--- + + +## When to Use This Skill + +Use this skill when you need to: + +- Visually validate MetaMask UI changes +- Test extension behavior in a real browser +- Verify onboarding, unlock, or transaction flows +- Capture screenshots for validation +- Debug UI state issues + +## Prerequisites + +**Validate that there's an Extension build** (required before any MCP tool will work): + +Extension build is on `dist/chrome/` folder. + +- If there is NO build, then proceed and build the Extension +- If there is a build, then: +- if the user has asked to build proceed with rebuilding the Extension +- if the user has asked to NOT build, then proceed WITHOUT building the Extension and re-use the existing build +- if the user was not explicit about it, then ASK the user how he wants to proceed. Use existing build or re-build the Extension. + +**Build the extension** (required before any MCP tool will work): + +```bash +yarn install # Install dependencies (first time only) +yarn build:test # Build extension to dist/chrome/ +``` + +You only need to rebuild after source code changes. `mm_launch` validates the +build exists and returns a clear error with the exact command if it's missing. + +If ports are in use from previous runs: + +```bash +lsof -ti:8545,12345,8000 | xargs kill -9 +``` + +## MCP Tools Overview + +The MetaMask MCP server provides tools for browser automation: + +| Tool | Description | +| --------------------------- | ----------------------------------------------------------- | +| `mm_launch` | Launch MetaMask in headed Chrome | +| `mm_cleanup` | Stop browser and all services | +| `mm_get_state` | Get current extension state (includes tab info) | +| `mm_navigate` | Navigate to home, settings, notification, or URL | +| `mm_wait_for_notification` | Wait for sidepanel confirmation route and set it as active | +| `mm_switch_to_tab` | Switch active page to a different tab (by role or URL) | +| `mm_close_tab` | Close a tab (notification, dapp, or other) | +| `mm_list_testids` | List visible data-testid attributes | +| `mm_accessibility_snapshot` | Get trimmed a11y tree with refs (e1, e2...) | +| `mm_describe_screen` | Combined state + testIds + a11y snapshot (+ priorKnowledge) | +| `mm_screenshot` | Take and save screenshot | +| `mm_click` | Click element by a11yRef, testId, or selector | +| `mm_type` | Type text into element | +| `mm_wait_for` | Wait for element to be visible | +| `mm_clipboard` | Read from or write to browser clipboard | +| `mm_knowledge_last` | Get last N recorded steps | +| `mm_knowledge_search` | Search recorded steps (cross-session supported) | +| `mm_knowledge_summarize` | Generate session recipe | +| `mm_knowledge_sessions` | List recent sessions and their metadata (tags/flowTags) | +| `mm_run_steps` | Execute multiple tools in sequence with error handling | +| `mm_set_context` | Switch workflow context, optionally with context options | +| `mm_get_context` | Get current context and available capabilities | + +## Context Switching (e2e vs prod) + +The MCP server supports two execution contexts with different capabilities: + +### Available Contexts + +| Context | Description | +| ------- | ---------------------------------------------------------------------------- | +| `e2e` | **Default.** Local Anvil blockchain, pre-onboarded wallet, fixtures, seeding | +| `prod` | Production-like mode. No fixtures, no local chain, limited capabilities | + +### E2E Context Capabilities (Default) + +- `fixture` - Wallet state management with presets +- `chain` - Local Anvil blockchain (port 8545) +- `contractSeeding` - Deploy test contracts (ERC-20, NFTs, etc.) +- `stateSnapshot` - Extension state detection +- `mockServer` - Mock API responses + +### Prod Context Capabilities + +- `stateSnapshot` - Extension state detection + +### Switching Contexts + +**Check current context:** + +``` +mm_get_context +``` + +Returns: + +```json +{ + "canSwitchContext": true, + "capabilities": { + "available": [ + "build", + "fixture", + "chain", + "contractSeeding", + "stateSnapshot", + "mockServer" + ] + }, + "currentContext": "e2e", + "hasActiveSession": false, + "sessionId": null +} +``` + +**Switch to prod context:** + +``` +mm_set_context { "context": "prod" } +``` + +**Switch back to e2e:** + +``` +mm_set_context { "context": "e2e" } +``` + +**Reconfigure e2e context with options (same-context update):** + +```json +mm_set_context { + "context": "e2e", + "options": { + "mockServer": { + "enabled": true, + "port": 8000 + } + } +} +``` + +Notes: + +- `options` is optional. +- In `e2e`, `mockServer.enabled` defaults to `false`. +- Calling `mm_set_context` with the same context and non-empty `options` rebuilds that context with the new settings. + +### Context Switching Rules + +1. **Cannot switch during active session** - You must call `mm_cleanup` first +2. **Default context is e2e** - On server startup, context is always e2e +3. **Context persists** - Once switched, context remains until changed or server restarts +4. **Mock server is opt-in** - In `e2e`, enable it explicitly via `mm_set_context` options + +### Recommended Order (Enable Mock Server) + +Use this exact sequence when you need mocked external API responses: + +```json +mm_cleanup +mm_set_context { + "context": "e2e", + "options": { + "mockServer": { + "enabled": true, + "port": 8000 + } + } +} +mm_get_context +mm_launch { "stateMode": "default" } +``` + +### Example: Testing in Different Contexts + +``` +# Start in e2e (default) +mm_get_context # Verify e2e context +mm_launch { "stateMode": "default" } # Launch with fixtures +mm_describe_screen +mm_cleanup # End session + +# Switch to prod +mm_set_context { "context": "prod" } # Switch context +mm_get_context # Verify prod context +mm_launch { "stateMode": "onboarding" } # No fixtures in prod +mm_describe_screen +mm_cleanup + +# Switch back to e2e +mm_set_context { "context": "e2e" } + +# Reconfigure e2e with mock server enabled +mm_cleanup +mm_set_context { + "context": "e2e", + "options": { "mockServer": { "enabled": true, "port": 8000 } } +} +mm_launch { "stateMode": "default" } +``` + +## Sidepanel Mode (Default) + +The MCP server runs in **headless mode by default**, which means the extension uses Chrome's side panel (`sidepanel.html`) instead of the traditional popup (`notification.html`). This is the default behavior — no configuration is needed. + +### Sidepanel vs Popup: Key Differences + +| Behavior | Sidepanel (headless, default) | Popup (legacy) | +| -------------------------------- | ---------------------------------------------------------------- | ----------------------------------------------- | +| **Confirmation UI** | Renders inside `sidepanel.html` | Opens separate `notification.html` popup window | +| **After confirming** | Sidepanel stays open and navigates back to the home page (route) | Popup auto-closes | +| **After rejecting** | Sidepanel stays open and navigates back to the home page (route) | Popup auto-closes | +| **Page lifecycle** | Single persistent page, URL hash changes between routes | New page opens, closes on completion | +| **Confirmation route detection** | Checks URL hash against known confirmation route prefixes | Waits for `notification.html` page event | + +### What This Means for Testing + +1. **No tab closing after confirmation**: In sidepanel mode, the confirmation page does not close. After clicking confirm/reject, the sidepanel navigates back to the home route within `sidepanel.html`. You do NOT need to switch tabs after a confirmation — just continue interacting with the sidepanel page. + +2. **`mm_wait_for_notification` behavior**: This tool opens or finds the `sidepanel.html` page and waits for a confirmation route to appear in its URL hash (e.g., `#/confirm-transaction/...`). It does NOT wait for a new popup window. + +3. **Post-confirmation state**: After a confirmation action, use `mm_describe_screen` to verify the extension returned to the home screen. The sidepanel page remains the active page. + +4. **Confirmation routes detected**: + - `/connect` + - `/confirm-transaction` + - `/confirmation` + - `/confirm-import-token` + - `/confirm-add-suggested-token` + - `/confirm-add-suggested-nft` + +## Core Workflow + +### 0. Reuse Existing Knowledge (REQUIRED) + +Before attempting any non-trivial flow (send/swap/connect/sign), query what worked previously. + +Recommended pattern: + +``` +mm_knowledge_search { "query": "send flow", "scope": "all", "filters": { "flowTag": "send", "sinceHours": 48 } } +``` + +If you’re not sure which `flowTag` applies yet: + +``` +mm_knowledge_search { "query": "send", "scope": "all" } +``` + +If you need to discover which sessions exist: + +``` +mm_knowledge_sessions { "limit": 10, "filters": { "sinceHours": 48 } } +``` + +### 1. Build Extension (prerequisite — run outside MCP) + +Build the extension before using any MCP tools: + +```bash +yarn build:test +``` + +Skip if already built. `mm_launch` validates the build and returns an +actionable error if it's missing. + +### 2. Launch Extension (ALWAYS TAG THE SESSION) + +``` +mm_launch +``` + +Options: + +- `stateMode`: `"default"` (pre-onboarded with 25 ETH), `"onboarding"` (fresh wallet), or `"custom"` +- `fixturePreset`: Name of preset fixture (e.g., `"withMultipleAccounts"`) +- `fixture`: Custom fixture object +- `ports`: `{ anvil: 8545, fixtureServer: 12345 }` +- `goal`: Short description of what you’re doing +- `flowTags`: Flow categorization (e.g., `send`, `swap`, `connect`, `sign`, `onboarding`) +- `tags`: Free-form tags (e.g., `smoke`, `regression`) + +Examples: + +```json +// Pre-onboarded wallet (default) +{ + "stateMode": "default", + "goal": "Send flow smoke", + "flowTags": ["send"], + "tags": ["smoke"] +} + +// Fresh wallet requiring onboarding +{ + "stateMode": "onboarding", + "goal": "Onboarding flow", + "flowTags": ["onboarding"], + "tags": ["smoke"] +} + +// Custom fixture +{ + "stateMode": "custom", + "fixturePreset": "withMultipleAccounts", + "goal": "Send flow with multiple accounts", + "flowTags": ["send"], + "tags": ["regression"] +} +``` + +### 3. Describe Current Screen + +``` +mm_describe_screen +``` + +Returns combined state information: + +- Current screen (home, unlock, onboarding-\*, settings, unknown) +- Visible testIds +- Accessibility tree with refs (e1, e2, ...) +- Optional screenshot + +### 4. Interact with UI + +Use one of three targeting methods (exactly ONE required): + +**By a11yRef** (from accessibility snapshot): + +```json +{ "a11yRef": "e5" } +``` + +**By testId** (data-testid attribute): + +```json +{ "testId": "unlock-password" } +``` + +**By CSS selector**: + +```json +{ "selector": "button.primary" } +``` + +#### Click Element + +``` +mm_click { "testId": "unlock-submit" } +mm_click { "a11yRef": "e12" } +``` + +#### Type Text + +``` +mm_type { "testId": "unlock-password", "text": "correct horse battery staple" } +``` + +#### Wait for Element + +``` +mm_wait_for { "testId": "home-balance", "timeoutMs": 10000 } +``` + +#### Clipboard (Fast SRP Entry) + +Use `mm_clipboard` to write text to the browser clipboard, then trigger paste via UI button. This is much faster than typing 12 words individually during onboarding. + +``` +mm_clipboard { "action": "write", "text": "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" } +mm_click { "testId": "srp-input-import__paste-button" } +``` + +This populates all 12 SRP words instantly via the component's paste handler. + +### 5. Take Screenshots + +``` +mm_screenshot { "name": "after-unlock" } +``` + +Options: + +- `name`: Screenshot filename (required) +- `fullPage`: Capture full page (default: true) +- `selector`: Capture specific element +- `includeBase64`: Include base64 in response + +### 6. Handle Confirmations (Dapp flows) + +When a dapp triggers a confirmation (connect, sign, send), wait for it: + +``` +mm_wait_for_notification { "timeoutMs": 10000 } +``` + +This opens or finds the `sidepanel.html` page, waits for a confirmation route to appear in the URL hash, and sets it as the **active page**. Subsequent `mm_click`, `mm_type`, and `mm_describe_screen` calls operate on the sidepanel. + +**Dapp connection flow example (sidepanel mode):** + +``` +mm_navigate { "screen": "url", "url": "https://test-dapp.io" } → Opens dapp in new tab, sets as active +mm_click { "testId": "connectButton" } → Triggers confirmation +mm_wait_for_notification → Active page = sidepanel (on confirmation route) +mm_describe_screen → Shows confirmation elements +mm_click { "testId": "confirm-btn" } → Confirms action +mm_describe_screen → Sidepanel navigates back to home page +mm_switch_to_tab { "role": "dapp" } → Switch back to dapp +mm_describe_screen → Verify connected state +``` + +**Important sidepanel behavior:** After clicking confirm or reject, the sidepanel does **NOT** close. It stays open and navigates back to the home page (route). Use `mm_describe_screen` to verify the sidepanel returned to home, then switch to the dapp tab to continue. + +**Tab roles:** `extension` (home), `notification` (sidepanel), `dapp` (external sites), `other` + +### 7. Navigate + +``` +mm_navigate { "screen": "home" } +mm_navigate { "screen": "settings" } +mm_navigate { "screen": "notification" } +mm_navigate { "screen": "url", "url": "chrome-extension://..." } +``` + +### 8. Cleanup (Always Required) + +``` +mm_cleanup +``` + +Stops browser and all background services. + +## Typical Workflow Example (Knowledge-First) + +``` +0. mm_knowledge_search { "query": "unlock", "scope": "all", "sinceHours": 48 } +1. [prerequisite] yarn build:test (run via Bash if not already built) +2. mm_launch { "stateMode": "default", "goal": "Unlock smoke", "flowTags": ["unlock"], "tags": ["smoke"] } +3. mm_describe_screen +4. mm_type { "testId": "unlock-password", "text": "correct horse battery staple" } +5. mm_click { "testId": "unlock-submit" } +6. mm_describe_screen +7. mm_screenshot { "name": "home-validated" } +8. mm_cleanup +``` + +Notes: + +- Prefer `mm_describe_screen` as your main feedback loop tool. +- Prefer `mm_knowledge_search` early, before exploring. +- Prefer `flowTags` on launch so future searches can filter. + +## Batching with mm_run_steps + +Use `mm_run_steps` to execute multiple tools in a single call when you **already know** the exact sequence of steps. This reduces round-trips and is ideal for known, deterministic flows. + +### When to Use Batching + +| Use mm_run_steps | Use Individual Calls | +| --------------------------------------------- | ------------------------------------------- | +| Known flows from prior knowledge | First-time exploration | +| Deterministic sequences (wizard steps) | Decisions based on intermediate state | +| Repetitive patterns (fill form, click submit) | Debugging or investigating issues | +| Replaying a successful flow | When you need to inspect each step's result | + +### Example: Batched Unlock Flow + +When you already know the unlock sequence (from prior knowledge or documentation): + +``` +mm_run_steps { + "steps": [ + { "tool": "mm_type", "args": { "testId": "unlock-password", "text": "correct horse battery staple" } }, + { "tool": "mm_click", "args": { "testId": "unlock-submit" } }, + { "tool": "mm_wait_for", "args": { "testId": "account-menu-icon", "timeoutMs": 10000 } } + ], + "stopOnError": true +} +``` + +### Example: Batched Form Fill + +``` +mm_run_steps { + "steps": [ + { "tool": "mm_click", "args": { "testId": "send-button" } }, + { "tool": "mm_type", "args": { "testId": "send-recipient", "text": "0x1234..." } }, + { "tool": "mm_type", "args": { "testId": "send-amount", "text": "0.1" } }, + { "tool": "mm_click", "args": { "testId": "send-continue" } } + ], + "stopOnError": true +} +``` + +### Options + +- `stopOnError: true` (default: false) - Stop executing on first failure +- `includeObservations`: Controls observation collection per step (see below) +- Returns a summary with `succeeded`/`failed` counts and individual step results + +### Observation Modes (includeObservations) + +| Value | Behavior | Use When | +| ---------- | --------------------------------------------------------- | ----------------------------------- | +| `all` | Full observation (state + testIds + a11y) after each step | Default. Exploration, debugging | +| `none` | Minimal observation (state only) - fastest | Known deterministic flows | +| `failures` | Minimal on success, full on failure - balanced | Production flows with error capture | + +**Example: Fast mode for known flows** + +``` +mm_run_steps { + "includeObservations": "none", + "steps": [ + { "tool": "mm_type", "args": { "testId": "unlock-password", "text": "correct horse battery staple" } }, + { "tool": "mm_click", "args": { "testId": "unlock-submit" } } + ], + "stopOnError": true +} +``` + +**Important:** When using `includeObservations: "none"` or `"failures"`, the a11y snapshot is not collected and `refMap` is not refreshed. This means `a11yRef` targets (e.g., `e5`) become stale. **Prefer `testId` targets in fast mode.** If you need `a11yRef`, call `mm_accessibility_snapshot` or `mm_describe_screen` first. + +### Pattern: Discover First, Then Batch + +1. Use `mm_describe_screen` to discover available elements +2. Use `mm_knowledge_search` to find prior successful sequences +3. Use `mm_run_steps` to execute the known sequence efficiently +4. Use `mm_describe_screen` again to verify the end state + +### Recommended Fast Workflow + +For maximum throughput on known, deterministic flows: + +1. **Describe once:** `mm_describe_screen` to discover targets +2. **Batch steps:** `mm_run_steps { "includeObservations": "none", ... }` with `testId` targets +3. **Describe on churn:** Call `mm_describe_screen` again after major navigation or if you need fresh `a11yRef` targets + +## Error Recovery + +### On Failure + +1. Call `mm_describe_screen` to see current state +2. Use the built-in `result.priorKnowledge` (when present) to guide next action +3. If still stuck, query prior runs: + +``` +mm_knowledge_search { "query": "send", "scope": "all", "filters": { "sinceHours": 48 } } +mm_knowledge_sessions { "limit": 10, "filters": { "sinceHours": 48 } } +``` + +4. Check the `state.currentScreen` value: + - `unlock` → Type password and click submit + - `home` → Already ready, check for modals + - `onboarding-*` → Complete onboarding flow + - `unknown` → Take screenshot, investigate + +5. Use `mm_knowledge_last { "n": 10 }` to review immediate history (current session) + +### IMPORTANT: Restart MCP server after code changes + +The MCP server is a long-lived process. If you update the MCP server code (including knowledge tagging/metadata), restart the MCP server so new sessions write the new record format. + +### Error Codes + +| Code | Meaning | +| ---------------------------- | ------------------------------------------- | +| `MM_SESSION_ALREADY_RUNNING` | Session exists, call mm_cleanup first | +| `MM_NO_ACTIVE_SESSION` | No session, call mm_launch first | +| `MM_LAUNCH_FAILED` | Browser launch failed | +| `MM_INVALID_INPUT` | Invalid tool parameters | +| `MM_TARGET_NOT_FOUND` | Element not found | +| `MM_TAB_NOT_FOUND` | Tab not found (for switch/close) | +| `MM_CLICK_FAILED` | Click operation failed | +| `MM_TYPE_FAILED` | Type operation failed | +| `MM_WAIT_TIMEOUT` | Wait timeout exceeded | +| `MM_SCREENSHOT_FAILED` | Screenshot capture failed | +| `MM_CONTEXT_SWITCH_BLOCKED` | Cannot switch context during active session | +| `MM_SET_CONTEXT_FAILED` | Context switch failed | + +## Knowledge Store (How to Actually Reuse It) + +Every tool invocation is recorded to `test-artifacts/llm-knowledge//steps/`. + +Sessions can also include `session.json` metadata (goal/tags/flowTags) when `mm_launch` is called with `goal`, `flowTags`, and `tags`. + +### Recommended usage patterns + +**Before starting a flow (cross-session search):** + +``` +mm_knowledge_search { "query": "send flow", "scope": "all", "filters": { "flowTag": "send", "sinceHours": 48 } } +``` + +**Find recent sessions for a flow:** + +``` +mm_knowledge_sessions { "limit": 10, "filters": { "flowTag": "send", "sinceHours": 48 } } +``` + +**Summarize a specific prior session:** + +``` +mm_knowledge_summarize { "scope": { "sessionId": "mm-..." } } +``` + +**Review current-session history (debugging):** + +``` +mm_knowledge_last { "n": 10 } +``` + +### Practical guidance + +- Use `mm_knowledge_search` early (before exploring UI) to reduce rediscovery. +- Always pass `flowTags` on `mm_launch` so filters work. +- Prefer selectors that were successful in recent sessions. + +## Default Credentials + +| Property | Value | +| -------- | ------------------------------ | +| Password | `correct horse battery staple` | +| Chain ID | `1337` | +| Balance | 25 ETH | + +## Response Format + +All tool responses follow this structure: + +```json +{ + "ok": true, + "result": { ... }, + "meta": { + "timestamp": "2026-01-15T15:30:00.000Z", + "sessionId": "mm-abc123-xyz789", + "durationMs": 150 + } +} +``` + +Error responses: + +```json +{ + "ok": false, + "error": { + "code": "MM_TARGET_NOT_FOUND", + "message": "Element not found", + "details": { ... } + }, + "meta": { ... } +} +``` + +## Known Limitations + +1. **Headless mode (default)**: The extension runs in headless mode using the Chrome side panel (`sidepanel.html`) instead of the popup (`notification.html`). This is the default and does not require a visible display. +2. **Single session**: Only one browser session at a time. Call `mm_cleanup` before `mm_launch`. +3. **macOS/Linux**: Port cleanup commands (`lsof`) are Unix-specific. + +## Common Failures & Solutions + +| Symptom | Likely Cause | Solution | +| ---------------------------------------------------- | ---------------------------------------------- | --------------------------------------------------------------------------------- | +| `MM_SESSION_ALREADY_RUNNING` | Previous session not cleaned | Call `mm_cleanup` first | +| `MM_NO_ACTIVE_SESSION` | No browser running | Call `mm_launch` first | +| Extension not loading | Extension not built | Run `yarn build:test` then retry `mm_launch` | +| `EADDRINUSE` port error | Orphan processes | `lsof -ti:8545,12345,8000 \| xargs kill -9` | +| `MM_TARGET_NOT_FOUND` | Element not visible | Use `mm_describe_screen` to check state | +| `MM_WAIT_TIMEOUT` | Slow environment or UI change | Increase timeout, check screenshot | +| `MM_CONTEXT_SWITCH_BLOCKED` | Switching during session | Call `mm_cleanup` before `mm_set_context` | +| Fixtures not available | Running in prod context | Switch to e2e: `mm_set_context {"context":"e2e"}` | +| Network shows provisional headers for gas/price APIs | Mock server not enabled in current e2e context | `mm_cleanup` → `mm_set_context` with `options.mockServer.enabled=true` → relaunch | + +## Key Files + +| File | Purpose | +| -------------------------------------------------------- | -------------------------- | +| `test/e2e/playwright/llm-workflow/README.md` | LLM Workflow documentation | +| `test/e2e/playwright/llm-workflow/mcp-server/README.md` | MCP server documentation | +| `test/e2e/playwright/llm-workflow/mcp-server/server.ts` | MCP server entrypoint | +| `test/e2e/playwright/llm-workflow/extension-launcher.ts` | Core launcher class | + +## Visual Testing Decision Rules + +When performing visual validation: + +1. **Before action**: `mm_screenshot { "name": "before-X" }` +2. **Perform action**: `mm_click` / `mm_type` with appropriate target +3. **After action**: `mm_describe_screen` to verify state +4. **Capture result**: `mm_screenshot { "name": "after-X" }` +5. **On failure**: Check `mm_knowledge_last` and screenshot for diagnosis diff --git a/domains/testing/skills/visual-testing/skill.md b/domains/testing/skills/visual-testing/skill.md new file mode 100644 index 0000000..a6c69ce --- /dev/null +++ b/domains/testing/skills/visual-testing/skill.md @@ -0,0 +1,4 @@ +--- +name: visual-testing +description: Browser automation and visual regression testing +--- diff --git a/domains/ui/skills/ui-development/repos/metamask-extension.md b/domains/ui/skills/ui-development/repos/metamask-extension.md new file mode 100644 index 0000000..c2e9368 --- /dev/null +++ b/domains/ui/skills/ui-development/repos/metamask-extension.md @@ -0,0 +1,607 @@ +--- +repo: metamask-extension +parent: ui-development +--- + + +# MetaMask Extension React UI Development Guidelines + +## Core Principle + +Always prioritize `@metamask/design-system-react` components and Tailwind CSS patterns over custom implementations. **Never write SASS** - we are actively reducing CSS file size by eliminating SASS usage. + +## Component Hierarchy (STRICT ORDER) + +### The Rule: Check Design System First + +**Before writing any new component or choosing what to use, ask: "Does `@metamask/design-system-react` have this?"** + +1. **FIRST**: Use `@metamask/design-system-react` components + - **Always use for**: Box (layout), Text (typography), Button/ButtonIcon, Icon, Checkbox + - **Always use for**: Avatar variants (AvatarAccount, AvatarBase, AvatarFavicon, AvatarGroup, AvatarIcon, AvatarNetwork, AvatarToken) + - **Always use for**: Badge variants (BadgeCount, BadgeIcon, BadgeNetwork, BadgeStatus, BadgeWrapper) + - **ButtonBase**: Only for highly custom button patterns (prefer Button component) + - **Rule**: If it exists in the design system, you MUST use it + +2. **SECOND**: Use `ui/components/component-library` ONLY if design system lacks it + - **Navigation Components** Modal, ModalOverlay, ModalContent, ModalHeader, ModalBody ,ModalFooter, Popover, PopoverHeader + - **Form Components**: FormTextField, TextFieldSearch, TextField, TextArea, Label, HelpText, SelectButton, SelectWrapper, SelectOption + - **Utility Components**: Skeleton, SensitiveText, Tag, BannerAlert, + - **Rule**: These are MetaMask-specific implementations not (yet) in the design system + - **Avoid "Base" components**: Components with "Base" in the name generally should not be used unless for custom implementations. We use the base/variant pattern for component development. + +3. **THIRD**: Feature-specific components + - **Use for**: Complex, domain-specific UI that combines multiple design system/component-library components + - **Examples**: `ConnectAccountsModal`, `AssetPickerModal`, `NotificationDetailAsset` + - **Rule**: Must be built using Box, Text, and other design system primitives - NO SASS, minimal CSS + - **Reuse**: Search for existing feature components before building new ones to avoid duplication + +4. **LAST RESORT**: Custom components with minimal CSS + - **Only when**: Highly specialized one-off needs with no design system equivalent AND no component-library equivalent + - **Requires**: Strong justification why design system primitives can't be composed + - **NEVER**: Write SASS files - use Tailwind classes only + +### Decision Tree + +``` +Need a component? + ├─ Is it Box, Text, Button, Icon, Avatar, Badge, or Checkbox? + │ └─ YES → Use @metamask/design-system-react [STOP] + │ + ├─ Is it Modal, Banner, Popover, Input, Label, Tag, Textarea, etc? + │ └─ YES → Use ui/components/component-library [STOP] + │ + ├─ Is it feature-specific UI (e.g., ConnectAccountsModal, AssetPicker)? + │ ├─ Does it already exist? (search codebase for similar components) + │ │ ├─ YES → Reuse existing component [STOP] + │ │ └─ NO → Build new component using design system primitives [STOP] + │ └─ + │ + └─ Can I compose it from Box + Text + other primitives? + ├─ YES → Compose from design system [STOP] + └─ NO → Consider if custom implementation is truly necessary +``` + +### Why This Hierarchy Matters + +- **Consistency**: Design system ensures consistent look, feel, and behavior +- **Maintenance**: Centralized updates benefit all consumers +- **Accessibility**: Design system components built with accessibility in mind +- **Type Safety**: Full TypeScript support with comprehensive type definitions +- **Performance**: Optimized components reduce bundle size +- **No SASS**: Reduces CSS file size and build complexity + +## Required Imports for Extension + +```tsx +// ALWAYS prefer these imports +import { + Box, + Text, + Button, + ButtonBase, + ButtonIcon, + Icon, + TextVariant, + IconName, + IconColor, + IconSize, + FontWeight, + TextColor, + ButtonVariant, + ButtonSize, + // Avatar components + AvatarAccount, + AvatarBase, + AvatarFavicon, + AvatarGroup, + AvatarIcon, + AvatarNetwork, + AvatarToken, + // Badge components + BadgeCount, + BadgeIcon, + BadgeNetwork, + BadgeStatus, + BadgeWrapper, + // Box enums + BoxAlignItems, + BoxFlexDirection, + BoxJustifyContent, + BoxFlexWrap, + BoxBackgroundColor, + BoxBorderColor, + // ... other design system components +} from '@metamask/design-system-react'; +``` + +## Component Documentation Access + +### Type Definitions & Documentation + +All `@metamask/design-system-react` components have comprehensive TypeScript definitions: + +- **Box**: `/node_modules/@metamask/design-system-react/dist/components/Box/*.d.cts` +- **Text**: `/node_modules/@metamask/design-system-react/dist/components/Text/*.d.cts` +- **Button**: `/node_modules/@metamask/design-system-react/dist/components/Button/*.d.cts` + +When unsure about component APIs: + +1. Read the `.d.cts` files for complete prop documentation +2. Reference `ui/pages/design-system/design-system.stories.tsx` for usage examples +3. Check GitHub source: https://github.com/MetaMask/metamask-design-system/tree/main/packages/design-system-react/src/components + +### Box Component Quick Reference + +**Box is a special cross-platform primitive component.** It's designed to share UI code between web (renders `div`) and React Native (renders `View`) with the same component API. This is why Box has utility props while other components use `className`. + +**Box is the ONLY component with layout and color props:** + +- **Spacing**: Use `gap`, `padding`, `margin` props (0-12 for 0px-48px) +- **Flexbox**: Use `flexDirection`, `alignItems`, `justifyContent` enum props +- **Colors (Box ONLY)**: Use `backgroundColor` and `borderColor` props with enums +- **Borders (Box ONLY)**: Use `borderWidth` prop (0, 1, 2, 4, or 8) and `borderColor` enum +- **Tailwind**: Use `className` prop for utilities not covered by props + +All other components (Button, Text, Icon, Checkbox, etc.) have their own component-specific props: + +- **Use component props FIRST**: `variant`, `size`, `color`, etc. +- **Use `className` for additional utilities**: layout, spacing, positioning, etc. + +**Note on Base Components**: MetaMask uses a Base/Variant pattern for component development: + +- **Variant components** (e.g., Button, BannerAlert, ModalHeader) - Use these FIRST +- **Base components** (e.g., ButtonBase, BannerBase, HeaderBase) - Only for custom patterns outside existing variants + +**When to use Base components**: + +- Existing variant components don't fit your design needs +- Building a new feature-specific variant pattern +- ⚠️ **Warning**: If you need a Base component, it may indicate a design inconsistency. Consider: + 1. Can an existing variant component work with minor adjustments? + 2. Should this pattern become a new variant in the design system? + 3. Is this a one-off pattern that suggests design debt? + +## Styling Rules (ENFORCE STRICTLY) + +### ✅ ALWAYS DO: + +- Use `Box` component instead of `div` for layout and utility props +- Use `Text` component with `variant` prop instead of raw text elements +- Use component-specific props FIRST: `variant`, `size`, `color`, etc. +- Use `Box` color props (`backgroundColor`, `borderColor`) for Box component +- Use `className` for additional utilities: layout, spacing, positioning only when not provided by a prop +- Use design system color tokens: `bg-default`, `text-default`, `border-default` or provided color props +- Use Tailwind classes in tailwind.config.js from `@metamask/design-system-tailwind-preset` + +**Priority Order**: Component Props → Box Utility Props → className for extras + +### ❌ NEVER SUGGEST: + +- SASS files (`.scss`) - we are eliminating SASS +- `StyleSheet.create()` or CSS-in-JS +- Arbitrary color values like `bg-[#3B82F6]` or `text-[#000000]` +- Inline style objects unless for truly dynamic values +- Custom CSS files for new components +- `backgroundColor` prop on components other than Box (use `className` instead) check component API +- `borderColor` prop on components other than Box (use `className` instead) check component API + +**Note**: Using `div` with Tailwind `className` is acceptable when no design system component fits the use case. + +## Code Pattern Templates + +### Basic Container: + +```tsx +const MyComponent = () => { + return ( + + + Title + + + ); +}; +``` + +### Flex Layout: + +```tsx + + Content + +``` + +### Button Element: + +```tsx +// ✅ PREFER: Use Button component with variants + + + + +// ✅ For highly custom buttons: Use ButtonBase + + + Custom Button + +``` + +### Using Avatars and Badges: + +```tsx + + } +> + + +``` + +## Box Component Best Practices + +### Prefer Props Over className for Box Layout and Colors + +✅ **DO** - Use typed props for Box component: + +```tsx + +``` + +❌ **DON'T** - Use className for Box properties that have dedicated props: + +```tsx + +``` + +**IMPORTANT**: Only Box has utility props like `backgroundColor`, `borderColor`, and layout props. This is because Box is a special cross-platform primitive (web `div` / React Native `View`). + +For other components (Button, Text, Icon, Checkbox, etc.): + +1. **Use component props FIRST**: `variant`, `size`, `color`, `startIconName`, etc. +2. **Use `className` for additional utilities**: layout spacing (`mb-2`), width (`w-full`), positioning, etc. + +**Component Base/Variant Pattern**: + +MetaMask follows a Base/Variant pattern across many component families: + +| Component Family | Variant Components (Use First) | Base Component (Use Sparingly) | +| ---------------- | ----------------------------------------------- | ------------------------------ | +| **Button** | Button (Primary/Secondary/Tertiary), ButtonIcon | ButtonBase | +| **Banner** | BannerAlert, BannerTip | BannerBase | +| **Header** | (feature-specific headers) | HeaderBase | + +**Always prefer variant components.** Only use base components when: + +1. No existing variant fits your use case +2. You're building a new reusable pattern +3. ⚠️ You've confirmed this isn't a design inconsistency + +### When to Use className on Box + +Box doesn't have props for everything - use `className` for: + +- Width and height: `w-full`, `h-20`, `w-96` +- Complex positioning: `absolute`, `relative`, `top-0`, `left-0` +- Border radius: `rounded-lg`, `rounded-full` +- Shadows and opacity: `shadow-lg`, `opacity-50` +- **Interactive states**: `hover:bg-hover`, `active:bg-pressed`, `focus:ring` +- Utilities not covered by props: `overflow-hidden`, `z-10`, `truncate` + +**DO NOT** use className on Box for properties that have dedicated props: + +- Static background colors (use `backgroundColor` prop with `BoxBackgroundColor` enum) +- Static border colors (use `borderColor` prop with `BoxBorderColor` enum) +- Border width (use `borderWidth` prop: 0, 1, 2, 4, or 8) +- Padding/margin for standard spacing (use `padding`/`margin` props with 0-12) +- Flexbox layout (use `flexDirection`, `alignItems`, `justifyContent` props) + +### When to Use Plain div + +Use `div` with `className` when: + +- No design system component fits the use case +- The element is highly specific to a feature +- You need DOM-specific props that Box doesn't support +- It's a temporary/experimental pattern not yet in the design system + +**Always prefer design system components first**, but don't force Box where it doesn't make sense. + +### Color Tokens - Component-Specific Rules + +**For Box component only:** + +```tsx +// ✅ Use backgroundColor and borderColor props with enums + + + + +// ✅ Interactive states use className (hover/active/focus states) + + +// ❌ DON'T use className for static Box background colors + {/* Use backgroundColor prop instead */} +``` + +**For Button, Text, Icon, and other components:** + +```tsx +// ✅ Use Button with variant prop (preferred over ButtonBase) + + +// ✅ Use ButtonBase only for highly custom buttons + + + Custom Button + + +// ✅ Text uses variant and color props, className for layout + + +// ✅ Icon uses name, size, and color props, className for positioning + + +// ❌ NEVER use arbitrary colors on any component + +