Compare commits
212 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fca93af230 | ||
|
|
4235b57cd8 | ||
|
|
cb362f1b2c | ||
|
|
a4d61d8eaa | ||
|
|
310623b948 | ||
|
|
b7303fb9c0 | ||
|
|
4fbb8e9117 | ||
|
|
f0502bfc33 | ||
|
|
98f4af55c9 | ||
|
|
08b8bdd769 | ||
|
|
1283199d0d | ||
|
|
c1fff5ea49 | ||
|
|
94adc449a1 | ||
|
|
4b24e5f754 | ||
|
|
e0a531e538 | ||
|
|
8cef1ca0f7 | ||
|
|
088f1d9ae4 | ||
|
|
89a7d98b41 | ||
|
|
0076fdc65b | ||
|
|
48b4a533c3 | ||
|
|
0b66fde26c | ||
|
|
5788385951 | ||
|
|
0891e67528 | ||
|
|
b319acd9c4 | ||
|
|
05977f950b | ||
|
|
5ac36d4156 | ||
|
|
fb998706c2 | ||
|
|
c45492c890 | ||
|
|
41219b435f | ||
|
|
e1321843de | ||
|
|
0baa8a1fff | ||
|
|
f2a3b66357 | ||
|
|
f2af46037e | ||
|
|
20a06c9b5a | ||
|
|
8ef54d41b6 | ||
|
|
1cdddd1321 | ||
|
|
f834af69fe | ||
|
|
08cd4bec41 | ||
|
|
63ac64f470 | ||
|
|
72686f1e32 | ||
|
|
c07359362f | ||
|
|
ed58c39bdc | ||
|
|
5d2254be27 | ||
|
|
83f4b53f55 | ||
|
|
4b5e2f7f16 | ||
|
|
0c5d915638 | ||
|
|
a03231d356 | ||
|
|
72936322b3 | ||
|
|
1bc6eb9a23 | ||
|
|
8954aa792a | ||
|
|
379437b720 | ||
|
|
02c0dee089 | ||
|
|
c55e1af101 | ||
|
|
87cbd1ab38 | ||
|
|
1090e97bb5 | ||
|
|
921c6b105f | ||
|
|
bc08e9263d | ||
|
|
8c8300a5de | ||
|
|
3f169adcf2 | ||
|
|
774368f325 | ||
|
|
f83f15d37a | ||
|
|
d9f1402576 | ||
|
|
d81cb28010 | ||
|
|
b57a5d7ddb | ||
|
|
e2dd78fd0e | ||
|
|
96d6e00ed6 | ||
|
|
dd29c59238 | ||
|
|
8c2a0f1905 | ||
|
|
682fba219d | ||
|
|
0f5e871054 | ||
|
|
07527bab26 | ||
|
|
7934777fa2 | ||
|
|
2415a1e3cd | ||
|
|
1ba796a895 | ||
|
|
4446eb9b84 | ||
|
|
d23b6e5da6 | ||
|
|
44c355c7f0 | ||
|
|
e4a9764a12 | ||
|
|
303fa15240 | ||
|
|
d180aef306 | ||
|
|
c07c401189 | ||
|
|
65d91eb363 | ||
|
|
bd4042aa16 | ||
|
|
1dcafd2f2d | ||
|
|
66fcdb4465 | ||
|
|
e398e8b950 | ||
|
|
6424fdfbcd | ||
|
|
95814e3271 | ||
|
|
caa2524e27 | ||
|
|
d28a4b34cd | ||
|
|
f3a80c6272 | ||
|
|
b89ee36448 | ||
|
|
fbc55db937 | ||
|
|
835c62acfa | ||
|
|
8fe7c7a0a7 | ||
|
|
41113e6e41 | ||
|
|
58b25d29a9 | ||
|
|
a2db3e2719 | ||
|
|
b62f9066a7 | ||
|
|
258f22e037 | ||
|
|
38b306a80f | ||
|
|
9050f09bfe | ||
|
|
77d46c361a | ||
|
|
4fb48bdd1f | ||
|
|
cf2b6bf568 | ||
|
|
03095f4e48 | ||
|
|
b2966f8d29 | ||
|
|
ef6d1f20ed | ||
|
|
10b156ff2b | ||
|
|
146c5f29cf | ||
|
|
66a3475bf6 | ||
|
|
f6c393b016 | ||
|
|
15f8d34320 | ||
|
|
504e09d3dc | ||
|
|
1f755e8b0d | ||
|
|
f45a7f921b | ||
|
|
29db61ff90 | ||
|
|
8431ad9161 | ||
|
|
fd1aded517 | ||
|
|
27ad7cbd41 | ||
|
|
01e5de436a | ||
|
|
fd5aa1b51e | ||
|
|
a6e1a7e6d9 | ||
|
|
629fe2c144 | ||
|
|
d73763ce75 | ||
|
|
fe6df38997 | ||
|
|
719ee0f5b5 | ||
|
|
75bdf9a8d4 | ||
|
|
efbe3994bb | ||
|
|
93fe8e4349 | ||
|
|
6062f615a0 | ||
|
|
42e3af7b5c | ||
|
|
57274a800d | ||
|
|
5e591455b3 | ||
|
|
ec547ab100 | ||
|
|
9de76c69ae | ||
|
|
85d1b90197 | ||
|
|
38af86e1bb | ||
|
|
11a8df2a3e | ||
|
|
3726b6bb47 | ||
|
|
bfde34c774 | ||
|
|
bd8113d018 | ||
|
|
14e3ef044e | ||
|
|
4fc4bdcd55 | ||
|
|
3733570a89 | ||
|
|
7a042a5199 | ||
|
|
6d5e17a6e7 | ||
|
|
8301c5892c | ||
|
|
59b757dda0 | ||
|
|
92a6ec67b3 | ||
|
|
1d6a1e90d7 | ||
|
|
01aa3291b3 | ||
|
|
5539ebedf7 | ||
|
|
e6adddbf55 | ||
|
|
0050b1e165 | ||
|
|
5bf33ab004 | ||
|
|
a4cfeecd8c | ||
|
|
76cbfdd0b9 | ||
|
|
0db9e51e41 | ||
|
|
82e7925a17 | ||
|
|
2150347ba2 | ||
|
|
2f80646ddd | ||
|
|
55eea6a724 | ||
|
|
709a70652f | ||
|
|
5a7a041f12 | ||
|
|
b60d98fc17 | ||
|
|
38d9ab67eb | ||
|
|
5e6ef1575b | ||
|
|
4a015e9d0d | ||
|
|
c383a0a0a2 | ||
|
|
d6e92804ad | ||
|
|
f0d8f8b23b | ||
|
|
ff080aa957 | ||
|
|
04d9ff7d71 | ||
|
|
406893d99a | ||
|
|
4630720956 | ||
|
|
e73e1540ad | ||
|
|
3a82642fe6 | ||
|
|
2a50d6a17e | ||
|
|
c8a8149127 | ||
|
|
38cde648cf | ||
|
|
33c099119d | ||
|
|
40247ff7e0 | ||
|
|
30d1de8e84 | ||
|
|
0e5328bee9 | ||
|
|
d1995bc5a6 | ||
|
|
4959836c2a | ||
|
|
ef4a80852d | ||
|
|
c2a972eb75 | ||
|
|
47a2768d89 | ||
|
|
ca60caf6b0 | ||
|
|
252acef199 | ||
|
|
00ecb933ac | ||
|
|
607d0d443e | ||
|
|
ff0209a78f | ||
|
|
924e83139b | ||
|
|
4d5cc8267e | ||
|
|
6bc7cfc996 | ||
|
|
97312bb174 | ||
|
|
d0a10fcf8d | ||
|
|
7a1a476e45 | ||
|
|
b3bfc1003a | ||
|
|
3b1ef8033b | ||
|
|
803485b96a | ||
|
|
6df5d3a701 | ||
|
|
2d4eafc6b3 | ||
|
|
9b52fea58a | ||
|
|
b92b286626 | ||
|
|
a8268728d3 | ||
|
|
ab6debbf46 | ||
|
|
4c664645c6 | ||
|
|
4416a84fd7 |
1
.env
1
.env
@@ -11,3 +11,4 @@ REACT_APP_MOONPAY_PUBLISHABLE_KEY="pk_test_DycfESRid31UaSxhI5yWKe1r5E5kKSz"
|
||||
REACT_APP_SENTRY_DSN="https://a3c62e400b8748b5a8d007150e2f38b7@o1037921.ingest.sentry.io/4504255148851200"
|
||||
REACT_APP_STATSIG_PROXY_URL="https://api.uniswap.org/v1/statsig-proxy"
|
||||
REACT_APP_TEMP_API_URL="https://temp.api.uniswap.org/v1"
|
||||
REACT_APP_WALLET_CONNECT_PROJECT_ID="c6c9bacd35afa3eb9e6cccf6d8464395"
|
||||
14
.eslintrc.js
14
.eslintrc.js
@@ -2,9 +2,21 @@
|
||||
|
||||
require('@uniswap/eslint-config/load')
|
||||
|
||||
const rulesDirPlugin = require('eslint-plugin-rulesdir')
|
||||
rulesDirPlugin.RULES_DIR = 'eslint_rules'
|
||||
|
||||
module.exports = {
|
||||
extends: '@uniswap/eslint-config/react',
|
||||
extends: ['@uniswap/eslint-config/react'],
|
||||
plugins: ['rulesdir'],
|
||||
overrides: [
|
||||
{
|
||||
files: ['**/*'],
|
||||
rules: {
|
||||
'multiline-comment-style': ['error', 'separate-lines'],
|
||||
'rulesdir/enforce-retry-on-import': 'error',
|
||||
'rulesdir/no-undefined-or': 'error',
|
||||
},
|
||||
},
|
||||
{
|
||||
// Configuration/typings typically export objects/definitions that are used outside of the transpiled package
|
||||
// (eg not captured by the tsconfig). Because it's typical and not exceptional, this is turned off entirely.
|
||||
|
||||
2
.github/CODEOWNERS
vendored
2
.github/CODEOWNERS
vendored
@@ -1 +1 @@
|
||||
@uniswap/web-reviewers
|
||||
@uniswap/web-admins
|
||||
|
||||
48
.github/actions/report/action.yml
vendored
Normal file
48
.github/actions/report/action.yml
vendored
Normal file
@@ -0,0 +1,48 @@
|
||||
name: Report
|
||||
description: Report test failures via Slack
|
||||
inputs:
|
||||
name:
|
||||
description: The name of the failing test
|
||||
required: true
|
||||
SLACK_WEBHOOK_URL:
|
||||
description: The webhook URL to send the report to
|
||||
required: true
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- uses: slackapi/slack-github-action@007b2c3c751a190b6f0f040e47ed024deaa72844
|
||||
with:
|
||||
payload: |
|
||||
{
|
||||
"text": "${{ inputs.name }} failing on `${{ github.ref_name }}`",
|
||||
"blocks": [
|
||||
{
|
||||
"type": "section",
|
||||
"text": {
|
||||
"type": "mrkdwn",
|
||||
"text": "*${{ inputs.name }} failing on `${{ github.ref_name }}`:* <https://github.com/${{ github.repository}}/actions/runs/${{ github.run_id }}|view failing action>"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "section",
|
||||
"text": {
|
||||
"type": "mrkdwn",
|
||||
"text": "_This is blocking pull requests and branch promotions._\n_Please prioritize fixing the build._"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
env:
|
||||
SLACK_WEBHOOK_URL: ${{ inputs.SLACK_WEBHOOK_URL }}
|
||||
SLACK_WEBHOOK_TYPE: INCOMING_WEBHOOK
|
||||
# The !oncall bot requires its own message:
|
||||
- uses: slackapi/slack-github-action@007b2c3c751a190b6f0f040e47ed024deaa72844
|
||||
with:
|
||||
payload: |
|
||||
{
|
||||
"text": "!oncall web"
|
||||
}
|
||||
env:
|
||||
SLACK_WEBHOOK_URL: ${{ inputs.SLACK_WEBHOOK_URL }}
|
||||
SLACK_WEBHOOK_TYPE: INCOMING_WEBHOOK
|
||||
55
.github/actions/setup/action.yml
vendored
55
.github/actions/setup/action.yml
vendored
@@ -8,13 +8,14 @@ runs:
|
||||
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 14
|
||||
node-version: 18
|
||||
registry-url: https://registry.npmjs.org
|
||||
cache: 'yarn'
|
||||
|
||||
# node_modules/.cache is intentionally omitted, as this is used for build tool caches.
|
||||
- uses: actions/cache@v3
|
||||
id: install-cache
|
||||
with:
|
||||
# node_modules/.cache is intentionally omitted, as this is used for build tool caches.
|
||||
path: |
|
||||
node_modules
|
||||
!node_modules/.cache
|
||||
@@ -22,3 +23,53 @@ runs:
|
||||
- if: steps.install-cache.outputs.cache-hit != 'true'
|
||||
run: yarn install --frozen-lockfile --ignore-scripts
|
||||
shell: bash
|
||||
|
||||
# Validators compile quickly, so caching can be omitted.
|
||||
- run: yarn ajv
|
||||
shell: bash
|
||||
|
||||
# Contracts are compiled from source. If source hasn't changed, the contracts do not need to be re-compiled.
|
||||
- uses: actions/cache@v3
|
||||
id: contracts-cache
|
||||
with:
|
||||
path: |
|
||||
src/abis/types
|
||||
src/types/v3
|
||||
key: ${{ runner.os }}-contracts-${{ hashFiles('src/abis/**/*.json', 'node_modules/@uniswap/**/artifacts/contracts/**/*.json') }}
|
||||
- if: steps.contracts-cache.outputs.cache-hit != 'true'
|
||||
run: yarn contracts
|
||||
shell: bash
|
||||
|
||||
# GraphQL is generated from schema. The schema is always fetched, but if unchanged, graphql does not need to be re-generated.
|
||||
- run: yarn graphql:fetch
|
||||
shell: bash
|
||||
- uses: actions/cache@v3
|
||||
id: graphql-cache
|
||||
with:
|
||||
path: src/graphql/**/__generated__
|
||||
key: ${{ runner.os }}-graphql-${{ hashFiles('src/graphql/**/schema.graphql') }}
|
||||
- if: steps.graphql-cache.outputs.cache-hit != 'true'
|
||||
run: yarn graphql:generate
|
||||
shell: bash
|
||||
|
||||
# Messages are extracted from source.
|
||||
# A record of source file content hashes and catalogs is maintained in node_modules/.cache/lingui.
|
||||
# Messages are always extracted, but extraction may short-circuit from the custom extractor's cache.
|
||||
- uses: actions/cache@v3
|
||||
id: i18n-extract-cache
|
||||
with:
|
||||
path: node_modules/.cache
|
||||
key: ${{ runner.os }}-i18n-extract-${{ github.run_id }}
|
||||
restore-keys: ${{ runner.os }}-i18n-extract-
|
||||
- run: yarn i18n:extract
|
||||
shell: bash
|
||||
|
||||
# Translations are compiled from messages. If messages haven't changed, the translations do not need to be re-compiled.
|
||||
- uses: actions/cache@v3
|
||||
id: i18n-compile-cache
|
||||
with:
|
||||
path: src/locales/*.js
|
||||
key: ${{ runner.os }}-i18n-compile-${{ hashFiles('src/locales/*.po') }}
|
||||
- if: steps.i18n-compile-cache.outputs.cache-hit !='true'
|
||||
run: yarn i18n:compile
|
||||
shell: bash
|
||||
|
||||
1
.github/dependabot.yml
vendored
1
.github/dependabot.yml
vendored
@@ -8,6 +8,5 @@ updates:
|
||||
allow:
|
||||
- dependency-name: '@uniswap/default-token-list'
|
||||
- dependency-name: '@uniswap/token-lists'
|
||||
- dependency-name: '@uniswap/widgets'
|
||||
reviewers:
|
||||
- 'Uniswap/dependabot-reviewers'
|
||||
|
||||
15
.github/pull_request_template.md
vendored
15
.github/pull_request_template.md
vendored
@@ -6,7 +6,7 @@
|
||||
|
||||
|
||||
<!-- Delete inapplicable lines: -->
|
||||
_JIRA ticket:_
|
||||
_Linear ticket:_
|
||||
_Slack thread:_
|
||||
_Relevant docs:_
|
||||
|
||||
@@ -14,9 +14,16 @@ _Relevant docs:_
|
||||
<!-- Delete this section if your change does not affect UI. -->
|
||||
## Screen capture
|
||||
|
||||
| Before | After (Desktop) | After (Mobile) |
|
||||
| ------------ |---------------- | -------------- |
|
||||
| paste_before | past_after | paste_after |
|
||||
### Before
|
||||
| Mobile | Desktop |
|
||||
| ------------ | ------------ |
|
||||
| paste_before | paste_before |
|
||||
|
||||
|
||||
### After
|
||||
| Mobile | Desktop |
|
||||
| ------------ | ----------- |
|
||||
| paste_after | paste_after |
|
||||
|
||||
|
||||
## Test plan
|
||||
|
||||
32
.github/workflows/1-main-to-staging.yml
vendored
Normal file
32
.github/workflows/1-main-to-staging.yml
vendored
Normal file
@@ -0,0 +1,32 @@
|
||||
name: 1 | Push main -> staging
|
||||
|
||||
# This CI job is responsible for pushing the current contents of the `main` branch to the
|
||||
# `releases/staging` branch, which will in turn kick off a deploy to the staging environment.
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
# https://stackoverflow.com/questions/57921401/push-to-origin-from-github-action
|
||||
jobs:
|
||||
push-staging:
|
||||
name: 'Push to staging branch'
|
||||
runs-on: ubuntu-latest
|
||||
environment:
|
||||
name: push/staging
|
||||
steps:
|
||||
- uses: actions/checkout@8e5e7e5ab8b370d6c329ec480221332ada57f0ab
|
||||
with:
|
||||
token: ${{ secrets.RELEASE_SERVICE_ACCESS_TOKEN }}
|
||||
ref: main
|
||||
- name: Git config
|
||||
run: |
|
||||
git config user.name "UL Service Account"
|
||||
git config user.email "hello-happy-puppy@users.noreply.github.com"
|
||||
- name: Add CODEOWNERS file
|
||||
run: |
|
||||
echo "@uniswap/web-admins" > CODEOWNERS
|
||||
git add CODEOWNERS
|
||||
git commit -m "ci: add global CODEOWNERS"
|
||||
- name: Git push
|
||||
run: |
|
||||
git push origin main:releases/staging --force
|
||||
63
.github/workflows/2-deploy-to-staging.yml
vendored
Normal file
63
.github/workflows/2-deploy-to-staging.yml
vendored
Normal file
@@ -0,0 +1,63 @@
|
||||
name: 2 | Deploy staging
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- 'releases/staging'
|
||||
|
||||
jobs:
|
||||
deploy-to-staging:
|
||||
runs-on: ubuntu-latest
|
||||
environment:
|
||||
name: deploy/staging
|
||||
steps:
|
||||
- name: Send Slack message that deploy is starting
|
||||
uses: slackapi/slack-github-action@007b2c3c751a190b6f0f040e47ed024deaa72844
|
||||
continue-on-error: true
|
||||
with:
|
||||
payload: |
|
||||
{
|
||||
"text": "Staging deploy started for branch: ${{ github.ref_name }}"
|
||||
}
|
||||
env:
|
||||
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
|
||||
SLACK_WEBHOOK_TYPE: INCOMING_WEBHOOK
|
||||
- uses: actions/checkout@v3
|
||||
- uses: ./.github/actions/setup
|
||||
- run: yarn prepare
|
||||
- run: yarn build
|
||||
env:
|
||||
REACT_APP_STAGING: 1
|
||||
- name: Update Cloudflare Pages deployment
|
||||
id: pages-deployment
|
||||
uses: cloudflare/pages-action@364c7ca09a4b57837c5967871d64a2c31adb8c0d
|
||||
with:
|
||||
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
projectName: interface-staging
|
||||
directory: build
|
||||
githubToken: ${{ secrets.GITHUB_TOKEN }}
|
||||
# Cloudflare uses `main` as the default production branch, so we push using the `main` branch so that it can be aliased by a custom domain.
|
||||
branch: main
|
||||
- name: Send Slack message about deployment outcome
|
||||
uses: slackapi/slack-github-action@007b2c3c751a190b6f0f040e47ed024deaa72844
|
||||
continue-on-error: true
|
||||
if: always()
|
||||
with:
|
||||
payload: |
|
||||
{
|
||||
"text": "Staging deploy **${{ steps.pages-deployment.outcome }}** for: ${{ github.ref_name }}"
|
||||
}
|
||||
env:
|
||||
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
|
||||
SLACK_WEBHOOK_TYPE: INCOMING_WEBHOOK
|
||||
- name: Upload source maps to Sentry
|
||||
uses: getsentry/action-release@bd5f874fcda966ba48139b0140fb3ec0cb3aabdd
|
||||
continue-on-error: true
|
||||
env:
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
|
||||
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
|
||||
with:
|
||||
environment: staging
|
||||
sourcemaps: './build/static/js'
|
||||
url_prefix: '~/static/js'
|
||||
27
.github/workflows/3-staging-to-prod.yml
vendored
Normal file
27
.github/workflows/3-staging-to-prod.yml
vendored
Normal file
@@ -0,0 +1,27 @@
|
||||
name: 3 | Push staging -> prod
|
||||
|
||||
# This CI job is responsible for force pushing the content of releases/staging to releases/prod. It
|
||||
# is restricted to web-reviewers through virtue of the GitHub environment protection rules for the
|
||||
# prod environment.
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
push-prod:
|
||||
name: 'Push to prod branch'
|
||||
runs-on: ubuntu-latest
|
||||
environment:
|
||||
name: push/prod
|
||||
steps:
|
||||
- uses: actions/checkout@8e5e7e5ab8b370d6c329ec480221332ada57f0ab
|
||||
with:
|
||||
token: ${{ secrets.RELEASE_SERVICE_ACCESS_TOKEN }}
|
||||
ref: releases/staging
|
||||
- name: Git config
|
||||
run: |
|
||||
git config user.name "UL Service Account"
|
||||
git config user.email "hello-happy-puppy@users.noreply.github.com"
|
||||
- name: Git push
|
||||
run: |
|
||||
git push origin releases/staging:releases/prod --force
|
||||
@@ -1,72 +1,47 @@
|
||||
name: Release
|
||||
name: 4 | Deploy prod
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 12 * * 1-4' # every day 12:00 UTC Monday-Thursday
|
||||
# manual trigger
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- 'releases/prod'
|
||||
|
||||
jobs:
|
||||
wait-on-tests:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- id: unit-tests
|
||||
uses: fountainhead/action-wait-for-check@v1.0.0
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
checkName: unit-tests
|
||||
- id: cypress-tests
|
||||
uses: fountainhead/action-wait-for-check@v1.0.0
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
checkName: cypress-tests
|
||||
- if: steps.unit-tests.outputs.conclusion != 'success' || steps.cypress-tests.outputs.conclusion != 'success'
|
||||
run: exit 1
|
||||
|
||||
tag:
|
||||
needs: wait-on-tests
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
new_tag: ${{ steps.github-tag-action.outputs.new_tag }}
|
||||
changelog: ${{ steps.github-tag-action.outputs.changelog }}
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Bump and tag
|
||||
id: github-tag-action
|
||||
uses: mathieudutour/github-tag-action@v6.0
|
||||
with:
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
release_branches: .*
|
||||
default_bump: patch
|
||||
|
||||
release:
|
||||
needs: tag
|
||||
if: ${{ needs.tag.outputs.new_tag != null }}
|
||||
deploy-to-prod:
|
||||
runs-on: ubuntu-latest
|
||||
environment:
|
||||
name: release
|
||||
name: deploy/prod
|
||||
steps:
|
||||
- name: Send Slack message that build is starting
|
||||
uses: slackapi/slack-github-action@007b2c3c751a190b6f0f040e47ed024deaa72844
|
||||
continue-on-error: true
|
||||
with:
|
||||
payload: |
|
||||
{
|
||||
"text": "Production deploy started for branch: ${{ github.ref_name }}"
|
||||
}
|
||||
env:
|
||||
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
|
||||
SLACK_WEBHOOK_TYPE: INCOMING_WEBHOOK
|
||||
- uses: actions/checkout@v3
|
||||
- uses: ./.github/actions/setup
|
||||
- run: yarn prepare
|
||||
- run: yarn build
|
||||
- name: Bump and tag
|
||||
id: github-tag-action
|
||||
uses: mathieudutour/github-tag-action@d745f2e74aaf1ee82e747b181f7a0967978abee0
|
||||
with:
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
release_branches: releases/prod
|
||||
default_bump: patch
|
||||
|
||||
- name: Pin to IPFS
|
||||
id: pinata
|
||||
uses: anantaramdas/ipfs-pinata-deploy-action@39bbda1ce1fe24c69c6f57861b8038278d53688d
|
||||
with:
|
||||
pin-name: Uniswap ${{ needs.tag.outputs.new_tag }}
|
||||
pin-name: Uniswap ${{ steps.github-tag-action.outputs.new_tag }}
|
||||
path: './build'
|
||||
pinata-api-key: ${{ secrets.PINATA_API_KEY }}
|
||||
pinata-secret-api-key: ${{ secrets.PINATA_API_SECRET_KEY }}
|
||||
|
||||
- name: Pin to Crust
|
||||
uses: crustio/ipfs-crust-action@v2.0.3
|
||||
continue-on-error: true
|
||||
timeout-minutes: 2
|
||||
with:
|
||||
cid: ${{ steps.pinata.outputs.hash }}
|
||||
seeds: ${{ secrets.CRUST_SEEDS }}
|
||||
|
||||
- name: Convert CIDv0 to CIDv1
|
||||
id: convert-cidv0
|
||||
uses: uniswap/convert-cidv0-cidv1@v1.0.0
|
||||
@@ -78,18 +53,18 @@ jobs:
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
tag_name: ${{ needs.tag.outputs.new_tag }}
|
||||
release_name: Release ${{ needs.tag.outputs.new_tag }}
|
||||
tag_name: ${{ steps.github-tag-action.outputs.new_tag }}
|
||||
release_name: Release ${{ steps.github-tag-action.outputs.new_tag }}
|
||||
body: |
|
||||
IPFS hash of the deployment:
|
||||
- CIDv0: `${{ steps.pinata.outputs.hash }}`
|
||||
- CIDv1: `${{ steps.convert-cidv0.outputs.cidv1 }}`
|
||||
|
||||
The latest release is always accessible via our alias to the Cloudflare IPFS gateway at [app.uniswap.org](https://app.uniswap.org).
|
||||
The latest release is always mirrored at [app.uniswap.org](https://app.uniswap.org).
|
||||
|
||||
You can also access the Uniswap Interface directly from an IPFS gateway.
|
||||
You can also access the Uniswap Interface from an IPFS gateway.
|
||||
**BEWARE**: The Uniswap interface uses [`localStorage`](https://developer.mozilla.org/en-US/docs/Web/API/Window/localStorage) to remember your settings, such as which tokens you have imported.
|
||||
**You should always use an IPFS gateway that enforces origin separation**, or our alias to the latest release at [app.uniswap.org](https://app.uniswap.org).
|
||||
**You should always use an IPFS gateway that enforces origin separation**, or our hosted deployment of the latest release at [app.uniswap.org](https://app.uniswap.org).
|
||||
Your Uniswap settings are never remembered across different URLs.
|
||||
|
||||
IPFS gateways:
|
||||
@@ -97,24 +72,34 @@ jobs:
|
||||
- https://${{ steps.convert-cidv0.outputs.cidv1 }}.ipfs.cf-ipfs.com/
|
||||
- [ipfs://${{ steps.pinata.outputs.hash }}/](ipfs://${{ steps.pinata.outputs.hash }}/)
|
||||
|
||||
${{ needs.tag.outputs.changelog }}
|
||||
|
||||
- name: Setup node@16 (required by Cloudflare Pages)
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 16
|
||||
${{ steps.github-tag-action.outputs.changelog }}
|
||||
|
||||
- name: Update Cloudflare Pages deployment
|
||||
uses: cloudflare/pages-action@364c7ca09a4b57837c5967871d64a2c31adb8c0d
|
||||
id: pages-deployment
|
||||
with:
|
||||
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
projectName: ${{ secrets.CLOUDFLARE_PROJECT_NAME }}
|
||||
directory: build
|
||||
githubToken: ${{ secrets.GITHUB_TOKEN }}
|
||||
# Cloudflare uses `main` as the default production branch, so we push using the `main` branch so that it can be aliased by a custom domain.
|
||||
branch: main
|
||||
|
||||
- name: Send Slack message about deployment outcome
|
||||
uses: slackapi/slack-github-action@007b2c3c751a190b6f0f040e47ed024deaa72844
|
||||
continue-on-error: true
|
||||
if: always()
|
||||
with:
|
||||
payload: |
|
||||
{
|
||||
"text": "Production deploy **${{ steps.pages-deployment.outcome }}** for: ${{ github.ref_name }}"
|
||||
}
|
||||
env:
|
||||
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
|
||||
SLACK_WEBHOOK_TYPE: INCOMING_WEBHOOK
|
||||
- name: Upload source maps to Sentry
|
||||
uses: getsentry/action-release@bd5f874fcda966ba48139b0140fb3ec0cb3aabdd
|
||||
uses: getsentry/action-release@4744f6a65149f441c5f396d5b0877307c0db52c7
|
||||
continue-on-error: true
|
||||
env:
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
@@ -11,8 +11,10 @@ name: Slack notifications for releases/* merges
|
||||
# | sed 's/"//g' | sed 's/\\t/;/g' | sed 's/\\n/;/g' | sed 's/\\//g' \
|
||||
# We then use awk to format the TSV into a Slack message
|
||||
# | awk -F';' '{print "• <"$1"|"$2"> (<https://github.com/"$3"|"$3">, "$4") - "$5}' \
|
||||
# Finally, we need to deal with some escaping issues with newlines so that we don't break the Slack message format
|
||||
# We need to deal with some escaping issues with newlines so that we don't break the Slack message format
|
||||
# | sed 's/$/\\n/g' | tr -d '\n' \
|
||||
# Finally we have to truncate the message to 3,000 characters max, otherwise Slack will reject it
|
||||
# | awk '{print substr($0,0,3000);}' \
|
||||
# Then shove the bytes into a file to store them in their exact format
|
||||
# > /tmp/parsed_github_context
|
||||
|
||||
@@ -25,6 +27,8 @@ jobs:
|
||||
notify-slack:
|
||||
name: 'Emit Slack notification(s)'
|
||||
runs-on: ubuntu-latest
|
||||
environment:
|
||||
name: notify/releases
|
||||
steps:
|
||||
- name: Parse event to slug
|
||||
id: parse-slug
|
||||
@@ -38,6 +42,7 @@ jobs:
|
||||
| sed 's/"//g' | sed 's/\\t/;/g' | sed 's/\\n/;/g' | sed 's/\\//g' \
|
||||
| awk -F';' '{print "• <"$1"|"$2"> (<https://github.com/"$3"|"$3">, "$4") - "$5}' \
|
||||
| sed 's/$/\\n/g' | tr -d '\n' \
|
||||
| awk '{print substr($0,0,3000);}' \
|
||||
> /tmp/parsed_github_context
|
||||
echo "SLACK_COMMITS=$(cat /tmp/parsed_github_context)" >> "$GITHUB_OUTPUT"
|
||||
- name: Send custom JSON data to Slack workflow
|
||||
|
||||
91
.github/workflows/test.yml
vendored
91
.github/workflows/test.yml
vendored
@@ -2,6 +2,7 @@ name: Test
|
||||
|
||||
# Many build steps have their own caches, so each job has its own cache to improve subsequent build times.
|
||||
# Build tools are configured to cache cache to node_modules/.cache, so this is cached independently of node_modules.
|
||||
# Caches are saved every run (by keying on github.run_id), and the most recent available cache is loaded.
|
||||
# See https://jongleberry.medium.com/speed-up-your-ci-and-dx-with-node-modules-cache-ac8df82b7bb0.
|
||||
|
||||
on:
|
||||
@@ -25,6 +26,11 @@ jobs:
|
||||
key: ${{ runner.os }}-eslint-${{ hashFiles('**/yarn.lock') }}-${{ github.run_id }}
|
||||
restore-keys: ${{ runner.os }}-eslint-${{ hashFiles('**/yarn.lock') }}-
|
||||
- run: yarn lint
|
||||
- if: failure() && github.ref_name == 'main'
|
||||
uses: ./.github/actions/report
|
||||
with:
|
||||
name: Lint
|
||||
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_TEST_REPORTER_WEBHOOK }}
|
||||
|
||||
typecheck:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -37,8 +43,12 @@ jobs:
|
||||
path: node_modules/.cache
|
||||
key: ${{ runner.os }}-tsc-${{ hashFiles('**/yarn.lock') }}-${{ github.run_id }}
|
||||
restore-keys: ${{ runner.os }}-tsc-${{ hashFiles('**/yarn.lock') }}-
|
||||
- run: yarn prepare
|
||||
- run: yarn typecheck
|
||||
- if: failure() && github.ref_name == 'main'
|
||||
uses: ./.github/actions/report
|
||||
with:
|
||||
name: Typecheck
|
||||
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_TEST_REPORTER_WEBHOOK }}
|
||||
|
||||
deps-tests:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -46,6 +56,11 @@ jobs:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: ./.github/actions/setup
|
||||
- run: yarn yarn-deduplicate --strategy=highest --list --fail
|
||||
- if: failure() && github.ref_name == 'main'
|
||||
uses: ./.github/actions/report
|
||||
with:
|
||||
name: Dependency checks
|
||||
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_TEST_REPORTER_WEBHOOK }}
|
||||
|
||||
unit-tests:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -58,45 +73,17 @@ jobs:
|
||||
path: node_modules/.cache
|
||||
key: ${{ runner.os }}-jest-${{ hashFiles('**/yarn.lock') }}-${{ github.run_id }}
|
||||
restore-keys: ${{ runner.os }}-jest-${{ hashFiles('**/yarn.lock') }}-
|
||||
- run: yarn prepare
|
||||
- run: yarn test --silent --maxWorkers=100%
|
||||
- run: yarn test --coverage --maxWorkers=100%
|
||||
- uses: codecov/codecov-action@v3
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
fail_ci_if_error: false
|
||||
verbose: true
|
||||
flags: unit-tests
|
||||
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: ./.github/actions/setup
|
||||
- uses: actions/cache@v3
|
||||
id: build-cache
|
||||
- if: failure() && github.ref_name == 'main'
|
||||
uses: ./.github/actions/report
|
||||
with:
|
||||
path: node_modules/.cache
|
||||
key: ${{ runner.os }}-build-${{ hashFiles('**/yarn.lock') }}-${{ github.run_id }}
|
||||
restore-keys: ${{ runner.os }}-build-${{ hashFiles('**/yarn.lock') }}-
|
||||
- run: yarn prepare
|
||||
- run: yarn build
|
||||
- uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: build
|
||||
path: build
|
||||
if-no-files-found: error
|
||||
|
||||
size-tests:
|
||||
needs: [build]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: ./.github/actions/setup
|
||||
- uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: build
|
||||
path: build
|
||||
- run: yarn test:size
|
||||
name: Unit tests
|
||||
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_TEST_REPORTER_WEBHOOK }}
|
||||
|
||||
build-e2e:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -109,11 +96,10 @@ jobs:
|
||||
path: node_modules/.cache
|
||||
key: ${{ runner.os }}-build-e2e-${{ hashFiles('**/yarn.lock') }}-${{ github.run_id }}
|
||||
restore-keys: ${{ runner.os }}-build-e2e-${{ hashFiles('**/yarn.lock') }}-
|
||||
- run: yarn prepare
|
||||
- run: yarn build:e2e
|
||||
env:
|
||||
NODE_OPTIONS: "--max_old_space_size=4096"
|
||||
- uses: actions/upload-artifact@v2
|
||||
- uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: build-e2e
|
||||
path: build
|
||||
@@ -140,7 +126,7 @@ jobs:
|
||||
id: cypress-cache
|
||||
with:
|
||||
path: /root/.cache/Cypress
|
||||
key: ${{ runner.os }}-cypress
|
||||
key: ${{ runner.os }}-cypress-${{ hashFiles('**/node_modules/cypress/package.json') }}
|
||||
- run: |
|
||||
yarn cypress install
|
||||
yarn cypress info
|
||||
@@ -150,28 +136,49 @@ jobs:
|
||||
name: build-e2e
|
||||
path: build
|
||||
|
||||
- uses: actions/cache@v3
|
||||
id: hardhat-cache
|
||||
with:
|
||||
path: cache
|
||||
key: ${{ runner.os }}-hardhat-${{ hashFiles('hardhat.config.js') }}-${{ github.run_id }}
|
||||
restore-keys: ${{ runner.os }}-hardhat-${{ hashFiles('hardhat.config.js') }}-
|
||||
|
||||
- uses: cypress-io/github-action@v4
|
||||
with:
|
||||
install: false
|
||||
record: true
|
||||
parallel: true
|
||||
start: yarn serve
|
||||
wait-on: 'http://localhost:3000'
|
||||
browser: chrome
|
||||
record: true
|
||||
parallel: true
|
||||
group: e2e
|
||||
env:
|
||||
CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
COMMIT_INFO_BRANCH: ${{ github.event.pull_request.head.ref || github.ref_name }}
|
||||
COMMIT_INFO_MESSAGE: ${{ github.event.pull_request.title || github.event.head_commit.message }}
|
||||
COMMIT_INFO_AUTHOR: ${{ github.event.sender.login || github.event.head_commit.author.login }}
|
||||
# Cypress requires an email for filtering by author, but GitHub does not expose one.
|
||||
# GitHub's public profile email can be deterministically produced from user id/login.
|
||||
COMMIT_INFO_EMAIL: ${{ github.event.sender.id || github.event.head_commit.author.id }}+${{ github.event.sender.login || github.event.head_commit.author.login }}@users.noreply.github.com
|
||||
COMMIT_INFO_SHA: ${{ github.event.pull_request.head.sha || github.event.head_commit.sha }}
|
||||
COMMIT_INFO_TIMESTAMP: ${{ github.event.pull_request.updated_at || github.event.head_commit.timestamp }}
|
||||
CYPRESS_PULL_REQUEST_ID: ${{ github.event.pull_request.number }}
|
||||
CYPRESS_PULL_REQUEST_URL: ${{ github.event.pull_request.html_url }}
|
||||
- uses: codecov/codecov-action@v3
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
fail_ci_if_error: false
|
||||
verbose: true
|
||||
flags: e2e-tests
|
||||
- if: failure() && github.ref_name == 'main'
|
||||
uses: ./.github/actions/report
|
||||
with:
|
||||
name: Cypress tests
|
||||
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_TEST_REPORTER_WEBHOOK }}
|
||||
|
||||
# Included as a single job to check for cypress-test-matrix success, as a matrix cannot be checked.
|
||||
cypress-tests:
|
||||
if: ${{ always() }}
|
||||
if: always()
|
||||
needs: [cypress-test-matrix]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
|
||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -8,8 +8,10 @@
|
||||
/src/locales/**/en-US.po
|
||||
/src/locales/**/pseudo.po
|
||||
|
||||
# generated graphql types
|
||||
/src/graphql/**/__generated__
|
||||
# generated files
|
||||
/src/**/__generated__
|
||||
|
||||
# schema
|
||||
schema.graphql
|
||||
|
||||
# dependencies
|
||||
|
||||
11
.vscode/settings.json
vendored
11
.vscode/settings.json
vendored
@@ -12,5 +12,14 @@
|
||||
},
|
||||
"files.eol": "\n",
|
||||
"eslint.enable": true,
|
||||
"eslint.debug": true
|
||||
"eslint.debug": true,
|
||||
"[javascript]": {
|
||||
"editor.defaultFormatter": "dbaeumer.vscode-eslint"
|
||||
},
|
||||
"[typescript]": {
|
||||
"editor.defaultFormatter": "dbaeumer.vscode-eslint"
|
||||
},
|
||||
"[typescriptreact]": {
|
||||
"editor.defaultFormatter": "dbaeumer.vscode-eslint"
|
||||
},
|
||||
}
|
||||
|
||||
1
CODEOWNERS
Normal file
1
CODEOWNERS
Normal file
@@ -0,0 +1 @@
|
||||
@uniswap/web-admins
|
||||
21
codecov.yml
21
codecov.yml
@@ -9,17 +9,38 @@ ignore:
|
||||
- "**/constants/**/*"
|
||||
- "constants/**/*"
|
||||
|
||||
coverage:
|
||||
status:
|
||||
# Omit merging unit/e2e reports into the defaults, as it is nonsensical.
|
||||
project: off
|
||||
patch: off
|
||||
|
||||
flag_management:
|
||||
default_rules:
|
||||
statuses:
|
||||
- type: project
|
||||
target: auto
|
||||
threshold: 1%
|
||||
# Adjust the base when removing code to avoid penalizing tech debt payback / dead code removal.
|
||||
removed_code_behavior: adjust_base
|
||||
if_ci_failed: error
|
||||
- type: patch
|
||||
target: 80%
|
||||
individual_flags:
|
||||
- name: unit-tests
|
||||
- name: e2e-tests
|
||||
# Wait until all machines have reported coverage - e2e tests run across 4 machines.
|
||||
after_n_builds: 4
|
||||
statuses:
|
||||
- type: patch
|
||||
target: 0%
|
||||
|
||||
comment:
|
||||
layout: flags
|
||||
# Wait until all machines have reported coverage - e2e tests run across 4 machines + unit tests across 1.
|
||||
after_n_builds: 5
|
||||
hide_comment_details: false
|
||||
|
||||
github_checks:
|
||||
# Turn off GitHub Check annotations, as they make it more difficult to review code.
|
||||
annotations: false
|
||||
|
||||
115
craco.config.cjs
115
craco.config.cjs
@@ -1,9 +1,11 @@
|
||||
/* eslint-env node */
|
||||
const { VanillaExtractPlugin } = require('@vanilla-extract/webpack-plugin')
|
||||
const CaseSensitivePathsPlugin = require('case-sensitive-paths-webpack-plugin')
|
||||
const { execSync } = require('child_process')
|
||||
const MiniCssExtractPlugin = require('mini-css-extract-plugin')
|
||||
const CaseSensitivePathsPlugin = require('case-sensitive-paths-webpack-plugin')
|
||||
const { DefinePlugin, IgnorePlugin } = require('webpack')
|
||||
const path = require('path')
|
||||
const ModuleScopePlugin = require('react-dev-utils/ModuleScopePlugin')
|
||||
const { DefinePlugin, IgnorePlugin, ProvidePlugin } = require('webpack')
|
||||
|
||||
const commitHash = execSync('git rev-parse HEAD').toString().trim()
|
||||
const isProduction = process.env.NODE_ENV === 'production'
|
||||
@@ -12,6 +14,11 @@ const isProduction = process.env.NODE_ENV === 'production'
|
||||
// Omit them from production builds, as they slow down the feedback loop.
|
||||
const shouldLintOrTypeCheck = !isProduction
|
||||
|
||||
function getCacheDirectory(cacheName) {
|
||||
// Include the trailing slash to denote that this is a directory.
|
||||
return `${path.join(__dirname, 'node_modules/.cache/', cacheName)}/`
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
babel: {
|
||||
plugins: [
|
||||
@@ -44,8 +51,13 @@ module.exports = {
|
||||
pluginOptions(eslintConfig) {
|
||||
return Object.assign(eslintConfig, {
|
||||
cache: true,
|
||||
cacheLocation: 'node_modules/.cache/eslint/',
|
||||
cacheLocation: getCacheDirectory('eslint'),
|
||||
ignorePath: '.gitignore',
|
||||
// Use our own eslint/plugins/config, as overrides interfere with caching.
|
||||
// This ensures that `yarn start` and `yarn lint` share one cache.
|
||||
eslintPath: require.resolve('eslint'),
|
||||
resolvePluginsRelativeTo: null,
|
||||
baseConfig: null,
|
||||
})
|
||||
},
|
||||
},
|
||||
@@ -55,11 +67,13 @@ module.exports = {
|
||||
jest: {
|
||||
configure(jestConfig) {
|
||||
return Object.assign(jestConfig, {
|
||||
transform: {
|
||||
'\\.css\\.ts$': './vanilla.transform.cjs',
|
||||
...jestConfig.transform,
|
||||
},
|
||||
cacheDirectory: 'node_modules/.cache/jest',
|
||||
cacheDirectory: getCacheDirectory('jest'),
|
||||
transform: Object.assign(jestConfig.transform, {
|
||||
// Transform vanilla-extract using its own transformer.
|
||||
// See https://sandroroth.com/blog/vanilla-extract-cra#jest-transform.
|
||||
'\\.css\\.ts$': '@vanilla-extract/jest-transform',
|
||||
}),
|
||||
// Use @uniswap/conedison's build directly, as jest does not support its exports.
|
||||
transformIgnorePatterns: ['@uniswap/conedison/format', '@uniswap/conedison/provider'],
|
||||
moduleNameMapper: {
|
||||
'@uniswap/conedison/format': '@uniswap/conedison/dist/format',
|
||||
@@ -69,8 +83,19 @@ module.exports = {
|
||||
},
|
||||
},
|
||||
webpack: {
|
||||
plugins: [new VanillaExtractPlugin({ identifiers: 'short' })],
|
||||
plugins: [
|
||||
// Webpack 5 does not polyfill node globals, so we do so for those necessary:
|
||||
new ProvidePlugin({
|
||||
// - react-markdown requires process.cwd
|
||||
process: 'process/browser',
|
||||
}),
|
||||
// vanilla-extract has poor performance on M1 machines with 'debug' identifiers, so we use 'short' instead.
|
||||
// See https://vanilla-extract.style/documentation/integrations/webpack/#identifiers for docs.
|
||||
// See https://github.com/vanilla-extract-css/vanilla-extract/issues/771#issuecomment-1249524366.
|
||||
new VanillaExtractPlugin({ identifiers: 'short' }),
|
||||
],
|
||||
configure: (webpackConfig) => {
|
||||
// Configure webpack plugins:
|
||||
webpackConfig.plugins = webpackConfig.plugins
|
||||
.map((plugin) => {
|
||||
// Extend process.env with dynamic values (eg commit hash).
|
||||
@@ -87,10 +112,16 @@ module.exports = {
|
||||
plugin.options.ignoreOrder = true
|
||||
}
|
||||
|
||||
// Disable TypeScript's config overwrite, as it interferes with incremental build caching.
|
||||
// This ensures that `yarn start` and `yarn typecheck` share one cache.
|
||||
if (plugin.constructor.name == 'ForkTsCheckerWebpackPlugin') {
|
||||
delete plugin.options.typescript.configOverwrite
|
||||
}
|
||||
|
||||
return plugin
|
||||
})
|
||||
.filter((plugin) => {
|
||||
// Case sensitive paths are enforced by TypeScript.
|
||||
// Case sensitive paths are already enforced by TypeScript.
|
||||
// See https://www.typescriptlang.org/tsconfig#forceConsistentCasingInFileNames.
|
||||
if (plugin instanceof CaseSensitivePathsPlugin) return false
|
||||
|
||||
@@ -100,10 +131,66 @@ module.exports = {
|
||||
return true
|
||||
})
|
||||
|
||||
// We're currently on Webpack 4.x which doesn't support the `exports` field in package.json.
|
||||
// Instead, we need to manually map the import path to the correct exports path (eg dist or build folder).
|
||||
// See https://github.com/webpack/webpack/issues/9509.
|
||||
webpackConfig.resolve.alias['@uniswap/conedison'] = '@uniswap/conedison/dist'
|
||||
// Configure webpack resolution:
|
||||
webpackConfig.resolve = Object.assign(webpackConfig.resolve, {
|
||||
plugins: webpackConfig.resolve.plugins.map((plugin) => {
|
||||
// Allow vanilla-extract in production builds.
|
||||
// This is necessary because create-react-app guards against external imports.
|
||||
// See https://sandroroth.com/blog/vanilla-extract-cra#production-build.
|
||||
if (plugin instanceof ModuleScopePlugin) {
|
||||
plugin.allowedPaths.push(path.join(__dirname, 'node_modules/@vanilla-extract/webpack-plugin'))
|
||||
}
|
||||
|
||||
return plugin
|
||||
}),
|
||||
// Webpack 5 does not resolve node modules, so we do so for those necessary:
|
||||
fallback: {
|
||||
// - react-markdown requires path
|
||||
path: require.resolve('path-browserify'),
|
||||
},
|
||||
})
|
||||
|
||||
// Configure webpack transpilation (create-react-app specifies transpilation rules in a oneOf):
|
||||
webpackConfig.module.rules[1].oneOf = webpackConfig.module.rules[1].oneOf.map((rule) => {
|
||||
// The fallback rule (eg for dependencies).
|
||||
if (rule.loader && rule.loader.match(/babel-loader/) && !rule.include) {
|
||||
// Allow not-fully-specified modules so that legacy packages are still able to build.
|
||||
rule.resolve = { fullySpecified: false }
|
||||
|
||||
// The class properties transform is required for @uniswap/analytics to build.
|
||||
rule.options.plugins.push('@babel/plugin-proposal-class-properties')
|
||||
}
|
||||
return rule
|
||||
})
|
||||
|
||||
// Configure webpack optimization:
|
||||
webpackConfig.optimization = Object.assign(
|
||||
webpackConfig.optimization,
|
||||
isProduction
|
||||
? {
|
||||
splitChunks: {
|
||||
// Cap the chunk size to 5MB.
|
||||
// react-scripts suggests a chunk size under 1MB after gzip, but we can only measure maxSize before gzip.
|
||||
// react-scripts also caps cacheable chunks at 5MB, which gzips to below 1MB, so we cap chunk size there.
|
||||
// See https://github.com/facebook/create-react-app/blob/d960b9e/packages/react-scripts/config/webpack.config.js#L713-L716.
|
||||
maxSize: 5 * 1024 * 1024,
|
||||
// Optimize over all chunks, instead of async chunks (the default), so that initial chunks are also optimized.
|
||||
chunks: 'all',
|
||||
},
|
||||
}
|
||||
: {}
|
||||
)
|
||||
|
||||
// Configure webpack caching:
|
||||
webpackConfig.cache = Object.assign(webpackConfig.cache, {
|
||||
cacheDirectory: getCacheDirectory('webpack'),
|
||||
})
|
||||
|
||||
// Ignore failed source mappings to avoid spamming the console.
|
||||
// Source mappings for a package will fail if the package does not provide them, but the build will still succeed,
|
||||
// so it is unnecessary (and bothersome) to log it. This should be turned off when debugging missing sourcemaps.
|
||||
// See https://webpack.js.org/loaders/source-map-loader#ignoring-warnings.
|
||||
webpackConfig.ignoreWarnings = [/Failed to parse source map/]
|
||||
|
||||
return webpackConfig
|
||||
},
|
||||
|
||||
@@ -1,17 +1,30 @@
|
||||
import codeCoverageTask from '@cypress/code-coverage/task'
|
||||
import { defineConfig } from 'cypress'
|
||||
import { setupHardhatEvents } from 'cypress-hardhat'
|
||||
import { unlinkSync } from 'fs'
|
||||
|
||||
export default defineConfig({
|
||||
projectId: 'yp82ef',
|
||||
videoUploadOnPasses: false,
|
||||
defaultCommandTimeout: 24000, // 2x average block time
|
||||
chromeWebSecurity: false,
|
||||
experimentalMemoryManagement: true, // better memory management, see https://github.com/cypress-io/cypress/pull/25462
|
||||
retries: { runMode: 2 },
|
||||
videoCompression: false,
|
||||
e2e: {
|
||||
async setupNodeEvents(on, config) {
|
||||
await setupHardhatEvents(on, config)
|
||||
codeCoverageTask(on, config)
|
||||
|
||||
// Delete recorded videos for specs that passed without flakes.
|
||||
on('after:spec', async (spec, results) => {
|
||||
if (results && results.video) {
|
||||
// If there were no failures (including flakes), delete the recorded video.
|
||||
if (!results.tests?.some((test) => test.attempts.some((attempt) => attempt?.state === 'failed'))) {
|
||||
unlinkSync(results.video)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
...config,
|
||||
// Only enable Chrome.
|
||||
|
||||
202
cypress/README.md
Normal file
202
cypress/README.md
Normal file
@@ -0,0 +1,202 @@
|
||||
# e2e testing with Cypress
|
||||
|
||||
End-to-end tests are run through [Cypress](https://docs.cypress.io/api/table-of-contents/), which runs tests in a real browser. Cypress is a little different than other testing frameworks, and e2e tests are a little different than unit tests, so this directory has its own set of patterns, idioms, and best practices. Not only that, but we're testing against a forked blockchain, not just against typical Web APIs, so we have unique flows that you may not have seen elsewhere.
|
||||
|
||||
## Running your first e2e tests
|
||||
|
||||
Cypress tests run against a local server, so you'll need to run the application locally at the same time. The fastest way to run e2e tests is to use your dev server: `yarn start`.
|
||||
|
||||
Open cypress at the same time with `yarn cypress:open`. You should do this from another window or tab, so that you can continue to see any typechecking/linting warnings from `yarn start`.
|
||||
|
||||
Cypress opens its own instance of Chrome, with a list of "E2E specs" for you to select. When you're developing locally, you usually only want to run one spec file at a time. Select your spec by clicking on the filename and it will run.
|
||||
|
||||
## Glossary
|
||||
|
||||
#### spec
|
||||
Cypress considers each file a separate spec, or collection of tests.
|
||||
Specs are always run as a whole through `yarn cypress:open` or on the same machine through CI.
|
||||
|
||||
#### Thenable
|
||||
Cypress queues commands to run in the browser using [Thenables](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise#thenables), not Promises.
|
||||
For this reason, you should not use `async/await` syntax in Cypress unless it is wholly-contained in a `cy.then` function argument.
|
||||
|
||||
## Writing your first e2e test
|
||||
|
||||
_For an excellent treatment on tests, check out the [Cypress Fundamentals](https://learn.cypress.io/cypress-fundamentals/how-to-write-a-test) course._
|
||||
_While some of that will be paraphrased here, this should be sufficient to get you started:_
|
||||
|
||||
### What is a test?
|
||||
|
||||
Cypress tests are just like any other test: you should set up an initial state, execute an action, and verify the action's consequence. This is codified in the AAA (Arrange-Act-Assert) pattern, and you'll see this in most of our tests. In _our_ case, it plays out as:
|
||||
|
||||
1. Arrange: Visit a page, eg `cy.visit('/swap')`, and set up the state, on the blockchain and the page.
|
||||
2. Act: Initiate your action under test, eg `initiateSwap()`
|
||||
3. Assert: Verify that the action has occured, eg `// Verify swap has occured`
|
||||
|
||||
You'll usually see the setup, followed by a newline, followed by assertions with comments stating what they are asserting.
|
||||
Because Cypress tests are translated into user actions, it may be hard to follow the action being described. You should use comments liberally to describe what you are doing and what you intend to test, to make tests easier to read and maintain in the future.
|
||||
|
||||
### Thinking about tests: queuing up a sequence of commands
|
||||
|
||||
Cypress uses `Thenable`s to achieve "command chaining". A test is described as a series of commands, which are only executed once the previous command in the chain has executed.
|
||||
|
||||
```
|
||||
cy.visit('/swap')
|
||||
cy.contains('Select token').click()
|
||||
cy.contains('DAI').click()
|
||||
```
|
||||
|
||||
In this example, `cy.contains('Select token').click()` is queued up right away (all the code is synchronous), but it will not execute until `/swap` has loaded (all the commands are chained); and `click()` will not execute until `Select token` has been found.
|
||||
|
||||
This becomes more relevant as you work with data on the blockchain, as you'll need to load it at the correct time, _after_ it's been modified by the application:
|
||||
|
||||
```
|
||||
cy.hardhat().then(async (hardhat) => {
|
||||
cy.visit(`/swap?inputCurrency=ETH&outputCurrency=${USDC_MAINNET.address}`, { ethereum: 'hardhat' })
|
||||
cy.get('#swap-currency-output .token-amount-input').type('1').should('have.value', '1')
|
||||
cy.get('#swap-button').click()
|
||||
cy.contains('Confirm swap').click()
|
||||
|
||||
// wait for the transaction to be executed
|
||||
cy.get(getTestSelector('web3-status-connected')).should('contain', '1 Pending')
|
||||
cy.get(getTestSelector('web3-status-connected')).should('not.contain', 'Pending')
|
||||
|
||||
// BAD: This will get the balance _before_ the other queued actions have executed.
|
||||
const balance = await hardhat.getBalance(hardhat.wallet, USDC_MAINNET)
|
||||
cy.wrap(balance).should('deep.equal', expectedBalance)
|
||||
})
|
||||
```
|
||||
|
||||
```
|
||||
cy.visit(`/swap?inputCurrency=ETH&outputCurrency=${USDC_MAINNET.address}`, { ethereum: 'hardhat' })
|
||||
cy.get('#swap-currency-output .token-amount-input').type('1').should('have.value', '1')
|
||||
cy.get('#swap-button').click()
|
||||
cy.contains('Confirm swap').click()
|
||||
|
||||
// wait for the transaction to be executed
|
||||
cy.get(getTestSelector('web3-status-connected')).should('contain', '1 Pending')
|
||||
cy.get(getTestSelector('web3-status-connected')).should('not.contain', 'Pending')
|
||||
|
||||
// GOOD: cy.then chains the command so that it runs _after_ executing the swap
|
||||
cy.hardhat()
|
||||
.then((hardhat) => hardhat.getBalance(hardhat.wallet, USDC_MAINNET))
|
||||
.should('deep.equal', expectedBalance)
|
||||
})
|
||||
```
|
||||
|
||||
### Working with the blockchain (ie hardhat)
|
||||
|
||||
Our tests use a local hardhat node to simulate blockchain transactions. This can be accessed with `cy.hardhat().then((hardhat) => ...)`.
|
||||
Currently, tests using hardhat must opt-in in when they load the page: `cy.visit('/swap', { ethereum: 'hardhat' })`. This will not be necessary once we've totally migrated to hardhat.
|
||||
|
||||
By default, automining is turned on, so that any transaction that you send to the blockchain is mined immediately. If you want to assert on intermediate states (between sending a transaction and mining it), you can turn off automining: `cy.hardhat({ automine: false })`.
|
||||
|
||||
The hardhat integration has built-in utilities to let you modify and assert on balances, approvals, and permits, and should be fully typed. Check it out at [Uniswap/cypress-hardhat](https://github.com/Uniswap/cypress-hardhat).
|
||||
|
||||
### Asserting on wallet methods
|
||||
|
||||
Wallet methods to hardhat are all aliased. If you'd like to assert that a method was sent to the wallet, you can do so using the method name, prefixed with `@`:
|
||||
|
||||
```
|
||||
// Asserts that `eth_sendRawTransaction` was sent to the wallet.
|
||||
cy.wait('@eth_sendRawTransaction')
|
||||
```
|
||||
|
||||
Sometimes, you may want a method to _fail_. In this case, you can stub it, but you should disable logging to avoid spamming the test:
|
||||
|
||||
```
|
||||
// Stub calls to eth_signTypedData_v4 and fail them
|
||||
cy.hardhat().then((hardhat) => {
|
||||
// Note the closure to keep signTypedDataStub in scope. Using closures instead of variables (eg let) helps prevent misuse of chaining.
|
||||
const signTypedDataStub = cy.stub(hardhat.provider, 'send').log(false)
|
||||
signTypedDataStub.withArgs('eth_signTypedData_v4).rejects(USER_REJECTION)
|
||||
signTypedDataStub.callThrough() // allws other methods to call through to hardhat
|
||||
|
||||
cy.contains('Confirm swap').click()
|
||||
|
||||
// Verify the call occured
|
||||
// Note the call to cy.wrap to correctly queue the chained command. Without this, the test would occur before the stub is called.
|
||||
cy.wrap(permitApprovalStub).should('be.calledWith', 'eth_signTypedData_v4')
|
||||
|
||||
// Restore the stub
|
||||
// note the call to cy.then to correctly queue the chained command. Without this, the stub would be restored immediately.
|
||||
cy.then(() => permitApprovalStub.restore())
|
||||
})
|
||||
```
|
||||
|
||||
## Best practices
|
||||
|
||||
<!-- Best practices should all be labeled using H3, with the rationale italicized at the end of the section. -->
|
||||
<!-- Best practice 🤣 is to also include an example before your rationale. -->
|
||||
|
||||
### Spec / test grouping
|
||||
|
||||
Each spec should be specific to one route, _not_ one functional behavior.
|
||||
For example, `token-details.test.ts` is separated from `swap.test.ts`.
|
||||
|
||||
If a route has different functional behaviors, that route should become a directory name, and its spec should be split.
|
||||
For example, `swap.test.ts` may be split into `swap/swap.test.ts`, `swap/wrap.test.ts`, `swap/permit2.test.ts`.
|
||||
|
||||
_This prevents specs from growing too large, which is important because they are always run as a whole locally and on the same machine through CI. If a spec grows too large, it will have a longer local feedback loop, and it will become the bottleneck for CI test runtime._
|
||||
|
||||
_Similarly, avoid actions outside the scope of your spec, as it will cause total testing time to increase._
|
||||
|
||||
### Use closures instead of variables
|
||||
|
||||
Avoid usage of `let`, instead assigning a constant. In practice, this means using closures for your variables:
|
||||
|
||||
```javascript
|
||||
let badVariable
|
||||
|
||||
cy.hardhat({ automine: false })
|
||||
.then((hardhat) => cy.then(() => hardhat.provider.getBalance(hardhat.wallet.address)))
|
||||
.then((initialBalance) => {
|
||||
// Do not assign to a variable outside of your closure!
|
||||
badVariable = initialBalance // <-- bad!
|
||||
|
||||
// Use initial balance here, within the closure.
|
||||
})
|
||||
|
||||
cy.get('.class-name').then((el) => {
|
||||
// Do not use badVariable here! It may have changed value due to the queued async nature of Cypress.
|
||||
expect(el).should('contain', badVariable) // <-- bad!
|
||||
})
|
||||
```
|
||||
|
||||
_This prevents misuse of a not-yet-initialized variable, or a variable that has changed as the test progresses._
|
||||
|
||||
### Prefer selecting elements using on-screen text over data-testid attributes
|
||||
|
||||
When selecting components (eg with `cy.get`), prefer defining your selector with visible UI. Sometimes this is not possible (eg if the text is duplicated on-screen), and you'll need to add a `data-testid` property.
|
||||
|
||||
_Defining tests using visual fields helps ensure that we don't break them. `data-testid` may select an element that is only selectable programmatically, and should be used only when necessary, as its use may cover up UI breakages._
|
||||
|
||||
_You'll still want to use `data-testid` in cases where the text is rendered in multiple containers and you need to select the correct one, or where the component doesn't render predictable text output._
|
||||
|
||||
### Avoid branching logic
|
||||
|
||||
Do not write tests that rely on if-statements or conditionals. Do not create helper methods which do more than one thing, and rely on branching logic to apply to different but similar situations.
|
||||
|
||||
_Tests should be readable and simple. Branching logic makes it harder to reason about tests, and may hide otherwise flaky or ill-defined behaviors._
|
||||
|
||||
_Similarly, you should avoid complicated for-loops. Sometimes, for simple repetition, for-loops are ok._
|
||||
|
||||
### Avoid spamming the console
|
||||
|
||||
It is ok to include logging while you are developing a test, but that logging should be removed if it is not needed to debug (potential) errors.
|
||||
|
||||
For example, stubbing a wallet method will result in dumping a hex string (the calldata) to the log. Instead, suppress logging from methods which you know will flood the log.
|
||||
|
||||
```javascript
|
||||
cy.stub(hardhat.wallet, 'sendTransaction')
|
||||
.log(false) // <-- suppresses logs from this stub
|
||||
.rejects(new Error('user cancelled'))
|
||||
```
|
||||
|
||||
_Unnecessary logs it makes it harder to reason about a test overall._
|
||||
|
||||
### Name helper methods using transitive verbs
|
||||
|
||||
Name helper methods using "action verbs": `expectsThisToHappen`, not `expectThisToHappen`; `selectsToken(token: string)`, not `selectAToken(token: string)`.
|
||||
|
||||
_This makes your tests read more naturally, and makes it easier to follow given existing `should` syntax._
|
||||
28
cypress/e2e/buy-crypto-modal.test.ts
Normal file
28
cypress/e2e/buy-crypto-modal.test.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { getTestSelector } from '../utils'
|
||||
|
||||
describe('Buy Crypto Modal', () => {
|
||||
it('should open and close', () => {
|
||||
cy.visit('/')
|
||||
|
||||
// Open the fiat onramp modal
|
||||
cy.get(getTestSelector('buy-fiat-button')).click()
|
||||
cy.get(getTestSelector('fiat-onramp-modal')).should('be.visible')
|
||||
|
||||
// Click on a location that should be outside the modal, which should close it
|
||||
cy.get('body').click(0, 100)
|
||||
cy.get(getTestSelector('fiat-onramp-modal')).should('not.exist')
|
||||
})
|
||||
|
||||
it('should open and close, mobile viewport', () => {
|
||||
cy.viewport('iphone-6')
|
||||
cy.visit('/')
|
||||
|
||||
// Open the fiat onramp modal
|
||||
cy.get(getTestSelector('buy-fiat-button')).click()
|
||||
cy.get(getTestSelector('fiat-onramp-modal')).should('be.visible')
|
||||
|
||||
// Click on a location that should be outside the modal, which should close it
|
||||
cy.get('body').click(10, 10)
|
||||
cy.get(getTestSelector('fiat-onramp-modal')).should('not.exist')
|
||||
})
|
||||
})
|
||||
@@ -21,6 +21,7 @@ describe('Landing Page', () => {
|
||||
})
|
||||
|
||||
it('shows landing page when the unicorn icon in nav is selected', () => {
|
||||
cy.visit('/swap')
|
||||
cy.get(getTestSelector('uniswap-logo')).click()
|
||||
cy.get(getTestSelector('landing-page'))
|
||||
})
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
describe('Link', () => {
|
||||
it('should update route', () => {
|
||||
cy.viewport(2000, 1600)
|
||||
cy.visit('/')
|
||||
cy.visit('/swap')
|
||||
cy.contains('Pool').click()
|
||||
cy.get('[data-cy="join-pool-button"]').should('exist')
|
||||
})
|
||||
|
||||
79
cypress/e2e/mini-portfolio/accounts.test.ts
Normal file
79
cypress/e2e/mini-portfolio/accounts.test.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { getTestSelector } from '../../utils'
|
||||
|
||||
describe('Mini Portfolio account drawer', () => {
|
||||
beforeEach(() => {
|
||||
cy.intercept(/api.uniswap.org\/v1\/graphql/, cy.spy().as('gqlSpy'))
|
||||
cy.visit('/swap', { ethereum: 'hardhat' })
|
||||
})
|
||||
|
||||
it('fetches balances when account button is first hovered', () => {
|
||||
// The balances should not be fetched before the account button is hovered
|
||||
cy.get('@gqlSpy').should('not.have.been.called')
|
||||
|
||||
// Balances should have been fetched once after hover
|
||||
cy.get(getTestSelector('web3-status-connected')).trigger('mouseover')
|
||||
cy.get('@gqlSpy').should('have.been.calledOnce')
|
||||
|
||||
// Balances should not be refetched upon second hover
|
||||
cy.get(getTestSelector('web3-status-connected')).trigger('mouseover')
|
||||
cy.get('@gqlSpy').should('have.been.calledOnce')
|
||||
|
||||
// Balances should not be refetched upon opening drawer
|
||||
cy.get(getTestSelector('web3-status-connected')).click()
|
||||
cy.get('@gqlSpy').should('have.been.calledOnce')
|
||||
|
||||
// Balances should not be refetched upon closing & reopening drawer
|
||||
cy.get(getTestSelector('close-account-drawer')).click()
|
||||
cy.get(getTestSelector('web3-status-connected')).click()
|
||||
cy.get('@gqlSpy').should('have.been.calledOnce')
|
||||
})
|
||||
|
||||
it('fetches account information', () => {
|
||||
// Open the mini portfolio
|
||||
cy.intercept(/graphql/, { fixture: 'mini-portfolio/tokens.json' })
|
||||
cy.get(getTestSelector('web3-status-connected')).click()
|
||||
|
||||
// Verify that wallet state loads correctly
|
||||
cy.get(getTestSelector('mini-portfolio-navbar')).contains('Tokens')
|
||||
cy.get(getTestSelector('mini-portfolio-page')).contains('Hidden (201)')
|
||||
|
||||
cy.intercept(/graphql/, { fixture: 'mini-portfolio/nfts.json' })
|
||||
cy.get(getTestSelector('mini-portfolio-navbar')).contains('NFTs').click()
|
||||
cy.get(getTestSelector('mini-portfolio-page')).contains('I Got Plenty')
|
||||
|
||||
cy.get(getTestSelector('mini-portfolio-navbar')).contains('Pools').click()
|
||||
cy.get(getTestSelector('mini-portfolio-page')).contains('No pools yet')
|
||||
|
||||
cy.intercept(/graphql/, { fixture: 'mini-portfolio/activity.json' })
|
||||
cy.get(getTestSelector('mini-portfolio-navbar')).contains('Activity').click()
|
||||
cy.get(getTestSelector('mini-portfolio-page')).contains('Contract Interaction')
|
||||
})
|
||||
|
||||
it('refetches balances when account changes', () => {
|
||||
cy.hardhat().then((hardhat) => {
|
||||
const accountA = hardhat.wallets[0].address
|
||||
const accountB = hardhat.wallets[1].address
|
||||
|
||||
// Opens the account drawer
|
||||
cy.get(getTestSelector('web3-status-connected')).click()
|
||||
|
||||
// A shortened version of the first account's address should be shown
|
||||
cy.contains(accountA.slice(0, 6)).should('exist')
|
||||
|
||||
// Stores the current portfolio balance to later compare to next account's balance
|
||||
cy.get(getTestSelector('portfolio-total-balance'))
|
||||
.invoke('text')
|
||||
.then((originalBalance) => {
|
||||
// TODO(INFRA-3) Replace window.ethereum access below with cypress-hardhat utility
|
||||
// Simulates the wallet changing accounts via eip-1193 event
|
||||
cy.window().then((win) => win.ethereum.emit('accountsChanged', [accountB]))
|
||||
|
||||
// The second account's address should now be shown
|
||||
cy.contains(accountB.slice(0, 6)).should('exist')
|
||||
|
||||
// The second account's portfolio balance should differ from the original balance
|
||||
cy.get(getTestSelector('portfolio-total-balance')).should('not.have.text', originalBalance)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
116
cypress/e2e/mini-portfolio/activity-history.test.ts
Normal file
116
cypress/e2e/mini-portfolio/activity-history.test.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import { USDC_MAINNET } from '../../../src/constants/tokens'
|
||||
import { getTestSelector } from '../../utils'
|
||||
|
||||
describe('mini-portfolio activity history', () => {
|
||||
beforeEach(() => {
|
||||
cy.hardhat()
|
||||
.then((hardhat) => hardhat.wallet.getTransactionCount())
|
||||
.then((nonce) => {
|
||||
// Mock graphql response to include specific nonces.
|
||||
cy.intercept(
|
||||
{
|
||||
method: 'POST',
|
||||
url: 'https://beta.api.uniswap.org/v1/graphql',
|
||||
},
|
||||
{
|
||||
body: {
|
||||
data: {
|
||||
portfolios: [
|
||||
{
|
||||
id: 'UG9ydGZvbGlvOjB4NUNlYUI3NGU0NDZkQmQzYkY2OUUyNzcyMDBGMTI5ZDJiQzdBMzdhMQ==',
|
||||
assetActivities: [
|
||||
{
|
||||
id: 'QXNzZXRBY3Rpdml0eTpWSEpoYm5OaFkzUnBiMjQ2TUhnME5tUm1PVGs0T0RrNVl6UmtNR1kzWTJNNE9HRTVNVFEzTURBME9EWmtOVGhrTURnNFpqbG1NelkxTnpRM1l6WXdZek15WVRFNE4yWXlaRFEwWVdVNFh6QjRZV1EyWXpCa05XTmlOVEZsWWpjMU5qUTFaRGszT1RneE4yRTJZVEkxTmpreU1UbG1ZbVE1Wmw4d2VEQXpOR0UwTURjMk5EUTROV1kzWlRBNFkyRXhOak0yTm1VMU1ETTBPVEZoTm1GbU56ZzFNR1E9',
|
||||
timestamp: 1681150079,
|
||||
type: 'UNKNOWN',
|
||||
chain: 'ETHEREUM',
|
||||
transaction: {
|
||||
id: 'VHJhbnNhY3Rpb246MHg0NmRmOTk4ODk5YzRkMGY3Y2M4OGE5MTQ3MDA0ODZkNThkMDg4ZjlmMzY1NzQ3YzYwYzMyYTE4N2YyZDQ0YWU4XzB4YWQ2YzBkNWNiNTFlYjc1NjQ1ZDk3OTgxN2E2YTI1NjkyMTlmYmQ5Zl8weDAzNGE0MDc2NDQ4NWY3ZTA4Y2ExNjM2NmU1MDM0OTFhNmFmNzg1MGQ=',
|
||||
blockNumber: 17019453,
|
||||
hash: '0x46df998899c4d0f7cc88a914700486d58d088f9f365747c60c32a187f2d44ae8',
|
||||
status: 'CONFIRMED',
|
||||
to: '0x034a40764485f7e08ca16366e503491a6af7850d',
|
||||
from: '0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266',
|
||||
nonce,
|
||||
__typename: 'Transaction',
|
||||
},
|
||||
assetChanges: [],
|
||||
__typename: 'AssetActivity',
|
||||
},
|
||||
{
|
||||
id: 'QXNzZXRBY3Rpdml0eTpWSEpoYm5OaFkzUnBiMjQ2TUhneE16UXpaR1ppTlROaE9XRmpNR00yWW1aaVpqUTNNRFEyWWpObFkyRXhORGN3TUdZd00yWXhOMkV3WWpnM1pqWXpPRFpsWVRnNU16QTRNVFZtWmpoaFh6QjRZMkUzTXpOalkySm1OelZoTXpnME1ERXhPR1ZpT1RjNU9EVTJOemRpTkdRMk56TTBZemMwWmw4d2VERmlOVEUxTkdGaE5HSTRaakF5TjJJNVptUXhPVE0wTVRFek1tWmpPV1JoWlRFd1pqY3pOVGs9',
|
||||
timestamp: 1681149995,
|
||||
type: 'SEND',
|
||||
chain: 'ETHEREUM',
|
||||
transaction: {
|
||||
id: 'VHJhbnNhY3Rpb246MHgxMzQzZGZiNTNhOWFjMGM2YmZiZjQ3MDQ2YjNlY2ExNDcwMGYwM2YxN2EwYjg3ZjYzODZlYTg5MzA4MTVmZjhhXzB4Y2E3MzNjY2JmNzVhMzg0MDExOGViOTc5ODU2NzdiNGQ2NzM0Yzc0Zl8weDFiNTE1NGFhNGI4ZjAyN2I5ZmQxOTM0MTEzMmZjOWRhZTEwZjczNTk=',
|
||||
blockNumber: 17019446,
|
||||
hash: '0x1343dfb53a9ac0c6bfbf47046b3eca14700f03f17a0b87f6386ea8930815ff8a',
|
||||
status: 'CONFIRMED',
|
||||
to: '0x1b5154aa4b8f027b9fd19341132fc9dae10f7359',
|
||||
from: '0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266',
|
||||
nonce: nonce + 1,
|
||||
__typename: 'Transaction',
|
||||
},
|
||||
assetChanges: [
|
||||
{
|
||||
__typename: 'TokenTransfer',
|
||||
id: 'VG9rZW5UcmFuc2ZlcjoweDVjZWFiNzRlNDQ2ZGJkM2JmNjllMjc3MjAwZjEyOWQyYmM3YTM3YTFfMHhiMWRjNDlmMDY1N2FkNTA1YjUzNzUyN2RkOWE1MDk0YTM2NTkzMWMxXzB4MTM0M2RmYjUzYTlhYzBjNmJmYmY0NzA0NmIzZWNhMTQ3MDBmMDNmMTdhMGI4N2Y2Mzg2ZWE4OTMwODE1ZmY4YQ==',
|
||||
asset: {
|
||||
id: 'VG9rZW46RVRIRVJFVU1fMHgxY2MyYjA3MGNhZjAxNmE3ZGRjMzA0N2Y5MzI3MmU4Yzc3YzlkZGU5',
|
||||
name: 'USD Coin (USDC)',
|
||||
symbol: 'USDC',
|
||||
address: '0x1cc2b070caf016a7ddc3047f93272e8c77c9dde9',
|
||||
decimals: 6,
|
||||
chain: 'ETHEREUM',
|
||||
standard: null,
|
||||
project: {
|
||||
id: 'VG9rZW5Qcm9qZWN0OkVUSEVSRVVNXzB4MWNjMmIwNzBjYWYwMTZhN2RkYzMwNDdmOTMyNzJlOGM3N2M5ZGRlOQ==',
|
||||
isSpam: true,
|
||||
logo: null,
|
||||
__typename: 'TokenProject',
|
||||
},
|
||||
__typename: 'Token',
|
||||
},
|
||||
tokenStandard: 'ERC20',
|
||||
quantity: '18011.212084',
|
||||
sender: '0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266',
|
||||
recipient: '0xb1dc49f0657ad505b537527dd9a5094a365931c1',
|
||||
direction: 'OUT',
|
||||
transactedValue: null,
|
||||
},
|
||||
],
|
||||
__typename: 'AssetActivity',
|
||||
},
|
||||
],
|
||||
__typename: 'Portfolio',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
}
|
||||
).as('graphql')
|
||||
})
|
||||
})
|
||||
|
||||
it('should deduplicate activity history by nonce', () => {
|
||||
cy.visit(`/swap?inputCurrency=ETH&outputCurrency=${USDC_MAINNET.address}`, { ethereum: 'hardhat' }).hardhat({
|
||||
automine: false,
|
||||
})
|
||||
|
||||
// Input swap info.
|
||||
cy.get('#swap-currency-input .token-amount-input').clear().type('1').should('have.value', '1')
|
||||
cy.get('#swap-currency-output .token-amount-input').should('not.have.value', '')
|
||||
|
||||
cy.get('#swap-button').click()
|
||||
cy.get('#confirm-swap-or-send').click()
|
||||
cy.get(getTestSelector('confirmation-close-icon')).click()
|
||||
|
||||
// Check activity history tab.
|
||||
cy.get(getTestSelector('web3-status-connected')).click()
|
||||
cy.get(getTestSelector('mini-portfolio-navbar')).contains('Activity').click()
|
||||
|
||||
// Assert that the local pending transaction is replaced by a remote transaction with the same nonce.
|
||||
cy.contains('Swapping').should('not.exist')
|
||||
})
|
||||
})
|
||||
@@ -4,11 +4,8 @@ const PUDGY_COLLECTION_ADDRESS = '0xbd3531da5cf5857e7cfaa92426877b022e612cf8'
|
||||
const BONSAI_COLLECTION_ADDRESS = '0xec9c519d49856fd2f8133a0741b4dbe002ce211b'
|
||||
|
||||
describe('Testing nfts', () => {
|
||||
beforeEach(() => {
|
||||
cy.visit('/')
|
||||
})
|
||||
|
||||
it('should load nft leaderboard', () => {
|
||||
cy.visit('/')
|
||||
cy.get(getTestSelector('nft-nav')).first().click()
|
||||
cy.get(getTestSelector('nft-nav')).first().should('exist')
|
||||
cy.get(getTestSelector('nft-nav')).first().click()
|
||||
@@ -49,15 +46,11 @@ describe('Testing nfts', () => {
|
||||
cy.get(getTestSelector('nft-bag')).should('exist')
|
||||
})
|
||||
|
||||
it('should navigate to the owned nfts page', () => {
|
||||
it('should navigate to and from the owned nfts page', () => {
|
||||
cy.visit('/')
|
||||
cy.get(getTestSelector('web3-status-connected')).click()
|
||||
cy.get(getTestSelector('nft-view-self-nfts')).click()
|
||||
})
|
||||
|
||||
it('should close the sidebar when navigating to NFT details', () => {
|
||||
cy.get(getTestSelector('web3-status-connected')).click()
|
||||
cy.get(getTestSelector('mini-portfolio-nav-nfts')).click()
|
||||
cy.get(getTestSelector('mini-portfolio-nft')).first().click()
|
||||
cy.contains('Buy crypto').should('not.be.visible')
|
||||
cy.get(getTestSelector('mini-portfolio-navbar')).contains('NFTs').click()
|
||||
cy.get(getTestSelector('mini-portfolio-nft')).click()
|
||||
cy.get(getTestSelector('mini-portfolio-navbar')).should('not.be.visible')
|
||||
})
|
||||
})
|
||||
|
||||
199
cypress/e2e/permit2.test.ts
Normal file
199
cypress/e2e/permit2.test.ts
Normal file
@@ -0,0 +1,199 @@
|
||||
import { MaxUint160, MaxUint256 } from '@uniswap/permit2-sdk'
|
||||
|
||||
import { DAI, USDC_MAINNET } from '../../src/constants/tokens'
|
||||
import { getTestSelector } from '../utils'
|
||||
|
||||
/** Initiates a swap. */
|
||||
function initiateSwap() {
|
||||
// The swap button is re-rendered once enabled, so we must wait until the original button is not disabled to re-select the appropriate button.
|
||||
cy.get('#swap-button').should('not.be.disabled')
|
||||
// Completes the swap.
|
||||
cy.get('#swap-button').click()
|
||||
cy.contains('Confirm swap').click()
|
||||
}
|
||||
|
||||
describe('Permit2', () => {
|
||||
// The same tokens are used for all permit2 tests.
|
||||
const INPUT_TOKEN = DAI
|
||||
const OUTPUT_TOKEN = USDC_MAINNET
|
||||
|
||||
beforeEach(() => {
|
||||
// Sets up a swap between INPUT_TOKEN and OUTPUT_TOKEN.
|
||||
cy.visit(`/swap/?inputCurrency=${INPUT_TOKEN.address}&outputCurrency=${OUTPUT_TOKEN.address}`, {
|
||||
ethereum: 'hardhat',
|
||||
})
|
||||
cy.get('#swap-currency-input .token-amount-input').type('0.01')
|
||||
})
|
||||
|
||||
/** Asserts permit2 has a max approval for spend of the input token on-chain. */
|
||||
function expectTokenAllowanceForPermit2ToBeMax() {
|
||||
// check token approval
|
||||
cy.hardhat()
|
||||
.then(({ approval, wallet }) => approval.getTokenAllowanceForPermit2({ owner: wallet, token: INPUT_TOKEN }))
|
||||
.should('deep.equal', MaxUint256)
|
||||
}
|
||||
|
||||
/** Asserts the universal router has a max permit2 approval for spend of the input token on-chain. */
|
||||
function expectPermit2AllowanceForUniversalRouterToBeMax() {
|
||||
cy.hardhat()
|
||||
.then((hardhat) => hardhat.approval.getPermit2Allowance({ owner: hardhat.wallet, token: INPUT_TOKEN }))
|
||||
.then((allowance) => {
|
||||
cy.wrap(MaxUint160.eq(allowance.amount)).should('eq', true)
|
||||
// Asserts that the on-chain expiration is in 30 days, within a tolerance of 40 seconds.
|
||||
const THIRTY_DAYS_SECONDS = 2_592_000
|
||||
const expected = Math.floor(Date.now() / 1000 + THIRTY_DAYS_SECONDS)
|
||||
cy.wrap(allowance.expiration).should('be.closeTo', expected, 40)
|
||||
})
|
||||
}
|
||||
|
||||
describe('approval process (with intermediate screens)', () => {
|
||||
// Turn off automine so that intermediate screens are available to assert on.
|
||||
beforeEach(() => cy.hardhat({ automine: false }))
|
||||
|
||||
it('swaps after completing full permit2 approval process', () => {
|
||||
initiateSwap()
|
||||
|
||||
// verify that the modal retains its state when the window loses focus
|
||||
cy.window().trigger('blur')
|
||||
|
||||
// Verify token approval
|
||||
cy.contains('Enable spending DAI on Uniswap')
|
||||
cy.wait('@eth_sendRawTransaction')
|
||||
cy.hardhat().then((hardhat) => hardhat.mine())
|
||||
cy.get(getTestSelector('popups')).contains('Approved')
|
||||
expectTokenAllowanceForPermit2ToBeMax()
|
||||
|
||||
// Verify permit2 approval
|
||||
cy.contains('Allow DAI to be used for swapping')
|
||||
cy.wait('@eth_signTypedData_v4')
|
||||
cy.wait('@eth_sendRawTransaction')
|
||||
cy.hardhat().then((hardhat) => hardhat.mine())
|
||||
cy.contains('Success')
|
||||
cy.get(getTestSelector('popups')).contains('Swapped')
|
||||
expectPermit2AllowanceForUniversalRouterToBeMax()
|
||||
})
|
||||
|
||||
it('swaps with existing permit approval and missing token approval', () => {
|
||||
cy.hardhat().then(async (hardhat) => {
|
||||
await hardhat.approval.setPermit2Allowance({ owner: hardhat.wallet, token: INPUT_TOKEN })
|
||||
await hardhat.mine()
|
||||
})
|
||||
initiateSwap()
|
||||
|
||||
// Verify token approval
|
||||
cy.contains('Enable spending DAI on Uniswap')
|
||||
cy.wait('@eth_sendRawTransaction')
|
||||
cy.hardhat().then((hardhat) => hardhat.mine())
|
||||
cy.get(getTestSelector('popups')).contains('Approved')
|
||||
expectTokenAllowanceForPermit2ToBeMax()
|
||||
|
||||
// Verify transaction
|
||||
cy.wait('@eth_sendRawTransaction')
|
||||
cy.hardhat().then((hardhat) => hardhat.mine())
|
||||
cy.contains('Success')
|
||||
cy.get(getTestSelector('popups')).contains('Swapped')
|
||||
})
|
||||
})
|
||||
|
||||
it('swaps when user has already approved token and permit2', () => {
|
||||
cy.hardhat().then(({ approval, wallet }) =>
|
||||
Promise.all([
|
||||
approval.setTokenAllowanceForPermit2({ owner: wallet, token: INPUT_TOKEN }),
|
||||
approval.setPermit2Allowance({ owner: wallet, token: INPUT_TOKEN }),
|
||||
])
|
||||
)
|
||||
initiateSwap()
|
||||
|
||||
// Verify transaction
|
||||
cy.contains('Success')
|
||||
cy.get(getTestSelector('popups')).contains('Swapped')
|
||||
})
|
||||
|
||||
it('swaps after handling user rejection of both approval and signature', () => {
|
||||
const USER_REJECTION = { code: 4001 }
|
||||
cy.hardhat().then((hardhat) => {
|
||||
// Reject token approval
|
||||
const tokenApprovalStub = cy.stub(hardhat.wallet, 'sendTransaction').log(false)
|
||||
tokenApprovalStub.rejects(USER_REJECTION) // rejects token approval
|
||||
initiateSwap()
|
||||
|
||||
// Verify token approval rejection
|
||||
cy.wrap(tokenApprovalStub).should('be.calledOnce')
|
||||
cy.contains('Review swap')
|
||||
|
||||
// Allow token approval
|
||||
cy.then(() => tokenApprovalStub.restore())
|
||||
|
||||
// Reject permit2 approval
|
||||
const permitApprovalStub = cy.stub(hardhat.provider, 'send').log(false)
|
||||
permitApprovalStub.withArgs('eth_signTypedData_v4').rejects(USER_REJECTION) // rejects permit approval
|
||||
permitApprovalStub.callThrough() // allows non-eth_signTypedData_v4 send calls to return non-stubbed values
|
||||
cy.contains('Confirm swap').click()
|
||||
|
||||
// Verify token approval
|
||||
cy.get(getTestSelector('popups')).contains('Approved')
|
||||
expectTokenAllowanceForPermit2ToBeMax()
|
||||
|
||||
// Verify permit2 approval rejection
|
||||
cy.wrap(permitApprovalStub).should('be.calledWith', 'eth_signTypedData_v4')
|
||||
cy.contains('Review swap')
|
||||
|
||||
// Allow permit2 approval
|
||||
cy.then(() => permitApprovalStub.restore())
|
||||
cy.contains('Confirm swap').click()
|
||||
|
||||
// Verify permit2 approval
|
||||
cy.contains('Success')
|
||||
cy.get(getTestSelector('popups')).contains('Swapped')
|
||||
expectPermit2AllowanceForUniversalRouterToBeMax()
|
||||
})
|
||||
})
|
||||
|
||||
it('prompts token approval when existing approval amount is too low', () => {
|
||||
cy.hardhat().then(({ approval, wallet }) =>
|
||||
Promise.all([
|
||||
approval.setPermit2Allowance({ owner: wallet, token: INPUT_TOKEN }),
|
||||
approval.setTokenAllowanceForPermit2({ owner: wallet, token: INPUT_TOKEN }, 1),
|
||||
])
|
||||
)
|
||||
initiateSwap()
|
||||
|
||||
// Verify token approval
|
||||
cy.get(getTestSelector('popups')).contains('Approved')
|
||||
expectPermit2AllowanceForUniversalRouterToBeMax()
|
||||
})
|
||||
|
||||
it('prompts signature when existing permit approval is expired', () => {
|
||||
const expiredAllowance = { expiration: Math.floor((Date.now() - 1) / 1000) }
|
||||
cy.hardhat().then(({ approval, wallet }) =>
|
||||
Promise.all([
|
||||
approval.setTokenAllowanceForPermit2({ owner: wallet, token: INPUT_TOKEN }),
|
||||
approval.setPermit2Allowance({ owner: wallet, token: INPUT_TOKEN }, expiredAllowance),
|
||||
])
|
||||
)
|
||||
initiateSwap()
|
||||
|
||||
// Verify permit2 approval
|
||||
cy.wait('@eth_signTypedData_v4')
|
||||
cy.contains('Success')
|
||||
cy.get(getTestSelector('popups')).contains('Swapped')
|
||||
expectPermit2AllowanceForUniversalRouterToBeMax()
|
||||
})
|
||||
|
||||
it('prompts signature when existing permit approval amount is too low', () => {
|
||||
const smallAllowance = { amount: 1 }
|
||||
cy.hardhat().then(({ approval, wallet }) =>
|
||||
Promise.all([
|
||||
approval.setTokenAllowanceForPermit2({ owner: wallet, token: INPUT_TOKEN }),
|
||||
approval.setPermit2Allowance({ owner: wallet, token: INPUT_TOKEN }, smallAllowance),
|
||||
])
|
||||
)
|
||||
initiateSwap()
|
||||
|
||||
// Verify permit2 approval
|
||||
cy.wait('@eth_signTypedData_v4')
|
||||
cy.contains('Success')
|
||||
cy.get(getTestSelector('popups')).contains('Swapped')
|
||||
expectPermit2AllowanceForUniversalRouterToBeMax()
|
||||
})
|
||||
})
|
||||
@@ -9,7 +9,7 @@ describe('Service Worker', () => {
|
||||
throw new Error(
|
||||
'\n' +
|
||||
'Service Worker tests must be run on a production-like build\n' +
|
||||
'To test, build with `yarn build:e2e` and serve with `yarn serve`'
|
||||
'To test, build with `yarn build` and serve with `yarn serve`'
|
||||
)
|
||||
}
|
||||
})
|
||||
@@ -20,65 +20,78 @@ describe('Service Worker', () => {
|
||||
}
|
||||
})
|
||||
|
||||
function unregister() {
|
||||
return cy.log('unregister service worker').then(async () => {
|
||||
const cacheKeys = await window.caches.keys()
|
||||
const cacheKey = cacheKeys.find((key) => key.match(/precache/))
|
||||
if (cacheKey) {
|
||||
await window.caches.delete(cacheKey)
|
||||
}
|
||||
|
||||
function unregisterServiceWorker() {
|
||||
return cy.log('unregisters service worker').then(async () => {
|
||||
const sw = await window.navigator.serviceWorker.getRegistration(Cypress.config().baseUrl ?? undefined)
|
||||
await sw?.unregister()
|
||||
})
|
||||
}
|
||||
before(unregister)
|
||||
after(unregister)
|
||||
before(unregisterServiceWorker)
|
||||
after(unregisterServiceWorker)
|
||||
|
||||
beforeEach(() => {
|
||||
cy.intercept({ hostname: 'www.google-analytics.com' }, (req) => {
|
||||
const body = req.body.toString()
|
||||
if (req.query['ep.event_category'] === 'Service Worker' || body.includes('Service%20Worker')) {
|
||||
if (req.query['en'] === 'Not Installed' || body.includes('Not%20Installed')) {
|
||||
req.alias = 'NotInstalled'
|
||||
} else if (req.query['en'] === 'Cache Hit' || body.includes('Cache%20Hit')) {
|
||||
req.alias = 'CacheHit'
|
||||
} else if (req.query['en'] === 'Cache Miss' || body.includes('Cache%20Miss')) {
|
||||
req.alias = 'CacheMiss'
|
||||
}
|
||||
cy.intercept('https://api.uniswap.org/v1/amplitude-proxy', (req) => {
|
||||
const body = JSON.stringify(req.body)
|
||||
const serviceWorkerStatus = body.match(/"service_worker":"(\w+)"/)?.[1]
|
||||
if (serviceWorkerStatus) {
|
||||
req.alias = `ServiceWorker:${serviceWorkerStatus}`
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('installs a ServiceWorker', () => {
|
||||
it('installs a ServiceWorker and reports the uninstalled status to analytics', () => {
|
||||
cy.visit('/', { serviceWorker: true })
|
||||
.get('#swap-page')
|
||||
.wait('@NotInstalled', { timeout: 20000 })
|
||||
.window({ timeout: 20000 })
|
||||
.and((win) => {
|
||||
expect(win.navigator.serviceWorker.controller?.state).to.equal('activated')
|
||||
})
|
||||
cy.wait('@ServiceWorker:uninstalled')
|
||||
cy.window().should(
|
||||
'have.nested.property',
|
||||
// The parent is checked instead of the AUT because it is on the same origin,
|
||||
// and the AUT will not be considered "activated" until the parent is idle.
|
||||
'parent.navigator.serviceWorker.controller.state',
|
||||
'activated'
|
||||
)
|
||||
})
|
||||
|
||||
it('records a cache hit', () => {
|
||||
cy.visit('/', { serviceWorker: true }).get('#swap-page').wait('@CacheHit', { timeout: 20000 })
|
||||
})
|
||||
|
||||
it('records a cache miss', () => {
|
||||
cy.then(async () => {
|
||||
const cacheKeys = await window.caches.keys()
|
||||
const cacheKey = cacheKeys.find((key) => key.match(/precache/))
|
||||
assert(cacheKey)
|
||||
|
||||
const cache = await window.caches.open(cacheKey)
|
||||
const keys = await cache.keys()
|
||||
const key = keys.find((key) => key.url.match(/index/))
|
||||
assert(key)
|
||||
|
||||
await cache.put(key, new Response())
|
||||
describe('cache hit', () => {
|
||||
it('reports the hit to analytics', () => {
|
||||
cy.visit('/', { serviceWorker: true })
|
||||
cy.wait('@ServiceWorker:hit')
|
||||
})
|
||||
})
|
||||
|
||||
describe('cache miss', () => {
|
||||
let cache: Cache | undefined
|
||||
let request: Request | undefined
|
||||
let response: Response | undefined
|
||||
before(() => {
|
||||
// Mocks the index.html in the cache to force a cache miss.
|
||||
cy.visit('/', { serviceWorker: true }).then(async () => {
|
||||
const cacheKeys = await window.caches.keys()
|
||||
const cacheKey = cacheKeys.find((key) => key.match(/precache/))
|
||||
assert(cacheKey)
|
||||
|
||||
cache = await window.caches.open(cacheKey)
|
||||
const keys = await cache.keys()
|
||||
request = keys.find((key) => key.url.match(/index/))
|
||||
assert(request)
|
||||
|
||||
response = await cache.match(request)
|
||||
assert(response)
|
||||
|
||||
await cache.put(request, new Response())
|
||||
})
|
||||
})
|
||||
after(() => {
|
||||
// Restores the index.html in the cache so that re-runs behave as expected.
|
||||
// This is necessary because the Service Worker will not re-populate the cache.
|
||||
cy.then(async () => {
|
||||
if (cache && request && response) {
|
||||
await cache.put(request, response)
|
||||
}
|
||||
})
|
||||
})
|
||||
it('reports the miss to analytics', () => {
|
||||
cy.visit('/', { serviceWorker: true })
|
||||
cy.wait('@ServiceWorker:miss')
|
||||
})
|
||||
.visit('/', { serviceWorker: true })
|
||||
.get('#swap-page')
|
||||
.wait('@CacheMiss', { timeout: 20000 })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,108 +0,0 @@
|
||||
import { FeatureFlag } from '../../src/featureFlags/flags/featureFlags'
|
||||
import { getClassContainsSelector, getTestSelector } from '../utils'
|
||||
|
||||
const UNI_GOERLI = '0x1f9840a85d5aF5bf1D1762F925BDADdC4201F984'
|
||||
const WETH_GOERLI = '0xB4FBF271143F4FBf7B91A5ded31805e42b2208d6'
|
||||
|
||||
describe('swap widget integration tests', () => {
|
||||
const verifyInputToken = (inputText: string) => {
|
||||
cy.get(getClassContainsSelector('TokenButtonRow')).first().contains(inputText)
|
||||
}
|
||||
|
||||
const verifyOutputToken = (outputText: string) => {
|
||||
cy.get(getClassContainsSelector('TokenButtonRow')).last().contains(outputText)
|
||||
}
|
||||
|
||||
const selectOutputAndSwitch = (outputText: string) => {
|
||||
// open token selector...
|
||||
cy.contains('Select token').click()
|
||||
// select token...
|
||||
cy.contains(outputText).click({ force: true })
|
||||
|
||||
cy.get('body')
|
||||
.then(($body) => {
|
||||
if ($body.find(getTestSelector('TokenSafetyWrapper')).length) {
|
||||
return 'I understand'
|
||||
}
|
||||
|
||||
return 'You pay' // Just click on a random element as a no-op
|
||||
})
|
||||
.then((selector) => {
|
||||
cy.contains(selector).click()
|
||||
})
|
||||
|
||||
// token selector should close...
|
||||
cy.contains('Search name or paste address').should('not.exist')
|
||||
|
||||
cy.get(getClassContainsSelector('ReverseButton')).first().click()
|
||||
}
|
||||
|
||||
describe('widget on swap page', () => {
|
||||
beforeEach(() => {
|
||||
cy.viewport(1200, 800)
|
||||
})
|
||||
|
||||
it('should have the correct default input/output and token selection should work', () => {
|
||||
cy.visit('/swap', { featureFlags: [FeatureFlag.swapWidget] }).then(() => {
|
||||
cy.wait('@eth_blockNumber')
|
||||
verifyInputToken('ETH')
|
||||
verifyOutputToken('Select token')
|
||||
|
||||
selectOutputAndSwitch('WETH')
|
||||
|
||||
verifyInputToken('WETH')
|
||||
verifyOutputToken('ETH')
|
||||
})
|
||||
})
|
||||
|
||||
it('should have the correct default input from URL params ', () => {
|
||||
cy.visit(`/swap?inputCurrency=${WETH_GOERLI}`, {
|
||||
featureFlags: [FeatureFlag.swapWidget],
|
||||
}).then(() => {
|
||||
cy.wait('@eth_blockNumber')
|
||||
})
|
||||
|
||||
verifyInputToken('WETH')
|
||||
verifyOutputToken('Select token')
|
||||
|
||||
selectOutputAndSwitch('Ether')
|
||||
|
||||
verifyInputToken('ETH')
|
||||
verifyOutputToken('WETH')
|
||||
})
|
||||
|
||||
it('should have the correct default output from URL params ', () => {
|
||||
cy.visit(`/swap?outputCurrency=${WETH_GOERLI}`, {
|
||||
featureFlags: [FeatureFlag.swapWidget],
|
||||
}).then(() => {
|
||||
cy.wait('@eth_blockNumber')
|
||||
})
|
||||
|
||||
verifyInputToken('Select token')
|
||||
verifyOutputToken('WETH')
|
||||
|
||||
cy.get(getClassContainsSelector('ReverseButton')).first().click()
|
||||
verifyInputToken('WETH')
|
||||
verifyOutputToken('Select token')
|
||||
|
||||
selectOutputAndSwitch('Ether')
|
||||
|
||||
verifyInputToken('ETH')
|
||||
verifyOutputToken('WETH')
|
||||
})
|
||||
})
|
||||
|
||||
describe('widget on Token Detail Page', () => {
|
||||
beforeEach(() => {
|
||||
cy.viewport(1200, 800)
|
||||
cy.visit(`/tokens/ethereum/${UNI_GOERLI}`, { featureFlags: [FeatureFlag.swapWidget] }).then(() => {
|
||||
cy.wait('@eth_blockNumber')
|
||||
})
|
||||
})
|
||||
|
||||
it('should have the expected output for a tokens detail page', () => {
|
||||
verifyOutputToken('UNI')
|
||||
cy.contains('Connect to Ethereum').should('exist')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,202 +0,0 @@
|
||||
import { USDC_MAINNET } from '../../src/constants/tokens'
|
||||
import { WETH_GOERLI } from '../fixtures/constants'
|
||||
import { HardhatProvider } from '../support/hardhat'
|
||||
import { getTestSelector } from '../utils'
|
||||
|
||||
describe('Swap', () => {
|
||||
let hardhat: HardhatProvider
|
||||
const verifyAmount = (field: 'input' | 'output', amountText: string | null) => {
|
||||
if (amountText === null) {
|
||||
cy.get(`#swap-currency-${field} .token-amount-input`).should('not.have.value')
|
||||
} else {
|
||||
cy.get(`#swap-currency-${field} .token-amount-input`).should('have.value', amountText)
|
||||
}
|
||||
}
|
||||
|
||||
const verifyToken = (field: 'input' | 'output', tokenSymbol: string | null) => {
|
||||
if (tokenSymbol === null) {
|
||||
cy.get(`#swap-currency-${field} .token-symbol-container`).should('contain.text', 'Select token')
|
||||
} else {
|
||||
cy.get(`#swap-currency-${field} .token-symbol-container`).should('contain.text', tokenSymbol)
|
||||
}
|
||||
}
|
||||
|
||||
const selectOutput = (tokenSymbol: string) => {
|
||||
// open token selector...
|
||||
cy.contains('Select token').click()
|
||||
// select token...
|
||||
cy.contains(tokenSymbol).click()
|
||||
|
||||
cy.get('body')
|
||||
.then(($body) => {
|
||||
if ($body.find(getTestSelector('TokenSafetyWrapper')).length) {
|
||||
return 'I understand'
|
||||
}
|
||||
|
||||
return 'no-op' // Don't click on anything, a no-op
|
||||
})
|
||||
.then((content) => {
|
||||
if (content !== 'no-op') {
|
||||
cy.contains(content).click()
|
||||
}
|
||||
})
|
||||
|
||||
// token selector should close...
|
||||
cy.contains('Search name or paste address').should('not.exist')
|
||||
}
|
||||
|
||||
before(() => {
|
||||
cy.visit('/swap', { ethereum: 'hardhat' }).then((window) => {
|
||||
hardhat = window.hardhat
|
||||
})
|
||||
})
|
||||
|
||||
it('starts with ETH selected by default', () => {
|
||||
verifyAmount('input', '')
|
||||
verifyToken('input', 'ETH')
|
||||
verifyAmount('output', null)
|
||||
verifyToken('output', null)
|
||||
})
|
||||
|
||||
it('can enter an amount into input', () => {
|
||||
cy.get('#swap-currency-input .token-amount-input').clear().type('0.001').should('have.value', '0.001')
|
||||
})
|
||||
|
||||
it('zero swap amount', () => {
|
||||
cy.get('#swap-currency-input .token-amount-input').clear().type('0.0').should('have.value', '0.0')
|
||||
})
|
||||
|
||||
it('invalid swap amount', () => {
|
||||
cy.get('#swap-currency-input .token-amount-input').clear().type('\\').should('have.value', '')
|
||||
})
|
||||
|
||||
it('can enter an amount into output', () => {
|
||||
cy.get('#swap-currency-output .token-amount-input').clear().type('0.001').should('have.value', '0.001')
|
||||
})
|
||||
|
||||
it('zero output amount', () => {
|
||||
cy.get('#swap-currency-output .token-amount-input').clear().type('0.0').should('have.value', '0.0')
|
||||
})
|
||||
|
||||
it('can swap ETH for USDC', () => {
|
||||
const TOKEN_ADDRESS = USDC_MAINNET.address
|
||||
const BALANCE_INCREMENT = 1
|
||||
cy.visit('/swap', { ethereum: 'hardhat' })
|
||||
.then((window) => {
|
||||
hardhat = window.hardhat
|
||||
})
|
||||
.then(() => hardhat.utils.getBalance(hardhat.wallet.address, USDC_MAINNET))
|
||||
.then((balance) => Number(balance.toFixed(1)))
|
||||
.then((initialBalance) => {
|
||||
cy.get('#swap-currency-output .open-currency-select-button').click()
|
||||
cy.get(getTestSelector('token-search-input')).clear().type(TOKEN_ADDRESS)
|
||||
cy.contains('USDC').click()
|
||||
cy.get('#swap-currency-output .token-amount-input').clear().type(BALANCE_INCREMENT.toString())
|
||||
cy.get('#swap-currency-input .token-amount-input').should('not.equal', '')
|
||||
cy.get('#swap-button').click()
|
||||
cy.get('#confirm-swap-or-send').click()
|
||||
cy.get(getTestSelector('dismiss-tx-confirmation')).click()
|
||||
|
||||
cy.then(() => hardhat.send('hardhat_mine', ['0x1', '0xc'])).then(() => {
|
||||
// ui check
|
||||
cy.get('#swap-currency-output [data-testid="balance-text"]').should(
|
||||
'have.text',
|
||||
`Balance: ${initialBalance + BALANCE_INCREMENT}`
|
||||
)
|
||||
|
||||
// chain state check
|
||||
cy.then(() => hardhat.utils.getBalance(hardhat.wallet.address, USDC_MAINNET))
|
||||
.then((balance) => Number(balance.toFixed(1)))
|
||||
.should('eq', initialBalance + BALANCE_INCREMENT)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('should have the correct default input/output and token selection should work', () => {
|
||||
cy.visit('/swap')
|
||||
verifyToken('input', 'ETH')
|
||||
verifyToken('output', null)
|
||||
|
||||
selectOutput('WETH')
|
||||
cy.get(getTestSelector('swap-currency-button')).first().click()
|
||||
|
||||
verifyToken('input', 'WETH')
|
||||
verifyToken('output', 'ETH')
|
||||
})
|
||||
|
||||
it('should have the correct default input from URL params ', () => {
|
||||
cy.visit(`/swap?inputCurrency=${WETH_GOERLI}`)
|
||||
|
||||
verifyToken('input', 'WETH')
|
||||
verifyToken('output', null)
|
||||
|
||||
selectOutput('Ether')
|
||||
cy.get(getTestSelector('swap-currency-button')).first().click()
|
||||
|
||||
verifyToken('input', 'ETH')
|
||||
verifyToken('output', 'WETH')
|
||||
})
|
||||
|
||||
it('should have the correct default output from URL params ', () => {
|
||||
cy.visit(`/swap?outputCurrency=${WETH_GOERLI}`)
|
||||
|
||||
verifyToken('input', null)
|
||||
verifyToken('output', 'WETH')
|
||||
|
||||
cy.get(getTestSelector('swap-currency-button')).first().click()
|
||||
verifyToken('input', 'WETH')
|
||||
verifyToken('output', null)
|
||||
|
||||
selectOutput('Ether')
|
||||
cy.get(getTestSelector('swap-currency-button')).first().click()
|
||||
|
||||
verifyToken('input', 'ETH')
|
||||
verifyToken('output', 'WETH')
|
||||
})
|
||||
|
||||
it('ETH to wETH is same value (wrapped swaps have no price impact)', () => {
|
||||
cy.visit('/swap')
|
||||
selectOutput('WETH')
|
||||
cy.get('#swap-currency-input .token-amount-input').clear().type('0.01')
|
||||
cy.get('#swap-currency-output .token-amount-input').should('have.value', '0.01')
|
||||
})
|
||||
|
||||
it('should render and dismiss the wallet rejection modal', () => {
|
||||
cy.visit('/swap', { ethereum: 'hardhat' }).then((window) => {
|
||||
hardhat = window.hardhat
|
||||
cy.stub(hardhat.wallet, 'sendTransaction').rejects(new Error('user cancelled'))
|
||||
|
||||
cy.get('#swap-currency-output .open-currency-select-button').click()
|
||||
cy.get(getTestSelector('token-search-input')).clear().type(USDC_MAINNET.address)
|
||||
cy.contains('USDC').click()
|
||||
cy.get('#swap-currency-output .token-amount-input').clear().type('1')
|
||||
cy.get('#swap-currency-input .token-amount-input').should('not.equal', '')
|
||||
cy.get('#swap-button').click()
|
||||
cy.get('#confirm-swap-or-send').click()
|
||||
cy.contains('Transaction rejected').should('exist')
|
||||
cy.contains('Dismiss').click()
|
||||
cy.contains('Transaction rejected').should('not.exist')
|
||||
})
|
||||
})
|
||||
|
||||
it('Opens and closes the settings menu', () => {
|
||||
cy.visit('/swap')
|
||||
cy.contains('Settings').should('not.exist')
|
||||
cy.get(getTestSelector('swap-settings-button')).click()
|
||||
cy.contains('Slippage tolerance').should('exist')
|
||||
cy.contains('Transaction deadline').should('exist')
|
||||
cy.contains('Auto Router API').should('exist')
|
||||
cy.contains('Expert Mode').should('exist')
|
||||
cy.get(getTestSelector('swap-settings-button')).click()
|
||||
cy.contains('Settings').should('not.exist')
|
||||
})
|
||||
|
||||
it('inputs reset when navigating between pages', () => {
|
||||
cy.get('#swap-currency-input .token-amount-input').clear().type('0.01')
|
||||
cy.get('#swap-currency-output .token-amount-input').should('not.equal', '')
|
||||
cy.visit('/pool')
|
||||
cy.visit('/swap')
|
||||
cy.get('#swap-currency-input .token-amount-input').should('have.value', '')
|
||||
cy.get('#swap-currency-output .token-amount-input').should('not.equal', '')
|
||||
})
|
||||
})
|
||||
107
cypress/e2e/swap/errors.test.ts
Normal file
107
cypress/e2e/swap/errors.test.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { BigNumber } from '@ethersproject/bignumber'
|
||||
import { SupportedChainId } from '@uniswap/sdk-core'
|
||||
|
||||
import { DEFAULT_DEADLINE_FROM_NOW } from '../../../src/constants/misc'
|
||||
import { UNI, USDC_MAINNET } from '../../../src/constants/tokens'
|
||||
import { getBalance, getTestSelector } from '../../utils'
|
||||
|
||||
const UNI_MAINNET = UNI[SupportedChainId.MAINNET]
|
||||
|
||||
describe('Swap errors', () => {
|
||||
it('wallet rejection', () => {
|
||||
cy.visit(`/swap?inputCurrency=ETH&outputCurrency=${USDC_MAINNET.address}`, { ethereum: 'hardhat' })
|
||||
cy.hardhat().then((hardhat) => {
|
||||
// Stub the wallet to reject any transaction.
|
||||
cy.stub(hardhat.wallet, 'sendTransaction').log(false).rejects(new Error('user cancelled'))
|
||||
|
||||
// Enter amount to swap
|
||||
cy.get('#swap-currency-output .token-amount-input').type('1').should('have.value', '1')
|
||||
cy.get('#swap-currency-input .token-amount-input').should('not.have.value', '')
|
||||
|
||||
// Submit transaction
|
||||
cy.get('#swap-button').click()
|
||||
cy.contains('Confirm swap').click()
|
||||
cy.wait('@eth_estimateGas')
|
||||
|
||||
// Verify rejection
|
||||
cy.contains('Review swap')
|
||||
cy.contains('Confirm swap')
|
||||
})
|
||||
})
|
||||
|
||||
it('transaction past deadline', () => {
|
||||
cy.visit(`/swap?inputCurrency=ETH&outputCurrency=${USDC_MAINNET.address}`, { ethereum: 'hardhat' })
|
||||
cy.hardhat({ automine: false })
|
||||
getBalance(USDC_MAINNET).then((initialBalance) => {
|
||||
// Enter amount to swap
|
||||
cy.get('#swap-currency-output .token-amount-input').type('1').should('have.value', '1')
|
||||
cy.get('#swap-currency-input .token-amount-input').should('not.have.value', '')
|
||||
|
||||
// Submit transaction
|
||||
cy.get('#swap-button').click()
|
||||
cy.contains('Confirm swap').click()
|
||||
cy.wait('@eth_estimateGas').wait('@eth_sendRawTransaction').wait('@eth_getTransactionReceipt')
|
||||
cy.contains('Transaction submitted')
|
||||
cy.get(getTestSelector('confirmation-close-icon')).click()
|
||||
cy.get(getTestSelector('web3-status-connected')).should('contain', '1 Pending')
|
||||
|
||||
// Mine transaction
|
||||
cy.hardhat().then(async (hardhat) => {
|
||||
// Remove the transaction from the mempool, so that it doesn't fail but it is past the deadline.
|
||||
// This should result in it being removed from pending transactions, without a failure notificiation.
|
||||
const transactions = await hardhat.send('eth_pendingTransactions', [])
|
||||
await hardhat.send('hardhat_dropTransaction', [transactions[0].hash])
|
||||
// Mine past the deadline
|
||||
await hardhat.mine(1, DEFAULT_DEADLINE_FROM_NOW + 1)
|
||||
})
|
||||
cy.wait('@eth_getTransactionReceipt')
|
||||
|
||||
// Verify transaction did not occur
|
||||
cy.get(getTestSelector('web3-status-connected')).should('not.contain', 'Pending')
|
||||
cy.get(getTestSelector('popups')).should('not.contain', 'Swap failed')
|
||||
cy.get('#swap-currency-output').contains(`Balance: ${initialBalance}`)
|
||||
getBalance(USDC_MAINNET).should('eq', initialBalance)
|
||||
})
|
||||
})
|
||||
|
||||
it('slippage failure', () => {
|
||||
cy.visit(`/swap?inputCurrency=ETH&outputCurrency=${UNI_MAINNET.address}`, { ethereum: 'hardhat' })
|
||||
cy.hardhat({ automine: false })
|
||||
getBalance(USDC_MAINNET).then((initialBalance) => {
|
||||
// Gas estimation fails for this transaction (that would normally fail), so we stub it.
|
||||
cy.hardhat().then((hardhat) => {
|
||||
const send = cy.stub(hardhat.provider, 'send').log(false)
|
||||
send.withArgs('eth_estimateGas').resolves(BigNumber.from(2_000_000))
|
||||
send.callThrough()
|
||||
})
|
||||
|
||||
// Set slippage to a very low value.
|
||||
cy.get(getTestSelector('open-settings-dialog-button')).click()
|
||||
cy.get(getTestSelector('max-slippage-settings')).click()
|
||||
cy.get(getTestSelector('slippage-input')).clear().type('0.01')
|
||||
cy.get('body').click('topRight') // close modal
|
||||
cy.get(getTestSelector('slippage-input')).should('not.exist')
|
||||
|
||||
// Submit 2 transactions
|
||||
for (let i = 0; i < 2; i++) {
|
||||
cy.get('#swap-currency-input .token-amount-input').type('200').should('have.value', '200')
|
||||
cy.get('#swap-currency-output .token-amount-input').should('not.have.value', '')
|
||||
cy.get('#swap-button').click()
|
||||
cy.contains('Confirm swap').click()
|
||||
cy.wait('@eth_sendRawTransaction').wait('@eth_getTransactionReceipt')
|
||||
cy.contains('Transaction submitted')
|
||||
cy.get(getTestSelector('confirmation-close-icon')).click()
|
||||
}
|
||||
cy.get(getTestSelector('web3-status-connected')).should('contain', '2 Pending')
|
||||
|
||||
// Mine transactions
|
||||
cy.hardhat().then((hardhat) => hardhat.mine())
|
||||
cy.wait('@eth_getTransactionReceipt')
|
||||
|
||||
// Verify transaction did not occur
|
||||
cy.get(getTestSelector('web3-status-connected')).should('not.contain', 'Pending')
|
||||
cy.get(getTestSelector('popups')).contains('Swap failed')
|
||||
getBalance(UNI_MAINNET).should('eq', initialBalance)
|
||||
})
|
||||
})
|
||||
})
|
||||
14
cypress/e2e/swap/settings.test.ts
Normal file
14
cypress/e2e/swap/settings.test.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { getTestSelector } from '../../utils'
|
||||
|
||||
describe('Swap settings', () => {
|
||||
it('Opens and closes the settings menu', () => {
|
||||
cy.visit('/swap')
|
||||
cy.contains('Settings').should('not.exist')
|
||||
cy.get(getTestSelector('open-settings-dialog-button')).click()
|
||||
cy.contains('Max slippage').should('exist')
|
||||
cy.contains('Transaction deadline').should('exist')
|
||||
cy.contains('Auto Router API').should('exist')
|
||||
cy.get(getTestSelector('open-settings-dialog-button')).click()
|
||||
cy.contains('Settings').should('not.exist')
|
||||
})
|
||||
})
|
||||
76
cypress/e2e/swap/swap.test.ts
Normal file
76
cypress/e2e/swap/swap.test.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { SupportedChainId } from '@uniswap/sdk-core'
|
||||
|
||||
import { UNI, USDC_MAINNET } from '../../../src/constants/tokens'
|
||||
import { getBalance, getTestSelector } from '../../utils'
|
||||
|
||||
const UNI_MAINNET = UNI[SupportedChainId.MAINNET]
|
||||
|
||||
describe('Swap', () => {
|
||||
describe('Swap on main page', () => {
|
||||
it('starts with ETH selected by default', () => {
|
||||
cy.visit('/swap')
|
||||
cy.get(`#swap-currency-input .token-amount-input`).should('have.value', '')
|
||||
cy.get(`#swap-currency-input .token-symbol-container`).should('contain.text', 'ETH')
|
||||
cy.get(`#swap-currency-output .token-amount-input`).should('not.have.value')
|
||||
cy.get(`#swap-currency-output .token-symbol-container`).should('contain.text', 'Select token')
|
||||
})
|
||||
|
||||
it('should default inputs from URL params ', () => {
|
||||
cy.visit(`/swap?inputCurrency=${UNI_MAINNET.address}`)
|
||||
cy.get(`#swap-currency-input .token-symbol-container`).should('contain.text', 'UNI')
|
||||
cy.get(`#swap-currency-output .token-symbol-container`).should('contain.text', 'Select token')
|
||||
|
||||
cy.visit(`/swap?outputCurrency=${UNI_MAINNET.address}`)
|
||||
cy.get(`#swap-currency-input .token-symbol-container`).should('contain.text', 'Select token')
|
||||
cy.get(`#swap-currency-output .token-symbol-container`).should('contain.text', 'UNI')
|
||||
|
||||
cy.visit(`/swap?inputCurrency=ETH&outputCurrency=${UNI_MAINNET.address}`)
|
||||
cy.get(`#swap-currency-input .token-symbol-container`).should('contain.text', 'ETH')
|
||||
cy.get(`#swap-currency-output .token-symbol-container`).should('contain.text', 'UNI')
|
||||
})
|
||||
|
||||
it('inputs reset when navigating between pages', () => {
|
||||
cy.visit('/swap')
|
||||
cy.get('#swap-currency-input .token-amount-input').should('have.value', '')
|
||||
cy.get('#swap-currency-input .token-amount-input').type('0.01').should('have.value', '0.01')
|
||||
cy.visit('/pool').visit('/swap')
|
||||
cy.get('#swap-currency-input .token-amount-input').should('have.value', '')
|
||||
})
|
||||
|
||||
it('swaps ETH for USDC', () => {
|
||||
cy.visit('/swap', { ethereum: 'hardhat' })
|
||||
cy.hardhat({ automine: false })
|
||||
getBalance(USDC_MAINNET).then((initialBalance) => {
|
||||
// Select USDC
|
||||
cy.get('#swap-currency-output .open-currency-select-button').click()
|
||||
cy.get(getTestSelector('token-search-input')).type(USDC_MAINNET.address)
|
||||
cy.contains('USDC').click()
|
||||
|
||||
// Enter amount to swap
|
||||
cy.get('#swap-currency-output .token-amount-input').type('1').should('have.value', '1')
|
||||
cy.get('#swap-currency-input .token-amount-input').should('not.have.value', '')
|
||||
|
||||
// Submit transaction
|
||||
cy.get('#swap-button').click()
|
||||
cy.contains('Review swap')
|
||||
cy.contains('Confirm swap').click()
|
||||
cy.wait('@eth_estimateGas').wait('@eth_sendRawTransaction').wait('@eth_getTransactionReceipt')
|
||||
cy.contains('Transaction submitted')
|
||||
cy.get(getTestSelector('confirmation-close-icon')).click()
|
||||
cy.contains('Transaction submitted').should('not.exist')
|
||||
cy.get(getTestSelector('web3-status-connected')).should('contain', '1 Pending')
|
||||
|
||||
// Mine transaction
|
||||
cy.hardhat().then((hardhat) => hardhat.mine())
|
||||
cy.wait('@eth_getTransactionReceipt')
|
||||
|
||||
// Verify transaction
|
||||
cy.get(getTestSelector('web3-status-connected')).should('not.contain', 'Pending')
|
||||
cy.get(getTestSelector('popups')).contains('Swapped')
|
||||
const finalBalance = initialBalance + 1
|
||||
cy.get('#swap-currency-output').contains(`Balance: ${finalBalance}`)
|
||||
getBalance(USDC_MAINNET).should('eq', finalBalance)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
80
cypress/e2e/swap/wrap.test.ts
Normal file
80
cypress/e2e/swap/wrap.test.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { CurrencyAmount, SupportedChainId, WETH9 } from '@uniswap/sdk-core'
|
||||
|
||||
import { getBalance, getTestSelector } from '../../utils'
|
||||
|
||||
const WETH = WETH9[SupportedChainId.MAINNET]
|
||||
|
||||
describe('Swap wrap', () => {
|
||||
beforeEach(() => {
|
||||
cy.visit(`/swap?inputCurrency=ETH&outputCurrency=${WETH.address}`, { ethereum: 'hardhat' }).hardhat({
|
||||
automine: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('ETH to wETH is same value (wrapped swaps have no price impact)', () => {
|
||||
cy.get('#swap-currency-input .token-amount-input').type('0.01').should('have.value', '0.01')
|
||||
cy.get('#swap-currency-output .token-amount-input').should('have.value', '0.01')
|
||||
|
||||
cy.get('#swap-currency-output .token-amount-input').clear().type('0.02').should('have.value', '0.02')
|
||||
cy.get('#swap-currency-input .token-amount-input').should('have.value', '0.02')
|
||||
})
|
||||
|
||||
it('should be able to wrap ETH', () => {
|
||||
getBalance(WETH).then((initialBalance) => {
|
||||
cy.contains('Enter ETH amount')
|
||||
|
||||
// Enter amount to wrap
|
||||
cy.get('#swap-currency-output .token-amount-input').type('1').should('have.value', 1)
|
||||
cy.get('#swap-currency-input .token-amount-input').should('have.value', 1)
|
||||
|
||||
// Submit transaction
|
||||
cy.contains('Wrap').click()
|
||||
cy.wait('@eth_estimateGas').wait('@eth_sendRawTransaction').wait('@eth_getTransactionReceipt')
|
||||
cy.get(getTestSelector('web3-status-connected')).should('contain', '1 Pending')
|
||||
|
||||
// Mine transaction
|
||||
cy.hardhat().then((hardhat) => hardhat.mine())
|
||||
cy.wait('@eth_getTransactionReceipt')
|
||||
|
||||
// Verify transaction
|
||||
cy.get(getTestSelector('web3-status-connected')).should('not.contain', 'Pending')
|
||||
cy.get(getTestSelector('popups')).contains('Wrapped')
|
||||
const finalBalance = initialBalance + 1
|
||||
cy.get('#swap-currency-output').contains(`Balance: ${finalBalance}`)
|
||||
getBalance(WETH).should('equal', finalBalance)
|
||||
})
|
||||
})
|
||||
|
||||
it('should be able to unwrap WETH', () => {
|
||||
cy.hardhat().then(async (hardhat) => {
|
||||
await hardhat.fund(hardhat.wallet, CurrencyAmount.fromRawAmount(WETH, 1e18))
|
||||
await hardhat.mine()
|
||||
})
|
||||
|
||||
getBalance(WETH).then((initialBalance) => {
|
||||
// Swap input/output to unwrap WETH
|
||||
cy.get(getTestSelector('swap-currency-button')).click()
|
||||
cy.contains('Enter WETH amount')
|
||||
|
||||
// Enter the amount to unwrap
|
||||
cy.get('#swap-currency-output .token-amount-input').type('1').should('have.value', 1)
|
||||
cy.get('#swap-currency-input .token-amount-input').should('have.value', 1)
|
||||
|
||||
// Submit transaction
|
||||
cy.contains('Unwrap').click()
|
||||
cy.wait('@eth_estimateGas').wait('@eth_sendRawTransaction').wait('@eth_getTransactionReceipt')
|
||||
cy.get(getTestSelector('web3-status-connected')).should('contain', '1 Pending')
|
||||
|
||||
// Mine transaction
|
||||
cy.hardhat().then((hardhat) => hardhat.mine())
|
||||
cy.wait('@eth_getTransactionReceipt')
|
||||
|
||||
// Verify transaction
|
||||
cy.get(getTestSelector('web3-status-connected')).should('not.contain', 'Pending')
|
||||
cy.get(getTestSelector('popups')).contains('Unwrapped')
|
||||
const finalBalance = initialBalance - 1
|
||||
cy.get('#swap-currency-input').contains(`Balance: ${finalBalance}`)
|
||||
getBalance(WETH).should('equal', finalBalance)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,10 +1,15 @@
|
||||
import { getClassContainsSelector, getTestSelector } from '../utils'
|
||||
import { SupportedChainId, WETH9 } from '@uniswap/sdk-core'
|
||||
|
||||
import { UNI } from '../../src/constants/tokens'
|
||||
import { getTestSelector } from '../utils'
|
||||
|
||||
const UNI_MAINNET = UNI[SupportedChainId.MAINNET]
|
||||
|
||||
const UNI_ADDRESS = '0x1f9840a85d5aF5bf1D1762F925BDADdC4201F984'
|
||||
|
||||
describe('Token details', () => {
|
||||
before(() => {
|
||||
cy.visit('/')
|
||||
beforeEach(() => {
|
||||
cy.viewport(1440, 900)
|
||||
})
|
||||
|
||||
it('Uniswap token should have all information populated', () => {
|
||||
@@ -40,9 +45,6 @@ describe('Token details', () => {
|
||||
|
||||
// Contract address should be displayed
|
||||
cy.contains(UNI_ADDRESS).should('exist')
|
||||
|
||||
// Swap widget should have this token pre-selected as the “destination” token
|
||||
cy.get(getTestSelector('token-select')).should('include.text', 'UNI')
|
||||
})
|
||||
|
||||
it('token with warning and low trading volume should have all information populated', () => {
|
||||
@@ -81,36 +83,76 @@ describe('Token details', () => {
|
||||
// Contract address should be displayed
|
||||
cy.contains('0xa71d0588EAf47f12B13cF8eC750430d21DF04974').should('exist')
|
||||
|
||||
// Swap widget should have this token pre-selected as the “destination” token
|
||||
cy.get(getTestSelector('token-select')).should('include.text', 'QOM')
|
||||
|
||||
// Warning label should show if relevant ([spec](https://www.notion.so/3f7fce6f93694be08a94a6984d50298e))
|
||||
cy.get('[data-cy="token-safety-message"]')
|
||||
.should('include.text', 'Warning')
|
||||
.and('include.text', "This token isn't traded on leading U.S. centralized exchanges")
|
||||
})
|
||||
|
||||
describe('Swap on Token Detail Page', () => {
|
||||
const verifyOutputToken = (outputText: string) => {
|
||||
cy.get(getClassContainsSelector('TokenButtonRow')).last().contains(outputText)
|
||||
}
|
||||
|
||||
describe('swapping', () => {
|
||||
beforeEach(() => {
|
||||
// On mobile widths, we just link back to /swap instead of rendering the swap component.
|
||||
cy.viewport(1200, 800)
|
||||
cy.visit(`/tokens/goerli/${UNI_ADDRESS}`).then(() => {
|
||||
cy.visit(`/tokens/ethereum/${UNI_MAINNET.address}`, {
|
||||
ethereum: 'hardhat',
|
||||
}).then(() => {
|
||||
cy.wait('@eth_blockNumber')
|
||||
cy.scrollTo('top')
|
||||
})
|
||||
})
|
||||
|
||||
it('should have the expected output for a tokens detail page', () => {
|
||||
verifyOutputToken('UNI')
|
||||
cy.get(`#swap-currency-input .token-amount-input`).should('have.value', '')
|
||||
cy.get(`#swap-currency-input .token-symbol-container`).should('contain.text', 'Select token')
|
||||
cy.get(`#swap-currency-output .token-amount-input`).should('not.have.value')
|
||||
cy.get(`#swap-currency-output .token-symbol-container`).should('contain.text', 'UNI')
|
||||
})
|
||||
|
||||
it('should automatically navigate to the new TDP', () => {
|
||||
cy.get(`#swap-currency-output .open-currency-select-button`).click()
|
||||
cy.contains('WETH').click()
|
||||
cy.url().should('include', `${WETH9[1].address}`)
|
||||
cy.url().should('not.include', `${UNI_MAINNET.address}`)
|
||||
})
|
||||
|
||||
it('should not share swap state with the main swap page', () => {
|
||||
verifyOutputToken('UNI')
|
||||
cy.get(`#swap-currency-output .token-symbol-container`).should('contain.text', 'UNI')
|
||||
cy.get(`#swap-currency-input .open-currency-select-button`).click()
|
||||
cy.contains('WETH').click()
|
||||
cy.visit('/swap')
|
||||
cy.contains('UNI').should('not.exist')
|
||||
cy.contains('WETH').should('not.exist')
|
||||
})
|
||||
|
||||
it('can enter an amount into input', () => {
|
||||
cy.get('#swap-currency-input .token-amount-input').clear().type('0.001').should('have.value', '0.001')
|
||||
})
|
||||
|
||||
it('zero swap amount', () => {
|
||||
cy.get('#swap-currency-input .token-amount-input').clear().type('0.0').should('have.value', '0.0')
|
||||
})
|
||||
|
||||
it('invalid swap amount', () => {
|
||||
cy.get('#swap-currency-input .token-amount-input').clear().type('\\').should('have.value', '')
|
||||
})
|
||||
|
||||
it('can enter an amount into output', () => {
|
||||
cy.get('#swap-currency-output .token-amount-input').clear().type('0.001').should('have.value', '0.001')
|
||||
})
|
||||
|
||||
it('zero output amount', () => {
|
||||
cy.get('#swap-currency-output .token-amount-input').clear().type('0.0').should('have.value', '0.0')
|
||||
})
|
||||
|
||||
it('should show a L2 token even if the user is connected to a different network', () => {
|
||||
cy.visit('/tokens', { ethereum: 'hardhat' })
|
||||
cy.get(getTestSelector('tokens-network-filter-selected')).click()
|
||||
cy.get(getTestSelector('tokens-network-filter-option-arbitrum')).click()
|
||||
cy.get(getTestSelector('tokens-network-filter-selected')).should('contain', 'Arbitrum')
|
||||
cy.get(getTestSelector('token-table-row-ARB')).click()
|
||||
cy.get(`#swap-currency-output .token-symbol-container`).should('contain.text', 'ARB')
|
||||
cy.get(getTestSelector('open-settings-dialog-button')).should('be.disabled')
|
||||
cy.contains('Connect to Arbitrum').should('exist')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,83 +1,24 @@
|
||||
describe.skip('Token explore filter', () => {
|
||||
before(() => {
|
||||
cy.visit('/')
|
||||
})
|
||||
|
||||
it('should filter correctly by uni search term', () => {
|
||||
describe('Token explore filter', () => {
|
||||
beforeEach(() => {
|
||||
cy.visit('/tokens')
|
||||
cy.get('[data-cy="token-name"]').then(($els) => {
|
||||
const tokenNames = Array.from($els, (el) => el.innerText)
|
||||
const filteredByUni = tokenNames.filter((tokenName) => tokenName.toLowerCase().includes('uni'))
|
||||
cy.wrap(filteredByUni).as('filteredByUni')
|
||||
})
|
||||
|
||||
cy.get('[data-cy="explore-tokens-search-input"]')
|
||||
.clear()
|
||||
.type('uni')
|
||||
.type('{enter}')
|
||||
.then(() => {
|
||||
cy.get('[data-cy="token-name"]').its('length').should('be.lt', 100)
|
||||
cy.get('@filteredByUni').then((filteredByUni) => {
|
||||
cy.get('[data-cy="token-name"]').then(($els) => {
|
||||
const tokenNames = Array.from($els, (el) => el.innerText)
|
||||
expect(tokenNames.length).to.equal(filteredByUni.length)
|
||||
tokenNames.forEach((tokenName) => {
|
||||
expect(filteredByUni).to.include(tokenName)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
function aliasFilteredTokens(filter: string) {
|
||||
cy.get('[data-cy="token-name"]').then((tokens) => {
|
||||
cy.wrap(Array.from(tokens).filter((token) => token.innerText.toLowerCase().includes(filter))).as('filteredTokens')
|
||||
})
|
||||
}
|
||||
|
||||
function searchFor(filter: string) {
|
||||
cy.get('[data-cy="explore-tokens-search-input"]').clear().type(filter).type('{enter}')
|
||||
}
|
||||
|
||||
it('should filter correctly by dao search term', () => {
|
||||
cy.visit('/tokens')
|
||||
cy.get('[data-cy="token-name"]').then(($els) => {
|
||||
const tokenNames = Array.from($els, (el) => el.innerText)
|
||||
const filteredByDao = tokenNames.filter((tokenName) => tokenName.toLowerCase().includes('dao'))
|
||||
cy.wrap(filteredByDao).as('filteredByDao')
|
||||
aliasFilteredTokens('dao')
|
||||
searchFor('dao')
|
||||
|
||||
cy.get('@filteredTokens').then((filteredTokens) => {
|
||||
cy.get('[data-cy="token-name"]').should('deep.equal', filteredTokens)
|
||||
})
|
||||
|
||||
cy.get('[data-cy="explore-tokens-search-input"]')
|
||||
.clear()
|
||||
.type('dao')
|
||||
.type('{enter}')
|
||||
.then(() => {
|
||||
cy.get('[data-cy="token-name"]').its('length').should('be.lt', 100)
|
||||
cy.get('@filteredByDao').then((filteredByDao) => {
|
||||
cy.get('[data-cy="token-name"]').then(($els) => {
|
||||
const tokenNames = Array.from($els, (el) => el.innerText)
|
||||
expect(tokenNames.length).to.equal(filteredByDao.length)
|
||||
tokenNames.forEach((tokenName) => {
|
||||
expect(filteredByDao).to.include(tokenName)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('should filter correctly by ax search term', () => {
|
||||
cy.visit('/tokens')
|
||||
cy.get('[data-cy="token-name"]').then(($els) => {
|
||||
const tokenNames = Array.from($els, (el) => el.innerText)
|
||||
const filteredByAx = tokenNames.filter((tokenName) => tokenName.toLowerCase().includes('ax'))
|
||||
cy.wrap(filteredByAx).as('filteredByAx')
|
||||
})
|
||||
|
||||
cy.get('[data-cy="explore-tokens-search-input"]')
|
||||
.clear()
|
||||
.type('ax')
|
||||
.type('{enter}')
|
||||
.then(() => {
|
||||
cy.get('[data-cy="token-name"]').its('length').should('be.lt', 100)
|
||||
cy.get('@filteredByAx').then((filteredByAx) => {
|
||||
cy.get('[data-cy="token-name"]').then(($els) => {
|
||||
const tokenNames = Array.from($els, (el) => el.innerText)
|
||||
expect(tokenNames.length).to.equal(filteredByAx.length)
|
||||
tokenNames.forEach((tokenName) => {
|
||||
expect(filteredByAx).to.include(tokenName)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
40
cypress/e2e/wallet-connection/connect.test.ts
Normal file
40
cypress/e2e/wallet-connection/connect.test.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { getTestSelector } from '../../utils'
|
||||
|
||||
describe('disconnect wallet', () => {
|
||||
it('should clear state', () => {
|
||||
cy.visit('/swap', { ethereum: 'hardhat' })
|
||||
cy.get('#swap-currency-input .token-amount-input').clear().type('1')
|
||||
|
||||
// Verify wallet is connected
|
||||
cy.hardhat().then((hardhat) => cy.contains(hardhat.wallet.address.substring(0, 6)))
|
||||
cy.contains('Balance:')
|
||||
|
||||
// Disconnect the wallet
|
||||
cy.hardhat().then((hardhat) => cy.contains(hardhat.wallet.address.substring(0, 6)).click())
|
||||
cy.get(getTestSelector('wallet-disconnect')).click()
|
||||
cy.get(getTestSelector('wallet-disconnect')).contains('Disconnect')
|
||||
cy.get(getTestSelector('wallet-disconnect')).click()
|
||||
|
||||
// Verify wallet has disconnected
|
||||
cy.contains('Connect a wallet').should('exist')
|
||||
cy.get(getTestSelector('navbar-connect-wallet')).contains('Connect')
|
||||
cy.contains('Connect Wallet')
|
||||
|
||||
// Verify swap input is cleared
|
||||
cy.get('#swap-currency-input .token-amount-input').should('have.value', '')
|
||||
})
|
||||
})
|
||||
|
||||
describe('connect wallet', () => {
|
||||
it('should load state', () => {
|
||||
cy.visit('/swap', { ethereum: 'hardhat', userState: {} })
|
||||
|
||||
// Connect the wallet
|
||||
cy.get(getTestSelector('navbar-connect-wallet')).contains('Connect').click()
|
||||
cy.contains('MetaMask').click()
|
||||
|
||||
// Verify wallet is connected
|
||||
cy.hardhat().then((hardhat) => cy.contains(hardhat.wallet.address.substring(0, 6)))
|
||||
cy.contains('Balance:')
|
||||
})
|
||||
})
|
||||
@@ -1,96 +1,111 @@
|
||||
import { getTestSelector } from '../utils'
|
||||
|
||||
function visit(darkMode: boolean) {
|
||||
cy.visit('/swap', {
|
||||
onBeforeLoad(win) {
|
||||
cy.stub(win, 'matchMedia')
|
||||
.withArgs('(prefers-color-scheme: dark)')
|
||||
.returns({
|
||||
matches: darkMode,
|
||||
addEventListener() {
|
||||
// do nothing
|
||||
},
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
describe('Wallet Dropdown', () => {
|
||||
before(() => {
|
||||
cy.visit('/pools')
|
||||
function itShouldChangeTheTheme() {
|
||||
it('should change the theme', () => {
|
||||
cy.get(getTestSelector('theme-lightmode')).click()
|
||||
|
||||
cy.get(getTestSelector('theme-lightmode')).should('not.have.css', 'background-color', 'rgba(0, 0, 0, 0)')
|
||||
cy.get(getTestSelector('theme-darkmode')).should('have.css', 'background-color', 'rgba(0, 0, 0, 0)')
|
||||
cy.get(getTestSelector('theme-auto')).should('have.css', 'background-color', 'rgba(0, 0, 0, 0)')
|
||||
|
||||
cy.get(getTestSelector('theme-darkmode')).click()
|
||||
cy.get(getTestSelector('theme-lightmode')).should('have.css', 'background-color', 'rgba(0, 0, 0, 0)')
|
||||
cy.get(getTestSelector('theme-darkmode')).should('not.have.css', 'background-color', 'rgba(0, 0, 0, 0)')
|
||||
cy.get(getTestSelector('theme-auto')).should('have.css', 'background-color', 'rgba(0, 0, 0, 0)')
|
||||
|
||||
cy.get(getTestSelector('theme-auto')).click()
|
||||
cy.get(getTestSelector('theme-lightmode')).should('have.css', 'background-color', 'rgba(0, 0, 0, 0)')
|
||||
cy.get(getTestSelector('theme-darkmode')).should('have.css', 'background-color', 'rgba(0, 0, 0, 0)')
|
||||
cy.get(getTestSelector('theme-auto')).should('not.have.css', 'background-color', 'rgba(0, 0, 0, 0)')
|
||||
})
|
||||
}
|
||||
|
||||
function itShouldChangeTheLanguage() {
|
||||
it('should select a language', () => {
|
||||
cy.get(getTestSelector('wallet-language-item')).contains('Deutsch').click({ force: true })
|
||||
cy.get(getTestSelector('wallet-header')).should('contain', 'Sprache')
|
||||
cy.get(getTestSelector('wallet-language-item')).contains('English').click({ force: true })
|
||||
cy.get(getTestSelector('wallet-header')).should('contain', 'Language')
|
||||
cy.get(getTestSelector('wallet-back')).click()
|
||||
})
|
||||
}
|
||||
|
||||
describe('connected', () => {
|
||||
beforeEach(() => {
|
||||
cy.visit('/')
|
||||
cy.get(getTestSelector('web3-status-connected')).click()
|
||||
cy.get(getTestSelector('wallet-settings')).click()
|
||||
})
|
||||
itShouldChangeTheTheme()
|
||||
itShouldChangeTheLanguage()
|
||||
})
|
||||
|
||||
it('should change the theme', () => {
|
||||
cy.get(getTestSelector('web3-status-connected')).click()
|
||||
cy.get(getTestSelector('wallet-settings')).click()
|
||||
cy.get(getTestSelector('theme-lightmode')).click()
|
||||
|
||||
cy.get(getTestSelector('theme-lightmode')).should('not.have.css', 'background-color', 'rgba(0, 0, 0, 0)')
|
||||
cy.get(getTestSelector('theme-darkmode')).should('have.css', 'background-color', 'rgba(0, 0, 0, 0)')
|
||||
cy.get(getTestSelector('theme-auto')).should('have.css', 'background-color', 'rgba(0, 0, 0, 0)')
|
||||
|
||||
cy.get(getTestSelector('theme-darkmode')).click()
|
||||
cy.get(getTestSelector('theme-lightmode')).should('have.css', 'background-color', 'rgba(0, 0, 0, 0)')
|
||||
cy.get(getTestSelector('theme-darkmode')).should('not.have.css', 'background-color', 'rgba(0, 0, 0, 0)')
|
||||
cy.get(getTestSelector('theme-auto')).should('have.css', 'background-color', 'rgba(0, 0, 0, 0)')
|
||||
|
||||
cy.get(getTestSelector('theme-auto')).click()
|
||||
cy.get(getTestSelector('theme-lightmode')).should('have.css', 'background-color', 'rgba(0, 0, 0, 0)')
|
||||
cy.get(getTestSelector('theme-darkmode')).should('have.css', 'background-color', 'rgba(0, 0, 0, 0)')
|
||||
cy.get(getTestSelector('theme-auto')).should('not.have.css', 'background-color', 'rgba(0, 0, 0, 0)')
|
||||
describe('disconnected', () => {
|
||||
beforeEach(() => {
|
||||
cy.visit('/')
|
||||
cy.get(getTestSelector('web3-status-connected')).click()
|
||||
// click twice, first time to show confirmation, second to confirm
|
||||
cy.get(getTestSelector('wallet-disconnect')).click()
|
||||
cy.get(getTestSelector('wallet-disconnect')).should('contain', 'Disconnect')
|
||||
cy.get(getTestSelector('wallet-disconnect')).click()
|
||||
cy.get(getTestSelector('wallet-settings')).click()
|
||||
})
|
||||
itShouldChangeTheTheme()
|
||||
itShouldChangeTheLanguage()
|
||||
})
|
||||
|
||||
it('should select a language', () => {
|
||||
cy.get(getTestSelector('wallet-language-item')).contains('Deutsch').click({ force: true })
|
||||
cy.get(getTestSelector('wallet-header')).should('contain', 'Sprache')
|
||||
cy.get(getTestSelector('wallet-language-item')).contains('English').click({ force: true })
|
||||
cy.get(getTestSelector('wallet-header')).should('contain', 'Language')
|
||||
cy.get(getTestSelector('wallet-back')).click()
|
||||
describe('with color theme', () => {
|
||||
function visitSwapWithColorTheme({ dark }: { dark: boolean }) {
|
||||
cy.visit('/swap', {
|
||||
onBeforeLoad(win) {
|
||||
cy.stub(win, 'matchMedia')
|
||||
.withArgs('(prefers-color-scheme: dark)')
|
||||
.returns({
|
||||
matches: dark,
|
||||
addEventListener() {
|
||||
/* noop */
|
||||
},
|
||||
removeEventListener() {
|
||||
/* noop */
|
||||
},
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
it('should properly use dark system theme when auto theme setting is selected', () => {
|
||||
visitSwapWithColorTheme({ dark: true })
|
||||
cy.get(getTestSelector('web3-status-connected')).click()
|
||||
cy.get(getTestSelector('wallet-settings')).click()
|
||||
cy.get(getTestSelector('theme-auto')).click()
|
||||
cy.get(getTestSelector('wallet-header')).should('have.css', 'color', 'rgb(152, 161, 192)')
|
||||
})
|
||||
|
||||
it('should properly use light system theme when auto theme setting is selected', () => {
|
||||
visitSwapWithColorTheme({ dark: false })
|
||||
cy.get(getTestSelector('web3-status-connected')).click()
|
||||
cy.get(getTestSelector('wallet-settings')).click()
|
||||
cy.get(getTestSelector('theme-auto')).click()
|
||||
cy.get(getTestSelector('wallet-header')).should('have.css', 'color', 'rgb(119, 128, 160)')
|
||||
})
|
||||
})
|
||||
|
||||
it('should change the theme when not connected', () => {
|
||||
cy.get(getTestSelector('wallet-disconnect')).click()
|
||||
cy.get(getTestSelector('wallet-settings')).click()
|
||||
cy.get(getTestSelector('theme-lightmode')).should('exist')
|
||||
})
|
||||
describe('mobile', () => {
|
||||
beforeEach(() => {
|
||||
cy.viewport('iphone-6').visit('/')
|
||||
})
|
||||
|
||||
it('should select a language when not connected', () => {
|
||||
cy.get(getTestSelector('wallet-language-item')).contains('Deutsch').click({ force: true })
|
||||
cy.get(getTestSelector('wallet-header')).should('contain', 'Sprache')
|
||||
cy.get(getTestSelector('wallet-language-item')).contains('English').click({ force: true })
|
||||
cy.get(getTestSelector('wallet-header')).should('contain', 'Language')
|
||||
cy.get(getTestSelector('wallet-back')).click()
|
||||
})
|
||||
it('should dismiss the wallet bottom sheet when clicking buy crypto', () => {
|
||||
cy.get(getTestSelector('web3-status-connected')).click()
|
||||
cy.get(getTestSelector('wallet-buy-crypto')).click()
|
||||
cy.contains('Buy crypto').should('not.be.visible')
|
||||
})
|
||||
|
||||
it('should properly use dark system theme when auto theme setting is selected', () => {
|
||||
visit(true)
|
||||
cy.get(getTestSelector('web3-status-connected')).click()
|
||||
cy.get(getTestSelector('wallet-settings')).click()
|
||||
cy.get(getTestSelector('theme-auto')).click()
|
||||
cy.get(getTestSelector('wallet-header')).should('have.css', 'color', 'rgb(152, 161, 192)')
|
||||
})
|
||||
|
||||
it('should properly use light system theme when auto theme setting is selected', () => {
|
||||
visit(false)
|
||||
cy.get(getTestSelector('web3-status-connected')).click()
|
||||
cy.get(getTestSelector('wallet-settings')).click()
|
||||
cy.get(getTestSelector('theme-auto')).click()
|
||||
cy.get(getTestSelector('wallet-header')).should('have.css', 'color', 'rgb(119, 128, 160)')
|
||||
})
|
||||
|
||||
it('should dismiss the wallet bottom sheet when clicking buy crypto', () => {
|
||||
visit(false)
|
||||
cy.viewport('iphone-6')
|
||||
cy.get(getTestSelector('web3-status-connected')).click()
|
||||
cy.get(getTestSelector('wallet-buy-crypto')).click()
|
||||
cy.contains('Buy crypto').should('not.be.visible')
|
||||
})
|
||||
|
||||
it('should use a bottom sheet and dismiss when on a mobile screen size', () => {
|
||||
visit(true)
|
||||
cy.viewport('iphone-6')
|
||||
cy.get(getTestSelector('web3-status-connected')).click()
|
||||
cy.root().click(15, 40)
|
||||
cy.get(getTestSelector('wallet-settings')).should('not.be.visible')
|
||||
it('should use a bottom sheet and dismiss when on a mobile screen size', () => {
|
||||
cy.get(getTestSelector('web3-status-connected')).click()
|
||||
cy.root().click(15, 40)
|
||||
cy.get(getTestSelector('wallet-settings')).should('not.be.visible')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export const WETH_GOERLI = '0xB4FBF271143F4FBf7B91A5ded31805e42b2208d6'
|
||||
1
cypress/fixtures/mini-portfolio/activity.json
Normal file
1
cypress/fixtures/mini-portfolio/activity.json
Normal file
File diff suppressed because one or more lines are too long
1
cypress/fixtures/mini-portfolio/nfts.json
Normal file
1
cypress/fixtures/mini-portfolio/nfts.json
Normal file
File diff suppressed because one or more lines are too long
1
cypress/fixtures/mini-portfolio/tokens.json
Normal file
1
cypress/fixtures/mini-portfolio/tokens.json
Normal file
File diff suppressed because one or more lines are too long
80
cypress/support/commands.ts
Normal file
80
cypress/support/commands.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import 'cypress-hardhat/lib/browser'
|
||||
|
||||
import { Eip1193Bridge } from '@ethersproject/experimental/lib/eip1193-bridge'
|
||||
|
||||
import { FeatureFlag } from '../../src/featureFlags'
|
||||
import { UserState } from '../../src/state/user/reducer'
|
||||
import { CONNECTED_WALLET_USER_STATE } from '../utils/user-state'
|
||||
import { injected } from './ethereum'
|
||||
|
||||
declare global {
|
||||
// eslint-disable-next-line @typescript-eslint/no-namespace
|
||||
namespace Cypress {
|
||||
interface ApplicationWindow {
|
||||
ethereum: Eip1193Bridge
|
||||
}
|
||||
interface VisitOptions {
|
||||
serviceWorker?: true
|
||||
featureFlags?: Array<FeatureFlag>
|
||||
/**
|
||||
* The mock ethereum provider to inject into the page.
|
||||
* @default 'goerli'
|
||||
*/
|
||||
// TODO(INFRA-175): Migrate all usage of 'goerli' to 'hardhat'.
|
||||
ethereum?: 'goerli' | 'hardhat'
|
||||
/**
|
||||
* Initial user state.
|
||||
* @default {@type import('../utils/user-state').CONNECTED_WALLET_USER_STATE}
|
||||
*/
|
||||
userState?: Partial<UserState>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// sets up the injected provider to be a mock ethereum provider with the given mnemonic/index
|
||||
// eslint-disable-next-line no-undef
|
||||
Cypress.Commands.overwrite(
|
||||
'visit',
|
||||
(original, url: string | Partial<Cypress.VisitOptions>, options?: Partial<Cypress.VisitOptions>) => {
|
||||
if (typeof url !== 'string') throw new Error('Invalid arguments. The first argument to cy.visit must be the path.')
|
||||
|
||||
// Add a hash in the URL if it is not present (to use hash-based routing correctly with queryParams).
|
||||
let hashUrl = url.startsWith('/') && url.length > 2 && !url.startsWith('/#') ? `/#${url}` : url
|
||||
if (options?.ethereum === 'goerli') hashUrl += `${url.includes('?') ? '&' : '?'}chain=goerli`
|
||||
|
||||
return cy
|
||||
.intercept('/service-worker.js', options?.serviceWorker ? undefined : { statusCode: 404 })
|
||||
.provider()
|
||||
.then((provider) =>
|
||||
original({
|
||||
...options,
|
||||
url: hashUrl,
|
||||
onBeforeLoad(win) {
|
||||
options?.onBeforeLoad?.(win)
|
||||
|
||||
// We want to test from a clean state, so we clear the local storage (which clears redux).
|
||||
win.localStorage.clear()
|
||||
|
||||
// Set initial user state.
|
||||
win.localStorage.setItem(
|
||||
'redux_localstorage_simple_user', // storage key for the user reducer using 'redux-localstorage-simple'
|
||||
JSON.stringify(options?.userState ?? CONNECTED_WALLET_USER_STATE)
|
||||
)
|
||||
|
||||
// Set feature flags, if configured.
|
||||
if (options?.featureFlags) {
|
||||
const featureFlags = options.featureFlags.reduce((flags, flag) => ({ ...flags, [flag]: 'enabled' }), {})
|
||||
win.localStorage.setItem('featureFlags', JSON.stringify(featureFlags))
|
||||
}
|
||||
|
||||
// Inject the mock ethereum provider.
|
||||
if (options?.ethereum === 'hardhat') {
|
||||
win.ethereum = provider
|
||||
} else {
|
||||
win.ethereum = injected
|
||||
}
|
||||
},
|
||||
})
|
||||
)
|
||||
}
|
||||
)
|
||||
@@ -6,120 +6,17 @@
|
||||
// ***********************************************************
|
||||
|
||||
import '@cypress/code-coverage/support'
|
||||
import './commands'
|
||||
import './setupTests'
|
||||
|
||||
import { Eip1193Bridge } from '@ethersproject/experimental/lib/eip1193-bridge'
|
||||
import assert from 'assert'
|
||||
import { Network } from 'cypress-hardhat/lib/browser'
|
||||
|
||||
import { FeatureFlag } from '../../src/featureFlags/flags/featureFlags'
|
||||
import { UserState } from '../../src/state/user/reducer'
|
||||
import { CONNECTED_WALLET_USER_STATE } from '../utils/user-state'
|
||||
import { injected } from './ethereum'
|
||||
import { HardhatProvider } from './hardhat'
|
||||
|
||||
declare global {
|
||||
// eslint-disable-next-line @typescript-eslint/no-namespace
|
||||
namespace Cypress {
|
||||
interface ApplicationWindow {
|
||||
ethereum: Eip1193Bridge
|
||||
hardhat: HardhatProvider
|
||||
}
|
||||
interface VisitOptions {
|
||||
serviceWorker?: true
|
||||
featureFlags?: Array<FeatureFlag>
|
||||
/**
|
||||
* The mock ethereum provider to inject into the page.
|
||||
* @default 'goerli'
|
||||
*/
|
||||
// TODO(INFRA-175): Migrate all usage of 'goerli' to 'hardhat'.
|
||||
ethereum?: 'goerli' | 'hardhat'
|
||||
/**
|
||||
* Initial user state.
|
||||
* @default {@type import('../utils/user-state').CONNECTED_WALLET_USER_STATE}
|
||||
*/
|
||||
userState?: Partial<UserState>
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
interface Chainable<Subject> {
|
||||
task(event: 'hardhat'): Chainable<Network>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// sets up the injected provider to be a mock ethereum provider with the given mnemonic/index
|
||||
// eslint-disable-next-line no-undef
|
||||
Cypress.Commands.overwrite(
|
||||
'visit',
|
||||
(original, url: string | Partial<Cypress.VisitOptions>, options?: Partial<Cypress.VisitOptions>) => {
|
||||
assert(typeof url === 'string')
|
||||
|
||||
// Add a hash in the URL if it is not present (to use hash-based routing correctly with queryParams).
|
||||
let hashUrl = url.startsWith('/') && url.length > 2 && !url.startsWith('/#') ? `/#${url}` : url
|
||||
if (options?.ethereum === 'goerli') hashUrl += `${url.includes('?') ? '&' : '?'}chain=goerli`
|
||||
|
||||
return cy
|
||||
.intercept('/service-worker.js', options?.serviceWorker ? undefined : { statusCode: 404 })
|
||||
.task('hardhat')
|
||||
.then((network) =>
|
||||
original({
|
||||
...options,
|
||||
url: hashUrl,
|
||||
onBeforeLoad(win) {
|
||||
options?.onBeforeLoad?.(win)
|
||||
|
||||
// We want to test from a clean state, so we clear the local storage (which clears redux).
|
||||
win.localStorage.clear()
|
||||
|
||||
// Set initial user state.
|
||||
win.localStorage.setItem(
|
||||
'redux_localstorage_simple_user', // storage key for the user reducer using 'redux-localstorage-simple'
|
||||
JSON.stringify(options?.userState ?? CONNECTED_WALLET_USER_STATE)
|
||||
)
|
||||
|
||||
// Set feature flags, if configured.
|
||||
if (options?.featureFlags) {
|
||||
const featureFlags = options.featureFlags.reduce((flags, flag) => ({ ...flags, [flag]: 'enabled' }), {})
|
||||
win.localStorage.setItem('featureFlags', JSON.stringify(featureFlags))
|
||||
}
|
||||
|
||||
// Inject the mock ethereum provider.
|
||||
if (options?.ethereum === 'hardhat') {
|
||||
// The provider is exposed via hardhat to allow mocking / network manipulation.
|
||||
win.hardhat = new HardhatProvider(network)
|
||||
win.ethereum = win.hardhat
|
||||
} else {
|
||||
win.ethereum = injected
|
||||
}
|
||||
},
|
||||
})
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
beforeEach(() => {
|
||||
// Infura security policies are based on Origin headers.
|
||||
// These are stripped by cypress because chromeWebSecurity === false; this adds them back in.
|
||||
cy.intercept(/infura.io/, (res) => {
|
||||
res.headers['origin'] = 'http://localhost:3000'
|
||||
res.alias = res.body.method
|
||||
res.continue()
|
||||
})
|
||||
|
||||
// Graphql security policies are based on Origin headers.
|
||||
// These are stripped by cypress because chromeWebSecurity === false; this adds them back in.
|
||||
cy.intercept('https://api.uniswap.org/v1/graphql', (res) => {
|
||||
res.headers['origin'] = 'https://app.uniswap.org'
|
||||
res.continue()
|
||||
})
|
||||
cy.intercept('https://beta.api.uniswap.org/v1/graphql', (res) => {
|
||||
res.headers['origin'] = 'https://app.uniswap.org'
|
||||
res.continue()
|
||||
})
|
||||
|
||||
cy.intercept('https://api.uniswap.org/v1/amplitude-proxy', (res) => {
|
||||
res.reply(JSON.stringify({}))
|
||||
})
|
||||
})
|
||||
// Squelch logs from fetches, as they clutter the logs so much as to make them unusable.
|
||||
// See https://docs.cypress.io/api/commands/intercept#Disabling-logs-for-a-request.
|
||||
// TODO(https://github.com/cypress-io/cypress/issues/26069): Squelch only wildcard logs once Cypress allows it.
|
||||
const log = Cypress.log
|
||||
Cypress.log = function (options, ...args) {
|
||||
if (options.displayName === 'script' || options.name === 'request') return
|
||||
return log(options, ...args)
|
||||
} as typeof log
|
||||
|
||||
Cypress.on('uncaught:exception', () => {
|
||||
// returning false here prevents Cypress from failing the test
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
import { Eip1193Bridge } from '@ethersproject/experimental/lib/eip1193-bridge'
|
||||
import { JsonRpcProvider } from '@ethersproject/providers'
|
||||
import { Wallet } from '@ethersproject/wallet'
|
||||
import { HardhatUtils, Network } from 'cypress-hardhat/lib/browser'
|
||||
|
||||
export class HardhatProvider extends Eip1193Bridge {
|
||||
readonly utils: HardhatUtils
|
||||
readonly chainId: string
|
||||
readonly wallet: Wallet
|
||||
|
||||
isMetaMask = true
|
||||
|
||||
constructor(network: Network) {
|
||||
const utils = new HardhatUtils(network)
|
||||
const wallet = new Wallet(utils.account.privateKey, utils.provider)
|
||||
super(wallet, utils.provider)
|
||||
|
||||
this.utils = utils
|
||||
this.chainId = `0x${network.chainId.toString(16)}`
|
||||
this.wallet = wallet
|
||||
}
|
||||
|
||||
async sendAsync(...args: any[]) {
|
||||
return this.send(...args)
|
||||
}
|
||||
|
||||
async send(...args: any[]) {
|
||||
console.debug('hardhat:send', ...args)
|
||||
|
||||
// Parse callback form.
|
||||
const isCallbackForm = typeof args[0] === 'object' && typeof args[1] === 'function'
|
||||
let callback = <T>(error: Error | null, result?: { result: T }) => {
|
||||
if (error) throw error
|
||||
return result?.result
|
||||
}
|
||||
let method
|
||||
let params
|
||||
if (isCallbackForm) {
|
||||
callback = args[1]
|
||||
method = args[0].method
|
||||
params = args[0].params
|
||||
} else {
|
||||
method = args[0]
|
||||
params = args[1]
|
||||
}
|
||||
|
||||
let result
|
||||
try {
|
||||
switch (method) {
|
||||
case 'eth_requestAccounts':
|
||||
case 'eth_accounts':
|
||||
result = [this.wallet.address]
|
||||
break
|
||||
case 'eth_chainId':
|
||||
result = this.chainId
|
||||
break
|
||||
case 'eth_sendTransaction': {
|
||||
// Eip1193Bridge doesn't support .gas and .from directly, so we massage it to satisfy ethers' expectations.
|
||||
// See https://github.com/ethers-io/ethers.js/issues/1683.
|
||||
params[0].gasLimit = params[0].gas
|
||||
delete params[0].gas
|
||||
delete params[0].from
|
||||
|
||||
const req = JsonRpcProvider.hexlifyTransaction(params[0])
|
||||
req.gasLimit = req.gas
|
||||
delete req.gas
|
||||
|
||||
result = (await this.signer.sendTransaction(req)).hash
|
||||
break
|
||||
}
|
||||
default:
|
||||
result = await super.send(method, params)
|
||||
}
|
||||
console.debug('hardhat:receive', method, result)
|
||||
return callback(null, { result })
|
||||
} catch (error) {
|
||||
console.debug('hardhat:error', method, error)
|
||||
return callback(error as Error)
|
||||
}
|
||||
}
|
||||
}
|
||||
63
cypress/support/setupTests.ts
Normal file
63
cypress/support/setupTests.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
// @ts-ignore
|
||||
import TokenListJSON from '@uniswap/default-token-list'
|
||||
import { CyHttpMessages } from 'cypress/types/net-stubbing'
|
||||
|
||||
beforeEach(() => {
|
||||
// Many API calls enforce that requests come from our app, so we must mock Origin and Referer.
|
||||
cy.intercept('*', (req) => {
|
||||
req.headers['referer'] = 'https://app.uniswap.org'
|
||||
req.headers['origin'] = 'https://app.uniswap.org'
|
||||
})
|
||||
|
||||
// Infura uses a test endpoint, which allow-lists http://localhost:3000 instead.
|
||||
cy.intercept(/infura.io/, (req) => {
|
||||
req.headers['referer'] = 'http://localhost:3000'
|
||||
req.headers['origin'] = 'http://localhost:3000'
|
||||
req.alias = req.body.method
|
||||
req.continue()
|
||||
})
|
||||
|
||||
// Log requests to hardhat.
|
||||
cy.intercept(/:8545/, logJsonRpc)
|
||||
|
||||
// Mock analytics responses to avoid analytics in tests.
|
||||
cy.intercept('https://api.uniswap.org/v1/amplitude-proxy', (req) => {
|
||||
const requestBody = JSON.stringify(req.body)
|
||||
const byteSize = new Blob([requestBody]).size
|
||||
req.reply(
|
||||
JSON.stringify({
|
||||
code: 200,
|
||||
server_upload_time: Date.now(),
|
||||
payload_size_bytes: byteSize,
|
||||
events_ingested: req.body.events.length,
|
||||
})
|
||||
)
|
||||
}).intercept('https://*.sentry.io', { statusCode: 200 })
|
||||
|
||||
// Mock our own token list responses to avoid the latency of IPFS.
|
||||
cy.intercept('https://gateway.ipfs.io/ipns/tokens.uniswap.org', TokenListJSON)
|
||||
.intercept('https://gateway.ipfs.io/ipns/extendedtokens.uniswap.org', { statusCode: 404 })
|
||||
.intercept('https://gateway.ipfs.io/ipns/unsupportedtokens.uniswap.org', { statusCode: 404 })
|
||||
|
||||
// Reset hardhat between tests to ensure isolation.
|
||||
// This resets the fork, as well as options like automine.
|
||||
cy.hardhat().then((hardhat) => hardhat.reset())
|
||||
})
|
||||
|
||||
function logJsonRpc(req: CyHttpMessages.IncomingHttpRequest) {
|
||||
req.alias = req.body.method
|
||||
const log = Cypress.log({
|
||||
autoEnd: false,
|
||||
name: req.body.method,
|
||||
message: req.body.params?.map((param: unknown) =>
|
||||
typeof param === 'object' ? '{...}' : param?.toString().substring(0, 10)
|
||||
),
|
||||
})
|
||||
req.on('after:response', (res) => {
|
||||
if (res.statusCode === 200) {
|
||||
log.end()
|
||||
} else {
|
||||
log.error(new Error(`${res.statusCode}: ${res.statusMessage}`))
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -7,7 +7,8 @@
|
||||
"strict": true,
|
||||
"target": "ES5",
|
||||
"tsBuildInfoFile": "../node_modules/.cache/tsbuildinfo/cypress", // avoid clobbering the build tsbuildinfo
|
||||
"types": ["cypress", "node"]
|
||||
"types": ["cypress", "node"],
|
||||
"jsx": "react"
|
||||
},
|
||||
"exclude": ["node_modules"],
|
||||
"include": ["**/*.ts"],
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
import { Currency } from '@uniswap/sdk-core'
|
||||
|
||||
export const getTestSelector = (selectorId: string) => `[data-testid=${selectorId}]`
|
||||
|
||||
export const getTestSelectorStartsWith = (selectorId: string) => `[data-testid^=${selectorId}]`
|
||||
|
||||
export const getClassContainsSelector = (selectorId: string) => `[class*=${selectorId}]`
|
||||
/** Gets the balance of a token as a Chainable. */
|
||||
export function getBalance(token: Currency) {
|
||||
return cy
|
||||
.hardhat()
|
||||
.then((hardhat) => hardhat.getBalance(hardhat.wallet, token))
|
||||
.then((balance) => Number(balance.toFixed(1)))
|
||||
}
|
||||
|
||||
36
eslint_rules/enforce-retry-on-import.js
Normal file
36
eslint_rules/enforce-retry-on-import.js
Normal file
@@ -0,0 +1,36 @@
|
||||
/* eslint-env node */
|
||||
|
||||
module.exports = {
|
||||
meta: {
|
||||
type: 'problem',
|
||||
docs: {
|
||||
description: 'enforce use of retry() for dynamic imports',
|
||||
category: 'Best Practices',
|
||||
recommended: false,
|
||||
},
|
||||
schema: [],
|
||||
},
|
||||
create(context) {
|
||||
return {
|
||||
ImportExpression(node) {
|
||||
const grandParent = node.parent.parent
|
||||
if (
|
||||
!(
|
||||
grandParent &&
|
||||
grandParent.type === 'CallExpression' &&
|
||||
// Technically, we are only checking that a function named `retry` wraps the dynamic import.
|
||||
// We do not go as far as enforcing that it is import('utils/retry').retry
|
||||
grandParent.callee.name === 'retry' &&
|
||||
grandParent.arguments.length === 1 &&
|
||||
grandParent.arguments[0].type === 'ArrowFunctionExpression'
|
||||
)
|
||||
) {
|
||||
context.report({
|
||||
node,
|
||||
message: 'Dynamic import should be wrapped in retry (see `utils/retry.ts`): `retry(() => import(...))`',
|
||||
})
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
44
eslint_rules/no-undefined-or.js
Normal file
44
eslint_rules/no-undefined-or.js
Normal file
@@ -0,0 +1,44 @@
|
||||
/* eslint-env node */
|
||||
|
||||
module.exports = {
|
||||
meta: {
|
||||
type: 'suggestion',
|
||||
docs: {
|
||||
description: 'Enforce the use of optional object fields',
|
||||
category: 'Best Practices',
|
||||
recommended: true,
|
||||
},
|
||||
fixable: 'code',
|
||||
schema: [],
|
||||
},
|
||||
create(context) {
|
||||
return {
|
||||
'TSPropertySignature > TSTypeAnnotation > TSUnionType': (node) => {
|
||||
const types = node.types
|
||||
const hasUndefined = types.some((typeNode) => typeNode.type === 'TSUndefinedKeyword')
|
||||
|
||||
if (hasUndefined) {
|
||||
const typesWithoutUndefined = types.filter((typeNode) => typeNode.type !== 'TSUndefinedKeyword')
|
||||
|
||||
// If there is more than one type left after removing 'undefined',
|
||||
// join them together with ' | ' to create a new union type.
|
||||
const newTypeSource =
|
||||
typesWithoutUndefined.length > 1
|
||||
? typesWithoutUndefined.map((typeNode) => context.getSourceCode().getText(typeNode)).join(' | ')
|
||||
: context.getSourceCode().getText(typesWithoutUndefined[0])
|
||||
|
||||
context.report({
|
||||
node,
|
||||
message: `Prefer optional properties to "Type | undefined".`,
|
||||
fix(fixer) {
|
||||
const propertySignature = node.parent.parent
|
||||
const isAlreadyOptional = propertySignature.optional
|
||||
const newTypeAnnotation = isAlreadyOptional ? `: ${newTypeSource}` : `?: ${newTypeSource}`
|
||||
return fixer.replaceText(node.parent, newTypeAnnotation)
|
||||
},
|
||||
})
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
/* eslint-env node */
|
||||
|
||||
const defaultConfig = require('./graphql.config')
|
||||
const defaultConfig = require('./graphql.data.config')
|
||||
|
||||
module.exports = {
|
||||
src: defaultConfig.src,
|
||||
@@ -3,7 +3,8 @@ require('dotenv').config()
|
||||
|
||||
// Block selection is arbitrary, as e2e tests will build up their own state.
|
||||
// The only requirement is that all infrastructure under test (eg Permit2 contracts) are already deployed.
|
||||
const BLOCK_NUMBER = 17023328
|
||||
// TODO(WEB-2187): Make more dynamic to avoid manually updating
|
||||
const BLOCK_NUMBER = 17388567
|
||||
|
||||
const mainnetFork = {
|
||||
url: `https://mainnet.infura.io/v3/${process.env.REACT_APP_INFURA_KEY}`,
|
||||
@@ -19,12 +20,11 @@ module.exports = {
|
||||
chainId: 1,
|
||||
forking: mainnetFork,
|
||||
accounts: {
|
||||
count: 1,
|
||||
count: 2,
|
||||
},
|
||||
// Disable auto-mining so that e2e tests can explicitly test pending states.
|
||||
mining: {
|
||||
auto: false,
|
||||
interval: 0,
|
||||
auto: true, // automine to make tests easier to write.
|
||||
interval: 0, // do not interval mine so that tests remain deterministic
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,8 +1,77 @@
|
||||
/* eslint-env node */
|
||||
import { default as babelExtractor } from '@lingui/cli/api/extractors/babel'
|
||||
import { createHash } from 'crypto'
|
||||
import { mkdirSync, readFileSync, writeFileSync } from 'fs'
|
||||
import { existsSync } from 'fs'
|
||||
import * as path from 'path'
|
||||
|
||||
/** A custom caching extractor built on top of babelExtractor. */
|
||||
const cachingExtractor: typeof babelExtractor = {
|
||||
/** Delegates to babelExtractor.match. */
|
||||
match(filename: string) {
|
||||
return babelExtractor.match(filename)
|
||||
},
|
||||
/**
|
||||
* Checks a cache before extraction, only delegating to babelExtractor.extract if the file has changed.
|
||||
*
|
||||
* The lingui extractor works by extracting JSON (the catalog) from `filename` to `buildDir/filename.json`.
|
||||
* Caching works by man-in-the-middling this:
|
||||
* - File freshness is computed as a hash of `filename` contents.
|
||||
* - Before extracting, we check the cache to see if we already have a fresh catalog for the file.
|
||||
* If we do, we copy it to `localeDir/filename.json`. Copying is significantly faster than extracting.
|
||||
* - After extracting, we copy the catalog to the cache.
|
||||
*/
|
||||
extract(filename: string, localeDir: string, ...options: unknown[]) {
|
||||
// This runs from node_modules/@lingui/conf, so we need to back out to the root.
|
||||
const root = __dirname.split('/node_modules')[0]
|
||||
|
||||
// This logic mimics catalogFilename in @lingui/babel-plugin-extract-messages.
|
||||
const buildDir = path.join(localeDir, '_build')
|
||||
const localePath = path.join(buildDir, filename + '.json')
|
||||
|
||||
const filePath = path.join(root, filename)
|
||||
const fileHash = createHash('sha256').update(readFileSync(filePath)).digest('hex')
|
||||
|
||||
const cacheRoot = path.join(root, 'node_modules/.cache/lingui')
|
||||
const cachePath = path.join(cacheRoot, filename + '.json')
|
||||
|
||||
// If we have a matching cached copy of the catalog, we can copy it to localePath and return early.
|
||||
if (existsSync(cachePath)) {
|
||||
const { hash, catalog } = JSON.parse(readFileSync(cachePath, 'utf8'))
|
||||
if (hash === fileHash) {
|
||||
if (catalog) {
|
||||
mkdirSync(path.dirname(localePath), { recursive: true })
|
||||
writeFileSync(localePath, JSON.stringify(catalog, null, 2))
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
babelExtractor.extract(filename, localeDir, ...options)
|
||||
|
||||
// Cache the extracted catalog.
|
||||
mkdirSync(path.dirname(cachePath), { recursive: true })
|
||||
if (existsSync(localePath)) {
|
||||
const catalog = JSON.parse(readFileSync(localePath, 'utf8'))
|
||||
writeFileSync(cachePath, JSON.stringify({ hash: fileHash, catalog }))
|
||||
} else {
|
||||
writeFileSync(cachePath, JSON.stringify({ hash: fileHash }))
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
const linguiConfig = {
|
||||
catalogs: [
|
||||
{
|
||||
path: '<rootDir>/src/locales/{locale}',
|
||||
include: ['<rootDir>/src'],
|
||||
include: ['<rootDir>/src/**/*.ts', '<rootDir>/src/**/*.tsx'],
|
||||
exclude: [
|
||||
'<rootDir>/src/**/*.d.ts',
|
||||
'<rootDir>/src/**/*.test.*',
|
||||
'<rootDir>/src/types/v3/**',
|
||||
'<rootDir>/src/abis/types/**',
|
||||
'<rootDir>/src/graphql/**/__generated__/**',
|
||||
],
|
||||
},
|
||||
],
|
||||
compileNamespace: 'cjs',
|
||||
@@ -53,6 +122,7 @@ const linguiConfig = {
|
||||
runtimeConfigModule: ['@lingui/core', 'i18n'],
|
||||
sourceLocale: 'en-US',
|
||||
pseudoLocale: 'pseudo',
|
||||
extractors: [cachingExtractor],
|
||||
}
|
||||
|
||||
export default linguiConfig
|
||||
|
||||
75
package.json
75
package.json
@@ -5,18 +5,20 @@
|
||||
"homepage": ".",
|
||||
"license": "GPL-3.0-or-later",
|
||||
"scripts": {
|
||||
"ajv": "node scripts/compile-ajv-validators.js",
|
||||
"contracts:compile:abi": "typechain --target ethers-v5 --out-dir src/abis/types \"./src/abis/**/*.json\"",
|
||||
"contracts:compile:v3": "typechain --target ethers-v5 --out-dir src/types/v3 \"./node_modules/@uniswap/**/artifacts/contracts/**/*[!dbg].json\"",
|
||||
"contracts:compile": "yarn contracts:compile:abi && yarn contracts:compile:v3",
|
||||
"contracts": "yarn contracts:compile:abi && yarn contracts:compile:v3",
|
||||
"graphql:fetch": "node scripts/fetch-schema.js",
|
||||
"graphql:generate:data": "graphql-codegen --config apollo-codegen.ts",
|
||||
"graphql:generate:thegraph": "graphql-codegen --config apollo-codegen_thegraph.ts",
|
||||
"graphql:generate:data": "graphql-codegen --config graphql.data.codegen.config.ts",
|
||||
"graphql:generate:thegraph": "graphql-codegen --config graphql.thegraph.codegen.config.ts",
|
||||
"graphql:generate": "yarn graphql:generate:data && yarn graphql:generate:thegraph",
|
||||
"prei18n:extract": "node scripts/prei18n-extract.js",
|
||||
"graphql": "yarn graphql:fetch && yarn graphql:generate",
|
||||
"i18n:extract": "lingui extract --locale en-US",
|
||||
"i18n:compile": "yarn i18n:extract && lingui compile",
|
||||
"i18n:pseudo": "lingui extract --locale pseudo && lingui compile",
|
||||
"prepare": "yarn contracts:compile && yarn graphql:fetch && yarn graphql:generate && yarn i18n:compile",
|
||||
"i18n:pseudo": "lingui extract --locale pseudo",
|
||||
"i18n:compile": "lingui compile",
|
||||
"i18n": "yarn i18n:extract --clean && yarn i18n:compile",
|
||||
"prepare": "concurrently \"npm:ajv\" \"npm:contracts\" \"npm:graphql\" \"npm:i18n\"",
|
||||
"start": "craco start",
|
||||
"build": "craco build",
|
||||
"build:e2e": "REACT_APP_CSP_ALLOW_UNSAFE_EVAL=true REACT_APP_ADD_COVERAGE_INSTRUMENTATION=true craco build",
|
||||
@@ -24,8 +26,7 @@
|
||||
"serve": "serve build -l 3000",
|
||||
"lint": "yarn eslint --ignore-path .gitignore --cache --cache-location node_modules/.cache/eslint/ .",
|
||||
"typecheck": "tsc",
|
||||
"test": "craco test --coverage",
|
||||
"test:size": "node scripts/test-size.js",
|
||||
"test": "craco test",
|
||||
"cypress:open": "cypress open --browser chrome --e2e",
|
||||
"cypress:run": "cypress run --browser chrome --e2e",
|
||||
"deduplicate": "yarn-deduplicate --strategy=highest"
|
||||
@@ -66,7 +67,7 @@
|
||||
]
|
||||
},
|
||||
"devDependencies": {
|
||||
"@craco/craco": "6.4.3",
|
||||
"@craco/craco": "^7.1.0",
|
||||
"@ethersproject/experimental": "^5.4.0",
|
||||
"@lingui/cli": "^3.9.0",
|
||||
"@testing-library/jest-dom": "^5.16.4",
|
||||
@@ -94,25 +95,33 @@
|
||||
"@types/rebass": "^4.0.7",
|
||||
"@types/styled-components": "^5.1.25",
|
||||
"@types/testing-library__cypress": "^5.0.5",
|
||||
"@types/ua-parser-js": "^0.7.35",
|
||||
"@types/ua-parser-js": "^0.7.36",
|
||||
"@types/uuid": "^8.3.4",
|
||||
"@types/wcag-contrast": "^3.0.0",
|
||||
"@uniswap/eslint-config": "^1.1.1",
|
||||
"@uniswap/default-token-list": "^9.6.0",
|
||||
"@uniswap/eslint-config": "^1.2.0",
|
||||
"@vanilla-extract/babel-plugin": "^1.1.7",
|
||||
"@vanilla-extract/jest-transform": "^1.1.1",
|
||||
"@vanilla-extract/webpack-plugin": "^2.1.11",
|
||||
"babel-plugin-istanbul": "^6.1.1",
|
||||
"cypress": "10.3.1",
|
||||
"cypress-hardhat": "^1.0.1",
|
||||
"buffer": "^6.0.3",
|
||||
"concurrently": "^8.0.1",
|
||||
"cypress": "12.12.0",
|
||||
"cypress-hardhat": "^2.3.0",
|
||||
"env-cmd": "^10.1.0",
|
||||
"eslint": "^7.11.0",
|
||||
"eslint-plugin-import": "^2.27",
|
||||
"eslint-plugin-rulesdir": "^0.2.2",
|
||||
"hardhat": "^2.14.0",
|
||||
"jest-fail-on-console": "^3.1.1",
|
||||
"jest-fetch-mock": "^3.0.3",
|
||||
"jest-styled-components": "^7.0.8",
|
||||
"ms.macro": "^2.0.0",
|
||||
"path-browserify": "^1.0.1",
|
||||
"prettier": "^2.7.1",
|
||||
"react-scripts": "^4.0.3",
|
||||
"process": "^0.11.10",
|
||||
"react-scripts": "^5.0.1",
|
||||
"resize-observer-polyfill": "^1.5.1",
|
||||
"serve": "^11.3.2",
|
||||
"source-map-explorer": "^2.5.3",
|
||||
"ts-transform-graphql-tag": "^0.2.1",
|
||||
@@ -132,12 +141,13 @@
|
||||
"@graphql-codegen/typescript-operations": "^2.5.8",
|
||||
"@graphql-codegen/typescript-react-apollo": "^3.3.7",
|
||||
"@graphql-codegen/typescript-resolvers": "^2.7.8",
|
||||
"@juggle/resize-observer": "^3.4.0",
|
||||
"@lingui/core": "^3.14.0",
|
||||
"@lingui/macro": "^3.14.0",
|
||||
"@lingui/react": "^3.14.0",
|
||||
"@looksrare/sdk": "^0.10.2",
|
||||
"@metamask/jazzicon": "^2.0.0",
|
||||
"@opensea/seaport-js": "^1.0.10",
|
||||
"@opensea/seaport-js": "^1.2.0",
|
||||
"@popperjs/core": "^2.4.4",
|
||||
"@reach/dialog": "^0.10.3",
|
||||
"@reach/portal": "^0.10.3",
|
||||
@@ -148,24 +158,23 @@
|
||||
"@types/react-window-infinite-loader": "^1.0.6",
|
||||
"@uniswap/analytics": "^1.3.1",
|
||||
"@uniswap/analytics-events": "^2.10.0",
|
||||
"@uniswap/conedison": "^1.4.0",
|
||||
"@uniswap/conedison": "^1.7.1",
|
||||
"@uniswap/governance": "^1.0.2",
|
||||
"@uniswap/liquidity-staker": "^1.0.2",
|
||||
"@uniswap/merkle-distributor": "1.0.1",
|
||||
"@uniswap/permit2-sdk": "1.2.0",
|
||||
"@uniswap/redux-multicall": "^1.1.8",
|
||||
"@uniswap/router-sdk": "^1.3.0",
|
||||
"@uniswap/sdk-core": "^3.2.2",
|
||||
"@uniswap/smart-order-router": "^3.6.1",
|
||||
"@uniswap/token-lists": "^1.0.0-beta.30",
|
||||
"@uniswap/universal-router-sdk": "^1.3.8",
|
||||
"@uniswap/sdk-core": "^3.2.3",
|
||||
"@uniswap/smart-order-router": "^3.12.1",
|
||||
"@uniswap/token-lists": "^1.0.0-beta.31",
|
||||
"@uniswap/universal-router-sdk": "^1.5.1",
|
||||
"@uniswap/v2-core": "1.0.0",
|
||||
"@uniswap/v2-periphery": "^1.1.0-beta.0",
|
||||
"@uniswap/v2-sdk": "^3.0.1",
|
||||
"@uniswap/v3-core": "1.0.0",
|
||||
"@uniswap/v3-periphery": "^1.1.1",
|
||||
"@uniswap/v3-sdk": "^3.9.0",
|
||||
"@uniswap/widgets": "^2.49.0",
|
||||
"@vanilla-extract/css": "^1.7.2",
|
||||
"@vanilla-extract/css-utils": "^0.1.2",
|
||||
"@vanilla-extract/dynamic": "^2.0.2",
|
||||
@@ -177,7 +186,6 @@
|
||||
"@visx/react-spring": "^2.12.2",
|
||||
"@visx/responsive": "^2.10.0",
|
||||
"@visx/shape": "^2.11.1",
|
||||
"@walletconnect/ethereum-provider": "^1.8.0",
|
||||
"@web3-react/coinbase-wallet": "^8.2.0",
|
||||
"@web3-react/core": "^8.2.0",
|
||||
"@web3-react/eip1193": "^8.2.0",
|
||||
@@ -188,6 +196,9 @@
|
||||
"@web3-react/types": "^8.2.0",
|
||||
"@web3-react/url": "^8.2.0",
|
||||
"@web3-react/walletconnect": "^8.2.0",
|
||||
"@web3-react/walletconnect-v2": "8.3.2",
|
||||
"ajv": "^8.11.0",
|
||||
"ajv-formats": "^2.1.1",
|
||||
"array.prototype.flat": "^1.2.4",
|
||||
"array.prototype.flatmap": "^1.2.4",
|
||||
"cids": "^1.0.0",
|
||||
@@ -236,8 +247,8 @@
|
||||
"statsig-react": "^1.22.0",
|
||||
"styled-components": "^5.3.5",
|
||||
"tiny-invariant": "^1.2.0",
|
||||
"ua-parser-js": "^0.7.28",
|
||||
"use-resize-observer": "^9.0.2",
|
||||
"ua-parser-js": "^1.0.35",
|
||||
"use-resize-observer": "^9.1.0",
|
||||
"uuid": "^8.3.2",
|
||||
"video-extensions": "^1.2.0",
|
||||
"wcag-contrast": "^3.0.0",
|
||||
@@ -248,21 +259,9 @@
|
||||
"workbox-routing": "^6.1.0",
|
||||
"zustand": "^4.3.6"
|
||||
},
|
||||
"resolutions": {
|
||||
"@web3-react/coinbase-wallet": "^8.2.0",
|
||||
"@web3-react/core": "^8.2.0",
|
||||
"@web3-react/eip1193": "^8.2.0",
|
||||
"@web3-react/empty": "^8.2.0",
|
||||
"@web3-react/gnosis-safe": "^8.2.0",
|
||||
"@web3-react/metamask": "^8.2.0",
|
||||
"@web3-react/network": "^8.2.0",
|
||||
"@web3-react/types": "^8.2.0",
|
||||
"@web3-react/url": "^8.2.0",
|
||||
"@web3-react/walletconnect": "^8.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"npm": "please-use-yarn",
|
||||
"node": "14",
|
||||
"node": "18.x",
|
||||
"yarn": ">=1.22"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
<svg width="200" height="200" viewBox="0 0 200 200" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="200" height="200" fill="#04CD58"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M100 112C87.3026 112 77 101.708 77 89C77 76.2923 87.3026 66 100 66C112.697 66 123 76.2923 123 89C123 101.708 112.697 112 100 112ZM90 89C90 94.5251 94.4794 99 100 99C105.521 99 110 94.5251 110 89C110 83.4749 105.521 79 100 79C94.4794 79 90 83.4749 90 89Z" fill="black"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M26 89.0304L70 45H130L174 89.0304L100 163L26 89.0304ZM134 72.9998C115.305 54.2224 84.6953 54.2225 66 72.9999L50 89.0001L66 105C84.6953 123.778 115.305 123.777 134 105L150 89.0001L134 72.9998Z" fill="black"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 731 B |
@@ -1,5 +0,0 @@
|
||||
<svg width="100" height="100" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="100" height="100" fill="#2081E2"/>
|
||||
<path d="M24.6679 51.6802L24.8836 51.3411L37.8906 30.9933C38.0807 30.6954 38.5276 30.7262 38.6714 31.0498C40.8444 35.9197 42.7194 41.9763 41.841 45.7469C41.466 47.2983 40.4386 49.3993 39.2827 51.3411C39.1338 51.6237 38.9694 51.9011 38.7947 52.1682C38.7125 52.2915 38.5738 52.3634 38.4248 52.3634H25.048C24.6884 52.3634 24.4778 51.973 24.6679 51.6802Z" fill="white"/>
|
||||
<path d="M82.6444 55.461V58.6819C82.6444 58.8668 82.5314 59.0312 82.367 59.1031C81.3602 59.5346 77.9132 61.1168 76.48 63.11C72.8224 68.2008 70.0279 75.48 63.7812 75.48H37.721C28.4847 75.48 21 67.9697 21 58.7024V58.4045C21 58.1579 21.2003 57.9576 21.4469 57.9576H35.9745C36.2621 57.9576 36.4727 58.2247 36.4471 58.5072C36.3443 59.4524 36.519 60.4182 36.9659 61.2966C37.8289 63.0484 39.6166 64.1426 41.5481 64.1426H48.74V58.5278H41.6303C41.2656 58.5278 41.0499 58.1065 41.2605 57.8086C41.3375 57.6904 41.4249 57.5672 41.5173 57.4285C42.1903 56.473 43.1509 54.9884 44.1064 53.2983C44.7588 52.1579 45.3906 50.9404 45.8992 49.7178C46.002 49.4969 46.0841 49.2708 46.1663 49.0499C46.305 48.6595 46.4489 48.2948 46.5516 47.9301C46.6544 47.6218 46.7365 47.2982 46.8187 46.9951C47.0602 45.9574 47.1629 44.8581 47.1629 43.7177C47.1629 43.2708 47.1424 42.8033 47.1013 42.3564C47.0807 41.8684 47.0191 41.3803 46.9574 40.8923C46.9163 40.4608 46.8393 40.0344 46.7571 39.5875C46.6544 38.9351 46.5105 38.2879 46.3461 37.6354L46.2896 37.3889C46.1663 36.9419 46.0636 36.5156 45.9198 36.0687C45.5139 34.6662 45.0465 33.2998 44.5533 32.0207C44.3735 31.5121 44.168 31.0241 43.9625 30.5361C43.6595 29.8015 43.3512 29.1337 43.0687 28.5018C42.9249 28.2141 42.8016 27.9521 42.6783 27.685C42.5396 27.3819 42.3958 27.0788 42.2519 26.7912C42.1492 26.5703 42.031 26.3648 41.9488 26.1593L41.0704 24.536C40.9471 24.3151 41.1526 24.0531 41.394 24.1199L46.8907 25.6096H46.9061C46.9163 25.6096 46.9215 25.6148 46.9266 25.6148L47.6509 25.8151L48.4472 26.0412L48.74 26.1233V22.8562C48.74 21.2791 50.0037 20 51.5654 20C52.3462 20 53.0551 20.3185 53.5637 20.8373C54.0722 21.3562 54.3907 22.0651 54.3907 22.8562V27.7056L54.9764 27.8699C55.0226 27.8854 55.0688 27.9059 55.1099 27.9367C55.2538 28.0446 55.4592 28.2038 55.7212 28.3991C55.9267 28.5634 56.1476 28.7638 56.4147 28.9693C56.9438 29.3956 57.5757 29.9453 58.2692 30.5772C58.4541 30.7364 58.6339 30.9008 58.7983 31.0652C59.6922 31.8974 60.6939 32.8734 61.6494 33.9522C61.9165 34.2553 62.1785 34.5635 62.4456 34.8871C62.7127 35.2159 62.9953 35.5395 63.2418 35.8632C63.5655 36.2947 63.9148 36.7416 64.2179 37.2091C64.3617 37.43 64.5261 37.656 64.6648 37.8769C65.0552 38.4676 65.3994 39.079 65.7282 39.6903C65.8669 39.9728 66.0107 40.281 66.134 40.5841C66.4987 41.4009 66.7864 42.2331 66.9713 43.0653C67.0278 43.2451 67.0689 43.4403 67.0895 43.615V43.6561C67.1511 43.9026 67.1717 44.1646 67.1922 44.4317C67.2744 45.2845 67.2333 46.1372 67.0484 46.9951C66.9713 47.3599 66.8686 47.704 66.7453 48.0688C66.622 48.4181 66.4987 48.7828 66.3395 49.127C66.0313 49.841 65.6665 50.5551 65.235 51.2229C65.0963 51.4695 64.9319 51.7315 64.7675 51.9781C64.5877 52.24 64.4028 52.4866 64.2384 52.7281C64.0124 53.0363 63.771 53.3599 63.5244 53.6476C63.3035 53.9507 63.0775 54.2538 62.8309 54.5209C62.4867 54.9267 62.1579 55.312 61.8137 55.6819C61.6083 55.9233 61.3874 56.1699 61.1613 56.3908C60.9405 56.6373 60.7144 56.8582 60.5089 57.0637C60.1648 57.4079 59.8771 57.675 59.6356 57.8959L59.0706 58.4148C58.9884 58.4867 58.8805 58.5278 58.7675 58.5278H54.3907V64.1426H59.8976C61.1305 64.1426 62.3018 63.7059 63.247 62.9045C63.5706 62.622 64.9833 61.3994 66.6528 59.5552C66.7093 59.4935 66.7813 59.4473 66.8635 59.4268L82.0742 55.0295C82.3568 54.9473 82.6444 55.163 82.6444 55.461Z" fill="white"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 3.7 KiB |
@@ -1,20 +0,0 @@
|
||||
<svg width="28" height="28" viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="28" height="28" fill="#F9F9F9"/>
|
||||
<path d="M21.94 7.92004C20.4077 6.42277 18.3116 5.5 16 5.5C11.3056 5.5 7.5 9.30559 7.5 14C7.5 18.6944 11.3056 22.5 16 22.5C18.3116 22.5 20.4077 21.5772 21.94 20.08C20.1123 22.4633 17.2356 24 14 24C8.47715 24 4 19.5229 4 14C4 8.47715 8.47715 4 14 4C17.2356 4 20.1123 5.53668 21.94 7.92004Z" fill="url(#paint0_linear_6993_17582)"/>
|
||||
<path d="M9.64795 18.864C10.8738 20.0618 12.5507 20.8 14.4 20.8C18.1555 20.8 21.2 17.7555 21.2 14C21.2 10.2445 18.1555 7.2 14.4 7.2C12.5507 7.2 10.8738 7.9382 9.64795 9.13601C11.1102 7.22934 13.4115 6 16 6C20.4183 6 24 9.58172 24 14C24 18.4183 20.4183 22 16 22C13.4115 22 11.1102 20.7707 9.64795 18.864Z" fill="url(#paint1_linear_6993_17582)"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M20 14C20 17.3137 17.3137 20 14 20C10.6863 20 8 17.3137 8 14C8 10.6863 10.6863 8 14 8C17.3137 8 20 10.6863 20 14ZM18 14C18 16.2091 16.2091 18 14 18C11.7909 18 10 16.2091 10 14C10 11.7909 11.7909 10 14 10C16.2091 10 18 11.7909 18 14Z" fill="url(#paint2_linear_6993_17582)"/>
|
||||
<defs>
|
||||
<linearGradient id="paint0_linear_6993_17582" x1="4" y1="13.6552" x2="24" y2="13.6552" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#00E0FF"/>
|
||||
<stop offset="1" stop-color="#562EC8"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint1_linear_6993_17582" x1="3.99998" y1="13.6552" x2="24" y2="13.6552" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#00E0FF"/>
|
||||
<stop offset="1" stop-color="#562EC8"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint2_linear_6993_17582" x1="4" y1="13.6552" x2="24" y2="13.6552" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#00E0FF"/>
|
||||
<stop offset="1" stop-color="#562EC8"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.7 KiB |
20
scripts/compile-ajv-validators.js
Normal file
20
scripts/compile-ajv-validators.js
Normal file
@@ -0,0 +1,20 @@
|
||||
/* eslint-env node */
|
||||
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
const Ajv = require('ajv')
|
||||
const standaloneCode = require('ajv/dist/standalone').default
|
||||
const addFormats = require('ajv-formats')
|
||||
const schema = require('@uniswap/token-lists/dist/tokenlist.schema.json')
|
||||
|
||||
const tokenListAjv = new Ajv({ code: { source: true, esm: true } })
|
||||
addFormats(tokenListAjv)
|
||||
const validateTokenList = tokenListAjv.compile(schema)
|
||||
let tokenListModuleCode = standaloneCode(tokenListAjv, validateTokenList)
|
||||
fs.writeFileSync(path.join(__dirname, '../src/utils/__generated__/validateTokenList.js'), tokenListModuleCode)
|
||||
|
||||
const tokensAjv = new Ajv({ code: { source: true, esm: true } })
|
||||
addFormats(tokensAjv)
|
||||
const validateTokens = tokensAjv.compile({ ...schema, required: ['tokens'] })
|
||||
let tokensModuleCode = standaloneCode(tokensAjv, validateTokens)
|
||||
fs.writeFileSync(path.join(__dirname, '../src/utils/__generated__/validateTokens.js'), tokensModuleCode)
|
||||
@@ -4,13 +4,13 @@ require('dotenv').config({ path: '.env.production' })
|
||||
const child_process = require('child_process')
|
||||
const fs = require('fs/promises')
|
||||
const { promisify } = require('util')
|
||||
const dataConfig = require('../graphql.config')
|
||||
const thegraphConfig = require('../graphql_thegraph.config')
|
||||
const dataConfig = require('../graphql.data.config')
|
||||
const thegraphConfig = require('../graphql.thegraph.config')
|
||||
|
||||
const exec = promisify(child_process.exec)
|
||||
|
||||
function fetchSchema(url, outputFile) {
|
||||
exec(`npx get-graphql-schema --h Origin=https://app.uniswap.org ${url}`)
|
||||
exec(`yarn --silent get-graphql-schema --h Origin=https://app.uniswap.org ${url}`)
|
||||
.then(({ stderr, stdout }) => {
|
||||
if (stderr) {
|
||||
throw new Error(stderr)
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
/* eslint-env node */
|
||||
|
||||
const { exec } = require('child_process')
|
||||
const isWindows = process.platform === 'win32' || /^(msys|cygwin)$/.test(process.env.OSTYPE)
|
||||
|
||||
if (isWindows) {
|
||||
exec(`type nul > src/locales/en-US.po`)
|
||||
} else {
|
||||
exec(`touch src/locales/en-US.po`)
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
/* eslint-disable no-undef */
|
||||
const assert = require('assert')
|
||||
const chalk = require('chalk')
|
||||
const fs = require('fs')
|
||||
const gzipSize = require('gzip-size').sync
|
||||
const path = require('path')
|
||||
|
||||
const buildDir = path.join(__dirname, '../build')
|
||||
|
||||
let entrypoints
|
||||
try {
|
||||
entrypoints = require(path.join(buildDir, 'asset-manifest.json')).entrypoints
|
||||
} catch (e) {
|
||||
console.log(chalk.yellow('You must build first: `yarn build`'))
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
// The last recorded size for these assets, as reported by `yarn build`.
|
||||
const LAST_SIZE_MAIN_KB = 435
|
||||
|
||||
// This is the async-loaded js, called <number>.<hash>.js, with a matching css file.
|
||||
const LAST_SIZE_ENTRY_KB = 1442
|
||||
|
||||
const SIZE_TOLERANCE_KB = 10
|
||||
|
||||
const jsEntrypoints = entrypoints.filter((entrypoint) => entrypoint.endsWith('js'))
|
||||
assert(jsEntrypoints.length === 3)
|
||||
|
||||
let fail = false
|
||||
console.log('File sizes after gzip:\n')
|
||||
jsEntrypoints.forEach((entrypoint) => {
|
||||
const name = entrypoint.match(/\/([\w\d-]*)\./)[1]
|
||||
const size = gzipSize(fs.readFileSync(path.join(buildDir, entrypoint))) / 1024
|
||||
|
||||
let maxSize = LAST_SIZE_ENTRY_KB + SIZE_TOLERANCE_KB
|
||||
if (name === 'runtime-main') {
|
||||
return
|
||||
} else if (name === 'main') {
|
||||
maxSize = LAST_SIZE_MAIN_KB + SIZE_TOLERANCE_KB
|
||||
}
|
||||
|
||||
const report = `\t${size.toFixed(2).padEnd(8)}kB\t${chalk.dim(
|
||||
`max: ${maxSize.toFixed().padEnd(4)} kB`
|
||||
)}\t${entrypoint}`
|
||||
if (maxSize > size) {
|
||||
console.log(chalk.green(report))
|
||||
} else {
|
||||
console.log(chalk.red(report), '\tdid you import an unnecessary dependency?')
|
||||
fail = true
|
||||
}
|
||||
})
|
||||
if (fail) {
|
||||
console.log(chalk.yellow('\nOne or more of your files has grown too large.'))
|
||||
console.log(chalk.yellow('Reduce the file size or update the size limit (in scripts/test-size.js)'))
|
||||
process.exit(1)
|
||||
}
|
||||
1
src/assets/svg/moonpay.svg
Normal file
1
src/assets/svg/moonpay.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg width="36" height="36" viewBox="0 0 36 36" fill="none" xmlns="http://www.w3.org/2000/svg"><rect width="36" height="36" rx="18" fill="#7D00FF"/><path d="M24.933 14.14a3.07 3.07 0 0 0 0-6.14 3.07 3.07 0 0 0 0 6.14ZM15.5 28A7.495 7.495 0 0 1 8 20.493a7.495 7.495 0 0 1 7.5-7.506c4.149 0 7.5 3.354 7.5 7.506A7.495 7.495 0 0 1 15.5 28Z" fill="#fff"/></svg>
|
||||
|
After Width: | Height: | Size: 356 B |
3
src/assets/svg/papers-text.svg
Normal file
3
src/assets/svg/papers-text.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M13.2601 18.3918C12.9161 18.5558 12.5141 18.6779 12.0591 18.7599L5.48907 19.9099C3.29907 20.2999 2.00896 19.3999 1.62896 17.2099L0.0891657 8.45987C-0.300834 6.26987 0.599117 4.97988 2.78912 4.58988L4.58697 4.27384C4.79197 4.23784 4.97114 4.41686 4.93414 4.62186L3.75811 11.2799C3.22811 14.3099 4.65803 16.3499 7.67803 16.8799C7.67803 16.8799 12.8971 17.7969 13.1661 17.8489C13.4981 17.9088 13.5511 18.2628 13.2601 18.3918ZM19.9131 5.10587L18.3689 13.8598C17.997 15.9678 16.7811 16.8688 14.7361 16.5888C14.6581 16.5778 14.5881 16.5779 14.5071 16.5639L7.94195 15.4059C5.75295 15.0199 4.85209 13.7329 5.23809 11.5439L6.58111 3.92783L6.78204 2.78983C7.16804 0.600828 8.4551 -0.300151 10.6441 0.0858488L17.21 1.24387C19.398 1.62987 20.2991 2.91787 19.9131 5.10587ZM13.554 11.8958C13.626 11.4878 13.3541 11.0988 12.9461 11.0268L8.8421 10.3028C8.4361 10.2298 8.04518 10.5039 7.97418 10.9109C7.90218 11.3189 8.17409 11.7079 8.58209 11.7799L12.6861 12.5039C12.7301 12.5119 12.7739 12.5149 12.8169 12.5149C13.1739 12.5159 13.49 12.2598 13.554 11.8958ZM16.597 9.03482C16.669 8.62682 16.3971 8.23787 15.9891 8.16587L9.42413 7.00785C9.02013 6.93685 8.62696 7.20888 8.55596 7.61588C8.48396 8.02388 8.75612 8.41284 9.16412 8.48484L15.7291 9.64286C15.7731 9.65086 15.8172 9.65384 15.8602 9.65384C16.2172 9.65384 16.533 9.39782 16.597 9.03482ZM17.2972 5.77286C17.3692 5.36486 17.097 4.97584 16.689 4.90384L10.1241 3.74582C9.72008 3.67382 9.32716 3.94685 9.25616 4.35385C9.18416 4.76185 9.45607 5.15087 9.86407 5.22287L16.429 6.38083C16.473 6.38883 16.5171 6.39188 16.5601 6.39188C16.9171 6.39288 17.2332 6.13686 17.2972 5.77286Z" fill="#F5F6FC" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
@@ -10,14 +10,14 @@ export enum CardType {
|
||||
Secondary = 'Secondary',
|
||||
}
|
||||
|
||||
const StyledCard = styled.div<{ isDarkMode: boolean; backgroundImgSrc?: string; type: CardType }>`
|
||||
const StyledCard = styled.div<{ $isDarkMode: boolean; $backgroundImgSrc?: string; $type: CardType }>`
|
||||
display: flex;
|
||||
background: ${({ isDarkMode, backgroundImgSrc, type, theme }) =>
|
||||
isDarkMode
|
||||
? `${type === CardType.Primary ? theme.backgroundModule : theme.backgroundSurface} ${
|
||||
backgroundImgSrc ? ` url(${backgroundImgSrc})` : ''
|
||||
background: ${({ $isDarkMode, $backgroundImgSrc, $type, theme }) =>
|
||||
$isDarkMode
|
||||
? `${$type === CardType.Primary ? theme.backgroundModule : theme.backgroundSurface} ${
|
||||
$backgroundImgSrc ? ` url(${$backgroundImgSrc})` : ''
|
||||
}`
|
||||
: `${type === CardType.Primary ? 'white' : theme.backgroundModule} url(${backgroundImgSrc})`};
|
||||
: `${$type === CardType.Primary ? 'white' : theme.backgroundModule} url(${$backgroundImgSrc})`};
|
||||
background-size: auto 100%;
|
||||
background-position: right;
|
||||
background-repeat: no-repeat;
|
||||
@@ -30,15 +30,15 @@ const StyledCard = styled.div<{ isDarkMode: boolean; backgroundImgSrc?: string;
|
||||
padding: 24px;
|
||||
height: 212px;
|
||||
border-radius: 24px;
|
||||
border: 1px solid ${({ theme, type }) => (type === CardType.Primary ? 'transparent' : theme.backgroundOutline)};
|
||||
border: 1px solid ${({ theme, $type }) => ($type === CardType.Primary ? 'transparent' : theme.backgroundOutline)};
|
||||
box-shadow: 0px 10px 24px 0px rgba(51, 53, 72, 0.04);
|
||||
transition: ${({ theme }) => `${theme.transition.duration.medium} ${theme.transition.timing.ease} border`};
|
||||
|
||||
&:hover {
|
||||
border: 1px solid ${({ theme, isDarkMode }) => (isDarkMode ? theme.backgroundInteractive : theme.textTertiary)};
|
||||
border: 1px solid ${({ theme, $isDarkMode }) => ($isDarkMode ? theme.backgroundInteractive : theme.textTertiary)};
|
||||
}
|
||||
@media screen and (min-width: ${BREAKPOINTS.sm}px) {
|
||||
height: ${({ backgroundImgSrc }) => (backgroundImgSrc ? 360 : 260)}px;
|
||||
height: ${({ $backgroundImgSrc }) => ($backgroundImgSrc ? 360 : 260)}px;
|
||||
}
|
||||
@media screen and (min-width: ${BREAKPOINTS.xl}px) {
|
||||
padding: 32px;
|
||||
@@ -125,14 +125,14 @@ const Card = ({
|
||||
return (
|
||||
<TraceEvent events={[BrowserEvent.onClick]} name={SharedEventName.ELEMENT_CLICKED} element={elementName}>
|
||||
<StyledCard
|
||||
type={type}
|
||||
as={external ? 'a' : Link}
|
||||
to={external ? undefined : to}
|
||||
href={external ? to : undefined}
|
||||
target={external ? '_blank' : undefined}
|
||||
rel={external ? 'noopenener noreferrer' : undefined}
|
||||
isDarkMode={isDarkMode}
|
||||
backgroundImgSrc={backgroundImgSrc}
|
||||
$backgroundImgSrc={backgroundImgSrc}
|
||||
$isDarkMode={isDarkMode}
|
||||
$type={type}
|
||||
>
|
||||
<TitleRow>
|
||||
<CardTitle>{title}</CardTitle>
|
||||
|
||||
@@ -10,14 +10,14 @@ import { AutoRow } from 'components/Row'
|
||||
import { LoadingBubble } from 'components/Tokens/loading'
|
||||
import { formatDelta } from 'components/Tokens/TokenDetails/PriceChart'
|
||||
import Tooltip from 'components/Tooltip'
|
||||
import { useGetConnection } from 'connection'
|
||||
import { getConnection } from 'connection'
|
||||
import { usePortfolioBalancesQuery } from 'graphql/data/__generated__/types-and-hooks'
|
||||
import { useAtomValue } from 'jotai/utils'
|
||||
import { useProfilePageState, useSellAsset, useWalletCollections } from 'nft/hooks'
|
||||
import { useIsNftClaimAvailable } from 'nft/hooks/useIsNftClaimAvailable'
|
||||
import { ProfilePageStateType } from 'nft/types'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { ArrowDownRight, ArrowUpRight, Copy, CreditCard, IconProps, Info, Power, Settings } from 'react-feather'
|
||||
import { ArrowDownRight, ArrowUpRight, Copy, CreditCard, IconProps, Info, LogOut, Settings } from 'react-feather'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { shouldDisableNFTRoutesAtom } from 'state/application/atoms'
|
||||
import { useAppDispatch } from 'state/hooks'
|
||||
@@ -31,7 +31,7 @@ import { ApplicationModal } from '../../state/application/reducer'
|
||||
import { useUserHasAvailableClaim, useUserUnclaimedAmount } from '../../state/claim/hooks'
|
||||
import StatusIcon from '../Identicon/StatusIcon'
|
||||
import { useToggleAccountDrawer } from '.'
|
||||
import IconButton, { IconHoverText } from './IconButton'
|
||||
import IconButton, { IconHoverText, IconWithConfirmTextButton } from './IconButton'
|
||||
import MiniPortfolio from './MiniPortfolio'
|
||||
import { portfolioFadeInAnimation } from './MiniPortfolio/PortfolioRow'
|
||||
|
||||
@@ -103,7 +103,9 @@ const FiatOnrampAvailabilityExternalLink = styled(ExternalLink)`
|
||||
const StatusWrapper = styled.div`
|
||||
display: inline-block;
|
||||
width: 70%;
|
||||
padding-right: 4px;
|
||||
max-width: 70%;
|
||||
overflow: hidden;
|
||||
padding-right: 14px;
|
||||
display: inline-flex;
|
||||
`
|
||||
|
||||
@@ -158,6 +160,10 @@ export function PortfolioArrow({ change, ...rest }: { change: number } & IconPro
|
||||
)
|
||||
}
|
||||
|
||||
const LogOutCentered = styled(LogOut)`
|
||||
transform: translateX(2px);
|
||||
`
|
||||
|
||||
export default function AuthenticatedHeader({ account, openSettings }: { account: string; openSettings: () => void }) {
|
||||
const { connector, ENSName } = useWeb3React()
|
||||
const dispatch = useAppDispatch()
|
||||
@@ -172,7 +178,6 @@ export default function AuthenticatedHeader({ account, openSettings }: { account
|
||||
|
||||
const unclaimedAmount: CurrencyAmount<Token> | undefined = useUserUnclaimedAmount(account)
|
||||
const isUnclaimed = useUserHasAvailableClaim(account)
|
||||
const getConnection = useGetConnection()
|
||||
const connection = getConnection(connector)
|
||||
const openClaimModal = useToggleModal(ApplicationModal.ADDRESS_CLAIM)
|
||||
const openNftModal = useToggleModal(ApplicationModal.UNISWAP_NFT_AIRDROP_CLAIM)
|
||||
@@ -232,6 +237,7 @@ export default function AuthenticatedHeader({ account, openSettings }: { account
|
||||
const totalBalance = portfolio?.tokensTotalDenominatedValue?.value
|
||||
const absoluteChange = portfolio?.tokensTotalDenominatedValueChange?.absolute?.value
|
||||
const percentChange = portfolio?.tokensTotalDenominatedValueChange?.percentage?.value
|
||||
const [showDisconnectConfirm, setShowDisconnectConfirm] = useState(false)
|
||||
|
||||
return (
|
||||
<AuthenticatedHeaderWrapper>
|
||||
@@ -253,20 +259,28 @@ export default function AuthenticatedHeader({ account, openSettings }: { account
|
||||
)}
|
||||
</StatusWrapper>
|
||||
<IconContainer>
|
||||
<IconButton data-testid="wallet-settings" onClick={openSettings} Icon={Settings} />
|
||||
{!showDisconnectConfirm && (
|
||||
<IconButton data-testid="wallet-settings" onClick={openSettings} Icon={Settings} />
|
||||
)}
|
||||
<TraceEvent
|
||||
events={[BrowserEvent.onClick]}
|
||||
name={SharedEventName.ELEMENT_CLICKED}
|
||||
element={InterfaceElementName.DISCONNECT_WALLET_BUTTON}
|
||||
>
|
||||
<IconButton data-testid="wallet-disconnect" onClick={disconnect} Icon={Power} />
|
||||
<IconWithConfirmTextButton
|
||||
data-testid="wallet-disconnect"
|
||||
onConfirm={disconnect}
|
||||
onShowConfirm={setShowDisconnectConfirm}
|
||||
Icon={LogOutCentered}
|
||||
text="Disconnect"
|
||||
/>
|
||||
</TraceEvent>
|
||||
</IconContainer>
|
||||
</HeaderWrapper>
|
||||
<PortfolioDrawerContainer>
|
||||
{totalBalance !== undefined ? (
|
||||
<FadeInColumn gap="xs">
|
||||
<ThemedText.HeadlineLarge fontWeight={500}>
|
||||
<ThemedText.HeadlineLarge fontWeight={500} data-testid="portfolio-total-balance">
|
||||
{formatNumber(totalBalance, NumberType.PortfolioBalance)}
|
||||
</ThemedText.HeadlineLarge>
|
||||
<AutoRow marginBottom="20px">
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import React, { forwardRef, useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { Icon } from 'react-feather'
|
||||
import styled, { css } from 'styled-components/macro'
|
||||
import useResizeObserver from 'use-resize-observer'
|
||||
|
||||
import Row from '../Row'
|
||||
|
||||
export const IconHoverText = styled.span`
|
||||
color: ${({ theme }) => theme.textPrimary};
|
||||
@@ -13,12 +17,17 @@ export const IconHoverText = styled.span`
|
||||
left: 10px;
|
||||
`
|
||||
|
||||
const widthTransition = `width ease-in 80ms`
|
||||
|
||||
const IconStyles = css`
|
||||
background-color: ${({ theme }) => theme.backgroundInteractive};
|
||||
transition: ${widthTransition};
|
||||
border-radius: 12px;
|
||||
display: inline-block;
|
||||
display: flex;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
height: 32px;
|
||||
width: 32px;
|
||||
color: ${({ theme }) => theme.textPrimary};
|
||||
@@ -28,7 +37,7 @@ const IconStyles = css`
|
||||
theme: {
|
||||
transition: { duration, timing },
|
||||
},
|
||||
}) => `${duration.fast} background-color ${timing.in}`};
|
||||
}) => `${duration.fast} background-color ${timing.in}, ${widthTransition}`};
|
||||
|
||||
${IconHoverText} {
|
||||
opacity: 1;
|
||||
@@ -36,7 +45,7 @@ const IconStyles = css`
|
||||
}
|
||||
:active {
|
||||
background-color: ${({ theme }) => theme.backgroundSurface};
|
||||
transition: background-color 50ms linear;
|
||||
transition: background-color 50ms linear, ${widthTransition};
|
||||
}
|
||||
`
|
||||
|
||||
@@ -51,27 +60,29 @@ const IconBlockButton = styled.button`
|
||||
`
|
||||
|
||||
const IconWrapper = styled.span`
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin: auto;
|
||||
display: flex;
|
||||
`
|
||||
interface BaseProps {
|
||||
Icon: Icon
|
||||
children?: React.ReactNode
|
||||
}
|
||||
|
||||
interface IconLinkProps extends React.ComponentPropsWithoutRef<'a'>, BaseProps {}
|
||||
interface IconButtonProps extends React.ComponentPropsWithoutRef<'button'>, BaseProps {}
|
||||
|
||||
const IconBlock = (props: React.ComponentPropsWithoutRef<'a' | 'button'>) => {
|
||||
type IconBlockProps = React.ComponentPropsWithoutRef<'a' | 'button'>
|
||||
|
||||
const IconBlock = forwardRef<HTMLAnchorElement | HTMLDivElement, IconBlockProps>(function IconBlock(props, ref) {
|
||||
if ('href' in props) {
|
||||
return <IconBlockLink {...props} />
|
||||
return <IconBlockLink ref={ref as React.ForwardedRef<HTMLAnchorElement>} {...props} />
|
||||
}
|
||||
// ignoring 'button' 'type' conflict between React and styled-components
|
||||
// @ts-ignore
|
||||
return <IconBlockButton {...props} />
|
||||
}
|
||||
return <IconBlockButton ref={ref} {...props} />
|
||||
})
|
||||
|
||||
const IconButton = ({ Icon, ...rest }: IconButtonProps | IconLinkProps) => (
|
||||
<IconBlock {...rest}>
|
||||
@@ -81,4 +92,119 @@ const IconButton = ({ Icon, ...rest }: IconButtonProps | IconLinkProps) => (
|
||||
</IconBlock>
|
||||
)
|
||||
|
||||
type IconWithTextProps = (IconButtonProps | IconLinkProps) & {
|
||||
text: string
|
||||
onConfirm?: () => void
|
||||
onShowConfirm?: (on: boolean) => void
|
||||
}
|
||||
|
||||
const TextWrapper = styled.div`
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
overflow: hidden;
|
||||
min-width: min-content;
|
||||
`
|
||||
|
||||
const TextHide = styled.div`
|
||||
overflow: hidden;
|
||||
`
|
||||
|
||||
/**
|
||||
* Allows for hiding and showing some text next to an IconButton
|
||||
* Note that for width transitions to animate in CSS we need to always specify the width (no auto)
|
||||
* so there's resize observing and measuring going on here.
|
||||
*/
|
||||
export const IconWithConfirmTextButton = ({
|
||||
Icon,
|
||||
text,
|
||||
onConfirm,
|
||||
onShowConfirm,
|
||||
onClick,
|
||||
...rest
|
||||
}: IconWithTextProps) => {
|
||||
const [showText, setShowTextWithoutCallback] = useState(false)
|
||||
const frameObserver = useResizeObserver<HTMLElement>()
|
||||
const hiddenObserver = useResizeObserver<HTMLElement>()
|
||||
|
||||
const setShowText = useCallback(
|
||||
(val: boolean) => {
|
||||
setShowTextWithoutCallback(val)
|
||||
onShowConfirm?.(val)
|
||||
},
|
||||
[onShowConfirm]
|
||||
)
|
||||
|
||||
const dimensionsRef = useRef({
|
||||
frame: 0,
|
||||
hidden: 0,
|
||||
})
|
||||
const dimensions = (() => {
|
||||
// once opened, we avoid updating it to prevent constant resize loop
|
||||
if (!showText) {
|
||||
dimensionsRef.current = { frame: frameObserver.width || 0, hidden: hiddenObserver.width || 0 }
|
||||
}
|
||||
return dimensionsRef.current
|
||||
})()
|
||||
|
||||
// keyboard action to cancel
|
||||
useEffect(() => {
|
||||
if (!showText) return
|
||||
const isClient = typeof window !== 'undefined'
|
||||
if (!isClient) return
|
||||
if (!showText) return
|
||||
const keyHandler = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
setShowText(false)
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', keyHandler, { capture: true })
|
||||
return () => {
|
||||
window.removeEventListener('keydown', keyHandler, { capture: true })
|
||||
}
|
||||
}, [setShowText, showText])
|
||||
|
||||
const xPad = showText ? 12 : 0
|
||||
const width = showText ? dimensions.frame + dimensions.hidden + xPad : 32
|
||||
|
||||
return (
|
||||
<IconBlock
|
||||
ref={frameObserver.ref}
|
||||
{...rest}
|
||||
style={{
|
||||
width,
|
||||
paddingLeft: xPad,
|
||||
paddingRight: xPad,
|
||||
}}
|
||||
// @ts-ignore MouseEvent is valid, its a subset of the two mouse events,
|
||||
// even manually typing this all out more specifically it still gets mad about any casting for some reason
|
||||
onClick={(e: MouseEvent<HTMLAnchorElement>) => {
|
||||
if (showText) {
|
||||
onConfirm?.()
|
||||
} else {
|
||||
onClick?.(e)
|
||||
setShowText(!showText)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Row height="100%" gap="xs">
|
||||
<IconWrapper>
|
||||
<Icon strokeWidth={1.5} size={16} />
|
||||
</IconWrapper>
|
||||
|
||||
{/* this outer div is so we can cut it off but keep the inner text width full-width so we can measure it */}
|
||||
<TextHide
|
||||
style={{
|
||||
maxWidth: showText ? dimensions.hidden : 0,
|
||||
minWidth: showText ? dimensions.hidden : 0,
|
||||
}}
|
||||
>
|
||||
<TextWrapper ref={hiddenObserver.ref}>{text}</TextWrapper>
|
||||
</TextHide>
|
||||
</Row>
|
||||
</IconBlock>
|
||||
)
|
||||
}
|
||||
|
||||
export default IconButton
|
||||
|
||||
@@ -82,15 +82,38 @@ const ActivityGroupWrapper = styled(Column)`
|
||||
gap: 8px;
|
||||
`
|
||||
|
||||
function combineActivities(localMap: ActivityMap = {}, remoteMap: ActivityMap = {}): Array<Activity> {
|
||||
/* Detects transactions from same account with the same nonce and different hash */
|
||||
function wasTxCancelled(localActivity: Activity, remoteMap: ActivityMap, account: string): boolean {
|
||||
// handles locally cached tx's that were stored before we started tracking nonces
|
||||
if (!localActivity.nonce || localActivity.status !== TransactionStatus.Pending) return false
|
||||
|
||||
return Object.values(remoteMap).some((remoteTx) => {
|
||||
if (!remoteTx) return false
|
||||
|
||||
// Cancellations are only possible when both nonce and tx.from are the same
|
||||
if (remoteTx.nonce === localActivity.nonce && remoteTx.receipt?.from.toLowerCase() === account.toLowerCase()) {
|
||||
// If the remote tx has a different hash than the local tx, the local tx was cancelled
|
||||
return remoteTx.hash.toLowerCase() !== localActivity.hash.toLowerCase()
|
||||
}
|
||||
return false
|
||||
})
|
||||
}
|
||||
|
||||
function combineActivities(localMap: ActivityMap = {}, remoteMap: ActivityMap = {}, account: string): Array<Activity> {
|
||||
const txHashes = [...new Set([...Object.keys(localMap), ...Object.keys(remoteMap)])]
|
||||
|
||||
// Merges local and remote activities w/ same hash, preferring remote data
|
||||
return txHashes.reduce((acc: Array<Activity>, hash) => {
|
||||
const localActivity = localMap?.[hash] ?? {}
|
||||
const remoteActivity = remoteMap?.[hash] ?? {}
|
||||
// TODO(cartcrom): determine best logic for which fields to prefer from which sources, i.e. prefer remote exact swap output instead of local estimated output
|
||||
acc.push({ ...remoteActivity, ...localActivity } as Activity)
|
||||
const localActivity = (localMap?.[hash] ?? {}) as Activity
|
||||
const remoteActivity = (remoteMap?.[hash] ?? {}) as Activity
|
||||
|
||||
// TODO(WEB-2064): Display cancelled status in UI rather than completely hiding cancelled TXs
|
||||
if (wasTxCancelled(localActivity, remoteMap, account)) return acc
|
||||
|
||||
// TODO(cartcrom): determine best logic for which fields to prefer from which sources
|
||||
// i.e.prefer remote exact swap output instead of local estimated output
|
||||
acc.push({ ...localActivity, ...remoteActivity } as Activity)
|
||||
|
||||
return acc
|
||||
}, [])
|
||||
}
|
||||
@@ -122,9 +145,9 @@ export function ActivityTab({ account }: { account: string }) {
|
||||
|
||||
const activityGroups = useMemo(() => {
|
||||
const remoteMap = parseRemoteActivities(data?.portfolios?.[0].assetActivities)
|
||||
const allActivities = combineActivities(localMap, remoteMap)
|
||||
const allActivities = combineActivities(localMap, remoteMap, account)
|
||||
return createGroups(allActivities)
|
||||
}, [data?.portfolios, localMap])
|
||||
}, [data?.portfolios, localMap, account])
|
||||
|
||||
if (!data && loading)
|
||||
return (
|
||||
|
||||
@@ -2,7 +2,7 @@ import { SupportedChainId, Token, TradeType as MockTradeType } from '@uniswap/sd
|
||||
import { PERMIT2_ADDRESS } from '@uniswap/universal-router-sdk'
|
||||
import { DAI as MockDAI, nativeOnChain, USDC_MAINNET as MockUSDC_MAINNET } from 'constants/tokens'
|
||||
import { TransactionStatus as MockTxStatus } from 'graphql/data/__generated__/types-and-hooks'
|
||||
import { TokenAddressMap } from 'state/lists/hooks'
|
||||
import { ChainTokenMap } from 'hooks/Tokens'
|
||||
import {
|
||||
ExactInputSwapTransactionInfo,
|
||||
ExactOutputSwapTransactionInfo,
|
||||
@@ -89,15 +89,15 @@ function mockMultiStatus(info: TransactionInfo, id: string): [TransactionDetails
|
||||
]
|
||||
}
|
||||
|
||||
const mockTokenAddressMap: TokenAddressMap = {
|
||||
const mockTokenAddressMap: ChainTokenMap = {
|
||||
[mockChainId]: {
|
||||
[MockDAI.address]: { token: MockDAI },
|
||||
[MockUSDC_MAINNET.address]: { token: MockUSDC_MAINNET },
|
||||
} as TokenAddressMap[number],
|
||||
[MockDAI.address]: MockDAI,
|
||||
[MockUSDC_MAINNET.address]: MockUSDC_MAINNET,
|
||||
},
|
||||
}
|
||||
|
||||
jest.mock('../../../../state/lists/hooks', () => ({
|
||||
useCombinedActiveList: () => mockTokenAddressMap,
|
||||
jest.mock('../../../../hooks/Tokens', () => ({
|
||||
useAllTokensMultichain: () => mockTokenAddressMap,
|
||||
}))
|
||||
|
||||
jest.mock('../../../../state/transactions/hooks', () => {
|
||||
@@ -300,7 +300,7 @@ describe('parseLocalActivity', () => {
|
||||
},
|
||||
} as TransactionDetails
|
||||
const chainId = SupportedChainId.MAINNET
|
||||
const tokens = {} as TokenAddressMap
|
||||
const tokens = {} as ChainTokenMap
|
||||
expect(parseLocalActivity(details, chainId, tokens)).toMatchObject({
|
||||
chainId: 1,
|
||||
currencies: [undefined, undefined],
|
||||
|
||||
@@ -3,9 +3,9 @@ import { formatCurrencyAmount } from '@uniswap/conedison/format'
|
||||
import { Currency, CurrencyAmount, TradeType } from '@uniswap/sdk-core'
|
||||
import { nativeOnChain } from '@uniswap/smart-order-router'
|
||||
import { SupportedChainId } from 'constants/chains'
|
||||
import { TransactionPartsFragment, TransactionStatus } from 'graphql/data/__generated__/types-and-hooks'
|
||||
import { TransactionStatus } from 'graphql/data/__generated__/types-and-hooks'
|
||||
import { ChainTokenMap, useAllTokensMultichain } from 'hooks/Tokens'
|
||||
import { useMemo } from 'react'
|
||||
import { TokenAddressMap, useCombinedActiveList } from 'state/lists/hooks'
|
||||
import { useMultichainTransactions } from 'state/transactions/hooks'
|
||||
import {
|
||||
AddLiquidityV2PoolTransactionInfo,
|
||||
@@ -25,8 +25,8 @@ import {
|
||||
import { getActivityTitle } from '../constants'
|
||||
import { Activity, ActivityMap } from './types'
|
||||
|
||||
function getCurrency(currencyId: string, chainId: SupportedChainId, tokens: TokenAddressMap): Currency | undefined {
|
||||
return currencyId === 'ETH' ? nativeOnChain(chainId) : tokens[chainId]?.[currencyId]?.token
|
||||
function getCurrency(currencyId: string, chainId: SupportedChainId, tokens: ChainTokenMap): Currency | undefined {
|
||||
return currencyId === 'ETH' ? nativeOnChain(chainId) : tokens[chainId]?.[currencyId]
|
||||
}
|
||||
|
||||
function buildCurrencyDescriptor(
|
||||
@@ -46,7 +46,7 @@ function buildCurrencyDescriptor(
|
||||
function parseSwap(
|
||||
swap: ExactInputSwapTransactionInfo | ExactOutputSwapTransactionInfo,
|
||||
chainId: SupportedChainId,
|
||||
tokens: TokenAddressMap
|
||||
tokens: ChainTokenMap
|
||||
): Partial<Activity> {
|
||||
const tokenIn = getCurrency(swap.inputCurrencyId, chainId, tokens)
|
||||
const tokenOut = getCurrency(swap.outputCurrencyId, chainId, tokens)
|
||||
@@ -76,7 +76,7 @@ function parseWrap(wrap: WrapTransactionInfo, chainId: SupportedChainId, status:
|
||||
function parseApproval(
|
||||
approval: ApproveTransactionInfo,
|
||||
chainId: SupportedChainId,
|
||||
tokens: TokenAddressMap
|
||||
tokens: ChainTokenMap
|
||||
): Partial<Activity> {
|
||||
// TODO: Add 'amount' approved to ApproveTransactionInfo so we can distinguish between revoke and approve
|
||||
const currency = getCurrency(approval.tokenAddress, chainId, tokens)
|
||||
@@ -91,7 +91,7 @@ type GenericLPInfo = Omit<
|
||||
AddLiquidityV3PoolTransactionInfo | RemoveLiquidityV3TransactionInfo | AddLiquidityV2PoolTransactionInfo,
|
||||
'type'
|
||||
>
|
||||
function parseLP(lp: GenericLPInfo, chainId: SupportedChainId, tokens: TokenAddressMap): Partial<Activity> {
|
||||
function parseLP(lp: GenericLPInfo, chainId: SupportedChainId, tokens: ChainTokenMap): Partial<Activity> {
|
||||
const baseCurrency = getCurrency(lp.baseCurrencyId, chainId, tokens)
|
||||
const quoteCurrency = getCurrency(lp.quoteCurrencyId, chainId, tokens)
|
||||
const [baseRaw, quoteRaw] = [lp.expectedAmountBaseRaw, lp.expectedAmountQuoteRaw]
|
||||
@@ -103,7 +103,7 @@ function parseLP(lp: GenericLPInfo, chainId: SupportedChainId, tokens: TokenAddr
|
||||
function parseCollectFees(
|
||||
collect: CollectFeesTransactionInfo,
|
||||
chainId: SupportedChainId,
|
||||
tokens: TokenAddressMap
|
||||
tokens: ChainTokenMap
|
||||
): Partial<Activity> {
|
||||
// Adapts CollectFeesTransactionInfo to generic LP type
|
||||
const {
|
||||
@@ -118,7 +118,7 @@ function parseCollectFees(
|
||||
function parseMigrateCreateV3(
|
||||
lp: MigrateV2LiquidityToV3TransactionInfo | CreateV3PoolTransactionInfo,
|
||||
chainId: SupportedChainId,
|
||||
tokens: TokenAddressMap
|
||||
tokens: ChainTokenMap
|
||||
): Partial<Activity> {
|
||||
const baseCurrency = getCurrency(lp.baseCurrencyId, chainId, tokens)
|
||||
const baseSymbol = baseCurrency?.symbol ?? t`Unknown`
|
||||
@@ -132,7 +132,7 @@ function parseMigrateCreateV3(
|
||||
export function parseLocalActivity(
|
||||
details: TransactionDetails,
|
||||
chainId: SupportedChainId,
|
||||
tokens: TokenAddressMap
|
||||
tokens: ChainTokenMap
|
||||
): Activity | undefined {
|
||||
try {
|
||||
const status = !details.receipt
|
||||
@@ -141,7 +141,7 @@ export function parseLocalActivity(
|
||||
? TransactionStatus.Confirmed
|
||||
: TransactionStatus.Failed
|
||||
|
||||
const receipt: TransactionPartsFragment | undefined = details.receipt
|
||||
const receipt = details.receipt
|
||||
? {
|
||||
id: details.receipt.transactionHash,
|
||||
...details.receipt,
|
||||
@@ -157,6 +157,7 @@ export function parseLocalActivity(
|
||||
status,
|
||||
timestamp: (details.confirmedTime ?? details.addedTime) / 1000,
|
||||
receipt,
|
||||
nonce: details.nonce,
|
||||
}
|
||||
|
||||
let additionalFields: Partial<Activity> = {}
|
||||
@@ -188,7 +189,7 @@ export function parseLocalActivity(
|
||||
|
||||
export function useLocalActivities(account: string): ActivityMap {
|
||||
const allTransactions = useMultichainTransactions()
|
||||
const tokens = useCombinedActiveList()
|
||||
const tokens = useAllTokensMultichain()
|
||||
|
||||
return useMemo(() => {
|
||||
const activityByHash: ActivityMap = {}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { t } from '@lingui/macro'
|
||||
import { formatNumberOrString, NumberType } from '@uniswap/conedison/format'
|
||||
import { formatFiatPrice, formatNumberOrString, NumberType } from '@uniswap/conedison/format'
|
||||
import { SupportedChainId } from '@uniswap/sdk-core'
|
||||
import moonpayLogoSrc from 'assets/svg/moonpay.svg'
|
||||
import { NONFUNGIBLE_POSITION_MANAGER_ADDRESSES, UNI_ADDRESS } from 'constants/addresses'
|
||||
import { nativeOnChain } from 'constants/tokens'
|
||||
import {
|
||||
ActivityType,
|
||||
AssetActivityPartsFragment,
|
||||
Currency,
|
||||
NftApprovalPartsFragment,
|
||||
NftApproveForAllPartsFragment,
|
||||
NftTransferPartsFragment,
|
||||
@@ -17,6 +19,7 @@ import ms from 'ms.macro'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { isAddress } from 'utils'
|
||||
|
||||
import { MOONPAY_SENDER_ADDRESSES } from '../constants'
|
||||
import { Activity } from './types'
|
||||
|
||||
type TransactionChanges = {
|
||||
@@ -106,6 +109,17 @@ function getSwapTitle(sent: TokenTransferPartsFragment, received: TokenTransferP
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param transactedValue Transacted value amount from TokenTransfer API response
|
||||
* @returns parsed & formatted USD value as a string if currency is of type USD
|
||||
*/
|
||||
function formatTransactedValue(transactedValue: TokenTransferPartsFragment['transactedValue']): string {
|
||||
if (!transactedValue) return '-'
|
||||
const price = transactedValue?.currency === Currency.Usd ? transactedValue.value ?? undefined : undefined
|
||||
return formatFiatPrice(price)
|
||||
}
|
||||
|
||||
function parseSwap(changes: TransactionChanges) {
|
||||
if (changes.NftTransfer.length > 0 && changes.TokenTransfer.length === 1) {
|
||||
const collectionCounts = getCollectionCounts(changes.NftTransfer)
|
||||
@@ -175,17 +189,27 @@ function parseSendReceive(changes: TransactionChanges, assetActivity: AssetActiv
|
||||
}
|
||||
|
||||
if (transfer && assetName && amount) {
|
||||
return transfer.direction === 'IN'
|
||||
? {
|
||||
title: t`Received`,
|
||||
descriptor: `${amount} ${assetName} ${t`from`} `,
|
||||
otherAccount: isAddress(transfer.sender) || undefined,
|
||||
}
|
||||
: {
|
||||
title: t`Sent`,
|
||||
descriptor: `${amount} ${assetName} ${t`to`} `,
|
||||
otherAccount: isAddress(transfer.recipient) || undefined,
|
||||
}
|
||||
const isMoonpayPurchase = MOONPAY_SENDER_ADDRESSES.some((address) => isSameAddress(address, transfer?.sender))
|
||||
|
||||
if (transfer.direction === 'IN') {
|
||||
return isMoonpayPurchase && transfer.__typename === 'TokenTransfer'
|
||||
? {
|
||||
title: t`Purchased`,
|
||||
descriptor: `${amount} ${assetName} ${t`for`} ${formatTransactedValue(transfer.transactedValue)}`,
|
||||
logos: [moonpayLogoSrc],
|
||||
}
|
||||
: {
|
||||
title: t`Received`,
|
||||
descriptor: `${amount} ${assetName} ${t`from`} `,
|
||||
otherAccount: isAddress(transfer.sender) || undefined,
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
title: t`Sent`,
|
||||
descriptor: `${amount} ${assetName} ${t`to`} `,
|
||||
otherAccount: isAddress(transfer.recipient) || undefined,
|
||||
}
|
||||
}
|
||||
}
|
||||
return { title: t`Unknown Send` }
|
||||
}
|
||||
@@ -254,6 +278,7 @@ function parseRemoteActivity(assetActivity: AssetActivityPartsFragment): Activit
|
||||
title: assetActivity.type,
|
||||
descriptor: assetActivity.transaction.to,
|
||||
receipt: assetActivity.transaction,
|
||||
nonce: assetActivity.transaction.nonce,
|
||||
}
|
||||
const parsedFields = ActivityParserByType[assetActivity.type]?.(changes, assetActivity)
|
||||
|
||||
|
||||
@@ -14,7 +14,8 @@ export type Activity = {
|
||||
logos?: Array<string | undefined>
|
||||
currencies?: Array<Currency | undefined>
|
||||
otherAccount?: string
|
||||
receipt?: Receipt
|
||||
receipt?: Omit<Receipt, 'nonce'>
|
||||
nonce?: number | null
|
||||
}
|
||||
|
||||
export type ActivityMap = { [hash: string]: Activity | undefined }
|
||||
|
||||
@@ -30,6 +30,7 @@ const Wrapper = styled(Column)<{ numItems: number; isExpanded: boolean }>`
|
||||
overflow: hidden;
|
||||
`
|
||||
|
||||
// TODO(WEB-3288): Replace this component to use `components/Expand` under the hood
|
||||
type ExpandoRowProps = PropsWithChildren<{ title?: string; numItems: number; isExpanded: boolean; toggle: () => void }>
|
||||
export function ExpandoRow({ title = t`Hidden`, numItems, isExpanded, toggle, children }: ExpandoRowProps) {
|
||||
if (numItems === 0) return null
|
||||
|
||||
@@ -126,7 +126,7 @@ export function useGetCachedTokens(chains: SupportedChainId[]): TokenGetterFn {
|
||||
const local: { [address: string]: Token | undefined } = {}
|
||||
const missing = new Set<string>()
|
||||
addresses.forEach((address) => {
|
||||
const cached = tokenCache.get(chainId, address) ?? allTokens[chainId][address]?.token
|
||||
const cached = tokenCache.get(chainId, address) ?? allTokens[chainId]?.[address]
|
||||
cached ? (local[address] = cached) : missing.add(address)
|
||||
})
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Token } from '@uniswap/sdk-core'
|
||||
import { AddressMap } from '@uniswap/smart-order-router'
|
||||
import { abi as MulticallABI } from '@uniswap/v3-periphery/artifacts/contracts/lens/UniswapInterfaceMulticall.sol/UniswapInterfaceMulticall.json'
|
||||
import { abi as NFTPositionManagerABI } from '@uniswap/v3-periphery/artifacts/contracts/NonfungiblePositionManager.sol/NonfungiblePositionManager.json'
|
||||
import MulticallJSON from '@uniswap/v3-periphery/artifacts/contracts/lens/UniswapInterfaceMulticall.sol/UniswapInterfaceMulticall.json'
|
||||
import NFTPositionManagerJSON from '@uniswap/v3-periphery/artifacts/contracts/NonfungiblePositionManager.sol/NonfungiblePositionManager.json'
|
||||
import { useWeb3React } from '@web3-react/core'
|
||||
import { MULTICALL_ADDRESS, NONFUNGIBLE_POSITION_MANAGER_ADDRESSES as V3NFT_ADDRESSES } from 'constants/addresses'
|
||||
import { isSupportedChain, SupportedChainId } from 'constants/chains'
|
||||
@@ -43,11 +43,11 @@ function useContractMultichain<T extends BaseContract>(
|
||||
}
|
||||
|
||||
export function useV3ManagerContracts(chainIds: SupportedChainId[]): ContractMap<NonfungiblePositionManager> {
|
||||
return useContractMultichain<NonfungiblePositionManager>(V3NFT_ADDRESSES, NFTPositionManagerABI, chainIds)
|
||||
return useContractMultichain<NonfungiblePositionManager>(V3NFT_ADDRESSES, NFTPositionManagerJSON.abi, chainIds)
|
||||
}
|
||||
|
||||
export function useInterfaceMulticallContracts(chainIds: SupportedChainId[]): ContractMap<UniswapInterfaceMulticall> {
|
||||
return useContractMultichain<UniswapInterfaceMulticall>(MULTICALL_ADDRESS, MulticallABI, chainIds)
|
||||
return useContractMultichain<UniswapInterfaceMulticall>(MULTICALL_ADDRESS, MulticallJSON.abi, chainIds)
|
||||
}
|
||||
|
||||
type PriceMap = { [key: CurrencyKey]: number | undefined }
|
||||
|
||||
@@ -22,12 +22,12 @@ import { PositionInfo } from './cache'
|
||||
import { useFeeValues } from './hooks'
|
||||
import useMultiChainPositions from './useMultiChainPositions'
|
||||
|
||||
/*
|
||||
This hook takes an array of PositionInfo objects (format used by the Uniswap Labs gql API).
|
||||
The hook access PositionInfo.details (format used by the NFT position contract),
|
||||
filters the PositionDetails data for malicious content,
|
||||
and then returns the original data in its original format.
|
||||
*/
|
||||
/**
|
||||
* Takes an array of PositionInfo objects (format used by the Uniswap Labs gql API).
|
||||
* The hook access PositionInfo.details (format used by the NFT position contract),
|
||||
* filters the PositionDetails data for malicious content,
|
||||
* and then returns the original data in its original format.
|
||||
*/
|
||||
function useFilterPossiblyMaliciousPositionInfo(positions: PositionInfo[] | undefined): PositionInfo[] {
|
||||
const tokenIdsToPositionInfo: Record<string, PositionInfo> = useMemo(
|
||||
() =>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { CurrencyAmount, Token } from '@uniswap/sdk-core'
|
||||
import { abi as IUniswapV3PoolStateABI } from '@uniswap/v3-core/artifacts/contracts/interfaces/pool/IUniswapV3PoolState.sol/IUniswapV3PoolState.json'
|
||||
import IUniswapV3PoolStateJSON from '@uniswap/v3-core/artifacts/contracts/interfaces/pool/IUniswapV3PoolState.sol/IUniswapV3PoolState.json'
|
||||
import { computePoolAddress, Pool, Position } from '@uniswap/v3-sdk'
|
||||
import { V3_CORE_FACTORY_ADDRESSES } from 'constants/addresses'
|
||||
import { SupportedChainId } from 'constants/chains'
|
||||
@@ -49,7 +49,7 @@ const DEFAULT_CHAINS = [
|
||||
SupportedChainId.CELO,
|
||||
]
|
||||
|
||||
type UseMultiChainPositionsData = { positions: PositionInfo[] | undefined; loading: boolean }
|
||||
type UseMultiChainPositionsData = { positions?: PositionInfo[]; loading: boolean }
|
||||
|
||||
/**
|
||||
* Returns all positions for a given account on multiple chains.
|
||||
@@ -118,7 +118,7 @@ export default function useMultiChainPositions(account: string, chains = DEFAULT
|
||||
// Combines PositionDetails with Pool data to build our return type
|
||||
const fetchPositionInfo = useCallback(
|
||||
async (positionDetails: PositionDetails[], chainId: SupportedChainId, multicall: UniswapInterfaceMulticall) => {
|
||||
const poolInterface = new Interface(IUniswapV3PoolStateABI) as UniswapV3PoolInterface
|
||||
const poolInterface = new Interface(IUniswapV3PoolStateJSON.abi) as UniswapV3PoolInterface
|
||||
const tokens = await getTokens(
|
||||
positionDetails.flatMap((details) => [details.token0, details.token1]),
|
||||
chainId
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { SupportedChainId } from '@uniswap/sdk-core'
|
||||
import { DAI_ARBITRUM } from '@uniswap/smart-order-router'
|
||||
import { DAI, USDC_ARBITRUM, USDC_MAINNET } from 'constants/tokens'
|
||||
import { BRIDGED_USDC_ARBITRUM, DAI, USDC_MAINNET } from 'constants/tokens'
|
||||
import { render } from 'test-utils/render'
|
||||
|
||||
import { PortfolioLogo } from './PortfolioLogo'
|
||||
@@ -13,7 +13,7 @@ describe('PortfolioLogo', () => {
|
||||
|
||||
it('renders with L2 icon', () => {
|
||||
const { container } = render(
|
||||
<PortfolioLogo chainId={SupportedChainId.ARBITRUM_ONE} currencies={[DAI_ARBITRUM, USDC_ARBITRUM]} />
|
||||
<PortfolioLogo chainId={SupportedChainId.ARBITRUM_ONE} currencies={[DAI_ARBITRUM, BRIDGED_USDC_ARBITRUM]} />
|
||||
)
|
||||
expect(container).toMatchSnapshot()
|
||||
})
|
||||
|
||||
@@ -22,12 +22,14 @@ const EndColumn = styled(Column)`
|
||||
`
|
||||
|
||||
export default function PortfolioRow({
|
||||
['data-testid']: testId,
|
||||
left,
|
||||
title,
|
||||
descriptor,
|
||||
right,
|
||||
onClick,
|
||||
}: {
|
||||
'data-testid'?: string
|
||||
left: React.ReactNode
|
||||
title: React.ReactNode
|
||||
descriptor?: React.ReactNode
|
||||
@@ -36,7 +38,7 @@ export default function PortfolioRow({
|
||||
onClick?: () => void
|
||||
}) {
|
||||
return (
|
||||
<PortfolioRowWrapper onClick={onClick}>
|
||||
<PortfolioRowWrapper data-testid={testId} onClick={onClick}>
|
||||
{left}
|
||||
<AutoColumn grow>
|
||||
{title}
|
||||
|
||||
@@ -80,11 +80,11 @@ exports[`PortfolioLogo renders with L2 icon 1`] = `
|
||||
>
|
||||
<img
|
||||
class="c2 c3"
|
||||
src="https://raw.githubusercontent.com/Uniswap/assets/master/blockchains/arbitrum/assets/0xDA10009cBd5D07dd0CeCc66161FC93D7c9000da1/logo.png"
|
||||
src="blank_token.svg"
|
||||
/>
|
||||
<img
|
||||
class="c2 c3"
|
||||
src="https://raw.githubusercontent.com/Uniswap/assets/master/blockchains/arbitrum/assets/0xFF970A61A04b1cA14834A43f5dE4533eBDDB5CC8/logo.png"
|
||||
src="blank_token.svg"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
@@ -152,11 +152,11 @@ exports[`PortfolioLogo renders without L2 icon 1`] = `
|
||||
>
|
||||
<img
|
||||
class="c2 c3"
|
||||
src="https://raw.githubusercontent.com/Uniswap/assets/master/blockchains/ethereum/assets/0x6B175474E89094C44Da98b954EedeAC495271d0F/logo.png"
|
||||
src="blank_token.svg"
|
||||
/>
|
||||
<img
|
||||
class="c2 c3"
|
||||
src="https://raw.githubusercontent.com/Uniswap/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png"
|
||||
src="blank_token.svg"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -153,3 +153,11 @@ export function getActivityTitle(type: TransactionType, status: TransactionStatu
|
||||
}
|
||||
return TransactionTitleTable[type][status]
|
||||
}
|
||||
|
||||
// Non-exhaustive list of addresses Moonpay uses when sending purchased tokens
|
||||
export const MOONPAY_SENDER_ADDRESSES = [
|
||||
'0x8216874887415e2650d12d53ff53516f04a74fd7',
|
||||
'0x151b381058f91cf871e7ea1ee83c45326f61e96d',
|
||||
'0xb287eac48ab21c5fb1d3723830d60b4c797555b0',
|
||||
'0xd108fd0e8c8e71552a167e7a44ff1d345d233ba6',
|
||||
]
|
||||
|
||||
@@ -95,7 +95,7 @@ export default function MiniPortfolio({ account }: { account: string }) {
|
||||
return (
|
||||
<Trace section={InterfaceSectionName.MINI_PORTFOLIO}>
|
||||
<Wrapper>
|
||||
<Nav>
|
||||
<Nav data-testid="mini-portfolio-navbar">
|
||||
{Pages.map(({ title, loggingElementName, key }, index) => {
|
||||
if (shouldDisableNFTRoutes && loggingElementName.includes('nft')) return null
|
||||
return (
|
||||
@@ -105,19 +105,14 @@ export default function MiniPortfolio({ account }: { account: string }) {
|
||||
element={loggingElementName}
|
||||
key={index}
|
||||
>
|
||||
<NavItem
|
||||
data-testid={`mini-portfolio-nav-${key}`}
|
||||
onClick={() => setCurrentPage(index)}
|
||||
active={currentPage === index}
|
||||
key={`Mini Portfolio page ${index}`}
|
||||
>
|
||||
<NavItem onClick={() => setCurrentPage(index)} active={currentPage === index} key={key}>
|
||||
{title}
|
||||
</NavItem>
|
||||
</TraceEvent>
|
||||
)
|
||||
})}
|
||||
</Nav>
|
||||
<PageWrapper>
|
||||
<PageWrapper data-testid="mini-portfolio-page">
|
||||
<Page account={account} />
|
||||
</PageWrapper>
|
||||
</Wrapper>
|
||||
|
||||
@@ -13,7 +13,7 @@ function wasPending(previousTxs: { [hash: string]: TransactionDetails | undefine
|
||||
return previousTx && isTxPending(previousTx)
|
||||
}
|
||||
|
||||
function useHasUpdatedTx() {
|
||||
function useHasUpdatedTx(account: string | undefined) {
|
||||
// TODO: consider monitoring tx's on chains other than the wallet's current chain
|
||||
const currentChainTxs = useAllTransactions()
|
||||
|
||||
@@ -27,12 +27,12 @@ function useHasUpdatedTx() {
|
||||
const previousPendingTxs = usePrevious(pendingTxs)
|
||||
|
||||
return useMemo(() => {
|
||||
if (!previousPendingTxs) return false
|
||||
if (!previousPendingTxs || !account) return false
|
||||
return Object.values(currentChainTxs).some(
|
||||
(tx) => !isTxPending(tx) && wasPending(previousPendingTxs, tx),
|
||||
(tx) => tx.from === account && !isTxPending(tx) && wasPending(previousPendingTxs, tx),
|
||||
[currentChainTxs, previousPendingTxs]
|
||||
)
|
||||
}, [currentChainTxs, previousPendingTxs])
|
||||
}, [account, currentChainTxs, previousPendingTxs])
|
||||
}
|
||||
|
||||
/* Prefetches & caches portfolio balances when the wrapped component is hovered or the user completes a transaction */
|
||||
@@ -49,16 +49,22 @@ export default function PrefetchBalancesWrapper({ children }: PropsWithChildren)
|
||||
}
|
||||
}, [account, prefetchPortfolioBalances])
|
||||
|
||||
// TODO(cartcrom): add delay for refetching on optimism, as there is high latency in new balances being available
|
||||
const hasUpdatedTx = useHasUpdatedTx()
|
||||
// Listens for recently updated transactions to keep portfolio balances fresh in apollo cache
|
||||
useEffect(() => {
|
||||
if (!hasUpdatedTx) return
|
||||
const prevAccount = usePrevious(account)
|
||||
|
||||
// If the drawer is open, fetch balances immediately, else set a flag to fetch on next hover
|
||||
if (drawerOpen) fetchBalances()
|
||||
else setHasUnfetchedBalances(true)
|
||||
}, [drawerOpen, fetchBalances, hasUpdatedTx])
|
||||
// TODO(cartcrom): add delay for refetching on optimism, as there is high latency in new balances being available
|
||||
const hasUpdatedTx = useHasUpdatedTx(account)
|
||||
// Listens for account changes & recently updated transactions to keep portfolio balances fresh in apollo cache
|
||||
useEffect(() => {
|
||||
const accountChanged = prevAccount !== undefined && prevAccount !== account
|
||||
if (hasUpdatedTx || accountChanged) {
|
||||
// If the drawer is open, fetch balances immediately, else set a flag to fetch on next hover
|
||||
if (drawerOpen) {
|
||||
fetchBalances()
|
||||
} else {
|
||||
setHasUnfetchedBalances(true)
|
||||
}
|
||||
}
|
||||
}, [account, prevAccount, drawerOpen, fetchBalances, hasUpdatedTx])
|
||||
|
||||
const onHover = useCallback(() => {
|
||||
if (hasUnfetchedBalances) fetchBalances()
|
||||
|
||||
@@ -1,17 +1,16 @@
|
||||
import { Trans } from '@lingui/macro'
|
||||
import { sendAnalyticsEvent } from '@uniswap/analytics'
|
||||
import { InterfaceElementName } from '@uniswap/analytics-events'
|
||||
import { useWeb3React } from '@web3-react/core'
|
||||
import { WalletConnect } from '@web3-react/walletconnect'
|
||||
import Column, { AutoColumn } from 'components/Column'
|
||||
import Modal from 'components/Modal'
|
||||
import { RowBetween } from 'components/Row'
|
||||
import { uniwalletConnectConnection } from 'connection'
|
||||
import { ActivationStatus, useActivationState } from 'connection/activate'
|
||||
import { ConnectionType } from 'connection/types'
|
||||
import { UniwalletConnect } from 'connection/WalletConnect'
|
||||
import { QRCodeSVG } from 'qrcode.react'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useModalIsOpen, useToggleUniwalletModal } from 'state/application/hooks'
|
||||
import { ApplicationModal } from 'state/application/reducer'
|
||||
import { useEffect, useState } from 'react'
|
||||
import styled, { useTheme } from 'styled-components/macro'
|
||||
import { CloseIcon, ThemedText } from 'theme'
|
||||
|
||||
@@ -39,44 +38,37 @@ const Divider = styled.div`
|
||||
`
|
||||
|
||||
export default function UniwalletModal() {
|
||||
const open = useModalIsOpen(ApplicationModal.UNIWALLET_CONNECT)
|
||||
const toggle = useToggleUniwalletModal()
|
||||
|
||||
const { activationState, cancelActivation } = useActivationState()
|
||||
const [uri, setUri] = useState<string>()
|
||||
|
||||
// Displays the modal if a Uniswap Wallet Connection is pending & qrcode URI is available
|
||||
const open =
|
||||
activationState.status === ActivationStatus.PENDING &&
|
||||
activationState.connection.type === ConnectionType.UNISWAP_WALLET &&
|
||||
!!uri
|
||||
|
||||
useEffect(() => {
|
||||
;(uniwalletConnectConnection.connector as WalletConnect).events.addListener(
|
||||
UniwalletConnect.UNI_URI_AVAILABLE,
|
||||
(uri) => {
|
||||
uri && setUri(uri)
|
||||
toggle()
|
||||
}
|
||||
)
|
||||
}, [toggle])
|
||||
}, [])
|
||||
|
||||
const { account } = useWeb3React()
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
sendAnalyticsEvent('Uniswap wallet modal opened', { userConnected: !!account })
|
||||
if (account) {
|
||||
toggle()
|
||||
}
|
||||
}
|
||||
}, [account, open, toggle])
|
||||
|
||||
const onClose = useCallback(() => {
|
||||
uniwalletConnectConnection.connector.deactivate?.()
|
||||
toggle()
|
||||
}, [toggle])
|
||||
if (open) sendAnalyticsEvent('Uniswap wallet modal opened')
|
||||
}, [open])
|
||||
|
||||
const theme = useTheme()
|
||||
return (
|
||||
<Modal isOpen={open} onDismiss={onClose}>
|
||||
<Modal isOpen={open} onDismiss={cancelActivation}>
|
||||
<UniwalletConnectWrapper>
|
||||
<HeaderRow>
|
||||
<ThemedText.SubHeader>
|
||||
<Trans>Scan with Uniswap Wallet</Trans>
|
||||
</ThemedText.SubHeader>
|
||||
<CloseIcon onClick={onClose} />
|
||||
<CloseIcon onClick={cancelActivation} />
|
||||
</HeaderRow>
|
||||
<QRCodeWrapper>
|
||||
{uri && (
|
||||
|
||||
@@ -27,6 +27,11 @@ export function useToggleAccountDrawer() {
|
||||
}, [updateAccountDrawerOpen])
|
||||
}
|
||||
|
||||
export function useCloseAccountDrawer() {
|
||||
const updateAccountDrawerOpen = useUpdateAtom(accountDrawerOpenAtom)
|
||||
return useCallback(() => updateAccountDrawerOpen(false), [updateAccountDrawerOpen])
|
||||
}
|
||||
|
||||
export function useAccountDrawer(): [boolean, () => void] {
|
||||
const accountDrawerOpen = useAtomValue(accountDrawerOpenAtom)
|
||||
return [accountDrawerOpen, useToggleAccountDrawer()]
|
||||
@@ -200,7 +205,7 @@ function AccountDrawer() {
|
||||
name={InterfaceEventName.MINI_PORTFOLIO_TOGGLED}
|
||||
properties={{ type: 'close' }}
|
||||
>
|
||||
<CloseDrawer onClick={toggleWalletDrawer}>
|
||||
<CloseDrawer onClick={toggleWalletDrawer} data-testid="close-account-drawer">
|
||||
<CloseIcon />
|
||||
</CloseDrawer>
|
||||
</TraceEvent>
|
||||
|
||||
31
src/components/AnimatedDropdown/index.test.tsx
Normal file
31
src/components/AnimatedDropdown/index.test.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import { render, screen, waitFor } from 'test-utils/render'
|
||||
|
||||
import AnimatedDropdown from './index'
|
||||
|
||||
describe('AnimatedDropdown', () => {
|
||||
it('does not render children when closed', () => {
|
||||
render(<AnimatedDropdown open={false}>Body</AnimatedDropdown>)
|
||||
expect(screen.getByText('Body')).not.toBeVisible()
|
||||
})
|
||||
|
||||
it('renders children when open', () => {
|
||||
render(<AnimatedDropdown open={true}>Body</AnimatedDropdown>)
|
||||
expect(screen.getByText('Body')).toBeVisible()
|
||||
})
|
||||
|
||||
it('animates when open changes', async () => {
|
||||
const { rerender } = render(<AnimatedDropdown open={false}>Body</AnimatedDropdown>)
|
||||
|
||||
const body = screen.getByText('Body')
|
||||
|
||||
expect(body).not.toBeVisible()
|
||||
|
||||
rerender(<AnimatedDropdown open={true}>Body</AnimatedDropdown>)
|
||||
expect(body).not.toBeVisible()
|
||||
|
||||
// wait for React Spring animation to finish
|
||||
await waitFor(() => {
|
||||
expect(body).toBeVisible()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -9,7 +9,10 @@ export default function AnimatedDropdown({ open, children }: React.PropsWithChil
|
||||
const { ref, height } = useResizeObserver()
|
||||
|
||||
const props = useSpring({
|
||||
height: open ? height ?? 0 : 0,
|
||||
// On initial render, `height` will be undefined as ref has not been set yet.
|
||||
// If the dropdown should be open, we fallback to `auto` to avoid flickering.
|
||||
// Otherwise, we just animate between actual height (when open) and 0 (when closed).
|
||||
height: open ? height ?? 'auto' : 0,
|
||||
config: {
|
||||
mass: 1.2,
|
||||
tension: 300,
|
||||
@@ -20,14 +23,7 @@ export default function AnimatedDropdown({ open, children }: React.PropsWithChil
|
||||
})
|
||||
|
||||
return (
|
||||
<animated.div
|
||||
style={{
|
||||
...props,
|
||||
overflow: 'hidden',
|
||||
width: '100%',
|
||||
willChange: 'height',
|
||||
}}
|
||||
>
|
||||
<animated.div style={{ ...props, overflow: 'hidden', width: '100%', willChange: 'height' }}>
|
||||
<div ref={ref}>{children}</div>
|
||||
</animated.div>
|
||||
)
|
||||
|
||||
@@ -32,13 +32,7 @@ const LabelText = styled.div<{ color: string }>`
|
||||
justify-content: flex-end;
|
||||
`
|
||||
|
||||
export default function RangeBadge({
|
||||
removed,
|
||||
inRange,
|
||||
}: {
|
||||
removed: boolean | undefined
|
||||
inRange: boolean | undefined
|
||||
}) {
|
||||
export default function RangeBadge({ removed, inRange }: { removed?: boolean; inRange?: boolean }) {
|
||||
const theme = useTheme()
|
||||
return (
|
||||
<BadgeWrapper>
|
||||
|
||||
@@ -14,7 +14,7 @@ import { useHideUniswapWalletBanner } from 'state/user/hooks'
|
||||
import styled from 'styled-components/macro'
|
||||
import { ThemedText } from 'theme'
|
||||
import { Z_INDEX } from 'theme/zIndex'
|
||||
import { isIOS } from 'utils/userAgent'
|
||||
import { isIOS, isMobileSafari } from 'utils/userAgent'
|
||||
|
||||
const PopupContainer = styled.div<{ show: boolean }>`
|
||||
display: flex;
|
||||
@@ -93,6 +93,8 @@ export default function UniswapWalletBanner() {
|
||||
|
||||
const screenSize = useScreenSize()
|
||||
|
||||
if (isMobileSafari) return null
|
||||
|
||||
return (
|
||||
<PopupContainer show={shouldDisplay}>
|
||||
<StyledXButton
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { darken } from 'polished'
|
||||
import { forwardRef } from 'react'
|
||||
import { Check, ChevronDown } from 'react-feather'
|
||||
import { Button as RebassButton, ButtonProps as ButtonPropsOriginal } from 'rebass/styled-components'
|
||||
import styled, { DefaultTheme, useTheme } from 'styled-components/macro'
|
||||
@@ -296,7 +297,7 @@ export function ButtonConfirmed({
|
||||
}
|
||||
}
|
||||
|
||||
export function ButtonError({ error, ...rest }: { error?: boolean } & ButtonProps) {
|
||||
export function ButtonError({ error, ...rest }: { error?: boolean } & BaseButtonProps) {
|
||||
if (error) {
|
||||
return <ButtonErrorStyle {...rest} />
|
||||
} else {
|
||||
@@ -391,6 +392,7 @@ export enum ButtonEmphasis {
|
||||
low,
|
||||
warning,
|
||||
destructive,
|
||||
failure,
|
||||
}
|
||||
interface BaseThemeButtonProps {
|
||||
size: ButtonSize
|
||||
@@ -411,6 +413,8 @@ function pickThemeButtonBackgroundColor({ theme, emphasis }: { theme: DefaultThe
|
||||
return theme.accentWarningSoft
|
||||
case ButtonEmphasis.destructive:
|
||||
return theme.accentCritical
|
||||
case ButtonEmphasis.failure:
|
||||
return theme.accentFailureSoft
|
||||
case ButtonEmphasis.medium:
|
||||
default:
|
||||
return theme.backgroundInteractive
|
||||
@@ -465,6 +469,8 @@ function pickThemeButtonTextColor({ theme, emphasis }: { theme: DefaultTheme; em
|
||||
return theme.accentWarning
|
||||
case ButtonEmphasis.destructive:
|
||||
return theme.accentTextDarkPrimary
|
||||
case ButtonEmphasis.failure:
|
||||
return theme.accentFailure
|
||||
case ButtonEmphasis.medium:
|
||||
default:
|
||||
return theme.textPrimary
|
||||
@@ -519,15 +525,19 @@ const BaseThemeButton = styled.button<BaseThemeButtonProps>`
|
||||
`
|
||||
|
||||
interface ThemeButtonProps extends React.ComponentPropsWithoutRef<'button'>, BaseThemeButtonProps {}
|
||||
type ThemeButtonRef = HTMLButtonElement
|
||||
|
||||
export const ThemeButton = ({ children, ...rest }: ThemeButtonProps) => {
|
||||
export const ThemeButton = forwardRef<ThemeButtonRef, ThemeButtonProps>(function ThemeButton(
|
||||
{ children, ...rest },
|
||||
ref
|
||||
) {
|
||||
return (
|
||||
<BaseThemeButton {...rest}>
|
||||
<BaseThemeButton {...rest} ref={ref}>
|
||||
<ButtonOverlay />
|
||||
{children}
|
||||
</BaseThemeButton>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
export const ButtonLight = ({ children, ...rest }: BaseButtonProps) => {
|
||||
return (
|
||||
|
||||
@@ -19,7 +19,7 @@ interface SparklineChartProps {
|
||||
width: number
|
||||
height: number
|
||||
tokenData: TopToken
|
||||
pricePercentChange: number | undefined | null
|
||||
pricePercentChange?: number | null
|
||||
sparklineMap: SparklineMap
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import styled, { DefaultTheme } from 'styled-components/macro'
|
||||
|
||||
type Gap = keyof DefaultTheme['grids']
|
||||
import styled from 'styled-components/macro'
|
||||
import { Gap } from 'theme'
|
||||
|
||||
export const Column = styled.div<{
|
||||
gap?: Gap
|
||||
|
||||
@@ -14,7 +14,7 @@ const ContentWrapper = styled(Column)`
|
||||
font-size: 12px;
|
||||
`
|
||||
interface ConnectedAccountBlockedProps {
|
||||
account: string | null | undefined
|
||||
account?: string | null
|
||||
isOpen: boolean
|
||||
}
|
||||
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
import { Trans } from '@lingui/macro'
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { t } from '@lingui/macro'
|
||||
import { formatNumber, formatPriceImpact, NumberType } from '@uniswap/conedison/format'
|
||||
import { Percent } from '@uniswap/sdk-core'
|
||||
import Row from 'components/Row'
|
||||
import { LoadingBubble } from 'components/Tokens/loading'
|
||||
import { MouseoverTooltip } from 'components/Tooltip'
|
||||
import { useMemo } from 'react'
|
||||
import styled, { useTheme } from 'styled-components/macro'
|
||||
|
||||
import { ThemedText } from '../../theme'
|
||||
import { warningSeverity } from '../../utils/prices'
|
||||
import styled from 'styled-components/macro'
|
||||
import { ThemedText } from 'theme'
|
||||
import { warningSeverity } from 'utils/prices'
|
||||
|
||||
const FiatLoadingBubble = styled(LoadingBubble)`
|
||||
border-radius: 4px;
|
||||
@@ -21,36 +19,40 @@ export function FiatValue({
|
||||
fiatValue,
|
||||
priceImpact,
|
||||
}: {
|
||||
fiatValue?: { data?: number; isLoading: boolean }
|
||||
fiatValue: { data?: number; isLoading: boolean }
|
||||
priceImpact?: Percent
|
||||
}) {
|
||||
const theme = useTheme()
|
||||
const priceImpactColor = useMemo(() => {
|
||||
if (!priceImpact) return undefined
|
||||
if (priceImpact.lessThan('0')) return theme.accentSuccess
|
||||
if (priceImpact.lessThan('0')) return 'accentSuccess'
|
||||
const severity = warningSeverity(priceImpact)
|
||||
if (severity < 1) return theme.textTertiary
|
||||
if (severity < 3) return theme.deprecated_yellow1
|
||||
return theme.accentFailure
|
||||
}, [priceImpact, theme.accentSuccess, theme.accentFailure, theme.textTertiary, theme.deprecated_yellow1])
|
||||
if (severity < 1) return 'textTertiary'
|
||||
if (severity < 3) return 'deprecated_yellow1'
|
||||
return 'accentFailure'
|
||||
}, [priceImpact])
|
||||
|
||||
if (fiatValue.isLoading) {
|
||||
return <FiatLoadingBubble />
|
||||
}
|
||||
|
||||
return (
|
||||
<ThemedText.DeprecatedBody fontSize={14} color={theme.textSecondary}>
|
||||
{fiatValue?.isLoading ? (
|
||||
<FiatLoadingBubble />
|
||||
) : (
|
||||
<div>
|
||||
{fiatValue?.data ? formatNumber(fiatValue.data, NumberType.FiatTokenPrice) : undefined}
|
||||
{priceImpact && (
|
||||
<span style={{ color: priceImpactColor }}>
|
||||
{' '}
|
||||
<MouseoverTooltip text={t`The estimated difference between the USD values of input and output amounts.`}>
|
||||
(<Trans>{formatPriceImpact(priceImpact)}</Trans>)
|
||||
</MouseoverTooltip>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<Row gap="sm">
|
||||
<ThemedText.BodySmall>
|
||||
{fiatValue.data ? (
|
||||
formatNumber(fiatValue.data, NumberType.FiatTokenPrice)
|
||||
) : (
|
||||
<MouseoverTooltip text={<Trans>Not enough liquidity to show accurate USD value.</Trans>}>-</MouseoverTooltip>
|
||||
)}
|
||||
</ThemedText.BodySmall>
|
||||
{priceImpact && (
|
||||
<ThemedText.BodySmall color={priceImpactColor}>
|
||||
<MouseoverTooltip
|
||||
text={<Trans>The estimated difference between the USD values of input and output amounts.</Trans>}
|
||||
>
|
||||
(<Trans>{formatPriceImpact(priceImpact)}</Trans>)
|
||||
</MouseoverTooltip>
|
||||
</ThemedText.BodySmall>
|
||||
)}
|
||||
</ThemedText.DeprecatedBody>
|
||||
</Row>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -195,7 +195,7 @@ interface SwapCurrencyInputPanelProps {
|
||||
pair?: Pair | null
|
||||
hideInput?: boolean
|
||||
otherCurrency?: Currency | null
|
||||
fiatValue: { data?: number; isLoading: boolean }
|
||||
fiatValue?: { data?: number; isLoading: boolean }
|
||||
priceImpact?: Percent
|
||||
id: string
|
||||
showCommonBases?: boolean
|
||||
@@ -204,6 +204,7 @@ interface SwapCurrencyInputPanelProps {
|
||||
renderBalance?: (amount: CurrencyAmount<Currency>) => ReactNode
|
||||
locked?: boolean
|
||||
loading?: boolean
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export default function SwapCurrencyInputPanel({
|
||||
@@ -226,6 +227,7 @@ export default function SwapCurrencyInputPanel({
|
||||
hideInput = false,
|
||||
locked = false,
|
||||
loading = false,
|
||||
disabled = false,
|
||||
...rest
|
||||
}: SwapCurrencyInputPanelProps) {
|
||||
const [modalOpen, setModalOpen] = useState(false)
|
||||
@@ -258,13 +260,13 @@ export default function SwapCurrencyInputPanel({
|
||||
className="token-amount-input"
|
||||
value={value}
|
||||
onUserInput={onUserInput}
|
||||
disabled={!chainAllowed}
|
||||
disabled={!chainAllowed || disabled}
|
||||
$loading={loading}
|
||||
/>
|
||||
)}
|
||||
|
||||
<CurrencySelect
|
||||
disabled={!chainAllowed}
|
||||
disabled={!chainAllowed || disabled}
|
||||
visible={currency !== undefined}
|
||||
selected={!!currency}
|
||||
hideInput={hideInput}
|
||||
@@ -306,7 +308,7 @@ export default function SwapCurrencyInputPanel({
|
||||
<FiatRow>
|
||||
<RowBetween>
|
||||
<LoadingOpacityContainer $loading={loading}>
|
||||
<FiatValue fiatValue={fiatValue} priceImpact={priceImpact} />
|
||||
{fiatValue && <FiatValue fiatValue={fiatValue} priceImpact={priceImpact} />}
|
||||
</LoadingOpacityContainer>
|
||||
{account ? (
|
||||
<RowFixed style={{ height: '17px' }}>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Trans } from '@lingui/macro'
|
||||
import { TraceEvent } from '@uniswap/analytics'
|
||||
import { BrowserEvent, InterfaceElementName, SwapEventName } from '@uniswap/analytics-events'
|
||||
import { Currency, CurrencyAmount, Percent } from '@uniswap/sdk-core'
|
||||
import { Currency, CurrencyAmount } from '@uniswap/sdk-core'
|
||||
import { Pair } from '@uniswap/v2-sdk'
|
||||
import { useWeb3React } from '@web3-react/core'
|
||||
import { AutoColumn } from 'components/Column'
|
||||
@@ -183,7 +183,6 @@ interface CurrencyInputPanelProps {
|
||||
hideInput?: boolean
|
||||
otherCurrency?: Currency | null
|
||||
fiatValue?: { data?: number; isLoading: boolean }
|
||||
priceImpact?: Percent
|
||||
id: string
|
||||
showCommonBases?: boolean
|
||||
showCurrencyAmount?: boolean
|
||||
@@ -207,7 +206,6 @@ export default function CurrencyInputPanel({
|
||||
disableNonToken,
|
||||
renderBalance,
|
||||
fiatValue,
|
||||
priceImpact,
|
||||
hideBalance = false,
|
||||
pair = null, // used for double token logo
|
||||
hideInput = false,
|
||||
@@ -293,7 +291,7 @@ export default function CurrencyInputPanel({
|
||||
<FiatRow>
|
||||
<RowBetween>
|
||||
<LoadingOpacityContainer $loading={loading}>
|
||||
<FiatValue fiatValue={fiatValue} priceImpact={priceImpact} />
|
||||
{fiatValue && <FiatValue fiatValue={fiatValue} />}
|
||||
</LoadingOpacityContainer>
|
||||
{account ? (
|
||||
<RowFixed style={{ height: '17px' }}>
|
||||
|
||||
38
src/components/Expand/index.test.tsx
Normal file
38
src/components/Expand/index.test.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import { fireEvent, render, screen } from 'test-utils/render'
|
||||
import noop from 'utils/noop'
|
||||
|
||||
import Expand from './index'
|
||||
|
||||
describe('Expand', () => {
|
||||
it('does not render children when closed', () => {
|
||||
render(
|
||||
<Expand header={<span>Header</span>} isOpen={false} onToggle={noop} button={<span>Button</span>}>
|
||||
Body
|
||||
</Expand>
|
||||
)
|
||||
expect(screen.queryByText('Body')).not.toBeVisible()
|
||||
})
|
||||
|
||||
it('renders children when open', () => {
|
||||
render(
|
||||
<Expand header={<span>Header</span>} isOpen={true} onToggle={noop} button={<span>Button</span>}>
|
||||
Body
|
||||
</Expand>
|
||||
)
|
||||
expect(screen.queryByText('Body')).toBeVisible()
|
||||
})
|
||||
|
||||
it('calls `onToggle` when button is pressed', () => {
|
||||
const onToggle = jest.fn()
|
||||
render(
|
||||
<Expand header={<span>Header</span>} isOpen={false} onToggle={onToggle} button={<span>Button</span>}>
|
||||
Body
|
||||
</Expand>
|
||||
)
|
||||
|
||||
const button = screen.getByText('Button')
|
||||
|
||||
fireEvent.click(button)
|
||||
expect(onToggle).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user