diff --git a/packages/frontend/.dockerignore b/.dockerignore similarity index 51% rename from packages/frontend/.dockerignore rename to .dockerignore index 8f405ff2ac..d30a276f8e 100644 --- a/packages/frontend/.dockerignore +++ b/.dockerignore @@ -1,7 +1,8 @@ **/.git **/node_modules -dist -.cache +packages/frontend/dist +packages/frontend/.cache Dockerfile Dockerfile.fast +*.md *.swp diff --git a/.git-hooks/helpers/detect-foxtrot-merge b/.git-hooks/helpers/detect-foxtrot-merge new file mode 100755 index 0000000000..97496ae926 --- /dev/null +++ b/.git-hooks/helpers/detect-foxtrot-merge @@ -0,0 +1,41 @@ +#!/bin/bash + +#usage: +# foxtrot-merge-detector [] +# +# If foxtrot merge detected for branch (current branch if no branch), +# exit with 1. + +# foxtrot merges: +# See http://bit-booster.blogspot.cz/2016/02/no-foxtrots-allowed.html +# https://stackoverflow.com/questions/35962754/git-how-can-i-prevent-foxtrot-merges-in-my-master-branch +# https://git-blame.blogspot.com/2012/03/fun-with-first-parent.html +# https://stackoverflow.com/questions/35962754/how-can-i-prevent-foxtrot-merges-in-my-master-branch + +remoteBranch=$(git rev-parse --abbrev-ref "$1"@{u} 2>/dev/null) +# no remote tracking branch, exit +if [[ -z "$remoteBranch" ]]; then + # echo "no remote tracking branch, no foxtrot merge detected" + exit 0 +fi +branch=$(git rev-parse --abbrev-ref "${1-@}" 2>/dev/null) +# branch commit does not cover remote branch commit, exit +if ! $(git merge-base --is-ancestor $remoteBranch $branch); then + # echo "branch commit does not cover remote branch commit, no foxtrot merge detected" + exit 0 +fi +remoteBranchCommit=$(git rev-parse $remoteBranch) +# branch commit is same as remote branch commit, exit +if [[ $(git rev-parse $branch) == $remoteBranchCommit ]]; then + # echo "branch commit is same as remote branch commit, no foxtrot merge detected" + exit 0 +fi +# remote branch commit is first-parent of branch, exit +if [[ $(git log --first-parent --pretty='%P' $remoteBranchCommit..$branch | \ + cut -d' ' -f1 | \ + grep $remoteBranchCommit | wc -l) -eq 1 ]]; then + # echo "remote branch commit is first-parent of branch, no foxtrot merge detected" + exit 0 +fi +# foxtrot merge detected if here +exit 1 diff --git a/.husky/post-merge b/.husky/post-merge new file mode 100755 index 0000000000..9c807141e5 --- /dev/null +++ b/.husky/post-merge @@ -0,0 +1,12 @@ +#!/bin/bash + +Color_Off='\033[0m' # Text Reset +Yellow='\033[0;33m' # Yellow + +./.git-hooks/helpers/detect-foxtrot-merge +# check exit code and exit if needed +exitcode=$? +if [ $exitcode -ne 0 ]; then + echo -e " ${Yellow}WARNING:${Color_Off} foxtrot merge detected; do not merge master branch into feature branches." + # swallow exit code +fi diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100755 index 0000000000..297062dd87 --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1,5 @@ +#!/bin/sh +# This runs the precommit script on any modified packages +. "$(dirname "$0")/_/husky.sh" + +npx lerna run --concurrency 1 --stream precommit --exclude-dependents diff --git a/.husky/pre-push b/.husky/pre-push index 9fa37478f5..cc92145507 100755 --- a/.husky/pre-push +++ b/.husky/pre-push @@ -1,5 +1,27 @@ -#!/bin/sh +#!/bin/bash # This runs the prepush script on any modified packages . "$(dirname "$0")/_/husky.sh" npx lerna run --concurrency 1 --stream prepush --exclude-dependents + +remote="$1" +url="$2" +z40=0000000000000000000000000000000000000000 +while read local_ref local_sha remote_ref remote_sha +do + if [ "$local_sha" = $z40 ]; then + # handle delete, do nothing + : + else + # ex $local_ref as "refs/heads/dev" + branch=$(git rev-parse --abbrev-ref "$local_ref") + ./.git-hooks/helpers/detect-foxtrot-merge "$branch" + # check exit code and exit if needed + exitcode=$? + if [ $exitcode -ne 0 ]; then + echo 1>&2 "fatal: foxtrot merge detected, aborting push" + echo 1>&2 "fatal: branch $branch" + exit $exitcode + fi + fi +done diff --git a/.husky/pre-receive b/.husky/pre-receive new file mode 100755 index 0000000000..f1ac981e56 --- /dev/null +++ b/.husky/pre-receive @@ -0,0 +1,19 @@ +#!/bin/bash + +# Copyright (c) 2016 G. Sylvie Davies. http://bit-booster.com/ +# Copyright (c) 2016 torek. http://stackoverflow.com/users/1256452/torek +# License: MIT license. https://opensource.org/licenses/MIT +while read oldrev newrev refname +do +if [ "$refname" = "refs/heads/master" ]; then + MATCH=`git log --first-parent --pretty='%H %P' $oldrev..$newrev | + grep $oldrev | + awk '{ print $2 }'` + if [ "$oldrev" = "$MATCH" ]; then + exit 0 + else + echo "*** PUSH REJECTED! FOXTROT MERGE BLOCKED!!! ***" + exit 1 + fi +fi +done diff --git a/.travis.yml b/.travis.yml index 37f02f2d2d..e36a02542e 100644 --- a/.travis.yml +++ b/.travis.yml @@ -4,6 +4,8 @@ node_js: 12 jobs: include: - name: yarn + env: + - NEAR_WALLET_ENV=mainnet script: - yarn lint - yarn test diff --git a/packages/frontend/Dockerfile b/Dockerfile similarity index 72% rename from packages/frontend/Dockerfile rename to Dockerfile index cb94fff2ab..08540ae2b0 100644 --- a/packages/frontend/Dockerfile +++ b/Dockerfile @@ -2,19 +2,20 @@ FROM phusion/baseimage:0.11 as build -RUN curl -o /tmp/node_setup.sh "https://deb.nodesource.com/setup_11.x" +RUN curl -o /tmp/node_setup.sh "https://deb.nodesource.com/setup_12.x" RUN bash /tmp/node_setup.sh RUN curl -sL https://dl.yarnpkg.com/debian/pubkey.gpg | apt-key add - RUN echo 'deb https://dl.yarnpkg.com/debian/ stable main' | tee /etc/apt/sources.list.d/yarn.list RUN apt-get update -qq && apt-get install -y \ nodejs +RUN npm install -g yarn # near-wallet RUN mkdir /near-wallet COPY . /near-wallet/ WORKDIR /near-wallet -RUN npm install -RUN npm run build +RUN yarn install +RUN NEAR_WALLET_ENV=development yarn run build # ======================== EXECUTE ================================== FROM phusion/baseimage:0.11 as run @@ -27,9 +28,9 @@ RUN apt-get update -qq && apt-get install -y \ nginx RUN mkdir -p /var/www/html/wallet -COPY --from=build /near-wallet/dist /var/www/html/wallet +COPY --from=build /near-wallet/packages/frontend/dist /var/www/html/wallet # nginx RUN rm /etc/nginx/sites-enabled/default -COPY --from=build /near-wallet/scripts/wallet.nginx /etc/nginx/sites-enabled/wallet -COPY --from=build /near-wallet/scripts/init_nginx.sh /etc/my_init.d/ +COPY --from=build /near-wallet/packages/frontend/scripts/wallet.nginx /etc/nginx/sites-enabled/wallet +COPY --from=build /near-wallet/packages/frontend/scripts/init_nginx.sh /etc/my_init.d/ diff --git a/packages/frontend/Dockerfile.fast b/Dockerfile.fast similarity index 100% rename from packages/frontend/Dockerfile.fast rename to Dockerfile.fast diff --git a/Jenkinsfile b/Jenkinsfile new file mode 100644 index 0000000000..3b325b4185 --- /dev/null +++ b/Jenkinsfile @@ -0,0 +1,392 @@ +pipeline { + agent any + environment { + // frontend variables + FRONTEND_TESTNET_STAGING_BUNDLE_PATH = "$WORKSPACE/packages/frontend/dist_testnet_staging" + FRONTEND_TESTNET_BUNDLE_PATH = "$WORKSPACE/packages/frontend/dist_testnet" + FRONTEND_MAINNET_STAGING_BUNDLE_PATH = "$WORKSPACE/packages/frontend/dist_mainnet_staging" + FRONTEND_MAINNET_BUNDLE_PATH = "$WORKSPACE/packages/frontend/dist_mainnet" + + // aws configuration + AWS_CREDENTIALS = 'aws-credentials-password' + AWS_REGION = 'us-west-2' + TESTNET_AWS_ROLE = credentials('testnet-assumed-role') + TESTNET_AWS_ROLE_ACCOUNT = credentials('testnet-assumed-role-account') + MAINNET_AWS_ROLE = credentials('mainnet-assumed-role') + MAINNET_AWS_ROLE_ACCOUNT = credentials('mainnet-assumed-role-account') + + // s3 buckets + TESTNET_PR_PREVIEW_STATIC_SITE_BUCKET = credentials('testnet-pr-previews-static-website') + TESTNET_STAGING_STATIC_SITE_BUCKET = credentials('testnet-staging-static-website') + TESTNET_STATIC_SITE_BUCKET = credentials('testnet-static-website') + MAINNET_PR_PREVIEW_STATIC_SITE_BUCKET = credentials('mainnet-pr-previews-static-website') + MAINNET_STAGING_STATIC_SITE_BUCKET = credentials('mainnet-staging-static-website') + MAINNET_STATIC_SITE_BUCKET = credentials('mainnet-static-website') + + // package building configuration + AFFECTED_PACKAGES = 'frontend'.split() + /* TODO enable once nx is implemented + AFFECTED_PACKAGES = """${sh( + returnStdout: true, + script: 'npx nx affected:apps --plain' + )}""".trim().split() + */ + + BUILD_E2E = AFFECTED_PACKAGES.contains('e2e-tests') + BUILD_FRONTEND = AFFECTED_PACKAGES.contains('frontend') + } + stages { + stage('packages:prebuild') { + failFast true + + parallel { + stage('frontend:prebuild') { + when { + expression { env.BUILD_FRONTEND == 'true' } + } + steps { + dir("$WORKSPACE/packages/frontend") { + sh 'yarn install --frozen-lockfile' + } + } + } + } + } + + stage('packages:cleaned') { + steps { + milestone(100) + } + } + + stage('packages:test') { + failFast true + + parallel { + stage('frontend:prebuild:testnet-staging') { + when { + expression { env.BUILD_FRONTEND == 'true' } + } + environment { + NEAR_WALLET_ENV = 'testnet_STAGING' + } + steps { + dir("$WORKSPACE/packages/frontend") { + sh 'yarn test' + } + } + } + + stage('frontend:prebuild:testnet') { + when { + expression { env.BUILD_FRONTEND == 'true' } + } + environment { + NEAR_WALLET_ENV = 'testnet' + } + steps { + dir("$WORKSPACE/packages/frontend") { + sh 'yarn test' + } + } + } + + stage('frontend:prebuild:mainnet-staging') { + when { + expression { env.BUILD_FRONTEND == 'true' } + } + environment { + NEAR_WALLET_ENV = 'mainnet_STAGING' + } + steps { + dir("$WORKSPACE/packages/frontend") { + sh 'yarn test' + } + } + } + + stage('frontend:prebuild:mainnet') { + when { + expression { env.BUILD_FRONTEND == 'true' } + } + environment { + NEAR_WALLET_ENV = 'mainnet' + } + steps { + dir("$WORKSPACE/packages/frontend") { + sh 'yarn test' + } + } + } + } + } + + stage('packages:tested') { + steps { + milestone(200) + } + } + + // parallelize builds and tests for modified packages + stage('packages:build') { + // if any of the parallel stages for package builds fail, mark the entire pipeline as failed + failFast true + + // execute package-specific stages in parallel + parallel { + // build end-to-end testing package + stage('e2e-tests') { + when { + expression { env.BUILD_E2E == 'true' }; + anyOf { branch 'master' ; branch 'stable' } + } + stages { + stage('e2e-tests:build') { + steps { + dir("$WORKSPACE/packages/e2e-tests") { + sh 'yarn install' + sh 'yarn test' + } + } + } + } + } + + // build frontend bundles + stage('frontend:bundle:testnet-staging') { + when { + expression { env.BUILD_FRONTEND == 'true' } + } + environment { + NEAR_WALLET_ENV = 'testnet_STAGING' + REACT_APP_ACCOUNT_HELPER_URL = 'https://preflight-api.kitwallet.app' + } + steps { + dir("$WORKSPACE/packages/frontend") { + sh "yarn bundle --outDir=$FRONTEND_TESTNET_STAGING_BUNDLE_PATH" + } + } + } + + stage('frontend:bundle:testnet') { + when { + expression { env.BUILD_FRONTEND == 'true' } + } + environment { + NEAR_WALLET_ENV = 'testnet' + REACT_APP_ACCOUNT_HELPER_URL = 'https://testnet-api.kitwallet.app' + } + steps { + dir("$WORKSPACE/packages/frontend") { + sh "yarn bundle --outDir=$FRONTEND_TESTNET_BUNDLE_PATH" + } + } + } + + stage('frontend:bundle:mainnet-staging') { + when { + expression { env.BUILD_FRONTEND == 'true' } + } + environment { + NEAR_WALLET_ENV = 'mainnet_STAGING' + REACT_APP_ACCOUNT_HELPER_URL = 'https://staging-api.kitwallet.app' + } + steps { + dir("$WORKSPACE/packages/frontend") { + sh "yarn bundle --outDir=$FRONTEND_MAINNET_STAGING_BUNDLE_PATH" + } + } + } + + stage('frontend:bundle:mainnet') { + when { + expression { env.BUILD_FRONTEND == 'true' } + } + environment { + NEAR_WALLET_ENV = 'mainnet' + REACT_APP_ACCOUNT_HELPER_URL = 'https://api.kitwallet.app' + } + steps { + dir("$WORKSPACE/packages/frontend") { + sh "yarn bundle --outDir=$FRONTEND_MAINNET_BUNDLE_PATH" + } + } + } + } + } + + stage('packages:built') { + steps { + milestone(300) + } + } + + stage('packages:deploy') { + stages { + stage('frontend:deploy') { + when { + expression { env.BUILD_FRONTEND == 'true' } + } + stages { + stage('frontend:deploy:pr-previews') { + failFast true + + when { + not { anyOf { branch 'master' ; branch 'stable' } }; + expression { env.CHANGE_TARGET != "" } + } + + parallel { + stage('frontend:deploy:testnet-pr-preview') { + steps { + withAWS( + region: env.AWS_REGION, + credentials: env.AWS_CREDENTIALS, + role: env.TESTNET_AWS_ROLE, + roleAccount: env.TESTNET_AWS_ROLE_ACCOUNT + ) { + s3Upload( + bucket: "$TESTNET_PR_PREVIEW_STATIC_SITE_BUCKET/$CHANGE_ID", + includePathPattern: "*", + path: '', + workingDir: env.FRONTEND_TESTNET_BUNDLE_PATH + ) + } + } + } + + stage('frontend:deploy:mainnet-pr-preview') { + when { + not { anyOf { branch 'master' ; branch 'stable' } }; + expression { env.CHANGE_TARGET != "" } + } + steps { + withAWS( + region: env.AWS_REGION, + credentials: env.AWS_CREDENTIALS, + role: env.MAINNET_AWS_ROLE, + roleAccount: env.MAINNET_AWS_ROLE_ACCOUNT + ) { + s3Upload( + bucket: "$MAINNET_PR_PREVIEW_STATIC_SITE_BUCKET/$CHANGE_ID", + includePathPattern: "*", + path: '', + workingDir: env.FRONTEND_MAINNET_BUNDLE_PATH + ) + } + } + } + } + } + + stage('frontend:deployed:pr-previews') { + steps { + milestone(401) + } + } + + stage('frontend:deploy:testnet-staging') { + when { + branch 'master' + } + steps { + milestone(402) + withAWS( + region: env.AWS_REGION, + credentials: env.AWS_CREDENTIALS, + role: env.TESTNET_AWS_ROLE, + roleAccount: env.TESTNET_AWS_ROLE_ACCOUNT + ) { + s3Upload( + bucket: env.TESTNET_STAGING_STATIC_SITE_BUCKET, + includePathPattern: "*", + path: '', + workingDir: env.FRONTEND_TESTNET_STAGING_BUNDLE_PATH + ) + } + } + } + + stage('frontend:deploy:testnet') { + when { + branch 'master' + } + steps { + milestone(403) + input(message: 'Deploy to testnet?') + milestone(404) + withAWS( + region: env.AWS_REGION, + credentials: env.AWS_CREDENTIALS, + role: env.TESTNET_AWS_ROLE, + roleAccount: env.TESTNET_AWS_ROLE_ACCOUNT + ) { + s3Upload( + bucket: env.TESTNET_STATIC_SITE_BUCKET, + includePathPattern: "*", + path: '', + workingDir: env.FRONTEND_TESTNET_BUNDLE_PATH + ) + } + } + } + + stage('frontend:deploy:mainnet-staging') { + when { + branch 'stable' + } + steps { + milestone(405) + withAWS( + region: env.AWS_REGION, + credentials: env.AWS_CREDENTIALS, + role: env.MAINNET_AWS_ROLE, + roleAccount: env.MAINNET_AWS_ROLE_ACCOUNT + ) { + s3Upload( + bucket: env.MAINNET_STAGING_STATIC_SITE_BUCKET, + includePathPattern: "*", + path: '', + workingDir: env.FRONTEND_MAINNET_STAGING_BUNDLE_PATH + ) + } + } + } + + stage('frontend:deploy:mainnet') { + when { + branch 'stable' + } + steps { + milestone(406) + input(message: 'Deploy to mainnet?') + milestone(407) + withAWS( + region: env.AWS_REGION, + credentials: env.AWS_CREDENTIALS, + role: env.MAINNET_AWS_ROLE, + roleAccount: env.MAINNET_AWS_ROLE_ACCOUNT + ) { + s3Upload( + bucket: env.MAINNET_STATIC_SITE_BUCKET, + includePathPattern: "*", + path: '', + workingDir: env.FRONTEND_MAINNET_BUNDLE_PATH + ) + } + } + } + } + } + } + } + } + post { + always { + cleanWs( + disableDeferredWipeout: true, + deleteDirs: true + ) + } + } +} diff --git a/features/environments.json b/features/environments.json index 484a2b671d..ef6b0fb3d0 100644 --- a/features/environments.json +++ b/features/environments.json @@ -1,7 +1,7 @@ { "DEVELOPMENT": "development", "TESTNET": "testnet", - "TESTNET_STAGING": "testnet_STAGING", "MAINNET": "mainnet", - "MAINNET_STAGING": "mainnet_STAGING" -} \ No newline at end of file + "MAINNET_STAGING": "mainnet_STAGING", + "TESTNET_STAGING": "testnet_STAGING" +} diff --git a/features/features.d.ts b/features/features.d.ts index f3c26d4be6..bf25c08bb5 100644 --- a/features/features.d.ts +++ b/features/features.d.ts @@ -2,5 +2,8 @@ /* This file is generated by the flag editor util. Changes will be lost! */ export type Features = { - EXAMPLE_FLAG: boolean; + CREATE_IMPLICIT_ACCOUNT: boolean; + EXAMPLE_FLAG: boolean; + FARMING_VALIDATOR_APY_DISPLAY: boolean; + IMPORT_ACCOUNT_WITH_LINK_V2: boolean; }; diff --git a/features/flags.json b/features/flags.json index 9d9216d583..d218ff0576 100644 --- a/features/flags.json +++ b/features/flags.json @@ -1,4 +1,33 @@ { + "CREATE_IMPLICIT_ACCOUNT": { + "createdBy": "Patrick Tajima", + "createdAt": "2021-12-23T22:16:36.291Z", + "development": { + "enabled": true, + "lastEditedBy": "Patrick Tajima", + "lastEditedAt": "2021-12-23T22:16:36.291Z" + }, + "testnet": { + "enabled": true, + "lastEditedBy": "Daryl Collins", + "lastEditedAt": "2022-01-21T05:04:22.473Z" + }, + "mainnet": { + "enabled": true, + "lastEditedBy": "Daryl Collins", + "lastEditedAt": "2022-01-21T05:04:22.473Z" + }, + "mainnet_STAGING": { + "enabled": true, + "lastEditedBy": "Patrick Tajima", + "lastEditedAt": "2021-12-23T22:16:36.291Z" + }, + "testnet_STAGING": { + "enabled": true, + "lastEditedBy": "Andy Haynes", + "lastEditedAt": "2022-01-31T21:13:09.948Z" + } + }, "EXAMPLE_FLAG": { "createdBy": "Daryl Collins", "createdAt": "2021-05-24T09:33:27.489Z", @@ -12,11 +41,6 @@ "lastEditedBy": "Daryl Collins", "lastEditedAt": "2021-11-09T04:34:12.847Z" }, - "testnet_STAGING": { - "enabled": false, - "lastEditedBy": "Daryl Collins", - "lastEditedAt": "2021-11-09T04:34:33.102Z" - }, "mainnet": { "enabled": true, "lastEditedBy": "Andy Haynes", @@ -26,6 +50,69 @@ "enabled": false, "lastEditedBy": "Daryl Collins", "lastEditedAt": "2021-05-24T09:33:27.489Z" + }, + "testnet_STAGING": { + "enabled": true, + "lastEditedBy": "Andy Haynes", + "lastEditedAt": "2022-01-31T21:13:09.948Z" + } + }, + "FARMING_VALIDATOR_APY_DISPLAY": { + "createdBy": "esaminu", + "createdAt": "2022-03-09T21:26:58.971Z", + "development": { + "enabled": false, + "lastEditedBy": "esaminu", + "lastEditedAt": "2022-03-09T21:26:58.971Z" + }, + "testnet": { + "enabled": false, + "lastEditedBy": "esaminu", + "lastEditedAt": "2022-03-09T21:26:58.971Z" + }, + "mainnet": { + "enabled": false, + "lastEditedBy": "esaminu", + "lastEditedAt": "2022-03-09T21:26:58.971Z" + }, + "mainnet_STAGING": { + "enabled": false, + "lastEditedBy": "esaminu", + "lastEditedAt": "2022-03-09T21:26:58.971Z" + }, + "testnet_STAGING": { + "enabled": false, + "lastEditedBy": "esaminu", + "lastEditedAt": "2022-03-09T21:26:58.971Z" + } + }, + "IMPORT_ACCOUNT_WITH_LINK_V2": { + "createdBy": "Patrick Tajima", + "createdAt": "2022-03-12T01:30:32.577Z", + "development": { + "enabled": true, + "lastEditedBy": "Patrick Tajima", + "lastEditedAt": "2022-03-12T01:30:32.577Z" + }, + "testnet": { + "enabled": true, + "lastEditedBy": "Patrick Tajima", + "lastEditedAt": "2022-04-07T02:50:24.122Z" + }, + "mainnet": { + "enabled": true, + "lastEditedBy": "Patrick Tajima", + "lastEditedAt": "2022-04-07T02:50:24.123Z" + }, + "mainnet_STAGING": { + "enabled": true, + "lastEditedBy": "Patrick Tajima", + "lastEditedAt": "2022-04-07T02:50:24.123Z" + }, + "testnet_STAGING": { + "enabled": true, + "lastEditedBy": "Patrick Tajima", + "lastEditedAt": "2022-04-07T02:50:24.123Z" } } } diff --git a/lerna.json b/lerna.json index 5b9197036f..25802cfec2 100644 --- a/lerna.json +++ b/lerna.json @@ -2,9 +2,11 @@ "packages": [ "packages/*" ], - "commands": { + "command": { "publish": { - "message": "chore: NPM release" + "message": "chore(release): publish", + "conventionalCommits": true, + "yes": true } }, "useWorkspaces": true, diff --git a/package.json b/package.json index 7ec127f1c6..b1e1e12741 100644 --- a/package.json +++ b/package.json @@ -8,13 +8,21 @@ "lint": "cd packages/frontend && yarn install --frozen-lockfile && yarn lint", "build": "cd packages/frontend && yarn install --frozen-lockfile && yarn build", "test": "cd packages/frontend && yarn install --frozen-lockfile && yarn test", - "prepare": "husky install" + "prepare": "husky install", + "release": "lerna version --conventional-commits", + "commit": "cz" }, "devDependencies": { - "lerna": "^4.0.0", - "husky": "^7.0.0" + "cz-conventional-changelog": "^3.3.0", + "husky": "^7.0.0", + "lerna": "^4.0.0" }, "dependencies": { "@near-wallet/feature-flags": "^0.0.4" + }, + "config": { + "commitizen": { + "path": "./node_modules/cz-conventional-changelog" + } } } diff --git a/packages/e2e-tests/CHANGELOG.md b/packages/e2e-tests/CHANGELOG.md new file mode 100644 index 0000000000..6cc7a42882 --- /dev/null +++ b/packages/e2e-tests/CHANGELOG.md @@ -0,0 +1,16 @@ +# Change Log + +All notable changes to this project will be documented in this file. +See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. + +## 0.0.4 (2022-02-08) + +**Note:** Version bump only for package @near-wallet/e2e-tests + + + + + +## 0.0.3 (2022-01-07) + +**Note:** Version bump only for package @near-wallet/e2e-tests diff --git a/packages/e2e-tests/Dockerfile b/packages/e2e-tests/Dockerfile index ffbc9138af..3c371dedf0 100644 --- a/packages/e2e-tests/Dockerfile +++ b/packages/e2e-tests/Dockerfile @@ -1,5 +1,15 @@ FROM mcr.microsoft.com/playwright:bionic -COPY --chown=pwuser:pwuser . /e2e-tests -WORKDIR /e2e-tests +RUN mkdir -p /packages/e2e-tests +RUN mkdir -p /packages/frontend +WORKDIR /packages +COPY e2e-tests e2e-tests/ + +COPY frontend frontend/ +COPY .env-parcel-bundler frontend/.env + +RUN cd frontend && yarn && yarn build +RUN cd e2e-tests && yarn + +# CMD cd e2e-tests && yarn run e2e \ No newline at end of file diff --git a/packages/e2e-tests/constants.js b/packages/e2e-tests/constants.js index 0e02c4fa71..f09ac5814e 100644 --- a/packages/e2e-tests/constants.js +++ b/packages/e2e-tests/constants.js @@ -1,6 +1,8 @@ const { BN } = require("bn.js"); const { parseNearAmount } = require("near-api-js/lib/utils/format"); +const dateNowNanosBN = new BN(Date.now()).mul(new BN("1000000")); + module.exports = { WALLET_NETWORK: { MAINNET: "mainnet", @@ -8,7 +10,44 @@ module.exports = { BETANET: "betanet", }, CONTRACT_WASM_URLS: { - LINKDROP: "https://github.com/near/near-linkdrop/raw/63a4d0c4acbc2ffcf865be2b270c900bea765782/res/linkdrop.wasm" + LINKDROP: "https://github.com/near/near-linkdrop/raw/63a4d0c4acbc2ffcf865be2b270c900bea765782/res/linkdrop.wasm", + LOCKUP: "https://github.com/near/core-contracts/raw/master/lockup/res/lockup_contract.wasm", + LOCKUP_V2: "https://github.com/near/core-contracts/raw/2691bd68a284cf519c1dd1927eb03be6fdd7a44b/lockup/res/lockup_contract.wasm" + }, + LOCKUP_CONFIGS: { + HALF_VESTED_CONFIG: { + release_duration: "0", + lockup_timestamp: dateNowNanosBN.sub(new BN("60").mul(new BN("60000000000"))).toString(), // 1 hour ago + vesting_schedule: { + VestingSchedule: { + start_timestamp: dateNowNanosBN.sub(new BN("525600").mul(new BN("60000000000"))).toString(), // 1 year ago + end_timestamp: dateNowNanosBN.add(new BN("525600").mul(new BN("60000000000"))).toString(), // 1 year from now + cliff_timestamp: dateNowNanosBN.toString(), // now + }, + }, + }, + FULLY_UNVESTED_CONFIG: { + release_duration: "0", + lockup_timestamp: dateNowNanosBN.sub(new BN("60").mul(new BN("60000000000"))).toString(), // 1 hour ago + vesting_schedule: { + VestingSchedule: { + start_timestamp: dateNowNanosBN.toString(), // now + end_timestamp: dateNowNanosBN.add(new BN("1051200").mul(new BN("60000000000"))).toString(), // 2 years from now + cliff_timestamp: dateNowNanosBN.add(new BN("525600").mul(new BN("60000000000"))).toString(), // 1 year from now + }, + }, + }, + FULLY_VESTED_CONFIG: { + release_duration: "0", + lockup_timestamp: dateNowNanosBN.sub(new BN("60").mul(new BN("60000000000"))).toString(), // 1 hour ago + vesting_schedule: { + VestingSchedule: { + start_timestamp: dateNowNanosBN.sub(new BN("535600").mul(new BN("60000000000"))).toString(), // 1 year ago + end_timestamp: dateNowNanosBN.sub(new BN("60").mul(new BN("60000000000"))).toString(), // 1 hour ago + cliff_timestamp: dateNowNanosBN.sub(new BN("1051200").mul(new BN("60000000000"))).toString(), // 2 years ago + }, + }, + }, }, - LINKDROP_ACCESS_KEY_ALLOWANCE: new BN(parseNearAmount("1.0")) + LINKDROP_ACCESS_KEY_ALLOWANCE: new BN(parseNearAmount("1.0")), }; diff --git a/packages/e2e-tests/contracts/index.js b/packages/e2e-tests/contracts/index.js index 89e98b87dd..2173964724 100644 --- a/packages/e2e-tests/contracts/index.js +++ b/packages/e2e-tests/contracts/index.js @@ -5,4 +5,6 @@ const fetchAsBuffer = (url) => fetch(url).then((a) => a.buffer()); const fetchLinkdropContract = () => fetchAsBuffer(CONTRACT_WASM_URLS.LINKDROP); -module.exports = { fetchLinkdropContract }; +const fetchLockupContract = ({ v2Wasm } = {}) => fetchAsBuffer(CONTRACT_WASM_URLS[v2Wasm ? "LOCKUP_V2" : "LOCKUP"]); + +module.exports = { fetchLinkdropContract, fetchLockupContract }; diff --git a/packages/e2e-tests/linkDrop/newUser.spec.js b/packages/e2e-tests/linkDrop/newUser.spec.js index 2181d145d1..23213b9f3d 100644 --- a/packages/e2e-tests/linkDrop/newUser.spec.js +++ b/packages/e2e-tests/linkDrop/newUser.spec.js @@ -1,4 +1,4 @@ -const { test, expect } = require("@playwright/test"); +const { test, expect } = require("../playwrightWithFixtures"); const { BN } = require("bn.js"); const { parseNearAmount } = require("near-api-js/lib/utils/format"); @@ -7,8 +7,8 @@ const { HomePage } = require("../register/models/Home"); const { SetRecoveryOptionPage } = require("../register/models/SetRecoveryOption"); const { VerifySeedPhrasePage } = require("../register/models/VerifySeedPhrase"); const nearApiJsConnection = require("../utils/connectionSingleton"); -const { generateTestAccountId, getBankAccount } = require("../utils/account"); -const E2eTestAccount = require('../utils/E2eTestAccount'); +const { generateTestAccountId } = require("../utils/account"); +const E2eTestAccount = require("../utils/E2eTestAccount"); const { LinkDropPage } = require("./models/LinkDrop"); const { SetupSeedPhrasePage } = require("../register/models/SetupSeedPhrase"); const { WALLET_NETWORK, LINKDROP_ACCESS_KEY_ALLOWANCE } = require("../constants"); @@ -24,8 +24,7 @@ describe("Linkdrop flow", () => { const linkdropClaimableAmount = new BN(parseNearAmount(linkdropNEARAmount)).sub(LINKDROP_ACCESS_KEY_ALLOWANCE); - beforeAll(async () => { - const bankAccount = await getBankAccount(); + beforeAll(async ({ bankAccount }) => { linkdropAccountManager = await new LinkdropAccountManager(bankAccount).initialize("11.0"); }); diff --git a/packages/e2e-tests/linkDrop/signedInUser.spec.js b/packages/e2e-tests/linkDrop/signedInUser.spec.js index 056f6d5a19..344ae7914f 100644 --- a/packages/e2e-tests/linkDrop/signedInUser.spec.js +++ b/packages/e2e-tests/linkDrop/signedInUser.spec.js @@ -1,4 +1,4 @@ -const { test, expect } = require("@playwright/test"); +const { test, expect } = require("../playwrightWithFixtures"); const { BN } = require("bn.js"); const { parseNearAmount, formatNearAmount } = require("near-api-js/lib/utils/format"); const { KeyPairEd25519 } = require("near-api-js/lib/utils/key_pair"); @@ -8,7 +8,7 @@ const { HomePage } = require("../register/models/Home"); const { SetRecoveryOptionPage } = require("../register/models/SetRecoveryOption"); const { VerifySeedPhrasePage } = require("../register/models/VerifySeedPhrase"); const nearApiJsConnection = require("../utils/connectionSingleton"); -const { generateTestAccountId, getBankAccount } = require("../utils/account"); +const { generateTestAccountId } = require("../utils/account"); const E2eTestAccount = require('../utils/E2eTestAccount'); const { LinkDropPage } = require("./models/LinkDrop"); const { SetupSeedPhrasePage } = require("../register/models/SetupSeedPhrase"); @@ -25,8 +25,7 @@ describe("Linkdrop flow", () => { const linkdropClaimableAmount = new BN(parseNearAmount(linkdropNEARAmount)).sub(LINKDROP_ACCESS_KEY_ALLOWANCE); - beforeAll(async () => { - const bankAccount = await getBankAccount(); + beforeAll(async ({ bankAccount }) => { linkdropAccountManager = await new LinkdropAccountManager(bankAccount).initialize("11.0"); }); diff --git a/packages/e2e-tests/lockup/fullyUnvested.spec.js b/packages/e2e-tests/lockup/fullyUnvested.spec.js new file mode 100644 index 0000000000..46867e1f17 --- /dev/null +++ b/packages/e2e-tests/lockup/fullyUnvested.spec.js @@ -0,0 +1,101 @@ +const { BN } = require("bn.js"); +const { formatNearAmount, parseNearAmount } = require("near-api-js/lib/utils/format"); + +const { test, expect } = require("../playwrightWithFixtures"); +const { HomePage } = require("../register/models/Home"); +const { ProfilePage } = require("./models/ProfilePage"); +const { + LOCKUP_CONFIGS: { FULLY_UNVESTED_CONFIG }, +} = require("../constants"); + +const { describe, beforeAll, afterAll } = test; + +describe("Fully unvested lockup", () => { + let v2LockupTestAccount, latestLockupTestAccount, v2LockupContractAccount, latestLockupContractAccount; + const lockupAmount = '5.0'; + + beforeAll(async ({ bankAccount }) => { + v2LockupTestAccount = await bankAccount.spawnRandomSubAccountInstance().create({ amount: "6.0" }); + v2LockupContractAccount = await v2LockupTestAccount.createTestLockupSubAccountInstance({ + ...FULLY_UNVESTED_CONFIG, + v2Wasm: true, + amount: lockupAmount, + }); + latestLockupTestAccount = await bankAccount.spawnRandomSubAccountInstance().create({ amount: "6.0" }); + latestLockupContractAccount = await latestLockupTestAccount.createTestLockupSubAccountInstance({...FULLY_UNVESTED_CONFIG, amount: lockupAmount}); + }); + + afterAll(async () => { + await Promise.allSettled([ + v2LockupContractAccount && v2LockupContractAccount.delete().then(v2LockupTestAccount.delete), + latestLockupContractAccount && latestLockupContractAccount.delete().then(latestLockupTestAccount.delete), + ]); + }); + + test("latest lockup contract displays the whole amount as locked, correct unlocked, correct available to transfer and other info correctly", async ({ + page, + }) => { + const { total: lockupTotalBalance } = await latestLockupContractAccount.getUpdatedBalance(); + const lockupUnlockedAmount = new BN(lockupTotalBalance) + .sub(new BN(parseNearAmount(lockupAmount))) + .toString(); + const lockupAvailableToTransfer = await latestLockupContractAccount.nearApiJsAccount.viewFunction( + latestLockupContractAccount.accountId, + "get_liquid_owners_balance" + ); + const homePage = new HomePage(page); + await homePage.navigate(); + await homePage.loginWithSeedPhraseLocalStorage(latestLockupTestAccount.accountId, latestLockupTestAccount.seedPhrase); + const profilePage = new ProfilePage(page); + await profilePage.navigate(); + await expect(page).toMatchText("data-test-id=lockupAccount.total", new RegExp(formatNearAmount(lockupTotalBalance, 5))); + await expect(page).toMatchText("data-test-id=lockupAccount.locked", /5 NEAR/); + await expect(page).toMatchText( + "data-test-id=lockupAccount.unlocked", + new RegExp(`${formatNearAmount(lockupUnlockedAmount, 5)} NEAR`) + ); + await expect(page).toMatchText( + "data-test-id=lockupAccount.availableToTransfer", + new RegExp(`${formatNearAmount(lockupAvailableToTransfer, 5)} NEAR`) + ); + await expect(page).toMatchText("data-test-id=lockupAccount.reservedForStorage", /3.5 NEAR/); + await expect(page).toMatchText( + "data-test-id=lockupAccount.accountId", + new RegExp(`${latestLockupContractAccount.accountId}`) + ); + await expect(page).not.toHaveSelector("data-test-id=lockupTransferToWalletButton"); + }); + test("v2 lockup contract displays the whole amount as locked, correct unlocked, correct available to transfer and other info correctly", async ({ + page, + }) => { + const { total: lockupTotalBalance } = await v2LockupContractAccount.getUpdatedBalance(); + const lockupUnlockedAmount = new BN(lockupTotalBalance) + .sub(new BN(parseNearAmount(lockupAmount))) + .toString(); + const lockupAvailableToTransfer = await v2LockupContractAccount.nearApiJsAccount.viewFunction( + v2LockupContractAccount.accountId, + "get_liquid_owners_balance" + ); + const homePage = new HomePage(page); + await homePage.navigate(); + await homePage.loginWithSeedPhraseLocalStorage(v2LockupTestAccount.accountId, v2LockupTestAccount.seedPhrase); + const profilePage = new ProfilePage(page); + await profilePage.navigate(); + await expect(page).toMatchText("data-test-id=lockupAccount.total", new RegExp(formatNearAmount(lockupTotalBalance, 5))); + await expect(page).toMatchText("data-test-id=lockupAccount.locked", /5 NEAR/); + await expect(page).toMatchText( + "data-test-id=lockupAccount.unlocked", + new RegExp(`${formatNearAmount(lockupUnlockedAmount, 5)} NEAR`) + ); + await expect(page).toMatchText( + "data-test-id=lockupAccount.availableToTransfer", + new RegExp(`${formatNearAmount(lockupAvailableToTransfer, 5)} NEAR`) + ); + await expect(page).toMatchText("data-test-id=lockupAccount.reservedForStorage", /35 NEAR/); + await expect(page).toMatchText( + "data-test-id=lockupAccount.accountId", + new RegExp(`${v2LockupContractAccount.accountId}`) + ); + await expect(page).not.toHaveSelector("data-test-id=lockupTransferToWalletButton"); + }); +}); diff --git a/packages/e2e-tests/lockup/fullyVested.spec.js b/packages/e2e-tests/lockup/fullyVested.spec.js new file mode 100644 index 0000000000..f5e6637f28 --- /dev/null +++ b/packages/e2e-tests/lockup/fullyVested.spec.js @@ -0,0 +1,153 @@ +const { BN } = require("bn.js"); +const { formatNearAmount, parseNearAmount } = require("near-api-js/lib/utils/format"); + +const { test, expect } = require("../playwrightWithFixtures"); +const { HomePage } = require("../register/models/Home"); +const { ProfilePage } = require("./models/ProfilePage"); +const { + LOCKUP_CONFIGS: { FULLY_VESTED_CONFIG }, +} = require("../constants"); +const { bnIsWithinUncertainty } = require("../utils/helpers"); +const { StakeUnstakePage } = require("../stakeUnstake/models/StakeUnstake"); + +const { describe, beforeAll, afterAll } = test; + +describe("Fully vested lockup", () => { + let v2LockupTestAccount, latestLockupTestAccount, v2LockupContractAccount, latestLockupContractAccount; + + beforeAll(async ({ bankAccount }) => { + v2LockupTestAccount = await bankAccount.spawnRandomSubAccountInstance().create({ amount: "6.0" }); + v2LockupContractAccount = await v2LockupTestAccount.createTestLockupSubAccountInstance({ + v2Wasm: true, + ...FULLY_VESTED_CONFIG, + }); + latestLockupTestAccount = await bankAccount.spawnRandomSubAccountInstance().create({ amount: "6.0" }); + latestLockupContractAccount = await latestLockupTestAccount.createTestLockupSubAccountInstance(FULLY_VESTED_CONFIG); + }); + + afterAll(async () => { + await Promise.allSettled([ + v2LockupContractAccount && v2LockupContractAccount.delete().then(v2LockupTestAccount.delete), + latestLockupContractAccount && latestLockupContractAccount.delete().then(latestLockupTestAccount.delete), + ]); + }); + + test("latest lockup contract displays the zero as locked, correct unlocked, correct available to transfer and other info correctly", async ({ + page, + }) => { + const { total: lockupTotalBalance } = await latestLockupContractAccount.getUpdatedBalance(); + const homePage = new HomePage(page); + await homePage.navigate(); + await homePage.loginWithSeedPhraseLocalStorage(latestLockupTestAccount.accountId, latestLockupTestAccount.seedPhrase); + const profilePage = new ProfilePage(page); + await profilePage.navigate(); + await expect(page).toMatchText("data-test-id=lockupAccount.total", new RegExp(formatNearAmount(lockupTotalBalance, 5))); + await expect(page).toMatchText("data-test-id=lockupAccount.locked", /0 NEAR/); + await expect(page).toMatchText( + "data-test-id=lockupAccount.unlocked", + new RegExp(`${formatNearAmount(lockupTotalBalance, 5)} NEAR`) + ); + await expect(page).toMatchText( + "data-test-id=lockupAccount.availableToTransfer", + new RegExp(`${formatNearAmount(lockupTotalBalance, 5)} NEAR`) + ); + await expect(page).toMatchText("data-test-id=lockupAccount.reservedForStorage", /3.5 NEAR/); + await expect(page).toMatchText( + "data-test-id=lockupAccount.accountId", + new RegExp(`${latestLockupContractAccount.accountId}`) + ); + await expect(page).toHaveSelector("data-test-id=lockupTransferToWalletButton"); + }); + test("latest lockup contract withdraws and updates balances and cleans up correctly", async ({ page }) => { + const homePage = new HomePage(page); + await homePage.navigate(); + await homePage.loginWithSeedPhraseLocalStorage(latestLockupTestAccount.accountId, latestLockupTestAccount.seedPhrase); + + const profilePage = new ProfilePage(page); + await profilePage.navigate(); + + const { total: lockupTotalBalance } = await latestLockupContractAccount.getUpdatedBalance(); + const initialBalanceDisplay = await profilePage.getOwnerAccountTotalBalance(); + const initialOwnerAccountDisplayedBalance = new BN(parseNearAmount(initialBalanceDisplay)); + const { total: initialOwnerAccountBalance } = await latestLockupTestAccount.getUpdatedBalance(); + + await expect(page).toHaveSelector("data-test-id=lockupTransferToWalletButton"); + await profilePage.transferToWallet(); + await expect(page).not.toHaveSelector("data-test-id=lockupTransferToWalletButton"); + await expect(page).not.toHaveSelector("data-test-id=lockupAccount.total"); + + const balanceDisplay = await profilePage.getOwnerAccountTotalBalance(); + const ownerAccountDisplayedBalance = new BN(parseNearAmount(balanceDisplay)); + const displayedOwnersBalanceChange = ownerAccountDisplayedBalance.sub(initialOwnerAccountDisplayedBalance); + const { total: ownerAccountBalance } = await latestLockupTestAccount.getUpdatedBalance(); + const ownersBalanceChange = new BN(ownerAccountBalance).sub(new BN(initialOwnerAccountBalance)); + const uncertaintyForGas = new BN(parseNearAmount("0.1")); + + await expect(bnIsWithinUncertainty(uncertaintyForGas, new BN(lockupTotalBalance), displayedOwnersBalanceChange)).toBe( + true + ); + await expect(bnIsWithinUncertainty(uncertaintyForGas, new BN(lockupTotalBalance), ownersBalanceChange)).toBe(true); + + await new StakeUnstakePage(page).clickStakingTab(); + await expect(page).toHaveSelectorCount("data-test-id=accountSelectAvailableBalance", 1); + }); + test("v2 lockup contract displays zero as locked, correct unlocked, correct available to transfer and other info correctly", async ({ + page, + }) => { + const { total: lockupTotalBalance } = await v2LockupContractAccount.getUpdatedBalance(); + const homePage = new HomePage(page); + await homePage.navigate(); + await homePage.loginWithSeedPhraseLocalStorage(v2LockupTestAccount.accountId, v2LockupTestAccount.seedPhrase); + const profilePage = new ProfilePage(page); + await profilePage.navigate(); + await expect(page).toMatchText("data-test-id=lockupAccount.total", new RegExp(formatNearAmount(lockupTotalBalance, 5))); + await expect(page).toMatchText("data-test-id=lockupAccount.locked", /0 NEAR/); + await expect(page).toMatchText( + "data-test-id=lockupAccount.unlocked", + new RegExp(`${formatNearAmount(lockupTotalBalance, 5)} NEAR`) + ); + await expect(page).toMatchText( + "data-test-id=lockupAccount.availableToTransfer", + new RegExp(`${formatNearAmount(lockupTotalBalance, 5)} NEAR`) + ); + await expect(page).toMatchText("data-test-id=lockupAccount.reservedForStorage", /35 NEAR/); + await expect(page).toMatchText( + "data-test-id=lockupAccount.accountId", + new RegExp(`${v2LockupContractAccount.accountId}`) + ); + await expect(page).toHaveSelector("data-test-id=lockupTransferToWalletButton"); + }); + test("v2 lockup contract withdraws and updates balances and cleans up correctly", async ({ page }) => { + const homePage = new HomePage(page); + await homePage.navigate(); + await homePage.loginWithSeedPhraseLocalStorage(v2LockupTestAccount.accountId, v2LockupTestAccount.seedPhrase); + + const profilePage = new ProfilePage(page); + await profilePage.navigate(); + + const { total: lockupTotalBalance } = await v2LockupContractAccount.getUpdatedBalance(); + const initialBalanceDisplay = await profilePage.getOwnerAccountTotalBalance(); + const initialOwnerAccountDisplayedBalance = new BN(parseNearAmount(initialBalanceDisplay)); + const { total: initialOwnerAccountBalance } = await v2LockupTestAccount.getUpdatedBalance(); + + await expect(page).toHaveSelector("data-test-id=lockupTransferToWalletButton"); + await profilePage.transferToWallet(); + await expect(page).not.toHaveSelector("data-test-id=lockupTransferToWalletButton"); + await expect(page).not.toHaveSelector("data-test-id=lockupAccount.total"); + + const balanceDisplay = await profilePage.getOwnerAccountTotalBalance(); + const ownerAccountDisplayedBalance = new BN(parseNearAmount(balanceDisplay)); + const displayedOwnersBalanceChange = ownerAccountDisplayedBalance.sub(initialOwnerAccountDisplayedBalance); + const { total: ownerAccountBalance } = await v2LockupTestAccount.getUpdatedBalance(); + const ownersBalanceChange = new BN(ownerAccountBalance).sub(new BN(initialOwnerAccountBalance)); + const uncertaintyForGas = new BN(parseNearAmount("0.1")); + + await expect(bnIsWithinUncertainty(uncertaintyForGas, new BN(lockupTotalBalance), displayedOwnersBalanceChange)).toBe( + true + ); + await expect(bnIsWithinUncertainty(uncertaintyForGas, new BN(lockupTotalBalance), ownersBalanceChange)).toBe(true); + + await new StakeUnstakePage(page).clickStakingTab(); + await expect(page).toHaveSelectorCount("data-test-id=accountSelectAvailableBalance", 1); + }); +}); diff --git a/packages/e2e-tests/lockup/halfVested.spec.js b/packages/e2e-tests/lockup/halfVested.spec.js new file mode 100644 index 0000000000..9aa76339fa --- /dev/null +++ b/packages/e2e-tests/lockup/halfVested.spec.js @@ -0,0 +1,182 @@ +const { BN } = require("bn.js"); +const { formatNearAmount, parseNearAmount } = require("near-api-js/lib/utils/format"); + +const { test, expect } = require("../playwrightWithFixtures"); +const { HomePage } = require("../register/models/Home"); +const { bnSaturatingSub, bnIsWithinUncertainty } = require("../utils/helpers"); +const { ProfilePage } = require("./models/ProfilePage"); +const { + LOCKUP_CONFIGS: { HALF_VESTED_CONFIG }, +} = require("../constants"); + +const { describe, beforeAll, afterAll } = test; + +describe("haLf vested lockup", () => { + let v2LockupTestAccount, latestLockupTestAccount, v2LockupContractAccount, latestLockupContractAccount; + + beforeAll(async ({ bankAccount }) => { + v2LockupTestAccount = await bankAccount.spawnRandomSubAccountInstance().create({ amount: "37.0" }); + v2LockupContractAccount = await v2LockupTestAccount.createTestLockupSubAccountInstance({ + ...HALF_VESTED_CONFIG, + v2Wasm: true, + amount: "36.0", + }); + latestLockupTestAccount = await bankAccount.spawnRandomSubAccountInstance().create({ amount: "6.0" }); + latestLockupContractAccount = await latestLockupTestAccount.createTestLockupSubAccountInstance({ + ...HALF_VESTED_CONFIG, + amount: "5.0", + }); + }); + + afterAll(async () => { + await Promise.allSettled([ + v2LockupContractAccount && v2LockupContractAccount.delete().then(v2LockupTestAccount.delete), + latestLockupContractAccount && latestLockupContractAccount.delete().then(latestLockupTestAccount.delete), + ]); + }); + + test("latest lockup contract displays the zero as locked, correct unlocked, correct available to transfer and other info correctly", async ({ + page, + bankAccount, + }) => { + const { total: lockupTotalBalance } = await latestLockupContractAccount.getUpdatedBalance(); + const lockupLockedAmount = new BN( + await bankAccount.nearApiJsAccount.viewFunction(latestLockupContractAccount.accountId, "get_locked_amount") + ); + const lockupUnlockedAmount = new BN(lockupTotalBalance).sub(lockupLockedAmount); + const storageCost = new BN(parseNearAmount("3.5")); + const lockupAvailableToTransfer = bnSaturatingSub(new BN(lockupTotalBalance), BN.max(storageCost, lockupLockedAmount)); + + const homePage = new HomePage(page); + await homePage.navigate(); + await homePage.loginWithSeedPhraseLocalStorage(latestLockupTestAccount.accountId, latestLockupTestAccount.seedPhrase); + + const profilePage = new ProfilePage(page); + await profilePage.navigate(); + await expect(page).toMatchText("data-test-id=lockupAccount.total", new RegExp(formatNearAmount(lockupTotalBalance, 5))); + const displayedLockedAmount = parseNearAmount(await profilePage.getLockupAccountLocked()); + const displayedUnlockedAmount = parseNearAmount(await profilePage.getLockupAccountUnlocked()); + + await expect( + bnIsWithinUncertainty(new BN(parseNearAmount("0.01")), new BN(displayedLockedAmount), lockupLockedAmount) + ).toBe(true); + await expect( + bnIsWithinUncertainty(new BN(parseNearAmount("0.01")), new BN(displayedUnlockedAmount), lockupUnlockedAmount) + ).toBe(true); + await expect(page).toMatchText( + "data-test-id=lockupAccount.availableToTransfer", + new RegExp(`^${formatNearAmount(lockupAvailableToTransfer.toString(), 5)}`) + ); + await expect(page).toMatchText("data-test-id=lockupAccount.reservedForStorage", /3.5 NEAR/); + await expect(page).toMatchText( + "data-test-id=lockupAccount.accountId", + new RegExp(`${latestLockupContractAccount.accountId}`) + ); + await expect(page).toHaveSelector("data-test-id=lockupTransferToWalletButton"); + }); + test("latest lockup contract withdraws and updates balances and cleans up correctly", async ({ page, bankAccount }) => { + const homePage = new HomePage(page); + await homePage.navigate(); + await homePage.loginWithSeedPhraseLocalStorage(latestLockupTestAccount.accountId, latestLockupTestAccount.seedPhrase); + + const profilePage = new ProfilePage(page); + await profilePage.navigate(); + + const initialLockupAvailableToTransfer = new BN( + await bankAccount.nearApiJsAccount.viewFunction(latestLockupContractAccount.accountId, "get_liquid_owners_balance") + ); + const initialBalanceDisplay = await profilePage.getOwnerAccountTotalBalance(); + const initialOwnerAccountDisplayedBalance = new BN(parseNearAmount(initialBalanceDisplay)); + const { total: initialOwnerAccountBalance } = await latestLockupTestAccount.getUpdatedBalance(); + + await expect(page).toHaveSelector("data-test-id=lockupTransferToWalletButton"); + await profilePage.transferToWallet(); + await expect(page).not.toHaveSelector("data-test-id=lockupTransferToWalletButton"); + + const balanceDisplay = await profilePage.getOwnerAccountTotalBalance(); + const ownerAccountDisplayedBalance = new BN(parseNearAmount(balanceDisplay)); + const displayedOwnersBalanceChange = ownerAccountDisplayedBalance.sub(initialOwnerAccountDisplayedBalance); + const { total: ownerAccountBalance } = await latestLockupTestAccount.getUpdatedBalance(); + const ownersBalanceChange = new BN(ownerAccountBalance).sub(new BN(initialOwnerAccountBalance)); + const lockupAvailableToTransfer = new BN( + await bankAccount.nearApiJsAccount.viewFunction(latestLockupContractAccount.accountId, "get_liquid_owners_balance") + ); + const uncertaintyForGas = new BN(parseNearAmount("0.1")); + + await expect(bnIsWithinUncertainty(uncertaintyForGas, initialLockupAvailableToTransfer, displayedOwnersBalanceChange)).toBe(true); + await expect(bnIsWithinUncertainty(uncertaintyForGas, initialLockupAvailableToTransfer, ownersBalanceChange)).toBe(true); + await expect(lockupAvailableToTransfer.lt(new BN(parseNearAmount("0.01")))).toBe(true); + }); + test("v2 lockup contract displays zero as locked, correct unlocked, correct available to transfer and other info correctly", async ({ + page, + bankAccount, + }) => { + const { total: lockupTotalBalance } = await v2LockupContractAccount.getUpdatedBalance(); + const lockupLockedAmount = new BN( + await bankAccount.nearApiJsAccount.viewFunction(v2LockupContractAccount.accountId, "get_locked_amount") + ); + const lockupUnlockedAmount = new BN(lockupTotalBalance).sub(lockupLockedAmount); + const storageCost = new BN(parseNearAmount("35")); + const lockupAvailableToTransfer = bnSaturatingSub(new BN(lockupTotalBalance), BN.max(storageCost, lockupLockedAmount)); + + const homePage = new HomePage(page); + await homePage.navigate(); + await homePage.loginWithSeedPhraseLocalStorage(v2LockupTestAccount.accountId, v2LockupTestAccount.seedPhrase); + + const profilePage = new ProfilePage(page); + await profilePage.navigate(); + await expect(page).toMatchText("data-test-id=lockupAccount.total", new RegExp(formatNearAmount(lockupTotalBalance, 5))); + const displayedLockedAmount = parseNearAmount(await profilePage.getLockupAccountLocked()); + const displayedUnlockedAmount = parseNearAmount(await profilePage.getLockupAccountUnlocked()); + + await expect( + bnIsWithinUncertainty(new BN(parseNearAmount("0.01")), new BN(displayedLockedAmount), lockupLockedAmount) + ).toBe(true); + await expect( + bnIsWithinUncertainty(new BN(parseNearAmount("0.01")), new BN(displayedUnlockedAmount), lockupUnlockedAmount) + ).toBe(true); + await expect(page).toMatchText( + "data-test-id=lockupAccount.availableToTransfer", + new RegExp(`^${formatNearAmount(lockupAvailableToTransfer.toString(), 5)}`) + ); + await expect(page).toMatchText("data-test-id=lockupAccount.reservedForStorage", /35 NEAR/); + await expect(page).toMatchText( + "data-test-id=lockupAccount.accountId", + new RegExp(`${v2LockupContractAccount.accountId}`) + ); + await expect(page).toHaveSelector("data-test-id=lockupTransferToWalletButton"); + }); + test("v2 lockup contract withdraws and updates balances and cleans up correctly", async ({ page, bankAccount }) => { + const homePage = new HomePage(page); + await homePage.navigate(); + await homePage.loginWithSeedPhraseLocalStorage(v2LockupTestAccount.accountId, v2LockupTestAccount.seedPhrase); + + const profilePage = new ProfilePage(page); + await profilePage.navigate(); + + const initialLockupAvailableToTransfer = new BN( + await bankAccount.nearApiJsAccount.viewFunction(v2LockupContractAccount.accountId, "get_liquid_owners_balance") + ); + const initialBalanceDisplay = await profilePage.getOwnerAccountTotalBalance(); + const initialOwnerAccountDisplayedBalance = new BN(parseNearAmount(initialBalanceDisplay)); + const { total: initialOwnerAccountBalance } = await v2LockupTestAccount.getUpdatedBalance(); + + await expect(page).toHaveSelector("data-test-id=lockupTransferToWalletButton"); + await profilePage.transferToWallet(); + await expect(page).not.toHaveSelector("data-test-id=lockupTransferToWalletButton"); + + const balanceDisplay = await profilePage.getOwnerAccountTotalBalance(); + const ownerAccountDisplayedBalance = new BN(parseNearAmount(balanceDisplay)); + const displayedOwnersBalanceChange = ownerAccountDisplayedBalance.sub(initialOwnerAccountDisplayedBalance); + const { total: ownerAccountBalance } = await v2LockupTestAccount.getUpdatedBalance(); + const ownersBalanceChange = new BN(ownerAccountBalance).sub(new BN(initialOwnerAccountBalance)); + const lockupAvailableToTransfer = new BN( + await bankAccount.nearApiJsAccount.viewFunction(v2LockupContractAccount.accountId, "get_liquid_owners_balance") + ); + const uncertaintyForGas = new BN(parseNearAmount("0.1")); + + await expect(bnIsWithinUncertainty(uncertaintyForGas, initialLockupAvailableToTransfer, displayedOwnersBalanceChange)).toBe(true); + await expect(bnIsWithinUncertainty(uncertaintyForGas, initialLockupAvailableToTransfer, ownersBalanceChange)).toBe(true); + await expect(lockupAvailableToTransfer.lt(new BN(parseNearAmount("0.01")))).toBe(true); + }); +}); diff --git a/packages/e2e-tests/lockup/models/ProfilePage.js b/packages/e2e-tests/lockup/models/ProfilePage.js new file mode 100644 index 0000000000..b2c71d7a5b --- /dev/null +++ b/packages/e2e-tests/lockup/models/ProfilePage.js @@ -0,0 +1,87 @@ +class ProfilePage { + constructor(page) { + this.page = page; + } + async navigate(accountId) { + await this.page.goto(accountId ? `/profile/${accountId}` : "/profile"); + } + getOwnerAccountId() { + return this.page.textContent("data-test-id=ownerAccount.accountId"); + } + getLockupAccountId() { + return this.page.textContent("data-test-id=lockupAccount.accountId"); + } + async getOwnerAccountTotalBalance() { + const balanceStr = await this.page.textContent("data-test-id=ownerAccount.total"); + return balanceStr.split(' ')[0]; + } + async getLockupAccountTotalBalance() { + const balanceStr = await this.page.textContent("data-test-id=lockupAccount.total"); + return balanceStr.split(' ')[0]; + } + async getOwnerAccountReservedForStorage() { + const balanceStr = await this.page.textContent("data-test-id=ownerAccount.reservedForStorage"); + return balanceStr.split(' ')[0]; + } + async getLockupAccountReservedForStorage() { + const balanceStr = await this.page.textContent("data-test-id=lockupAccount.reservedForStorage"); + return balanceStr.split(' ')[0]; + } + async getOwnerAccountReservedForTransactions() { + const balanceStr = await this.page.textContent("data-test-id=ownerAccount.reservedForTransactions"); + return balanceStr.split(' ')[0]; + } + async getOwnerAccountAvailableBalance() { + const balanceStr = await this.page.textContent("data-test-id=ownerAccount.available"); + return balanceStr.split(' ')[0]; + } + async getOwnerAccountStakingTotal() { + const balanceStr = await this.page.textContent("data-test-id=ownerAccount.staking.total"); + return balanceStr.split(' ')[0]; + } + async getLockupAccountStakingTotal() { + const balanceStr = await this.page.textContent("data-test-id=lockupAccount.staking.total"); + return balanceStr.split(' ')[0]; + } + async getOwnerAccountStaked() { + const balanceStr = await this.page.textContent("data-test-id=ownerAccount.staking.staked"); + return balanceStr.split(' ')[0]; + } + async getLockupAccountStaked() { + const balanceStr = await this.page.textContent("data-test-id=lockupAccount.staking.staked"); + return balanceStr.split(' ')[0]; + } + async getOwnerAccountPendingRelease() { + const balanceStr = await this.page.textContent("data-test-id=ownerAccount.staking.pendingRelease"); + return balanceStr.split(' ')[0]; + } + async getLockupAccountPendingRelease() { + const balanceStr = await this.page.textContent("data-test-id=lockupAccount.staking.pendingRelease"); + return balanceStr.split(' ')[0]; + } + async getOwnerAccountAvailableToWithdraw() { + const balanceStr = await this.page.textContent("data-test-id=ownerAccount.staking.availableToWithdraw"); + return balanceStr.split(' ')[0]; + } + async getLockupAccountAvailableToWithdraw() { + const balanceStr = await this.page.textContent("data-test-id=lockupAccount.staking.availableToWithdraw"); + return balanceStr.split(' ')[0]; + } + async getLockupAccountLocked() { + const balanceStr = await this.page.textContent("data-test-id=lockupAccount.locked"); + return balanceStr.split(' ')[0]; + } + async getLockupAccountUnlocked() { + const balanceStr = await this.page.textContent("data-test-id=lockupAccount.unlocked"); + return balanceStr.split(' ')[0]; + } + async getLockupAccountAvailableToTransfer() { + const balanceStr = await this.page.textContent("data-test-id=lockupAccount.availableToTransfer"); + return balanceStr.split(' ')[0]; + } + transferToWallet() { + return this.page.click("data-test-id=lockupTransferToWalletButton"); + } +} + +module.exports = { ProfilePage }; diff --git a/packages/e2e-tests/loginWithDapp/signedInUser.spec.js b/packages/e2e-tests/loginWithDapp/signedInUser.spec.js index 86daabc48c..1541dacb75 100644 --- a/packages/e2e-tests/loginWithDapp/signedInUser.spec.js +++ b/packages/e2e-tests/loginWithDapp/signedInUser.spec.js @@ -1,7 +1,6 @@ -const { test, expect } = require("@playwright/test"); +const { test, expect } = require("../playwrightWithFixtures"); const { HomePage } = require("../register/models/Home"); -const { getBankAccount } = require("../utils/account"); const { testDappURL } = require("../utils/config"); const { LoginPage } = require("./models/Login"); @@ -10,9 +9,8 @@ const { describe, beforeAll, afterAll, beforeEach } = test; describe("Login with Dapp", () => { let testAccount; - beforeAll(async () => { - const bankAccount = await getBankAccount(); - testAccount = bankAccount.spawnRandomSubAccountInstance(); + beforeAll(async ({ bankAccount }) => { + testAccount = await bankAccount.spawnRandomSubAccountInstance(); await testAccount.create(); }); diff --git a/packages/e2e-tests/loginWithDapp/signedInUserFAK.spec.js b/packages/e2e-tests/loginWithDapp/signedInUserFAK.spec.js index 8750abf0c6..ec10892ed8 100644 --- a/packages/e2e-tests/loginWithDapp/signedInUserFAK.spec.js +++ b/packages/e2e-tests/loginWithDapp/signedInUserFAK.spec.js @@ -1,7 +1,6 @@ -const { test, expect } = require("@playwright/test"); +const { test, expect } = require("../playwrightWithFixtures"); const { HomePage } = require("../register/models/Home"); -const { getBankAccount } = require("../utils/account"); const { testDappURL } = require("../utils/config"); const { LoginPage } = require("./models/Login"); @@ -10,8 +9,7 @@ const { describe, beforeAll, afterAll, beforeEach } = test; describe("Login with Dapp", () => { let testAccount; - beforeAll(async () => { - const bankAccount = await getBankAccount(); + beforeAll(async ({ bankAccount }) => { testAccount = bankAccount.spawnRandomSubAccountInstance(); await testAccount.create(); }); diff --git a/packages/e2e-tests/package.json b/packages/e2e-tests/package.json index c6cf276b98..9a3e4d1097 100644 --- a/packages/e2e-tests/package.json +++ b/packages/e2e-tests/package.json @@ -1,6 +1,6 @@ { "name": "@near-wallet/e2e-tests", - "version": "0.0.2", + "version": "0.0.4", "main": "index.js", "license": "MIT", "publishConfig": { @@ -14,7 +14,8 @@ "expect-playwright": "^0.7.2", "express": "^4.17.1", "lodash": "^4.17.21", - "near-api-js": "^0.36.3", + "ms": "^2.1.3", + "near-api-js": "^0.43.1", "near-seed-phrase": "^0.1.0", "node-fetch": "^2.6.2", "playwright": "^1.14.0" diff --git a/packages/e2e-tests/playwright.config.js b/packages/e2e-tests/playwright.config.js index 5586c063e2..482001272b 100644 --- a/packages/e2e-tests/playwright.config.js +++ b/packages/e2e-tests/playwright.config.js @@ -7,6 +7,14 @@ expect.extend(matchers); const config = { globalSetup: require.resolve("./global-setup.js"), + reporter: [["./reporters/WalletE2eLogsReporter.js", { logger: console }], ["./reporters/pagerduty-reporter.js"]], + webServer: { + command: + "cd ../frontend && npx serve dist -l 1234 -s --ssl-cert devServerCertificates/primary.crt --ssl-key devServerCertificates/private.pem", + port: 1234, + timeout: 120 * 1000, + reuseExistingServer: false, + }, timeout: 60000, use: { baseURL: process.env.WALLET_URL || "https://wallet.testnet.near.org", @@ -27,8 +35,6 @@ const config = { name: "Desktop Chromium", use: { browserName: "chromium", - // Test against Chrome Beta channel. - channel: "chrome-beta", }, }, { diff --git a/packages/e2e-tests/playwrightWithFixtures.js b/packages/e2e-tests/playwrightWithFixtures.js new file mode 100644 index 0000000000..f23b229ab0 --- /dev/null +++ b/packages/e2e-tests/playwrightWithFixtures.js @@ -0,0 +1,41 @@ +const base = require("@playwright/test"); +const { BN } = require("bn.js"); + +const { WALLET_NETWORK } = require("./constants"); +const { getBankAccount } = require("./utils/account"); +const nearApiJsConnection = require("./utils/connectionSingleton"); +const E2eTestAccount = require("./utils/E2eTestAccount"); +const { getTestAccountSeedPhrase, getWorkerAccountId } = require("./utils/helpers"); +const SelfReloadingE2eTestAccount = require("./utils/SelfReloadingE2eTestAccount"); + +module.exports = base; + +module.exports.test = base.test.extend({ + bankAccount: [ + async ({}, use, workerInfo) => { + const bankAccount = await getBankAccount(); + const workerBankAccountId = getWorkerAccountId(workerInfo.workerIndex); + const workerBankAccountSeedphrase = getTestAccountSeedPhrase(workerBankAccountId); + const workerBankAccount = await new (nearApiJsConnection.config.networkId !== WALLET_NETWORK.MAINNET + ? SelfReloadingE2eTestAccount + : E2eTestAccount)( + workerBankAccountId, + workerBankAccountSeedphrase, + bankAccount.nearApiJsAccount + ).connectOrCreate(); + const { total: startBalance } = await workerBankAccount.getUpdatedBalance(); + process.env.workerBankStartBalance = startBalance; + await use(workerBankAccount); + const { total: endBalance } = await workerBankAccount.getUpdatedBalance(); + const amountSpent = new BN(process.env.workerBankStartBalance).sub(new BN(endBalance)).toString(); + console.log( + JSON.stringify([ + "WorkerExpenseLog", + { workerBankAccount: workerBankAccount.accountId, amountSpent, workerIndex: workerInfo.workerIndex }, + ]) + ); + await workerBankAccount.delete(); + }, + { scope: "worker", auto: true }, + ], +}); diff --git a/packages/e2e-tests/recover/seedPhrase.spec.js b/packages/e2e-tests/recover/seedPhrase.spec.js index 77444e9f61..bdf6b47efa 100644 --- a/packages/e2e-tests/recover/seedPhrase.spec.js +++ b/packages/e2e-tests/recover/seedPhrase.spec.js @@ -1,14 +1,11 @@ -const { test, expect } = require("@playwright/test"); - -const { getBankAccount } = require("../utils/account"); +const { test, expect } = require("../playwrightWithFixtures"); const { describe, beforeAll, afterAll } = test; describe("Account Recovery Using Seed Phrase", () => { let testAccount; - beforeAll(async () => { - const bankAccount = await getBankAccount(); + beforeAll(async ({ bankAccount }) => { testAccount = bankAccount.spawnRandomSubAccountInstance(); await testAccount.create(); }); diff --git a/packages/e2e-tests/reporters/WalletE2eLogsReporter.js b/packages/e2e-tests/reporters/WalletE2eLogsReporter.js new file mode 100644 index 0000000000..8b381348ce --- /dev/null +++ b/packages/e2e-tests/reporters/WalletE2eLogsReporter.js @@ -0,0 +1,115 @@ +const { BN } = require("bn.js"); +const milliseconds = require("ms"); + +const { formatNearAmount } = require("near-api-js/lib/utils/format"); +const { getBankAccount } = require("../utils/account"); +const { formatTestTitle, formatFailure } = require("./playwright-formatters"); + +/** @implements {import('@playwright/test/reporter').Reporter} */ +class WalletE2eLogsReporter { + constructor({ logger }) { + this.workerExpenseLogs = []; + this.logger = logger; + this.bankStartBalance = "0"; + } + async onBegin(config, suite) { + const [seconds, nanoseconds] = process.hrtime(); + this.monotonicStartTime = seconds * 1000 + ((nanoseconds / 1000000) | 0); + this.config = config; + this.suite = suite; + const bankAccount = await getBankAccount(); + this.bankStartBalance = (await bankAccount.getUpdatedBalance()).total; + } + onStdOut(chunk) { + this.collectWorkerExpenseLogs(chunk); + } + formatTestResult(test, result) { + const duration = ` (${milliseconds(result.duration)})`; + const title = formatTestTitle(this.config, test); + let text = ""; + if (result.status === "skipped") { + text = " - " + title; + } else { + const statusMark = (" " + (result.status === "passed" ? "✓" : "✘")).padEnd(5); + text = statusMark + title + duration; + } + return text; + } + onTestEnd(test, result) { + const text = this.formatTestResult(test, result); + result.status === "passed" ? this.logger.info(text) : this.logger.error(text); + } + collectWorkerExpenseLogs(chunk) { + if (/WorkerExpenseLog/.test(chunk)) { + this.workerExpenseLogs.push(JSON.parse(chunk)[1]); + } + } + getFailedTests() { + return this.suite.allTests().filter((test) => { + const outcome = test.outcome(); + const skippedWithFailure = outcome === "skipped" && test.results.some((result) => !!result.error); + return outcome === "unexpected" || outcome === "flaky" || skippedWithFailure; + }); + } + getPassedTests() { + return this.suite.allTests().filter((test) => test.outcome() === "expected"); + } + getSkippedTests() { + return this.suite.allTests().filter((test) => test.outcome() === "skipped"); + } + getTestsForWorkerIndex(idx) { + return this.suite.allTests().filter(({ results }) => results.some(({ workerIndex }) => workerIndex === idx)); + } + async printWorkerExpenses() { + this.workerExpenseLogs + .sort( + ({ amountSpent: amountSpentA }, { amountSpent: amountSpentB }) => -new BN(amountSpentA).cmp(new BN(amountSpentB)) + ) + .forEach(({ workerBankAccount, amountSpent, workerIndex }) => { + this.logger.info(`amount spent by worker acc ${workerBankAccount}: ${formatNearAmount(amountSpent)} Ⓝ`); + this.logger.info( + `tests:\n${this.getTestsForWorkerIndex(workerIndex) + .map((test, testIdx) => + test.results + .map( + (result, resultIdx) => + `\t${`${testIdx + 1}.`.replace(/./, (c) => + resultIdx === 0 ? c : " " + )} ${this.formatTestResult(test, result)}` + ) + .join("\n") + ) + .join("\n")}` + ); + }); + this.logger.info( + `Total amount spent: ${formatNearAmount( + this.workerExpenseLogs.reduce((acc, { amountSpent }) => new BN(amountSpent).add(acc), new BN(0)).toString() + )} Ⓝ` + ); + const bankAccount = await getBankAccount(); + const { total: endBalance } = await bankAccount.getUpdatedBalance(); + this.logger.info( + `Bank account difference: ${formatNearAmount(new BN(endBalance).sub(new BN(this.bankStartBalance)).toString())} Ⓝ` + ); + } + async onEnd() { + const failed = this.getFailedTests(); + const passed = this.getPassedTests(); + const skipped = this.getSkippedTests(); + this.logger.error(`${failed.length} failed`); + this.logger.info(`${passed.length} passed`); + this.logger.info(`${skipped.length} skipped`); + await this.printWorkerExpenses(); + // TODO: replace with below when playwright dependency version is updated + failed.forEach((test, index) => { + const formattedFailure = formatFailure(this.config, test, { + index: index + 1, + includeStdio: true, + }); + this.logger.error(formattedFailure.message); + }); + } +} + +module.exports = WalletE2eLogsReporter; diff --git a/packages/e2e-tests/reporters/pagerduty-reporter.js b/packages/e2e-tests/reporters/pagerduty-reporter.js new file mode 100644 index 0000000000..45102aa7c4 --- /dev/null +++ b/packages/e2e-tests/reporters/pagerduty-reporter.js @@ -0,0 +1,37 @@ +const { api } = require("@pagerduty/pdjs"); +const pd = api({ token: process.env.PAGERDUTY_API_KEY }); + +class PagerDutyReporter { + onEnd(result) { + if (result.status == "failed" && process.env.isCI) { + // Creating incident on PagerDuty + return pd.post("/incidents", { + data: { + incident: { + type: "incident", + title: "wallet e2e-tests failure", + service: { + id: process.env.PAGERDUTY_WALLET_SERVICE_ID, + type: "service_reference", + }, + assignments: [ + { + assignee: { + id: "PB796BV", // osman@near.org + type: "user_reference", + }, + }, + ], + body: { + type: "incident_body", + details: ` +Wallet e2e-test suite has failed. See ${process.env.E2E_TESTS_LOGS_URL} for details.`, + }, + }, + }, + }); + } + } +} + +module.exports = PagerDutyReporter; diff --git a/packages/e2e-tests/reporters/playwright-formatters.js b/packages/e2e-tests/reporters/playwright-formatters.js new file mode 100644 index 0000000000..b79f72d60f --- /dev/null +++ b/packages/e2e-tests/reporters/playwright-formatters.js @@ -0,0 +1,254 @@ +// copied from @playwright/test/lib/reporters/base +const { relative, resolve } = require("path"); +const { gray, red, cyan, enabled, dim } = require("colors/safe"); +var _codeFrame = require("@babel/code-frame"); +const fs = require("fs"); +const StackUtils = require("stack-utils"); + +const stackUtils = new StackUtils(); +const kOutputSymbol = Symbol('output'); + +function prepareErrorStack(stack, file) { + const lines = stack.split("\n"); + let firstStackLine = lines.findIndex((line) => line.startsWith(" at ")); + if (firstStackLine === -1) firstStackLine = lines.length; + const message = lines.slice(0, firstStackLine).join("\n"); + const stackLines = lines.slice(firstStackLine); + const position = file ? positionInFile(stackLines, file) : undefined; + return { + message, + stackLines, + position, + }; +} + +function formatError(error, highlightCode, file) { + const stack = error.stack; + const tokens = [""]; + let positionInFile; + + if (stack) { + const { message, stackLines, position } = prepareErrorStack(stack, file); + positionInFile = position; + tokens.push(message); + const codeFrame = generateCodeFrame( + { + highlightCode, + }, + file, + position + ); + + if (codeFrame) { + tokens.push(""); + tokens.push(codeFrame); + } + + tokens.push(""); + tokens.push(dim(stackLines.join("\n"))); + } else if (error.message) { + tokens.push(error.message); + } else if (error.value) { + tokens.push(error.value); + } + + return { + position: positionInFile, + message: tokens.join("\n"), + }; +} + +function generateCodeFrame(options, file, position) { + if (!position || !file) return; + + const source = fs.readFileSync(file, "utf8"); + + const codeFrame = (0, _codeFrame.codeFrameColumns)( + source, + { + start: position, + }, + options + ); + return codeFrame; +} + +function prepareErrorStack(stack, file) { + const lines = stack.split("\n"); + let firstStackLine = lines.findIndex((line) => line.startsWith(" at ")); + if (firstStackLine === -1) firstStackLine = lines.length; + const message = lines.slice(0, firstStackLine).join("\n"); + const stackLines = lines.slice(firstStackLine); + const position = file ? positionInFile(stackLines, file) : undefined; + return { + message, + stackLines, + position, + }; +} + +function positionInFile(stackLines, file) { + // Stack will have /private/var/folders instead of /var/folders on Mac. + file = fs.realpathSync(file); + + for (const line of stackLines) { + const parsed = stackUtils.parseLine(line); + if (!parsed || !parsed.file) continue; + if (resolve(process.cwd(), parsed.file) === file) + return { + column: parsed.column || 0, + line: parsed.line || 0, + }; + } +} + +function indent(lines, tab) { + return lines.replace(/^(?=.+$)/gm, tab); +} + +function stripAnsiEscapes(str) { + return str.replace(asciiRegex, ""); +} + +function pad(line, char) { + if (line) line += " "; + return line + gray(char.repeat(Math.max(0, 100 - line.length))); +} + +function formatTestHeader(config, test, indent, index) { + const title = formatTestTitle(config, test); + const header = `${indent}${index ? index + ") " : ""}${title}`; + return pad(header, "="); +} + +function stepSuffix(step) { + const stepTitles = step ? step.titlePath() : []; + return stepTitles.map((t) => " › " + t).join(""); +} + +function relativeTestPath(config, test) { + return relative(config.rootDir, test.location.file) || _path.default.basename(test.location.file); +} + +function formatResultFailure(test, result, initialIndent, highlightCode) { + var _error; + + const resultTokens = []; + + if (result.status === "timedOut") { + resultTokens.push(""); + resultTokens.push(indent(red(`Timeout of ${test.timeout}ms exceeded.`), initialIndent)); + } + + if (result.status === "passed" && test.expectedStatus === "failed") { + resultTokens.push(""); + resultTokens.push(indent(red(`Expected to fail, but passed.`), initialIndent)); + } + + let error = undefined; + + if (result.error !== undefined) { + error = formatError(result.error, highlightCode, test.location.file); + resultTokens.push(indent(error.message, initialIndent)); + } + + return { + tokens: resultTokens, + position: (_error = error) === null || _error === void 0 ? void 0 : _error.position, + }; +} + +function formatTestTitle(config, test, step) { + // root, project, file, ...describes, test + const [, projectName, , ...titles] = test.titlePath(); + const location = `${relativeTestPath(config, test)}:${test.location.line}:${test.location.column}`; + const projectTitle = projectName ? `[${projectName}] › ` : ""; + return `${projectTitle}${location} › ${titles.join(" › ")}${stepSuffix(step)}`; +} + +function formatFailure(config, test, options = {}) { + const { index, includeStdio, includeAttachments = true, filePath } = options; + const lines = []; + const title = formatTestTitle(config, test); + const annotations = []; + const header = formatTestHeader(config, test, " ", index); + lines.push(red(header)); + + for (const result of test.results) { + const resultLines = []; + const { tokens: resultTokens, position } = formatResultFailure(test, result, " ", enabled); + if (!resultTokens.length) continue; + + if (result.retry) { + resultLines.push(""); + resultLines.push(gray(pad(` Retry #${result.retry}`, "-"))); + } + + resultLines.push(...resultTokens); + + if (includeAttachments) { + for (let i = 0; i < result.attachments.length; ++i) { + const attachment = result.attachments[i]; + resultLines.push(""); + resultLines.push(cyan(pad(` attachment #${i + 1}: ${attachment.name} (${attachment.contentType})`, "-"))); + + if (attachment.path) { + const relativePath = relative(process.cwd(), attachment.path); + + resultLines.push(cyan(` ${relativePath}`)); // Make this extensible + + if (attachment.name === "trace") { + resultLines.push(cyan(` Usage:`)); + resultLines.push(""); + resultLines.push(cyan(` npx playwright show-trace ${relativePath}`)); + resultLines.push(""); + } + } else { + if (attachment.contentType.startsWith("text/")) { + let text = attachment.body.toString(); + if (text.length > 300) text = text.slice(0, 300) + "..."; + resultLines.push(cyan(` ${text}`)); + } + } + + resultLines.push(cyan(pad(" ", "-"))); + } + } + + const output = result[kOutputSymbol] || []; + + if (includeStdio && output.length) { + const outputText = output + .map(({ chunk, type }) => { + const text = chunk.toString("utf8"); + if (type === "stderr") return red(stripAnsiEscapes(text)); + return text; + }) + .join(""); + resultLines.push(""); + resultLines.push(gray(pad("--- Test output", "-")) + "\n\n" + outputText + "\n" + pad("", "-")); + } + + if (filePath) { + annotations.push({ + filePath, + position, + title, + message: [header, ...resultLines].join("\n"), + }); + } + + lines.push(...resultLines); + } + + lines.push(""); + return { + message: lines.join("\n"), + annotations, + }; +} + +module.exports = { + formatFailure, + formatTestTitle, +}; diff --git a/packages/e2e-tests/services/contractHelper.js b/packages/e2e-tests/services/contractHelper.js new file mode 100644 index 0000000000..aae50aa110 --- /dev/null +++ b/packages/e2e-tests/services/contractHelper.js @@ -0,0 +1,20 @@ +const fetch = require("node-fetch"); + +const { getKeyPairFromSeedPhrase } = require("../utils/helpers"); +const nearApiJsConnection = require("../utils/connectionSingleton"); + +const createAccountWithHelper = async (accountId, seedPhrase) => { + const { publicKey } = getKeyPairFromSeedPhrase(seedPhrase); + await fetch(`${nearApiJsConnection.config.helperUrl}/account`, { + method: "POST", + headers: { "Content-type": "application/json" }, + body: JSON.stringify({ + newAccountId: accountId, + newAccountPublicKey: publicKey.toString(), + }), + }).then((res) => res.json()); +}; + +module.exports = { + createAccountWithHelper, +}; diff --git a/packages/e2e-tests/stakeUnstake/lockupStaking.spec.js b/packages/e2e-tests/stakeUnstake/lockupStaking.spec.js new file mode 100644 index 0000000000..e387424b0d --- /dev/null +++ b/packages/e2e-tests/stakeUnstake/lockupStaking.spec.js @@ -0,0 +1,83 @@ +const { + LOCKUP_CONFIGS: { FULLY_UNVESTED_CONFIG }, +} = require("../constants"); + +const { test, expect } = require("../playwrightWithFixtures"); +const { HomePage } = require("../register/models/Home"); +const { generateNUniqueRandomNumbersInRange } = require("../utils/helpers"); +const { StakeUnstakePage } = require("./models/StakeUnstake"); + +const { describe, beforeAll, afterAll, beforeEach } = test; + +describe("Lockup stake and unstake", () => { + let testAccount, lockupAccount; + + beforeAll(async ({ bankAccount }) => { + testAccount = await bankAccount.spawnRandomSubAccountInstance().create({ amount: "6.0" }); + lockupAccount = await testAccount.createTestLockupSubAccountInstance(FULLY_UNVESTED_CONFIG); + }); + + afterAll(async () => { + lockupAccount && (await lockupAccount.delete().then(() => testAccount && testAccount.delete)); + }); + + beforeEach(async ({ page }) => { + const homePage = new HomePage(page); + await homePage.navigate(); + await homePage.loginWithSeedPhraseLocalStorage(testAccount.accountId, testAccount.seedPhrase); + }); + + test("Is able to run normal staking flow still", async ({ page }) => { + const stakeUnstakePage = new StakeUnstakePage(page); + await stakeUnstakePage.navigate(); + + await stakeUnstakePage.clickStakeButton(); + const validatorLastIndex = (await stakeUnstakePage.getNumberOfSelectableValidators()) - 1; + const [randomValidatorIndex] = generateNUniqueRandomNumbersInRange({ from: 0, to: validatorLastIndex }, 1); + await stakeUnstakePage.runStakingFlowWithAmount(0.1, randomValidatorIndex); + await expect(page).toMatchText("data-test-id=stakingPageTotalStakedAmount", /0.1 NEAR/); + }); + + test("Stakes and unstakes with locked funds and can't stake with multiple validators simultaneously", async ({ page }) => { + const stakeUnstakePage = new StakeUnstakePage(page); + await stakeUnstakePage.navigate(); + await stakeUnstakePage.selectNthAccount(0); + await expect(page).toMatchText("data-test-id=stakingPageTotalStakedAmount", /0.1 NEAR/); + await stakeUnstakePage.selectNthAccount(1); + await expect(page).toMatchText("data-test-id=stakingPageTotalStakedAmount", /0 NEAR/); + + await stakeUnstakePage.clickStakeButton(); + const validatorLastIndex = (await stakeUnstakePage.getNumberOfSelectableValidators()) - 1; + const randomValidatorIndexes = generateNUniqueRandomNumbersInRange({ from: 0, to: validatorLastIndex }, 2); + + await stakeUnstakePage.runStakingFlowWithAmount(0.2, randomValidatorIndexes[0]); + + await stakeUnstakePage.selectNthAccount(0); + await expect(page).toMatchText("data-test-id=stakingPageTotalStakedAmount", /0.1 NEAR/); + await stakeUnstakePage.selectNthAccount(1); + await expect(page).toMatchText("data-test-id=stakingPageTotalStakedAmount", /0.2 NEAR/); + + await stakeUnstakePage.clickStakeButton(); + await stakeUnstakePage.stakeWithValidator(randomValidatorIndexes[1]); + await expect(page).toHaveSelector("data-test-id=cantStakeWithValidatorContainer"); + + await stakeUnstakePage.clickViewCurrentValidator(); + await stakeUnstakePage.clickValidatorPageUnstakeButton(); + await stakeUnstakePage.submitStakeWithMaxAmount(); + await stakeUnstakePage.confirmStakeOnModal(); + await stakeUnstakePage.returnToDashboard(); + + await stakeUnstakePage.selectNthAccount(0); + await expect(page).toMatchText("data-test-id=stakingPageTotalStakedAmount", /0.1 NEAR/); + await stakeUnstakePage.selectNthAccount(1); + await expect(page).toMatchText("data-test-id=stakingPageTotalStakedAmount", /0 NEAR/); + + await stakeUnstakePage.clickStakeButton(); + await stakeUnstakePage.stakeWithValidator(randomValidatorIndexes[1]); + await expect(page).toHaveSelector("data-test-id=cantStakeWithValidatorContainer"); + + await stakeUnstakePage.navigate(); + await stakeUnstakePage.selectNthAccount(1); + await stakeUnstakePage.clickStakeButton(); + }); +}); diff --git a/packages/e2e-tests/stakeUnstake/models/StakeUnstake.js b/packages/e2e-tests/stakeUnstake/models/StakeUnstake.js index 0f3bea15eb..5ae8892a43 100644 --- a/packages/e2e-tests/stakeUnstake/models/StakeUnstake.js +++ b/packages/e2e-tests/stakeUnstake/models/StakeUnstake.js @@ -8,6 +8,9 @@ class StakeUnstakePage { async navigate() { await this.page.goto("/staking"); } + async clickStakingTab() { + await this.page.click("data-test-id=staking_navlink") + } async clickStakeButton() { await this.page.click("data-test-id=stakeMyTokensButton"); } @@ -20,11 +23,17 @@ class StakeUnstakePage { async stakeWithValidator(validatorIndex = 0) { await this.page.click(`data-test-id=stakingPageSelectValidator >> nth=${validatorIndex}`); } - async getNumberOfSelectableValidators() { + getNumberOfSelectableValidators() { return this.page.locator(`data-test-id=stakingPageSelectValidator`).count(); } - async getCurrentlyDisplayedBalance() { - const balanceString = await this.page.textContent("data-test-id=accountSelectAvailableBalance"); + clickViewCurrentValidator() { + return this.page.click("data-test-id=viewCurrentValidatorButton") + } + selectNthAccount(n = 0) { + return this.page.click(`data-test-id=accountSelectAvailableBalance >> nth=${n}`); + } + async getCurrentlyDisplayedBalance(index = 0) { + const balanceString = await this.page.textContent(`data-test-id=accountSelectAvailableBalance >> nth=${index}`); return new BN(parseNearAmount(balanceString.split(" ")[0])); } async clickStakeWithValidator() { @@ -50,9 +59,12 @@ class StakeUnstakePage { await this.confirmStakeOnModal(); await this.returnToDashboard(); } - async clickUnstakeButton() { + async clickStakingPageUnstakeButton() { await this.page.click("data-test-id=stakingPageUnstakingButton"); } + async clickValidatorPageUnstakeButton() { + await this.page.click("data-test-id=validatorPageUnstakeButton"); + } async submitStakeWithMaxAmount() { await this.page.click("data-test-id=stakingPageUseMaxButton"); const submittedAmount = await this.page.inputValue("data-test-id=stakingAmountInput"); diff --git a/packages/e2e-tests/stakeUnstake/stake.spec.js b/packages/e2e-tests/stakeUnstake/stake.spec.js index 163e5af9e7..d905c43e84 100644 --- a/packages/e2e-tests/stakeUnstake/stake.spec.js +++ b/packages/e2e-tests/stakeUnstake/stake.spec.js @@ -1,9 +1,8 @@ -const { test, expect } = require("@playwright/test"); +const { test, expect } = require("../playwrightWithFixtures"); const BN = require("bn.js"); const { parseNearAmount, formatNearAmount } = require("near-api-js/lib/utils/format"); const { HomePage } = require("../register/models/Home"); -const { getBankAccount } = require("../utils/account"); const { StakeUnstakePage } = require("./models/StakeUnstake"); const { describe, beforeAll, afterAll, beforeEach } = test; @@ -11,8 +10,7 @@ const { describe, beforeAll, afterAll, beforeEach } = test; describe("Staking flow", () => { let testAccount; - beforeAll(async () => { - const bankAccount = await getBankAccount(); + beforeAll(async ({ bankAccount }) => { testAccount = bankAccount.spawnRandomSubAccountInstance(); await testAccount.create(); }); diff --git a/packages/e2e-tests/stakeUnstake/unstake.spec.js b/packages/e2e-tests/stakeUnstake/unstake.spec.js index 1f2a90516c..272227bc42 100644 --- a/packages/e2e-tests/stakeUnstake/unstake.spec.js +++ b/packages/e2e-tests/stakeUnstake/unstake.spec.js @@ -1,18 +1,16 @@ -const { test, expect } = require("@playwright/test"); +const { test, expect } = require("../playwrightWithFixtures"); const { formatNearAmount } = require("near-api-js/lib/utils/format"); const { StakeUnstakePage } = require("./models/StakeUnstake"); const { HomePage } = require("../register/models/Home"); const { generateNUniqueRandomNumbersInRange } = require("../utils/helpers"); -const { getBankAccount } = require("../utils/account"); const { describe, afterEach, beforeEach } = test; describe("Unstaking flow", () => { let testAccount; - beforeEach(async ({ page }) => { - const bankAccount = await getBankAccount(); + beforeEach(async ({ page, bankAccount }) => { testAccount = bankAccount.spawnRandomSubAccountInstance(); await testAccount.create(); const homePage = new HomePage(page); @@ -33,7 +31,7 @@ describe("Unstaking flow", () => { await stakeUnstakePage.runStakingFlowWithAmount(0.1, randomValidatorIndexes[0]); await stakeUnstakePage.clickStakeButton(); await stakeUnstakePage.runStakingFlowWithAmount(0.2, randomValidatorIndexes[1]); - await stakeUnstakePage.clickUnstakeButton(); + await stakeUnstakePage.clickStakingPageUnstakeButton(); await expect(page).toMatchURL(/\/staking\/unstake$/); await expect(page).toHaveSelectorCount("data-test-id=stakingPageValidatorItem", 2); @@ -50,7 +48,7 @@ describe("Unstaking flow", () => { await stakeUnstakePage.runStakingFlowWithAmount(0.1, randomValidatorIndexes[0]); await stakeUnstakePage.clickStakeButton(); await stakeUnstakePage.runStakingFlowWithAmount(0.2, randomValidatorIndexes[1]); - await stakeUnstakePage.clickUnstakeButton(); + await stakeUnstakePage.clickStakingPageUnstakeButton(); const stakedValidatorName = await stakeUnstakePage.getValidatorName(1) await stakeUnstakePage.clickValidatorItem(0); const submittedUnstakeAmount = await stakeUnstakePage.submitStakeWithMaxAmount(); @@ -71,7 +69,7 @@ describe("Unstaking flow", () => { new RegExp(`${submittedUnstakeAmount} NEAR`) ); - await stakeUnstakePage.clickUnstakeButton(); + await stakeUnstakePage.clickStakingPageUnstakeButton(); await expect(page).toHaveSelectorCount("data-test-id=stakingPageValidatorItem", 1); await expect(page).toMatchText(new RegExp(`${amountStillStaked} NEAR`)); diff --git a/packages/e2e-tests/transfer-tokens/transfer-token.spec.js b/packages/e2e-tests/transfer-tokens/transfer-token.spec.js index 539f0a5350..a9c400f552 100644 --- a/packages/e2e-tests/transfer-tokens/transfer-token.spec.js +++ b/packages/e2e-tests/transfer-tokens/transfer-token.spec.js @@ -1,18 +1,16 @@ -const { test, expect } = require("@playwright/test"); +const { test, expect } = require("../playwrightWithFixtures"); const { parseNearAmount } = require("near-api-js/lib/utils/format"); const BN = require("bn.js"); const { HomePage } = require("../register/models/Home"); const { SendMoneyPage } = require("./models/SendMoney"); -const { getBankAccount } = require("../utils/account"); const { describe, beforeAll, afterAll } = test; describe("Transferring NEAR tokens between two accounts", () => { let firstAccount; - beforeAll(async () => { - const bankAccount = await getBankAccount(); + beforeAll(async ({ bankAccount }) => { firstAccount = bankAccount.spawnRandomSubAccountInstance(); await firstAccount.create(); }); @@ -37,8 +35,7 @@ describe("Transferring NEAR tokens between two accounts", () => { describe("sending between accounts", () => { let secondAccount; - beforeAll(async () => { - const bankAccount = await getBankAccount(); + beforeAll(async ({ bankAccount }) => { secondAccount = bankAccount.spawnRandomSubAccountInstance(); await secondAccount.create(); }); diff --git a/packages/e2e-tests/utils/E2eTestAccount.js b/packages/e2e-tests/utils/E2eTestAccount.js index 5b9a639aa8..4d5e4e6137 100644 --- a/packages/e2e-tests/utils/E2eTestAccount.js +++ b/packages/e2e-tests/utils/E2eTestAccount.js @@ -2,11 +2,20 @@ const { utils: { format: { parseNearAmount }, }, + transactions, } = require("near-api-js"); const BN = require("bn.js"); const nearApiJsConnection = require("./connectionSingleton"); const { getKeyPairFromSeedPhrase, generateTestAccountId } = require("./helpers"); +const { getTestAccountSeedPhrase } = require("./helpers"); +const { fetchLockupContract } = require("../contracts"); +const { PublicKey } = require("near-api-js/lib/utils"); +const { + LOCKUP_CONFIGS: { FULLY_VESTED_CONFIG }, + WALLET_NETWORK, +} = require("../constants"); +const { createAccount, transfer, addKey, deployContract, functionCall, fullAccessKey } = transactions; class E2eTestAccount { constructor(accountId, seedPhrase, parentNearApiJsAccount) { @@ -23,15 +32,27 @@ class E2eTestAccount { async connectToNearApiJs() { const near = await nearApiJsConnection.getConnection(); this.nearApiJsAccount = await near.account(this.accountId); + await this.nearApiJsAccount.state(); } - async create({ amount, contractWasm } = { amount: "1.0" }) { + async create({ amount, contractWasm, initArgs, initFunction } = { amount: "1.0" }) { if (contractWasm) { - await this.parentNearApiJsAccount.createAndDeployContract( - this.accountId, - getKeyPairFromSeedPhrase(this.seedPhrase).publicKey, - contractWasm, - parseNearAmount(amount) - ); + if (initFunction && initArgs) { + const accessKey = fullAccessKey(); + await this.parentNearApiJsAccount.signAndSendTransaction(this.accountId, [ + createAccount(), + transfer(parseNearAmount(amount)), + addKey(PublicKey.from(getKeyPairFromSeedPhrase(this.seedPhrase).publicKey), accessKey), + deployContract(contractWasm), + functionCall(initFunction, initArgs, new BN("30000000000000")), + ]); + } else { + await this.parentNearApiJsAccount.createAndDeployContract( + this.accountId, + getKeyPairFromSeedPhrase(this.seedPhrase).publicKey, + contractWasm, + parseNearAmount(amount) + ); + } } else { await this.parentNearApiJsAccount.createAccount( this.accountId, @@ -42,12 +63,54 @@ class E2eTestAccount { this.isCreated = true; return this.initialize(); } + // fully unlocked / vested by default + async createTestLockupSubAccountInstance({ amount, release_duration, lockup_timestamp, vesting_schedule, v2Wasm } = {}) { + if (!this.nearApiJsAccount) { + throw new Error("Account needs to be initialized to spawn sub accounts"); + } + // creates a testinglockup subaccount with a lockup_timestamp (locked until) in 1 minute with a release_duration (period to linearly unlock) of 1 minute + const lockupSubaccountId = `testinglockup.${this.accountId}`; + const lockupSubaccountSeedphrase = `${lockupSubaccountId} ${process.env.TEST_ACCOUNT_SEED_PHRASE}`; + const lockupWasm = await fetchLockupContract({ v2Wasm }); + let minuteInNanosBN = new BN("1").mul(new BN("60000000000")); + const vesting_schedule_config = vesting_schedule || FULLY_VESTED_CONFIG.vesting_schedule; + + return new E2eTestAccount(lockupSubaccountId, lockupSubaccountSeedphrase, this.nearApiJsAccount).create({ + amount: amount || "5.0", + contractWasm: lockupWasm, + initFunction: "new", + initArgs: { + owner_account_id: this.accountId, + vesting_schedule: vesting_schedule_config, + lockup_duration: "0", + lockup_timestamp: lockup_timestamp || FULLY_VESTED_CONFIG.lockup_timestamp, + transfers_information: { + TransfersEnabled: { + transfers_timestamp: new BN(Date.now()).mul(new BN("1000000")).sub(minuteInNanosBN).toString(), // 1 minute ago + }, + }, + release_duration: release_duration || FULLY_VESTED_CONFIG.release_duration, + staking_pool_whitelist_account_id: + nearApiJsConnection.config.networkId === WALLET_NETWORK.MAINNET ? "system" : "whitelist.f863973.m0", + foundation_account_id: vesting_schedule_config ? this.accountId : null, + }, + }); + } + connectOrCreate(...creationArgs) { + return this.initialize().catch((e) => { + if (new RegExp(`${this.accountId} does not exist while viewing`).test(e.message)) { + return this.create(...creationArgs); + } else { + throw e; + } + }); + } spawnRandomSubAccountInstance() { if (!this.nearApiJsAccount) { throw new Error("Account needs to be initialized to spawn sub accounts"); } const randomSubaccountId = `${generateTestAccountId()}.${this.accountId}`; - const randomSubaccountSeedphrase = `${randomSubaccountId} ${process.env.TEST_ACCOUNT_SEED_PHRASE}`; + const randomSubaccountSeedphrase = getTestAccountSeedPhrase(randomSubaccountId); return new E2eTestAccount(randomSubaccountId, randomSubaccountSeedphrase, this.nearApiJsAccount); } async delete() { diff --git a/packages/e2e-tests/utils/SelfReloadingE2eTestAccount.js b/packages/e2e-tests/utils/SelfReloadingE2eTestAccount.js new file mode 100644 index 0000000000..522bc3b776 --- /dev/null +++ b/packages/e2e-tests/utils/SelfReloadingE2eTestAccount.js @@ -0,0 +1,32 @@ +const { Connection, InMemorySigner, Account } = require("near-api-js"); +const assert = require("assert"); + +const { WALLET_NETWORK } = require("../constants"); +const E2eTestAccount = require("./E2eTestAccount"); +const nearApiJsConnection = require("./connectionSingleton"); +const SelfReloadingJSONRpcProvider = require("./SelfReloadingJSONRpcProvider"); + +class SelfReloadingE2eTestAccount extends E2eTestAccount { + constructor(...args) { + const config = nearApiJsConnection.config; + assert( + config.networkId === WALLET_NETWORK.TESTNET, + "cannot instantiate non testnet instance of SelfReloadingE2eTestAccount" + ); + super(...args); + } + async connectToNearApiJs() { + const config = nearApiJsConnection.config; + this.nearApiJsAccount = new Account( + new Connection( + config.networkId, + new SelfReloadingJSONRpcProvider(config.nodeUrl), + new InMemorySigner(config.keyStore) + ), + this.accountId + ); + await this.nearApiJsAccount.state(); + } +} + +module.exports = SelfReloadingE2eTestAccount; diff --git a/packages/e2e-tests/utils/SelfReloadingJSONRpcProvider.js b/packages/e2e-tests/utils/SelfReloadingJSONRpcProvider.js new file mode 100644 index 0000000000..298705b60b --- /dev/null +++ b/packages/e2e-tests/utils/SelfReloadingJSONRpcProvider.js @@ -0,0 +1,48 @@ +const { JsonRpcProvider } = require("near-api-js/lib/providers"); +const { BN } = require("bn.js"); +const { parseNearAmount } = require("near-api-js/lib/utils/format"); + +const { createAccountWithHelper } = require("../services/contractHelper"); +const E2eTestAccount = require("./E2eTestAccount"); +const { generateTestAccountId, getWorkerAccountRegex } = require("./helpers"); +const nearApiJsConnection = require("./connectionSingleton"); +const { getTestAccountSeedPhrase } = require("./helpers"); +const { TEST_WORKER_INDEX } = process.env; + +class SelfReloadingJSONRpcProvider extends JsonRpcProvider { + constructor(...args) { + super(...args); + this.reloadingPromise = null; + } + sendTransaction(signedTransaction) { + return super.sendTransaction.call(this, signedTransaction).catch(async (e) => { + if (e.type === "NotEnoughBalance") { + if (!this.reloadingPromise) { + this.reloadingPromise = SelfReloadingJSONRpcProvider.reloadAccount(signedTransaction.transaction.signerId).finally(() => { + this.reloadingPromise = null; + }); + } + return this.reloadingPromise.then(() => { + if (getWorkerAccountRegex(TEST_WORKER_INDEX).test(signedTransaction.transaction.signerId)) { + process.env.workerBankStartBalance = new BN(process.env.workerBankStartBalance) + .add(new BN(parseNearAmount("200"))) + .toString(); + } + return super.sendTransaction.call(this, signedTransaction); + }); + } + throw e; + }); + } + static async reloadAccount(accountId) { + const randomSubaccountId = `${generateTestAccountId()}.${nearApiJsConnection.config.networkId}`; + const randomSubaccountSeedphrase = getTestAccountSeedPhrase(randomSubaccountId); + await createAccountWithHelper(randomSubaccountId, randomSubaccountSeedphrase); + const randomAccount = await new E2eTestAccount(randomSubaccountId, randomSubaccountSeedphrase, { + accountId: nearApiJsConnection.config.networkId, + }).initialize(); + return randomAccount.nearApiJsAccount.deleteAccount(accountId); + } +} + +module.exports = SelfReloadingJSONRpcProvider; diff --git a/packages/e2e-tests/utils/account.js b/packages/e2e-tests/utils/account.js index 958934e77d..44f1c88982 100644 --- a/packages/e2e-tests/utils/account.js +++ b/packages/e2e-tests/utils/account.js @@ -1,9 +1,14 @@ const nearApiJsConnection = require("./connectionSingleton"); const E2eTestAccount = require("./E2eTestAccount"); +const { WALLET_NETWORK } = require("../constants"); +const SelfReloadingE2eTestAccount = require("./SelfReloadingE2eTestAccount"); const getBankAccount = async () => { const { BANK_ACCOUNT: accountId, BANK_SEED_PHRASE: seedPhrase } = process.env; - const account = new E2eTestAccount(accountId, seedPhrase, { accountId: nearApiJsConnection.config.networkId }); + const account = + nearApiJsConnection.config.networkId !== WALLET_NETWORK.MAINNET + ? new SelfReloadingE2eTestAccount(accountId, seedPhrase, { accountId: nearApiJsConnection.config.networkId }) + : new E2eTestAccount(accountId, seedPhrase, { accountId: nearApiJsConnection.config.networkId }); return account.initialize(); }; diff --git a/packages/e2e-tests/utils/connectionSingleton.js b/packages/e2e-tests/utils/connectionSingleton.js index 9ee4eb6e6e..c8e23aee69 100644 --- a/packages/e2e-tests/utils/connectionSingleton.js +++ b/packages/e2e-tests/utils/connectionSingleton.js @@ -12,6 +12,7 @@ class NearAPIJsConnection { nodeUrl: process.env.NODE_URL || "https://rpc.testnet.near.org", walletUrl: process.env.WALLET_URL || "https://wallet.testnet.near.org", keyStore: new InMemoryKeyStore(), + helperUrl: process.env.HELPER_URL || "https://helper.testnet.near.org" }); constructor(config = NearAPIJsConnection.getDefaultConfig()) { diff --git a/packages/e2e-tests/utils/helpers.js b/packages/e2e-tests/utils/helpers.js index 1fe7a28a62..3039c81cae 100644 --- a/packages/e2e-tests/utils/helpers.js +++ b/packages/e2e-tests/utils/helpers.js @@ -2,6 +2,7 @@ const { KeyPair } = require("near-api-js"); const { parseSeedPhrase } = require("near-seed-phrase"); const assert = require("assert"); const { random } = require("lodash"); +const { BN } = require("bn.js"); const generateNUniqueRandomNumbersInRange = ({ from, to }, n) => { assert(n <= Math.abs(from - to) + 1, "Range needs to have at least N unique numbers"); @@ -20,8 +21,34 @@ function generateTestAccountId() { return `twa-${Date.now()}-${Math.floor(Math.random() * 1000) % 1000}`; } +function getTestAccountSeedPhrase(testAccountId) { + return `${testAccountId} ${process.env.TEST_ACCOUNT_SEED_PHRASE}`; +} + +function getWorkerAccountId(workerIndex) { + return `w${workerIndex}-${Math.floor(Math.random() * 1000) % 1000}.${process.env.BANK_ACCOUNT}`; +} + +function getWorkerAccountRegex(workerIndex) { + return new RegExp(`w${workerIndex}-[0-9]+-[0-9]+.${process.env.BANK_ACCOUNT}`); +} + +const bnSaturatingSub = (a, b) => { + let res = a.sub(b); + return res.gte(new BN(0)) ? res : new BN(0); +}; + +const bnIsWithinUncertainty = (uncertaintyBn, bn1, bn2) => { + return bn1.sub(bn2).abs().lt(uncertaintyBn.abs()) +} + module.exports = { generateNUniqueRandomNumbersInRange, getKeyPairFromSeedPhrase, - generateTestAccountId + generateTestAccountId, + getTestAccountSeedPhrase, + getWorkerAccountId, + getWorkerAccountRegex, + bnSaturatingSub, + bnIsWithinUncertainty }; diff --git a/packages/feature-flags/CHANGELOG.md b/packages/feature-flags/CHANGELOG.md new file mode 100644 index 0000000000..8ef84a9879 --- /dev/null +++ b/packages/feature-flags/CHANGELOG.md @@ -0,0 +1,26 @@ +# Change Log + +All notable changes to this project will be documented in this file. +See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. + +# 0.1.0 (2022-02-08) + + +### Features + +* method for resolving flag configuration path ([5e8364d](https://github.com/near/near-wallet/commit/5e8364d4eb47c4a9d9778b65025c57a275e7e3be)) +* more specific name for environment creation ([cd58594](https://github.com/near/near-wallet/commit/cd585947456372e8e2522cdabe6edb093f617acf)) +* override lastEdited fields when adding new environment ([2c52540](https://github.com/near/near-wallet/commit/2c52540ec4c0e064332faea2defcf22fb958826f)) +* prominent warning when selecting environment to modify ([ffdcacf](https://github.com/near/near-wallet/commit/ffdcacf6040da11df7def6d200ec6db3820d3333)) +* prompts to add new environment sourced from an existing environment ([19a3425](https://github.com/near/near-wallet/commit/19a34254290c8170aecbcdbc76f822aeda87f8ce)) +* prompts to remove existing environment ([1ac83a6](https://github.com/near/near-wallet/commit/1ac83a6c60b613c604df1389273515e04a80a168)) +* this is specifically for environment deletion ([6b29a79](https://github.com/near/near-wallet/commit/6b29a79d53a639cc8b8417a40ad4b851454e2bb6)) +* treat flags and environments consistently ([c15f707](https://github.com/near/near-wallet/commit/c15f7073b4838801701ef622fb6e512be296e6bc)) + + + + + +## 0.0.5 (2022-01-07) + +**Note:** Version bump only for package @near-wallet/feature-flags diff --git a/packages/feature-flags/package.json b/packages/feature-flags/package.json index d5caf4ecde..938bed8178 100644 --- a/packages/feature-flags/package.json +++ b/packages/feature-flags/package.json @@ -1,6 +1,6 @@ { "name": "@near-wallet/feature-flags", - "version": "0.0.4", + "version": "0.1.0", "main": "src/index.js", "license": "MIT", "publishConfig": { @@ -8,7 +8,7 @@ }, "dependencies": { "fs-extra": "^10.0.0", - "ini": "^2.0.0", + "ini": "^1.3.4", "inquirer": "^8.2.0" }, "scripts": { diff --git a/packages/feature-flags/src/FlagEditor.js b/packages/feature-flags/src/FlagEditor.js index 880427dfb3..9cfb290f48 100644 --- a/packages/feature-flags/src/FlagEditor.js +++ b/packages/feature-flags/src/FlagEditor.js @@ -14,7 +14,9 @@ const ENABLE_DEBUG_LOGGING = process.env.NEAR_FLAG_DEBUG === 'true' || false; class FlagEditor { constructor({ prompts }) { this.prompts = prompts; + this._configPath = null; this._environments = null; + this._environmentsFilepath = null; this._flagsFilepath = null; this._flagsState = null; } @@ -27,45 +29,68 @@ class FlagEditor { const userEditing = await getGitUsername(); this.log({ userEditing }); - await this.loadContext({ basePath: path.parse(process.cwd()) }); - await this.loadFlags(this._flagsFilepath); + await this.loadContext(); + await this.loadEnvironments(); + await this.loadFlags(); - const flagNames = Object.keys(this._flagsState) + const flagNames = Object.keys(this._flagsState); const action = await this.prompts.action(flagNames.length !== 0); this.log({ action }) - let flagName; - - if (action === ACTIONS.REMOVE_FLAG) { - flagName = await this.prompts.selectExistingFlag(flagNames) - delete this._flagsState[flagName] - } else { - let flagEntry; - - if (action === ACTIONS.EDIT_FLAG) { - flagName = await this.prompts.selectExistingFlag(flagNames) - } else { - flagName = await this.prompts.enterNewFlagName(flagNames); + switch (action) { + case ACTIONS.ADD_FLAG: { + const flagName = await this.prompts.enterNewFlagName(flagNames); + await this.setFlagState(flagName, userEditing); + break; } - this.log({ flagName }); - - flagEntry = this._flagsState[flagName]; - const environmentsEnabledIn = await this.prompts.getEnvironmentStates({ - environments: this._environments, - flagEntry - }); - - this._flagsState = { - ...this._flagsState, - [flagName]: this.flagEntry({ environmentsEnabledIn, flagEntry, userEditing }) + case ACTIONS.EDIT_FLAG: { + const flagName = await this.prompts.selectExistingFlag(flagNames); + await this.setFlagState(flagName, userEditing); + break; + } + case ACTIONS.REMOVE_FLAG: { + const flagName = await this.prompts.selectExistingFlag(flagNames); + delete this._flagsState[flagName]; + break; + } + case ACTIONS.ADD_ENVIRONMENT: { + const { + environmentName, + sourceEnvironment, + } = await this.prompts.setupNewEnvironment(Object.values(this._environments)); + await this.addEnvironment({ environmentName, sourceEnvironment, userEditing }); + break; + } + case ACTIONS.REMOVE_ENVIRONMENT: { + const environmentName = await this.prompts.selectEnvironmentForDeletion(Object.values(this._environments)); + if (!environmentName) { + return; + } + + await this.removeEnvironment(environmentName); + break; } } + await this.saveEnvironments(); await this.saveFlags(); await this.writeTypeDefinitions(); } + async setFlagState(flagName, userEditing) { + const flagEntry = this._flagsState[flagName]; + const environmentsEnabledIn = await this.prompts.getEnvironmentStates({ + environments: this._environments, + flagEntry + }); + + this._flagsState = { + ...this._flagsState, + [flagName]: this.flagEntry({ environmentsEnabledIn, flagEntry, userEditing }) + } + } + flagEntry({ environmentsEnabledIn, flagEntry, userEditing }) { this.log({ userEditing }); @@ -91,8 +116,35 @@ class FlagEditor { } } - async loadContext({ basePath }) { - const { base, dir, root } = basePath; + async addEnvironment({ environmentName, sourceEnvironment, userEditing }) { + this._environments = { + ...this._environments, + [environmentName.toUpperCase()]: environmentName, + }; + + Object.entries(this._flagsState).forEach(([flagName, flagState]) => { + this._flagsState[flagName][environmentName] = { + ...flagState[sourceEnvironment], + lastEditedBy: userEditing, + lastEditedAt: new Date().toISOString(), + }; + }); + } + + async removeEnvironment(environmentName) { + delete this._environments[environmentName.toUpperCase()]; + + Object.keys(this._flagsState).forEach((flagName) => { + delete this._flagsState[flagName][environmentName]; + }); + } + + async resolveConfigPath() { + if (this._configPath) { + return this._configPath; + } + + const { base, dir, root } = path.parse(process.cwd()); let fileFound = false; let currPath = path.join(dir, base); @@ -111,9 +163,23 @@ class FlagEditor { throw new Error(`Could not find a ${FLAGS_FILENAME} in CWD or any parent dir. Run this tool from a NEAR repo!`) } - currPath = path.join(currPath, CONFIG_DIRECTORY); - this._flagsFilepath = path.join(currPath, FLAGS_FILENAME); - this._environments = await fsx.readJson(path.join(currPath, ENVIRONMENTS_FILENAME)); + this._configPath = path.join(currPath, CONFIG_DIRECTORY); + return this._configPath; + } + + async loadContext() { + const configPath = await this.resolveConfigPath(); + this._environmentsFilepath = path.join(configPath, ENVIRONMENTS_FILENAME); + this._flagsFilepath = path.join(configPath, FLAGS_FILENAME); + } + + async loadEnvironments() { + try { + this._environments = await fsx.readJson(this._environmentsFilepath); + } catch (e) { + console.log(e); + throw new Error(`Failed to load JSON from ${this._environmentsFilepath}. Probably not valid JSON!`); + } } async loadFlags() { @@ -125,6 +191,11 @@ class FlagEditor { } } + async saveEnvironments() { + this.log("writing file", { filepath: this._environmentsFilepath, state: this._environments }); + return fsx.writeJson(this._environmentsFilepath, this._environments, { spaces: 2 }); + } + async saveFlags() { this.log("writing file", { filepath: this._flagsFilepath, state: this._flagsState }); diff --git a/packages/feature-flags/src/constants.js b/packages/feature-flags/src/constants.js index 497bf2e9db..dbbf84a739 100644 --- a/packages/feature-flags/src/constants.js +++ b/packages/feature-flags/src/constants.js @@ -1,9 +1,11 @@ const ACTIONS = { - ADD_FLAG: 'add', - REMOVE_FLAG: 'delete', - EDIT_FLAG: 'edit' -} + ADD_FLAG: 'add flag', + REMOVE_FLAG: 'delete flag', + EDIT_FLAG: 'edit flag', + ADD_ENVIRONMENT: 'add environment', + REMOVE_ENVIRONMENT: 'delete environment', +}; module.exports = { ACTIONS, -} \ No newline at end of file +}; diff --git a/packages/feature-flags/src/prompts/action.js b/packages/feature-flags/src/prompts/action.js index d8be52e571..9d03b54023 100644 --- a/packages/feature-flags/src/prompts/action.js +++ b/packages/feature-flags/src/prompts/action.js @@ -6,7 +6,9 @@ module.exports = async function actionPrompt(hasFlags) { name: 'action', type: 'list', message: 'What do you want to do?', - choices: hasFlags ? [ACTIONS.ADD_FLAG, ACTIONS.EDIT_FLAG, ACTIONS.REMOVE_FLAG] : [ACTIONS.ADD_FLAG], + choices: hasFlags + ? [ACTIONS.ADD_FLAG, ACTIONS.EDIT_FLAG, ACTIONS.REMOVE_FLAG, ACTIONS.ADD_ENVIRONMENT, ACTIONS.REMOVE_ENVIRONMENT] + : [ACTIONS.ADD_FLAG, ACTIONS.ADD_ENVIRONMENT, ACTIONS.REMOVE_ENVIRONMENT], default: hasFlags ? ACTIONS.EDIT_FLAG : ACTIONS.ADD_FLAG, }); diff --git a/packages/feature-flags/src/prompts/index.js b/packages/feature-flags/src/prompts/index.js index d70310830b..a0266ba71d 100644 --- a/packages/feature-flags/src/prompts/index.js +++ b/packages/feature-flags/src/prompts/index.js @@ -1,11 +1,15 @@ const action = require('./action'); -const selectExistingFlag = require('./selectExistingFlag'); const enterNewFlagName = require('./enterNewFlagName'); const getEnvironmentStates = require('./getEnvironmentStates'); +const selectEnvironmentForDeletion = require('./selectEnvironmentForDeletion'); +const selectExistingFlag = require('./selectExistingFlag'); +const setupNewEnvironment = require('./setupNewEnvironment'); module.exports = { action, enterNewFlagName, getEnvironmentStates, + selectEnvironmentForDeletion, selectExistingFlag, + setupNewEnvironment, } \ No newline at end of file diff --git a/packages/feature-flags/src/prompts/selectEnvironmentForDeletion.js b/packages/feature-flags/src/prompts/selectEnvironmentForDeletion.js new file mode 100644 index 0000000000..e84f4ffb01 --- /dev/null +++ b/packages/feature-flags/src/prompts/selectEnvironmentForDeletion.js @@ -0,0 +1,29 @@ +const inquirer = require('inquirer'); + +module.exports = async function selectExistingEnvironment(environments) { + const { proceed } = await inquirer.prompt({ + type: 'confirm', + name: 'proceed', + message: ` +💥💥💥💥💥💥💥💥💥💥💥💥💥💥💥💥💥💥💥💥💥💥💥💥💥💥💥💥💥💥💥💥💥💥💥💥💥💥💥💥💥💥💥💥💥💥💥💥💥💥💥💥💥💥💥 +Removing existing environments WILL break any production environments using this configuration. +Only proceed if the target environment is no longer active. + +Continue? +💥💥💥💥💥💥💥💥💥💥💥💥💥💥💥💥💥💥💥💥💥💥💥💥💥💥💥💥💥💥💥💥💥💥💥💥💥💥💥💥💥💥💥💥💥💥💥💥💥💥💥💥💥💥💥 +`, + }); + + if (!proceed) { + return null; + } + + const { environmentName } = await inquirer.prompt({ + type: 'list', + name: 'environmentName', + message: 'Select the environment you wish to modify', + choices: environments, + }) + + return environmentName; +} \ No newline at end of file diff --git a/packages/feature-flags/src/prompts/setupNewEnvironment.js b/packages/feature-flags/src/prompts/setupNewEnvironment.js new file mode 100644 index 0000000000..575e6c1ced --- /dev/null +++ b/packages/feature-flags/src/prompts/setupNewEnvironment.js @@ -0,0 +1,24 @@ +const inquirer = require('inquirer'); + +module.exports = async function setupNewEnvironment(existingEnvironments) { + const { environmentName } = await inquirer.prompt({ + name: 'environmentName', + type: 'input', + message: 'Enter the name of the new environment', + validate: (value) => { + if (existingEnvironments.includes(value)) { + throw new Error(`${value} already exists!`); + } + return true; + } + }); + + const { sourceEnvironment } = await inquirer.prompt({ + name: 'sourceEnvironment', + type: 'list', + message: 'Select the environment from which to copy existing states', + choices: existingEnvironments, + }); + + return { environmentName, sourceEnvironment }; +}; diff --git a/packages/frontend/.eslintrc.js b/packages/frontend/.eslintrc.js index a41a8c4441..32ab521cfb 100644 --- a/packages/frontend/.eslintrc.js +++ b/packages/frontend/.eslintrc.js @@ -1,13 +1,24 @@ module.exports = { extends: ['react-app', 'eslint:recommended'], + plugins: ['es'], rules: { + 'arrow-parens': 'error', + 'es/no-dynamic-import': 'error', 'jsx-a11y/no-access-key': 'off', 'no-useless-escape': 'off', + 'quotes': [2, 'single'], + 'keyword-spacing': ['error', { + 'before': true, + 'after': true, + }], + 'no-duplicate-imports': ['error', { includeExports: true }], + 'eol-last': ['error', 'always'], 'semi': ['error', 'always'], 'no-console': 'off', 'no-extra-boolean-cast':'off', 'no-extra-semi':'off', 'no-irregular-whitespace':'off', + 'import/named': ['error', 'always'], 'import/order': [ 'error', { @@ -24,5 +35,37 @@ module.exports = { ] } ], - } + 'no-process-env': ['error'], + 'no-restricted-globals': ['error', { + 'name': 'URLSearchParams', + 'message': 'Use `query-string` package instead' + }], + 'no-restricted-syntax': ['error', { + 'selector': `VariableDeclarator[id.type='ObjectPattern'] Property[key.name='searchParams']`, + 'message': 'URL.searchParams is not allowed, Use `query-string` package instead' + }, { + 'selector': `CallExpression[callee.name='useSelector'] MemberExpression[object.name='state']`, + 'message': 'Please use a selector for any state accesses within useSelector' + }, { + 'selector': `CallExpression[callee.name='useSelector'] VariableDeclarator[id.type='ObjectPattern'][init.name='state']`, + 'message': 'Please use a selector for any state accesses within useSelector' + }, { + 'selector': `CallExpression[callee.name='useSelector'] *[type=/FunctionExpression$/][params.0.type='ObjectPattern']`, + 'message': 'Please use a selector for any state accesses within useSelector' + }] + }, + settings: { + 'import/ignore': ['src/config/*'], + }, + overrides: [ + { + files: [ + "src/config/configFromEnvironment.js", + "ci/configFromEnvironment.js", + ], + rules: { + 'no-process-env': ['off'] + }, + }, + ], } \ No newline at end of file diff --git a/packages/frontend/.githooks/format-json b/packages/frontend/.githooks/format-json new file mode 100755 index 0000000000..e536d668fe --- /dev/null +++ b/packages/frontend/.githooks/format-json @@ -0,0 +1,18 @@ +#!/bin/bash + +for folder in "src/translations" +do + # Finds only modified files that are currently staged in the current commit set and extracts their file name + diff="$(git diff HEAD^ --staged --name-only --diff-filter=ACMR "$folder" | xargs -n1 basename)" + if [ ! -z "$diff" ] + then + cd src/translations || exit 1 + for file in $diff + do + # Some day, `sort-json` will support multiple files, but for now we will run it once per file the user modified + npx sort-json $file --ignore-case true + # Re-add file to git index so that reformatted files won't show as modified but with no actual changes + git add $file + done + fi +done diff --git a/packages/frontend/CHANGELOG.md b/packages/frontend/CHANGELOG.md new file mode 100644 index 0000000000..b303e04b71 --- /dev/null +++ b/packages/frontend/CHANGELOG.md @@ -0,0 +1,26 @@ +# Change Log + +All notable changes to this project will be documented in this file. +See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. + +## 0.1.3 (2022-02-08) + + +### Bug Fixes + +* centrally import NEAR_WALLET_ENV from feattures.js and bundle ([9826978](https://github.com/near/near-wallet/commit/9826978014be945c05164cc365646c5bb8b8cc78)) +* compute liquid amount locally instead of fetching ([d75cea1](https://github.com/near/near-wallet/commit/d75cea16b3ecc65221339bafefd90fdc719e17a4)) +* issue 2330. filter out empty contracts. ([d5c4abe](https://github.com/near/near-wallet/commit/d5c4abebdc77399ef4f42ee6756d48b0802e40b5)) +* lint errors ([9c8b81c](https://github.com/near/near-wallet/commit/9c8b81c09943eb7c41ed6fee62da390232fde4e4)) +* only pick relevant ft_metadata props ([5852fb9](https://github.com/near/near-wallet/commit/5852fb996d37fa96deb596030432bc80d2fca683)) +* remove DEBUG_BUILD coercion and centralized whitespace formatting ([b87523a](https://github.com/near/near-wallet/commit/b87523abebe02c4fd38651ee5e1c71d3f6c0ff64)) +* remove SHOW_PRERELEASE_WARNING references in netlify.toml ([13f66e7](https://github.com/near/near-wallet/commit/13f66e7c7aaff01ac4ea4a06da8a8fee446eff14)) + + +## 0.1.2 (2022-01-07) + + +### Bug Fixes + +* issue 2330. filter out empty contracts. ([d5c4abe](https://github.com/near/near-wallet/commit/d5c4abebdc77399ef4f42ee6756d48b0802e40b5)) + diff --git a/packages/frontend/ci/ParcelBundler.js b/packages/frontend/ci/ParcelBundler.js index 0ba056ec3f..bd9788a091 100644 --- a/packages/frontend/ci/ParcelBundler.js +++ b/packages/frontend/ci/ParcelBundler.js @@ -10,7 +10,7 @@ const ENTRY_FILE_PATH = path.join(__dirname, '../src/index.html'); const WASM_PATH = path.join(__dirname, '../src/wasm/'); const SSL_PATH = path.join(__dirname, '../devServerCertificates/'); -const enableDebugLogging = !Config.DEBUG_BUILD ? true : Config.DEBUG_BUILD === 'true'; +const enableDebugLogging = Config.DEBUG_BUILD; class ParcelBundler { constructor({ @@ -130,14 +130,14 @@ class ParcelBundler { // Netlify staging is a dedicated deployment using 'master' as the production branch return { ...this.getBaseConfig(), - publicUrl: this.buildCloudflarePath(`/ntl/staging/`) + publicUrl: this.buildCloudflarePath('/ntl/staging/') }; } // Netlify production/mainnet is a dedicated deployment using 'stable' as the production branch return { ...this.getBaseConfig(), - publicUrl: this.buildCloudflarePath(`/ntl/mainnet/`) + publicUrl: this.buildCloudflarePath('/ntl/mainnet/') }; case 'branch-deploy': @@ -212,4 +212,4 @@ class ParcelBundler { } } -module.exports = ParcelBundler; \ No newline at end of file +module.exports = ParcelBundler; diff --git a/packages/frontend/ci/config.js b/packages/frontend/ci/config.js index 4db86438c0..4052e539ad 100644 --- a/packages/frontend/ci/config.js +++ b/packages/frontend/ci/config.js @@ -1,21 +1,25 @@ -module.exports = { - BRANCH: process.env.BRANCH, - CLOUDFLARE_BASE_URL: process.env.CLOUDFLARE_BASE_URL || 'https://content.near-wallet.workers.dev', - CONTEXT: process.env.CONTEXT, - DEBUG_BUILD: process.env.DEBUG_BUILD, - DEPLOY_PRIME_URL: process.env.DEPLOY_PRIME_URL, - IS_DEVELOPMENT: process.env.NODE_ENV === 'development', - IS_NETLIFY: process.env.NETLIFY === 'true', - IS_RENDER: process.env.RENDER === 'true', - IS_PULL_REQUEST: process.env.IS_PULL_REQUEST === 'true', - RENDER: process.env.RENDER, - RENDER_EXTERNAL_URL: process.env.RENDER_EXTERNAL_URL, - RENDER_GIT_COMMIT: process.env.RENDER_GIT_COMMIT, - REVIEW_ID: process.env.REVIEW_ID, - SENTRY_AUTH_TOKEN: process.env.SENTRY_AUTH_TOKEN, - SENTRY_DSN: process.env.SENTRY_DSN, - SENTRY_RELEASE: process.env.SENTRY_RELEASE - || (process.env.RENDER && `render:${process.env.RENDER_SERVICE_NAME}:${process.env.RENDER_GIT_BRANCH}:${process.env.RENDER_GIT_COMMIT}`) - || 'development', - SHOULD_USE_CLOUDFLARE: process.env.USE_CLOUDFLARE === 'true', +const { defaults } = require('lodash'); + +const Environments = require('../../../features/environments.json'); +const environmentConfig = require('./configFromEnvironment'); + +const envDefaults = { + [Environments.DEVELOPMENT]: { + CLOUDFLARE_BASE_URL: 'https://content.near-wallet.workers.dev', + SENTRY_RELEASE: 'development', + }, + [Environments.TESTNET]: { + CLOUDFLARE_BASE_URL: 'https://content.near-wallet.workers.dev', + }, + [Environments.MAINNET]: { + CLOUDFLARE_BASE_URL: 'https://content.near-wallet.workers.dev', + }, + [Environments.MAINNET_STAGING]: { + CLOUDFLARE_BASE_URL: 'https://content.near-wallet.workers.dev', + }, }; + +module.exports = defaults( + environmentConfig, + envDefaults[environmentConfig.NEAR_WALLET_ENV] +); diff --git a/packages/frontend/ci/configFromEnvironment.js b/packages/frontend/ci/configFromEnvironment.js new file mode 100644 index 0000000000..a4b4f956fd --- /dev/null +++ b/packages/frontend/ci/configFromEnvironment.js @@ -0,0 +1,36 @@ +const assert = require('assert'); + +const Environments = require('../../../features/environments.json'); +const { parseBooleanFromShell } = require('../src/config/envParsers'); + +const NEAR_WALLET_ENV = process.env.NEAR_WALLET_ENV; + +assert( + Object.values(Environments).some((env) => NEAR_WALLET_ENV === env), + `Invalid environment: "${NEAR_WALLET_ENV}"` +); + +module.exports = { + BRANCH: process.env.BRANCH, + CLOUDFLARE_BASE_URL: process.env.CLOUDFLARE_BASE_URL, + CONTEXT: process.env.CONTEXT, + DEBUG_BUILD: parseBooleanFromShell(process.env.DEBUG_BUILD) || true, + DEPLOY_PRIME_URL: process.env.DEPLOY_PRIME_URL, + IS_DEVELOPMENT: process.env.NODE_ENV === 'development', + IS_NETLIFY: parseBooleanFromShell(process.env.NETLIFY), + IS_RENDER: parseBooleanFromShell(process.env.RENDER), + IS_PULL_REQUEST: parseBooleanFromShell(process.env.IS_PULL_REQUEST), + NEAR_WALLET_ENV, + RENDER: parseBooleanFromShell(process.env.RENDER), + RENDER_EXTERNAL_URL: process.env.RENDER_EXTERNAL_URL, + RENDER_GIT_COMMIT: process.env.RENDER_GIT_COMMIT, + REVIEW_ID: process.env.REVIEW_ID, + SENTRY_AUTH_TOKEN: process.env.SENTRY_AUTH_TOKEN, + SENTRY_DSN: process.env.SENTRY_DSN, + SENTRY_RELEASE: + process.env.SENTRY_RELEASE || + (parseBooleanFromShell(process.env.RENDER) && + `render:${process.env.RENDER_SERVICE_NAME}:${process.env.RENDER_GIT_BRANCH}:${process.env.RENDER_GIT_COMMIT}`), + SHOULD_USE_CLOUDFLARE: parseBooleanFromShell(process.env.USE_CLOUDFLARE), + TRAVIS: parseBooleanFromShell(process.env.TRAVIS), +}; diff --git a/packages/frontend/ci/runBundler.js b/packages/frontend/ci/runBundler.js index 24f58a9542..22bd552e4e 100644 --- a/packages/frontend/ci/runBundler.js +++ b/packages/frontend/ci/runBundler.js @@ -1,11 +1,20 @@ const ParcelBundler = require('./ParcelBundler'); async function runBundler() { - const bundler = new ParcelBundler(); + const args = process.argv + .slice(2) + .map((arg) => arg.replace('--', '')) + .reduce((argMap, param) => { + const [key, value] = param.split('='); + argMap[key] = value; + return argMap; + }, {}); + + const bundler = new ParcelBundler(args); bundler.initializeBundlerInstance(); await bundler.bundle(); } -runBundler(); \ No newline at end of file +runBundler(); diff --git a/packages/frontend/docs/Login.md b/packages/frontend/docs/Login.md new file mode 100644 index 0000000000..9c675a03c1 --- /dev/null +++ b/packages/frontend/docs/Login.md @@ -0,0 +1,136 @@ +Wallet Login +=== + +To implement authentication using the NEAR Wallet, you will need to follow the instructions provided below. + +## Prerequisites +* You should already have `near-api-js` installed. + +## Steps +1. [Construct wallet login parameters](#construct-wallet-login-parameters) +2. [Initialize a `WalletConnection` and call `requestSignIn`](#initialize-a-walletconnection-and-call-requestsignin) +3. [Wallet redirect on success on failure](#wallet-redirect-on-success-on-failure) +4. [Retrieving the logged in user](#retrieving-the-logged-in-user) + +
+ +## Construct wallet login parameters +The wallet login API accepts 1 parameter as shown below: + +| Command | Type | Required | Description +| --- | --- | --- | --- | +| contractIdOrOptions | SignInOptions | `true` |A configuration object for the request as shown below. | + + +```typescript + // The configuration for SignInOptions + interface SignInOptions { + // The ID of the smart contract requesting access + contractId?: string; + // And array of contract methods (Leave empty for access to all methods) + methodNames?: string[]; + // NEAR wallet will redirect to this URL on sign in success + successUrl?: string; + // NEAR wallet will redirect to this URL on sign in failure + failureUrl?: string; + } +``` + +### Note +* If you wish to request full access to the wallet, do not pass a `contractId` field to signup options. + + +
+ +## Initialize a `WalletConnection` and call `requestSignIn` + +Setup a `WalletConnection` instance by passing in your `Near` instance. You can then call the `requestSignIn` method on the `WalletConnection` instance to redirect the user to the wallet for approval. + +```typescript + + const nearAPI = require("near-api-js"); + const { connect, WalletConnection } = nearApi; + const near = await connect(config); + const wallet = new WalletConnection(near); + + const contractId = 'example.testnet'; + const successUrl = 'https://demodapp.com/auth/success'; + const failureUrl = 'https://demodapp.com/auth/failure'; + + wallet.requestSignIn({ contractId, successUrl, failureUrl }); + +``` + +
+ +## Wallet redirect on success on failure +Once sign in is successfully initiated, the user will be taken to the wallet where they will be presented with a screen to grant access to the contract (see image below). + + + + +
+ +Once the user allows or denies the request, they will be redirected to the `successUrl` and `failureUrl` respectively,with the following parameters: + +* `account_id` - The ID of the authenticated wallet. +* `all_keys` - A comma separated, concatenated string of available keys on the wallet. +* `public_key` - The public key supplied. + + +
+ +## Retrieving the logged in user +Once a user is logged in, you can retrieve the account ID of the authorized wallet using the `getAccountId` method as shown below. + + +```typescript + + const nearAPI = require("near-api-js"); + const { connect, WalletConnection } = nearApi; + const near = await connect(config); + const wallet = new WalletConnection(near); + + const signedInWalletId = wallet.getAccountId(); + +``` + + +
+ +## Wallet URL API + +The `/login` path of the wallet recognizes the following url parameters: + +| Param | Description +| --- | --- | +| `contract_id` | The account ID of the contract to be accessed with the key | +| `public_key` | The corresponding public key to the private key that will be granted access | +| `failure_url` | The url to redirect to if the request is denied | +| `success_url` | The url to redirect to if the request is successful | +| `methodNames` | The `methodNames` that will be accessible with the key | + +When requesting an access key (full or function call), a key pair is generated and the public key is sent to the wallet under the `public_key` parameter along with other information about the request. + +### Requesting a function call access key + +To request a function call access key, the `contract_id` parameter is set to the target contract account ID and the `methodNames` parameter is set to any methods on the contract the access key should be limited to. If left blank the access key will be able to call all methods on the contract. + +Multiple `methodNames` params are added when the key needs to scoped to more than one method. + +``` +GET https://wallet.testnet.near.org/login?public_key=ed25519%3A4Y1rQKB8STnPBWVo29mRc3Z5ByJwg1FLmX6EMzisVAa4&contract_id=v2.test-contract.testnet?methodNames=method1&methodNames=method2 +``` + +### Requesting a full access key + +To request a full access key, omit the `contract_id` or use the target approving user's account ID as `contract_id`. + +``` +GET https://wallet.testnet.near.org/login?public_key=ed25519%3A4Y1rQKB8STnPBWVo29mRc3Z5ByJwg1FLmX6EMzisVAa4 +``` + +The wallet will then redirect to either `success_url` or `failure_url` (see [wallet redirect](#wallet-redirect-on-success-on-failure)) and transactions can be signed using the private key of the key pair on behalf of the user. + +## Related Resources: +* [Near API Docs | Wallet](https://docs.near.org/docs/api/naj-quick-reference#wallet) diff --git a/packages/frontend/docs/NonFungibleTokenDiscovery.md b/packages/frontend/docs/NonFungibleTokenDiscovery.md new file mode 100644 index 0000000000..8600a5d15c --- /dev/null +++ b/packages/frontend/docs/NonFungibleTokenDiscovery.md @@ -0,0 +1,29 @@ +NEAR Wallet Non-Fungible Token ([NEP-171](https://nomicon.io/Standards/NonFungibleToken/Core.html)) Discovery and Display +=== + +NEAR Wallet discovers non-fungible tokens using a range of indexer queries and displays them using data in the token's metadata per the Non-Fungible Token Metadata spec ([NEP-177](https://nomicon.io/Standards/NonFungibleToken/Metadata.html)) + +## Contents + +1. [NEAR Wallet non-fungible token discovery](#NEAR-Wallet-non-fungible-token-discovery) +2. [NEAR Wallet non-fungible token display](#NEAR-Wallet-non-fungible-token-display) + +## NEAR Wallet non-fungible token discovery +The wallet will consider contracts as non-fungible token contracts relevant to the user if they meet any of the following conditions: + +1. Any account makes a call to the contract with any method prefixed with `nft_` and the `receiver_id` property of the `args` is the user's account ID. + +2. The contract emits a [Non-Fungible Token Event](https://nomicon.io/Standards/NonFungibleToken/Event.html#interface) with `NftTransferLog` data and the `new_owner_id` value is the user's account ID. + +NEAR Wallet then makes a call to `nft_metadata` to fetch the NFT contract's [metadata](https://nomicon.io/Standards/NonFungibleToken/Metadata.html) as `NFTContractMetadata` and then calls `nft_tokens_for_owner` with the `accountId` arg set to the user's account ID to retrieve the tokens owned by the user per the [NFT enumeration spec (NEP-181)](https://nomicon.io/Standards/NonFungibleToken/Enumeration.html). This will include the [`TokenMetadata`](https://nomicon.io/Standards/NonFungibleToken/Metadata.html#interface) used to display the tokens. + +## NEAR Wallet non-fungible token display +The NFT is then displayed as follows: + + + +1. This is the icon for the NFT contract. It's retrieved from the `icon` prop of `NFTContractMetadata` and is rendered if it's a [data URI](https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/Data_URIs). +2. This is the `name` prop of the `NFTContractMetadata`. It will link to the NFT contract's account page on [explorer](https://explorer.near.org/). +3. This is the length of the list of tokens returned by the NFT contract's `nft_tokens_for_owner`. +4. This is the image of the token. It's displayed as the `media` prop of [`TokenMetadata`](https://nomicon.io/Standards/NonFungibleToken/Metadata.html#interface) if it's a url or a [data URI](https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/Data_URIs). Otherwise the `media` prop is appended to the `base_uri` prop of `NFTContractMetadata` (fetched from `nft_metadata`) and if `base_uri` is not set, then to `https://cloudflare-ipfs.com/ipfs/` and that is used as the url. +5. This is the `title` prop of [`TokenMetadata`](https://nomicon.io/Standards/NonFungibleToken/Metadata.html#interface) \ No newline at end of file diff --git a/packages/frontend/docs/SignTransaction.md b/packages/frontend/docs/SignTransaction.md index 2b68812f0c..1b598af988 100644 --- a/packages/frontend/docs/SignTransaction.md +++ b/packages/frontend/docs/SignTransaction.md @@ -12,7 +12,7 @@ Signing a transaction or series of transactions on NEAR Wallet involves construc ## Construct a transaction At a high level, constructing a transaction is done by calling `createTransaction` from `near-api-js`: -```typescript= +```javascript= const nearAPI = require("near-api-js"); const transaction = nearAPI.transactions.createTransaction( @@ -31,7 +31,7 @@ More information on each argument can be found [here](https://docs.near.org/docs Setup a `WalletConnection` instance by passing in your `Near` instance. You can then call `requestSignTransactions` on the `WalletConnection` instance: -```typescript= +```javascript= const nearAPI = require("near-api-js"); const { connect, WalletConnection } = nearApi; @@ -63,6 +63,80 @@ RequestSignTransactionsOptions { * `callbackUrl` is optional and will default to the current url `window.location.href` when not provided. * `meta` is optional and can include any information that we'd like forwarded to the `callbackUrl` by the wallet as the `meta` search param. +## Wallet URL API + +The `/sign` path of the wallet recognizes the following url parameters: + +| Param | Description | +| -------------- | ---------------------------------------------------------------------------------------------------------------- | +| `transactions` | A comma seperated list of transactions serialized in [borsh](https://borsh.io/) and then encoded in base64 | +| `callbackUrl` | The url to [redirect to](#Wallet-redirect-and-callback) after the flow is completed with success or error params | +| `meta` | Any data the integrator wants passed to the `callbackUrl` | + +### Serializing transactions in [borsh](https://borsh.io/) +A transaction is serialized using [borsh](https://borsh.io/) as follows: + +```javascript= +const { transactions } = require("near-api-js"); +const borsh = require("borsh"); + +const tx = { + "signerId": "sender.near", + "publicKey": { + "keyType": 0, + "data": { + "0": 199, + "1": 84, + // ... + } + }, + "nonce": 123456, + "receiverId": "receiver.near", + "actions": [ + { + "createAccount": {}, + "enum": "createAccount" + }, + { + "transfer": { + "deposit": "1000000000000000000000000" + }, + "enum": "transfer" + } + ], + "blockHash": { + "type": "Buffer", + "data": [ + 30, + 179, + // ... + ] + } +} + +const serializedTx = borsh.serialize(transactions.SCHEMA, tx); // -> Buffer <0B, 00, 00, 00, 73, 65, 6E, 64, 65, 72, 2E, 6E, 65, 61, 72, 00, 25, 62, 11, 78, BC, E5, 68, B9, 9C, 2E, 7A, …> +// borsh.serialize is also re-exported by near-api-js as utils.serialize.serialize +``` + +The transactions then need to be outputted as `base64` to be passed to the wallet: + +```javascript= +const serializedEncodedTx = serializedTx.toString('base64'); // -> "CwAAAHNlbmRlci5uZWFyAGT3V2lh0VAA/mrod+KWM30Iae3jRgA4Uum87Q4YvR6WQOIBAAAAAAANAAAAcmVjZWl2ZXIubmVhch6z3oIrhTeXGliWsOilax3Aoq0rrwkYdb8fNSw75IW3AgAAAAADAAAAoe3MzhvC0wAAAAAAAA==" +``` + +The resulting string can then be added to the url as a param with `encodeURIComponent`. + + +### Example usage of wallet URL API + +``` +GET https://wallet.near.org/sign?transactions=CwAAAHNlbmRlci5uZWFyAGT3V2lh0VAA%2Fmrod%2BKWM30Iae3jRgA4Uum87Q4YvR6WQOIBAAAAAAANAAAAcmVjZWl2ZXIubmVhch6z3oIrhTeXGliWsOilax3Aoq0rrwkYdb8fNSw75IW3AgAAAAADAAAAoe3MzhvC0wAAAAAAAA%3D%3D&callbackUrl=https%3A%2F%2Fwww.google.com&meta=test +``` + +### Wallet transaction sending behavior + +For each transaction in the batch, the wallet will create a new transaction based on the `receiverId` and the `actions` that are sent in and sign and send them using the account that the user selects as the `signerId` and signer of the transaction. The account that will be signing and sending the transaction once approved will always be displayed in the **From** section. + ## Wallet redirect and callback The user will be presented with a request to sign the transaction(s) that were passed in in the wallet UI: diff --git a/packages/frontend/docs/assets/login-request.png b/packages/frontend/docs/assets/login-request.png new file mode 100644 index 0000000000..d09af2e2d1 Binary files /dev/null and b/packages/frontend/docs/assets/login-request.png differ diff --git a/packages/frontend/docs/assets/non-fungible-token-display.png b/packages/frontend/docs/assets/non-fungible-token-display.png new file mode 100644 index 0000000000..91428029fd Binary files /dev/null and b/packages/frontend/docs/assets/non-fungible-token-display.png differ diff --git a/packages/frontend/docs/assets/sign-transaction-request.png b/packages/frontend/docs/assets/sign-transaction-request.png index ebb846e281..f1c9d4d8f6 100644 Binary files a/packages/frontend/docs/assets/sign-transaction-request.png and b/packages/frontend/docs/assets/sign-transaction-request.png differ diff --git a/packages/frontend/jest.config.js b/packages/frontend/jest.config.js index b8041c2490..4f9ccdfef6 100644 --- a/packages/frontend/jest.config.js +++ b/packages/frontend/jest.config.js @@ -1,3 +1,4 @@ module.exports = { setupFilesAfterEnv: ['./jest.setup.js'], -}; \ No newline at end of file + setupFiles: ['dotenv/config'], +}; diff --git a/packages/frontend/jest.setup.js b/packages/frontend/jest.setup.js index ddebd93872..b535f6a2cc 100644 --- a/packages/frontend/jest.setup.js +++ b/packages/frontend/jest.setup.js @@ -1 +1 @@ -import "regenerator-runtime/runtime"; \ No newline at end of file +import 'regenerator-runtime/runtime'; diff --git a/packages/frontend/netlify.toml b/packages/frontend/netlify.toml index 4c2ba71c2d..b7f612c9a5 100644 --- a/packages/frontend/netlify.toml +++ b/packages/frontend/netlify.toml @@ -13,7 +13,6 @@ REACT_APP_ACCOUNT_ID_SUFFIX = "near" EXPLORER_URL = "https://explorer.mainnet.near.org" REACT_APP_IS_MAINNET = "true" - SHOW_PRERELEASE_WARNING = "yes" DISABLE_SEND_MONEY = "no" DISABLE_CREATE_ACCOUNT = "true" REACT_APP_MULTISIG_MIN_AMOUNT = "4" @@ -28,7 +27,6 @@ NETLIFY_USE_YARN = "true" [context.production.environment] - SHOW_PRERELEASE_WARNING = "no" BROWSER_MIXPANEL_TOKEN = "d5bbbbcc3a77ef8427f2b806b5689bf8" SENTRY_PROJECT = "mainnet-wallet" USE_CLOUDFLARE = "true" diff --git a/packages/frontend/package.json b/packages/frontend/package.json index ddc2e9a517..78443675e1 100644 --- a/packages/frontend/package.json +++ b/packages/frontend/package.json @@ -1,13 +1,13 @@ { "homepage": "https://wallet.near.org", "name": "@near-wallet/frontend", - "version": "0.1.1", + "version": "0.1.3", "publishConfig": { "access": "public" }, "dependencies": { "@ledgerhq/hw-transport-u2f": "^5.34.0", - "@near-wallet/feature-flags": "^0.0.4", + "@near-wallet/feature-flags": "^0.1.0", "@reduxjs/toolkit": "1.6.2", "@sentry/browser": "^6.4.1", "connected-react-router": "^6.6.0", @@ -25,6 +25,7 @@ "lodash.once": "^4.1.1", "lodash.set": "^4.3.2", "lodash.throttle": "^4.1.1", + "lodash.unset": "^4.5.2", "lodash.update": "^4.10.2", "mixpanel-browser": "^2.41.0", "near-api-js": "^0.43.1", @@ -49,8 +50,6 @@ "redux-thunk": "^2.3.0", "regenerator-runtime": "^0.13.5", "reselect": "^4.0.0", - "semantic-ui-css": "^2.4.1", - "semantic-ui-react": "^0.88.1", "styled-components": "^5.3.0", "timeago.js": "^4.0.2" }, @@ -59,13 +58,17 @@ "update:static": "cp src/wasm/multisig.wasm dist/multisig.wasm && cp src/wasm/main.wasm dist/main.wasm", "start": "yarn update:static; parcel src/index.html --https --cert devServerCertificates/primary.crt --key devServerCertificates/private.pem", "prebuild": "rm -rf dist/", - "build": "NODE_ENV=production node ci/runBundler.js && node ci/sentry-send-release.js", + "build": "yarn run bundle && yarn run sentry", + "bundle": "NODE_ENV=production node ci/runBundler.js", + "sentry": "node ci/sentry-send-release.js", "test": "jest", "lint": "eslint --ext .js --ext .jsx .", "fix": "eslint --ext .js --ext .jsx . --fix", "storybook": "start-storybook -p 6006", "build-storybook": "build-storybook", - "prepush": "concurrently \"yarn run fix\" \"cd src/translations && npx sort-json * --ignore-case true\" \"yarn run test\"" + "prepush": "yarn run test", + "precommit": "concurrently \"yarn run fix\" \".githooks/format-json\"", + "preinstall": "npx only-allow yarn" }, "browserslist": [ ">0.2%", @@ -81,10 +84,13 @@ "babel-eslint": "^10.1.0", "babel-jest": "^27.0.1", "babel-loader": "^8.1.0", + "concurrently": "^7.0.0", + "dotenv": "^11.0.0", "enzyme": "^3.10.0", "enzyme-adapter-react-16": "^1.15.6", "eslint": "5.16.0", "eslint-config-react-app": "^3.0.8", + "eslint-plugin-es": "^4.1.0", "eslint-plugin-flowtype": "^5.7.2", "eslint-plugin-import": "^2.23.4", "eslint-plugin-jsx-a11y": "^6.4.1", diff --git a/packages/frontend/src/ExampleFlag.js b/packages/frontend/src/ExampleFlag.js index 7829ee46da..3523f8b98d 100644 --- a/packages/frontend/src/ExampleFlag.js +++ b/packages/frontend/src/ExampleFlag.js @@ -3,7 +3,8 @@ import React from 'react'; // Note: Referencing the FF in module scope is recommended over referencing the flag state inside of function calls // This way if you typo a feature flag name the entire build will fail import { EXAMPLE_FLAG } from '../../../features'; +import { NEAR_WALLET_ENV } from './config'; export default () => { - return (
{`EXAMPLE_FLAG state for ${process.env.NEAR_WALLET_ENV} is: ${EXAMPLE_FLAG}`}
); + return (
{`EXAMPLE_FLAG state for ${NEAR_WALLET_ENV} is: ${EXAMPLE_FLAG}`}
); }; diff --git a/packages/frontend/src/components/GlobalStyle.js b/packages/frontend/src/components/GlobalStyle.js index 83117074e2..9029473a8d 100644 --- a/packages/frontend/src/components/GlobalStyle.js +++ b/packages/frontend/src/components/GlobalStyle.js @@ -1,10 +1,8 @@ -import { createGlobalStyle } from "styled-components"; +import { createGlobalStyle } from 'styled-components'; -import "semantic-ui-css/semantic.min.css"; -import CloseBtn from "../images/close-btn.svg"; +import CloseBtn from '../images/close-btn.svg'; export default createGlobalStyle` - * { box-sizing: inherit; } @@ -24,7 +22,7 @@ export default createGlobalStyle` min-height: 100vh !important; position: relative; - color: #3F4045; + color: #72727A; font-size: 14px !important; -webkit-overflow-scrolling: touch; } @@ -33,6 +31,7 @@ export default createGlobalStyle` a { color: #0072ce; } + a:hover { color: #0072ce; text-decoration: underline; @@ -57,7 +56,7 @@ export default createGlobalStyle` } body, p, h1, h2, h3, h4, h5, h6, button, .button, input, select, textarea { - font-family: "Inter", Lato, "Lucida Grande", Tahoma, Sans-Serif !important; + font-family: "Inter", Lato, "Lucida Grande", Tahoma, Sans-Serif; } .font-monospace { @@ -93,7 +92,7 @@ export default createGlobalStyle` h3, .h3 { font-size: 20px; font-weight: 900; - color: #24272a; + color: #272729; margin: 0px; } h4, .h4 { @@ -171,6 +170,13 @@ export default createGlobalStyle` } } + //Navbar styles + .nav-links{ + a{ + text-decoration: none; + } + } + input, .react-phone-number-input__input { font-size: 16px; width: 100%; @@ -256,6 +262,24 @@ export default createGlobalStyle` } } + .input-label { + font-size: 14px; + color: #72727A; + margin-bottom: 8px; + } + + textarea { + border: 2px solid #E5E5E6; + border-radius: 8px; + width: 100%; + padding: 14px; + line-height: 180%; + font-size: 16px; + ::placeholder { + color: #A2A2A8; + } + } + .input-sub-label { font-size: 12px; font-style: italic; @@ -597,4 +621,14 @@ export default createGlobalStyle` box-shadow: 0px 45px 56px 0px rgba(0,0,0,0.07), 0px 10.05px 12.51px 0px rgba(0,0,0,0.0417), 0px 2.99px 3.72px 0px rgba(0,0,0,0.0283); } } + + .flex-center-center { + display: flex; + justify-content: center; + align-items: center; + } + + a{ + text-decoration:none; + } `; diff --git a/packages/frontend/src/components/Recaptcha.js b/packages/frontend/src/components/Recaptcha.js index 37af16063f..f1faa89bbd 100644 --- a/packages/frontend/src/components/Recaptcha.js +++ b/packages/frontend/src/components/Recaptcha.js @@ -191,4 +191,4 @@ export const isRetryableRecaptchaError = (e) => { if (!e.code) { return false; } return ['invalid-input-response','missing-input-response', 'timeout-or-duplicate'].includes(e.code); -}; \ No newline at end of file +}; diff --git a/packages/frontend/src/components/Routing.js b/packages/frontend/src/components/Routing.js index 220a1ed741..36bec603f7 100644 --- a/packages/frontend/src/components/Routing.js +++ b/packages/frontend/src/components/Routing.js @@ -2,6 +2,7 @@ import { ConnectedRouter, getRouter } from 'connected-react-router'; import isString from 'lodash.isstring'; import { parseSeedPhrase } from 'near-seed-phrase'; import PropTypes from 'prop-types'; +import { stringify } from 'query-string'; import React, { Component } from 'react'; import ReactDOMServer from 'react-dom/server'; import { withLocalize } from 'react-localize-redux'; @@ -9,27 +10,36 @@ import { connect } from 'react-redux'; import { Redirect, Switch } from 'react-router-dom'; import styled, { ThemeProvider } from 'styled-components'; +import { CREATE_IMPLICIT_ACCOUNT, IMPORT_ACCOUNT_WITH_LINK_V2 } from '../../../../features'; import TwoFactorVerifyModal from '../components/accounts/two_factor/TwoFactorVerifyModal'; -import { IS_MAINNET, PUBLIC_URL, SHOW_PRERELEASE_WARNING } from '../config'; +import { IS_MAINNET, PUBLIC_URL, SHOW_PRERELEASE_WARNING, DISABLE_CREATE_ACCOUNT } from '../config'; import ExampleFlag from '../ExampleFlag'; -import { Mixpanel } from "../mixpanel/index"; +import { Mixpanel } from '../mixpanel/index'; import * as accountActions from '../redux/actions/account'; +import { handleClearAlert } from '../redux/reducers/status'; import { selectAccountSlice } from '../redux/slices/account'; +import { actions as flowLimitationActions } from '../redux/slices/flowLimitation'; import { actions as tokenFiatValueActions } from '../redux/slices/tokenFiatValues'; +import { CreateImplicitAccountWrapper } from '../routes/CreateImplicitAccountWrapper'; +import { ImportAccountWithLinkWrapper } from '../routes/ImportAccountWithLinkWrapper'; import { LoginWrapper } from '../routes/LoginWrapper'; +import { SetupLedgerNewAccountWrapper } from '../routes/SetupLedgerNewAccountWrapper'; +import { SetupPassphraseNewAccountWrapper } from '../routes/SetupPassphraseNewAccountWrapper'; +import { SetupRecoveryImplicitAccountWrapper } from '../routes/SetupRecoveryImplicitAccountWrapper'; +import { SignWrapper } from '../routes/SignWrapper'; +import { WalletWrapper } from '../routes/WalletWrapper'; import translations_en from '../translations/en.global.json'; import translations_pt from '../translations/pt.global.json'; import translations_ru from '../translations/ru.global.json'; +import translations_tr from '../translations/tr.global.json'; import translations_vi from '../translations/vi.global.json'; import translations_zh_hans from '../translations/zh-hans.global.json'; import translations_zh_hant from '../translations/zh-hant.global.json'; -import { handleClearAlert } from '../utils/alerts'; import classNames from '../utils/classNames'; import getBrowserLocale from '../utils/getBrowserLocale'; -import { getAccountIsInactive, removeAccountIsInactive, setAccountIsInactive } from '../utils/localStorage'; import { reportUiActiveMixpanelThrottled } from '../utils/reportUiActiveMixpanelThrottled'; import ScrollToTop from '../utils/ScrollToTop'; -import { +import { WALLET_CREATE_NEW_ACCOUNT_FLOW_URLS, WALLET_LOGIN_URL, WALLET_SIGN_URL, @@ -37,9 +47,9 @@ import { } from '../utils/wallet'; import AccessKeysWrapper from './access-keys/v2/AccessKeysWrapper'; import { AutoImportWrapper } from './accounts/auto_import/AutoImportWrapper'; -import { ActivateAccountWithRouter } from './accounts/create/ActivateAccount'; import { ExistingAccountWrapper } from './accounts/create/existing_account/ExistingAccountWrapper'; import { InitialDepositWrapper } from './accounts/create/initial_deposit/InitialDepositWrapper'; +import { CreateAccountLanding } from './accounts/create/landing/CreateAccountLanding'; import { VerifyAccountWrapper } from './accounts/create/verify_account/VerifyAccountWrapper'; import { CreateAccountWithRouter } from './accounts/CreateAccount'; import LedgerConfirmActionModal from './accounts/ledger/LedgerConfirmActionModal'; @@ -59,27 +69,28 @@ import Footer from './common/Footer'; import GlobalAlert from './common/GlobalAlert'; import GuestLandingRoute from './common/GuestLandingRoute'; import NetworkBanner from './common/NetworkBanner'; -import PrivateRoute from './common/PrivateRoute'; -import PublicRoute from './common/PublicRoute'; +import PrivateRoute from './common/routing/PrivateRoute'; +import PublicRoute from './common/routing/PublicRoute'; +import Route from './common/routing/Route'; import GlobalStyle from './GlobalStyle'; import { LoginCliLoginSuccess } from './login/LoginCliLoginSuccess'; -import Navigation from './navigation/Navigation'; -import {PageNotFound} from './page-not-found/PageNotFound'; +import NavigationWrapper from './navigation/NavigationWrapper'; +import { NFTDetailWrapper } from './nft/NFTDetailWrapper'; +import { PageNotFound } from './page-not-found/PageNotFound'; import { Profile } from './profile/Profile'; import { ReceiveContainerWrapper } from './receive-money/ReceiveContainerWrapper'; import { SendContainerWrapper } from './send/SendContainerWrapper'; -import { SignWithRouter } from './sign/Sign'; import { StakingContainer } from './staking/StakingContainer'; import Terms from './terms/Terms'; -import { Wallet } from './wallet/Wallet'; + import '../index.css'; -const { - fetchTokenFiatValues +const { + fetchTokenFiatValues, + getTokenWhiteList, } = tokenFiatValueActions; -const { - getAccountHelperWalletState, +const { handleClearUrl, handleRedirectUrl, handleRefreshUrl, @@ -88,6 +99,8 @@ const { refreshAccount } = accountActions; +const { handleFlowLimitation } = flowLimitationActions; + const theme = {}; const PATH_PREFIX = PUBLIC_URL; @@ -123,23 +136,20 @@ class Routing extends Component { constructor(props) { super(props); - this.state = { - isInactiveAccount: null - }; - this.pollTokenFiatValue = null; const languages = [ - { name: "English", code: "en" }, - { name: "Português", code: "pt" }, - { name: "Русский", code: "ru" }, - { name: "Tiếng Việt", code: "vi" }, - { name: "简体中文", code: "zh-hans" }, - { name: "繁體中文", code: "zh-hant" } + { name: 'English', code: 'en' }, + { name: 'Português', code: 'pt' }, + { name: 'Русский', code: 'ru' }, + { name: 'Tiếng Việt', code: 'vi' }, + { name: '简体中文', code: 'zh-hans' }, + { name: '繁體中文', code: 'zh-hant' }, + { name: 'Türkçe', code: 'tr' } ]; - const browserLanguage = getBrowserLocale(languages.map(l => l.code)); - const activeLang = localStorage.getItem("languageCode") || browserLanguage || languages[0].code; + const browserLanguage = getBrowserLocale(languages.map((l) => l.code)); + const activeLang = localStorage.getItem('languageCode') || browserLanguage || languages[0].code; this.props.initialize({ languages, @@ -160,12 +170,13 @@ class Routing extends Component { }); // TODO: Figure out how to load only necessary translations dynamically - this.props.addTranslationForLanguage(translations_en, "en"); - this.props.addTranslationForLanguage(translations_pt, "pt"); - this.props.addTranslationForLanguage(translations_ru, "ru"); - this.props.addTranslationForLanguage(translations_zh_hans, "zh-hans"); - this.props.addTranslationForLanguage(translations_zh_hant, "zh-hant"); - this.props.addTranslationForLanguage(translations_vi, "vi"); + this.props.addTranslationForLanguage(translations_en, 'en'); + this.props.addTranslationForLanguage(translations_pt, 'pt'); + this.props.addTranslationForLanguage(translations_ru, 'ru'); + this.props.addTranslationForLanguage(translations_vi, 'vi'); + this.props.addTranslationForLanguage(translations_zh_hans, 'zh-hans'); + this.props.addTranslationForLanguage(translations_zh_hant, 'zh-hant'); + this.props.addTranslationForLanguage(translations_tr, 'tr'); this.props.setActiveLanguage(activeLang); // this.addTranslationsForActiveLanguage(defaultLanguage) @@ -179,9 +190,11 @@ class Routing extends Component { handleRedirectUrl, handleClearUrl, router, - fetchTokenFiatValues + fetchTokenFiatValues, + handleClearAlert, + handleFlowLimitation } = this.props; - + fetchTokenFiatValues(); this.startPollingTokenFiatValue(); handleRefreshUrl(router); @@ -195,31 +208,15 @@ class Routing extends Component { } handleClearAlert(); + handleFlowLimitation(); }); } componentDidUpdate(prevProps) { - const { activeLanguage, account, getAccountHelperWalletState } = this.props; - - if (prevProps.account.localStorage?.accountId !== account.localStorage?.accountId) { - this.setState({ isInactiveAccount: getAccountIsInactive(`${account.accountId || account.localStorage?.accountId}`) }); - } + const { activeLanguage, account } = this.props; if (prevProps.account.accountId !== account.accountId && account.accountId !== undefined) { - getAccountHelperWalletState(account.accountId); - } - - if (prevProps.account.accountHelperWalletState.isLoaded !== account.accountHelperWalletState.isLoaded) { - const needsDeposit = account.accountHelperWalletState.fundedAccountNeedsDeposit; - const accountId = account.accountId || account.localStorage?.accountId; - - this.setState({ isInactiveAccount: needsDeposit }); - - if (!needsDeposit) { - removeAccountIsInactive(accountId); - } else { - setAccountIsInactive(accountId); - } + this.props.getTokenWhiteList(account.accountId); } const prevLangCode = prevProps.activeLanguage && prevProps.activeLanguage.code; @@ -228,7 +225,7 @@ class Routing extends Component { if (hasLanguageChanged) { // this.addTranslationsForActiveLanguage(curLangCode) - localStorage.setItem("languageCode", curLangCode); + localStorage.setItem('languageCode', curLangCode); } } @@ -237,10 +234,10 @@ class Routing extends Component { } startPollingTokenFiatValue = () => { - const { fetchTokenFiatValues } = this.props; + const { fetchTokenFiatValues } = this.props; const handlePollTokenFiatValue = async () => { - await fetchTokenFiatValues().catch(() => {}); + await fetchTokenFiatValues().catch(() => { }); if (this.pollTokenFiatValue) { this.pollTokenFiatValue = setTimeout(() => handlePollTokenFiatValue(), 30000); } @@ -258,25 +255,22 @@ class Routing extends Component { const { account } = this.props; const setTab = (nextTab) => { if (tab !== nextTab) { - const destinationSearch = new URLSearchParams(search); - - if (nextTab) { - destinationSearch.set('tab', nextTab); - } else { - destinationSearch.delete('tab'); - } - // Ensure any `hash` value remains in the URL when we toggle tab - this.props.history.push({ search: destinationSearch.toString(), hash }); + this.props.history.push({ + search: stringify( + { tab: nextTab }, + { skipNull: true, skipEmptyString: true } + ), + hash, + }); } }; - const { isInactiveAccount } = this.state; - + const hideFooterOnMobile = [ WALLET_LOGIN_URL, WALLET_SEND_MONEY_URL, WALLET_SIGN_URL - ].includes(pathname.replace(/\//g,'')); + ].includes(pathname.replace(/\//g, '')); const accountFound = this.props.account.localStorage?.accountFound; @@ -287,37 +281,37 @@ class Routing extends Component { - + - - - + + + { account.requestPending !== null && { const { account, promptTwoFactor } = this.props; - Mixpanel.track("2FA Modal Verify start"); + Mixpanel.track('2FA Modal Verify start'); // requestPending will resolve (verified == true) or reject the Promise being awaited in the method that dispatched promptTwoFactor account.requestPending(verified, error); // clears requestPending and closes the modal promptTwoFactor(null); if (error) { // tracking error - Mixpanel.track("2FA Modal Verify fail", {error: error.message}); + Mixpanel.track('2FA Modal Verify fail', { error: error.message }); } if (verified) { - Mixpanel.track("2FA Modal Verify finish"); + Mixpanel.track('2FA Modal Verify finish'); } }} /> @@ -327,7 +321,7 @@ class Routing extends Component { pathname: '/*', search: search }} /> - isInactiveAccount ? : } + render={(props) => } accountFound={accountFound} indexBySearchEngines={!accountFound} /> - - + {CREATE_IMPLICIT_ACCOUNT && + + accountFound || !DISABLE_CREATE_ACCOUNT ? ( + + ) : ( + + ) + } + // Logged in users always create a named account + /> + } + - - - + } + {CREATE_IMPLICIT_ACCOUNT && + + } + {CREATE_IMPLICIT_ACCOUNT && + + } + {CREATE_IMPLICIT_ACCOUNT && + + } + - - - - - - - - - { @@ -431,7 +472,7 @@ class Routing extends Component { ); }} /> - { @@ -446,7 +487,7 @@ class Routing extends Component { ); }} /> - } + render={() => } /> } + render={() => } + /> + + - {!isInactiveAccount && - - } - - {!isInactiveAccount && - - } + - {!isInactiveAccount && - ( - - )} - /> - } - + ( + + )} + /> + - -