diff --git a/client/cypress/e2e/_2makePandingSnapshot.cy.ts b/client/cypress/e2e/_2makePendingSnapshot.cy.ts similarity index 63% rename from client/cypress/e2e/_2makePandingSnapshot.cy.ts rename to client/cypress/e2e/_2makePendingSnapshot.cy.ts index 1a78151a13..a1b96c0ee4 100644 --- a/client/cypress/e2e/_2makePandingSnapshot.cy.ts +++ b/client/cypress/e2e/_2makePendingSnapshot.cy.ts @@ -1,6 +1,7 @@ import axios from 'axios'; import { mockCoinPricesServer, visitWithLoader } from 'cypress/utils/e2e'; +import { QUERY_KEYS } from 'src/api/queryKeys'; import { IS_ONBOARDING_ALWAYS_VISIBLE, IS_ONBOARDING_DONE } from 'src/constants/localStorageKeys'; import env from 'src/env'; import { ROOT_ROUTES } from 'src/routes/RootRoutes/routes'; @@ -21,13 +22,25 @@ describe('Make pending snapshot', () => { mockCoinPricesServer(); localStorage.setItem(IS_ONBOARDING_ALWAYS_VISIBLE, 'false'); localStorage.setItem(IS_ONBOARDING_DONE, 'true'); - visitWithLoader(ROOT_ROUTES.earn.absolute); + visitWithLoader(ROOT_ROUTES.playground.absolute); }); it('make pending snapshot', () => { - cy.window().then(async () => { - await axios.post(`${env.serverEndpoint}snapshots/pending`); - cy.get('[data-test=SyncView]', { timeout: 60000 }).should('not.exist'); + cy.window().then(async win => { + const isDecisionWindowOpen = win.clientReactQuery.getQueryData( + QUERY_KEYS.isDecisionWindowOpen, + ); + + if (!isDecisionWindowOpen) { + expect(true).to.be.true; + return; + } + + cy.wrap(null).then(() => { + axios.post(`${env.serverEndpoint}snapshots/pending`).then(() => { + cy.get('[data-test=SyncView]', { timeout: 60000 }).should('not.exist'); + }); + }); }); }); }); diff --git a/client/cypress/e2e/allocationItemWindowClosed.cy.ts b/client/cypress/e2e/allocationItemWindowClosed.cy.ts new file mode 100644 index 0000000000..f5a42a70ea --- /dev/null +++ b/client/cypress/e2e/allocationItemWindowClosed.cy.ts @@ -0,0 +1,108 @@ +import { + connectWallet, + visitWithLoader, + mockCoinPricesServer, + navigateWithCheck, +} from 'cypress/utils/e2e'; +import { moveTime, setupAndMoveToPlayground } from 'cypress/utils/moveTime'; +import viewports from 'cypress/utils/viewports'; +import { QUERY_KEYS } from 'src/api/queryKeys'; +import { + ALLOCATION_ITEMS_KEY, + IS_ONBOARDING_ALWAYS_VISIBLE, + IS_ONBOARDING_DONE, +} from 'src/constants/localStorageKeys'; +import { ROOT_ROUTES } from 'src/routes/RootRoutes/routes'; + +describe('allocation (allocation window closed)', () => { + describe('move time', () => { + before(() => { + /** + * Global Metamask setup done by Synpress is not always done. + * Since Synpress needs to have valid provider to fetch the data from contracts, + * setupMetamask is required in each test suite. + */ + cy.setupMetamask(); + }); + + it('allocation window is closed, when it is not, move time', () => { + setupAndMoveToPlayground(); + + cy.window().then(async win => { + moveTime(win, 'nextEpochDecisionWindowClosed').then(() => { + cy.get('[data-test=PlaygroundView]').should('be.visible'); + const isDecisionWindowOpenAfter = win.clientReactQuery.getQueryData( + QUERY_KEYS.isDecisionWindowOpen, + ); + expect(isDecisionWindowOpenAfter).to.be.false; + }); + }); + }); + }); + + Object.values(viewports).forEach(({ device, viewportWidth, viewportHeight, isDesktop }) => { + describe(`test cases: ${device}`, { viewportHeight, viewportWidth }, () => { + before(() => { + /** + * Global Metamask setup done by Synpress is not always done. + * Since Synpress needs to have valid provider to fetch the data from contracts, + * setupMetamask is required in each test suite. + */ + cy.setupMetamask(); + }); + + beforeEach(() => { + cy.disconnectMetamaskWalletFromAllDapps(); + mockCoinPricesServer(); + localStorage.setItem(IS_ONBOARDING_ALWAYS_VISIBLE, 'false'); + localStorage.setItem(IS_ONBOARDING_DONE, 'true'); + localStorage.setItem(ALLOCATION_ITEMS_KEY, '[]'); + visitWithLoader(ROOT_ROUTES.projects.absolute); + + cy.get('[data-test^=ProjectItemSkeleton').should('not.exist'); + cy.get('[data-test^=ProjectsView__ProjectsListItem]') + .eq(0) + .should('be.visible') + .find('[data-test=ProjectsListItem__name]') + .then($text => { + cy.wrap($text.text()).as('projectName'); + }); + + cy.get('[data-test^=ProjectsView__ProjectsListItem') + .eq(0) + .find('[data-test=ProjectsListItem__ButtonAddToAllocate]') + .click(); + navigateWithCheck(ROOT_ROUTES.allocation.absolute); + }); + + it('AllocationItem shows all the elements', () => { + connectWallet(true, false); + cy.get('[data-test=AllocationItem]') + .eq(0) + .find('[data-test=AllocationItem__name]') + .then($allocationItemName => { + cy.get('@projectName').then(projectName => { + expect(projectName).to.eq($allocationItemName.text()); + }); + }); + + cy.get('[data-test=AllocationItem]') + .eq(0) + .find('[data-test=AllocationItem__imageProfile]') + .should(isDesktop ? 'be.visible' : 'not.be.visible'); + cy.get('[data-test=AllocationItem]') + .eq(0) + .find('[data-test=AllocationItemRewards]') + .contains(isDesktop ? 'Threshold data unavailable' : 'No threshold data'); + cy.get('[data-test=AllocationItem]') + .eq(0) + .find('[data-test=AllocationItem__InputText]') + .should('be.disabled'); + cy.get('[data-test=AllocationItem]') + .eq(0) + .find('[data-test=AllocationItem__InputText__suffix]') + .contains('GWEI'); + }); + }); + }); +}); diff --git a/client/cypress/e2e/allocationItemWindowOpen.cy.ts b/client/cypress/e2e/allocationItemWindowOpen.cy.ts new file mode 100644 index 0000000000..58a9360a9b --- /dev/null +++ b/client/cypress/e2e/allocationItemWindowOpen.cy.ts @@ -0,0 +1,139 @@ +// eslint-disable-next-line import/no-extraneous-dependencies +import chaiColors from 'chai-colors'; + +import { + visitWithLoader, + mockCoinPricesServer, + navigateWithCheck, + connectWallet, +} from 'cypress/utils/e2e'; +import { moveTime, setupAndMoveToPlayground } from 'cypress/utils/moveTime'; +import viewports from 'cypress/utils/viewports'; +import { QUERY_KEYS } from 'src/api/queryKeys'; +import { + ALLOCATION_ITEMS_KEY, + IS_ONBOARDING_ALWAYS_VISIBLE, + IS_ONBOARDING_DONE, +} from 'src/constants/localStorageKeys'; +import { ROOT_ROUTES } from 'src/routes/RootRoutes/routes'; + +chai.use(chaiColors); + +const budget = '10000000000'; // 10 GWEI. + +describe('allocation (allocation window open)', () => { + describe('move time', () => { + before(() => { + /** + * Global Metamask setup done by Synpress is not always done. + * Since Synpress needs to have valid provider to fetch the data from contracts, + * setupMetamask is required in each test suite. + */ + cy.setupMetamask(); + }); + + it('allocation window is open, when it is not, move time', () => { + setupAndMoveToPlayground(); + + cy.window().then(async win => { + moveTime(win, 'nextEpochDecisionWindowOpen').then(() => { + const isDecisionWindowOpenAfter = win.clientReactQuery.getQueryData( + QUERY_KEYS.isDecisionWindowOpen, + ); + expect(isDecisionWindowOpenAfter).to.be.true; + }); + }); + }); + }); + + Object.values(viewports).forEach(({ device, viewportWidth, viewportHeight, isDesktop }) => { + describe(`test cases: ${device}`, { viewportHeight, viewportWidth }, () => { + before(() => { + /** + * Global Metamask setup done by Synpress is not always done. + * Since Synpress needs to have valid provider to fetch the data from contracts, + * setupMetamask is required in each test suite. + */ + cy.setupMetamask(); + }); + + beforeEach(() => { + cy.intercept('GET', '/rewards/budget/*/epoch/*', { body: { budget } }); + cy.disconnectMetamaskWalletFromAllDapps(); + mockCoinPricesServer(); + localStorage.setItem(IS_ONBOARDING_ALWAYS_VISIBLE, 'false'); + localStorage.setItem(IS_ONBOARDING_DONE, 'true'); + localStorage.setItem(ALLOCATION_ITEMS_KEY, '[]'); + visitWithLoader(ROOT_ROUTES.projects.absolute); + connectWallet(true, false); + + cy.get('[data-test^=ProjectItemSkeleton').should('not.exist'); + cy.get('[data-test^=ProjectsView__ProjectsListItem]') + .eq(0) + .should('be.visible') + .find('[data-test=ProjectsListItem__name]') + .then($text => { + cy.wrap($text.text()).as('projectName'); + }); + + cy.get('[data-test^=ProjectsView__ProjectsListItem') + .eq(0) + .find('[data-test=ProjectsListItem__ButtonAddToAllocate]') + .click(); + navigateWithCheck(ROOT_ROUTES.allocation.absolute); + cy.get('[data-test=AllocationItemSkeleton]').should('not.exist'); + }); + + it('AllocationItem shows all the elements', () => { + cy.get('[data-test=AllocationItem]') + .eq(0) + .find('[data-test=AllocationItem__name]') + .then($allocationItemName => { + cy.get('@projectName').then(projectName => { + expect(projectName).to.eq($allocationItemName.text()); + }); + }); + cy.get('[data-test=AllocationItem]') + .eq(0) + .find('[data-test=AllocationItem__imageProfile]') + .should(isDesktop ? 'be.visible' : 'not.be.visible'); + cy.get('[data-test=AllocationItem]') + .eq(0) + .find('[data-test=AllocationItem__InputText]') + .should('be.enabled'); + }); + + it('AllocationItem__InputText correctly changes background color on focus', () => { + cy.get('[data-test=AllocationItem]') + .eq(0) + .find('[data-test=AllocationItem__InputText]') + .focus(); + cy.get('[data-test=AllocationItem]') + .eq(0) + .find('[data-test=AllocationItem__InputText]') + .should('have.focus'); + cy.get('[data-test=AllocationItem]') + .eq(0) + .find('[data-test=AllocationItem__InputText]') + .should('have.css', 'background-color') + .and('be.colored', '#f1faf8'); + }); + + it('AllocationItem__InputText correctly changes background color on error', () => { + cy.get('[data-test=AllocationItem]') + .eq(0) + .find('[data-test=AllocationItem__InputText__suffix]') + .contains('GWEI'); + cy.get('[data-test=AllocationItem]') + .eq(0) + .find('[data-test=AllocationItem__InputText]') + .type('99'); + cy.get('[data-test=AllocationItem]') + .eq(0) + .find('[data-test=AllocationItem__InputText]') + .should('have.css', 'background-color') + .and('be.colored', '#f1faf8'); + }); + }); + }); +}); diff --git a/client/cypress/e2e/earn.cy.ts b/client/cypress/e2e/earn.cy.ts index 31234e86ca..c3bb07dc3a 100644 --- a/client/cypress/e2e/earn.cy.ts +++ b/client/cypress/e2e/earn.cy.ts @@ -1,4 +1,5 @@ -import { visitWithLoader, mockCoinPricesServer, moveEpoch } from 'cypress/utils/e2e'; +import { visitWithLoader, mockCoinPricesServer } from 'cypress/utils/e2e'; +import { moveTime } from 'cypress/utils/moveTime'; import viewports from 'cypress/utils/viewports'; import { IS_ONBOARDING_ALWAYS_VISIBLE, IS_ONBOARDING_DONE } from 'src/constants/localStorageKeys'; import { ROOT_ROUTES } from 'src/routes/RootRoutes/routes'; @@ -248,7 +249,7 @@ Object.values(viewports).forEach(({ device, viewportWidth, viewportHeight, isDes }).should('not.exist'); cy.window().then(async win => { cy.wrap(null).then(() => { - return moveEpoch(win).then(() => { + return moveTime(win, 'nextEpochDecisionWindowClosed').then(() => { cy.get('[data-test=BoxGlmLock__Section--current__DoubleValue__primary]', { timeout: 60000, }) diff --git a/client/cypress/e2e/projectsArchive.cy.ts b/client/cypress/e2e/projectsArchive.cy.ts index 02121a9cdf..237fcbd624 100644 --- a/client/cypress/e2e/projectsArchive.cy.ts +++ b/client/cypress/e2e/projectsArchive.cy.ts @@ -1,4 +1,5 @@ -import { checkLocationWithLoader, moveEpoch, visitWithLoader } from 'cypress/utils/e2e'; +import { checkLocationWithLoader, visitWithLoader } from 'cypress/utils/e2e'; +import { moveTime } from 'cypress/utils/moveTime'; import viewports from 'cypress/utils/viewports'; import { QUERY_KEYS } from 'src/api/queryKeys'; import { IS_ONBOARDING_ALWAYS_VISIBLE, IS_ONBOARDING_DONE } from 'src/constants/localStorageKeys'; @@ -23,7 +24,7 @@ Object.values(viewports).forEach(({ device, viewportWidth, viewportHeight }) => ); cy.wrap(null).then(() => { - return moveEpoch(win).then(() => { + return moveTime(win, 'nextEpochDecisionWindowClosed').then(() => { const currentEpochAfter = Number( win.clientReactQuery.getQueryData(QUERY_KEYS.currentEpoch), ); diff --git a/client/cypress/support/e2e.d.ts b/client/cypress/support/e2e.d.ts index 78c96bcb23..a7ce1357db 100644 --- a/client/cypress/support/e2e.d.ts +++ b/client/cypress/support/e2e.d.ts @@ -4,6 +4,8 @@ declare namespace Cypress { interface ApplicationWindow { // Importing QueryClient breaks making these types not visible. clientReactQuery: any; - mutateAsyncMoveEpoch: () => Promise; + mutateAsyncMakeSnapshot: (type: 'pending' | 'finalized') => Promise; + mutateAsyncMoveToDecisionWindowClosed: () => Promise; + mutateAsyncMoveToDecisionWindowOpen: () => Promise; } } diff --git a/client/cypress/utils/e2e.ts b/client/cypress/utils/e2e.ts index d5e8bd7580..1495efd423 100644 --- a/client/cypress/utils/e2e.ts +++ b/client/cypress/utils/e2e.ts @@ -1,7 +1,4 @@ -import axios from 'axios'; - import { navigationTabs } from 'src/constants/navigationTabs/navigationTabs'; -import env from 'src/env'; import Chainable = Cypress.Chainable; @@ -49,32 +46,3 @@ export const connectWallet = ( cy.switchToMetamaskNotification(); return cy.acceptMetamaskAccess(); }; - -export const moveEpoch = (cypressWindow: Cypress.AUTWindow): Promise => { - return new Promise(resolve => { - cypressWindow.mutateAsyncMoveEpoch().then(() => { - // Waiting 2s is a way to prevent the effects of slowing down the e2e environment (data update). - cy.wait(2000); - // Manually taking a pending snapshot after the epoch shift ensures that the snapshot is taken. Passing epoch multiple times without manually triggering pending snapshot in a short period of time may cause the e2e environment to fail. - axios.post(`${env.serverEndpoint}snapshots/pending`).then(() => { - // Waiting 2s is a way to prevent the effects of slowing down the e2e environment (data update). - cy.wait(2000); - // reload is needed to get updated data in the app - cy.reload(); - cy.get('[data-test=SyncView]', { timeout: 60000 }).should('not.exist'); - // reload is needed to get updated data in the app - cy.reload(); - axios.post(`${env.serverEndpoint}snapshots/finalized`).then(() => { - // Waiting 2s is a way to prevent the effects of slowing down the e2e environment (data update). - cy.wait(2000); - // reload is needed to get updated data in the app - cy.reload(); - cy.get('[data-test=SyncView]', { timeout: 60000 }).should('not.exist'); - // reload is needed to get updated data in the app - cy.reload(); - resolve(true); - }); - }); - }); - }); -}; diff --git a/client/cypress/utils/moveTime.ts b/client/cypress/utils/moveTime.ts new file mode 100644 index 0000000000..e8ca436043 --- /dev/null +++ b/client/cypress/utils/moveTime.ts @@ -0,0 +1,137 @@ +import { QUERY_KEYS } from 'src/api/queryKeys'; +import { + ALLOCATION_ITEMS_KEY, + IS_ONBOARDING_ALWAYS_VISIBLE, + IS_ONBOARDING_DONE, +} from 'src/constants/localStorageKeys'; +import { ROOT_ROUTES } from 'src/routes/RootRoutes/routes'; + +import { mockCoinPricesServer, visitWithLoader } from './e2e'; + +import Chainable = Cypress.Chainable; + +export const setupAndMoveToPlayground = (): Chainable => { + cy.disconnectMetamaskWalletFromAllDapps(); + mockCoinPricesServer(); + localStorage.setItem(IS_ONBOARDING_ALWAYS_VISIBLE, 'false'); + localStorage.setItem(IS_ONBOARDING_DONE, 'true'); + localStorage.setItem(ALLOCATION_ITEMS_KEY, '[]'); + return visitWithLoader(ROOT_ROUTES.playground.absolute); +}; + +const mutateAsyncMoveToDecisionWindowClosed = (cypressWindow: Cypress.AUTWindow): Promise => + new Promise(resolve => { + cypressWindow.mutateAsyncMoveToDecisionWindowClosed().then(() => { + resolve(true); + }); + }); + +const mutateAsyncMakeSnapshot = ( + cypressWindow: Cypress.AUTWindow, + type: 'finalized' | 'pending', +): Promise => + new Cypress.Promise(resolve => { + cypressWindow.mutateAsyncMakeSnapshot(type).then(() => { + resolve(true); + }); + }); + +const mutateAsyncMoveToDecisionWindowOpen = (cypressWindow: Cypress.AUTWindow): Promise => + new Cypress.Promise(resolve => { + cypressWindow.mutateAsyncMoveToDecisionWindowOpen().then(() => { + resolve(true); + }); + }); + +const waitForLoadersToDisappear = (): Chainable => { + cy.get('[data-test*=AppLoader]').should('not.exist'); + return cy.get('[data-test=SyncView]', { timeout: 60000 }).should('not.exist'); +}; + +const moveToDecisionWindowOpen = (cypressWindow: Cypress.AUTWindow): Chainable => { + waitForLoadersToDisappear(); + cy.wrap(null).then(() => { + return mutateAsyncMoveToDecisionWindowOpen(cypressWindow).then(str => { + expect(str).to.eq(true); + }); + }); + waitForLoadersToDisappear(); + // Waiting 2s is a way to prevent the effects of slowing down the e2e environment (data update). + cy.wait(2000); + cy.wrap(null).then(() => { + return mutateAsyncMakeSnapshot(cypressWindow, 'pending').then(str => { + expect(str).to.eq(true); + }); + }); + // Waiting 2s is a way to prevent the effects of slowing down the e2e environment (data update). + cy.wait(2000); + // Reload is needed to get updated data in the app + cy.reload(); + return waitForLoadersToDisappear(); +}; + +const moveToDecisionWindowClosed = (cypressWindow: Cypress.AUTWindow): Chainable => { + cy.wrap(null).then(() => { + return mutateAsyncMoveToDecisionWindowClosed(cypressWindow).then(str => { + expect(str).to.eq(true); + }); + }); + waitForLoadersToDisappear(); + // Waiting 2s is a way to prevent the effects of slowing down the e2e environment (data update). + cy.wait(2000); + cy.wrap(null).then(() => { + return mutateAsyncMakeSnapshot(cypressWindow, 'finalized').then(str => { + expect(str).to.eq(true); + }); + }); + // Waiting 2s is a way to prevent the effects of slowing down the e2e environment (data update). + cy.wait(2000); + // Reload is needed to get updated data in the app + cy.reload(); + return waitForLoadersToDisappear(); +}; + +/** + * General note: this util moves the time to the next epoch, window open or closed. + * In the future we will add ability to move to window closed without changing the epoch. + */ +export const moveTime = ( + cypressWindow: Cypress.AUTWindow, + moveTo: 'nextEpochDecisionWindowClosed' | 'nextEpochDecisionWindowOpen', + shouldMoveToPlayground = false, +): Chainable => { + if (shouldMoveToPlayground) { + cy.disconnectMetamaskWalletFromAllDapps(); + mockCoinPricesServer(); + localStorage.setItem(IS_ONBOARDING_ALWAYS_VISIBLE, 'false'); + localStorage.setItem(IS_ONBOARDING_DONE, 'true'); + localStorage.setItem(ALLOCATION_ITEMS_KEY, '[]'); + visitWithLoader(ROOT_ROUTES.playground.absolute); + } + + const isDecisionWindowOpen = cypressWindow.clientReactQuery.getQueryData( + QUERY_KEYS.isDecisionWindowOpen, + ); + + if (isDecisionWindowOpen) { + moveToDecisionWindowClosed(cypressWindow); + waitForLoadersToDisappear(); + // reload is needed to get updated data in the app + cy.reload(); + } + + if (moveTo === 'nextEpochDecisionWindowOpen') { + moveToDecisionWindowOpen(cypressWindow); + } else { + moveToDecisionWindowOpen(cypressWindow); + // Reload is needed to get updated data in the app + cy.reload(); + waitForLoadersToDisappear(); + moveToDecisionWindowClosed(cypressWindow); + } + + // Waiting 2s is a way to prevent the effects of slowing down the e2e environment (data update). + cy.wait(2000); + cy.reload(); + return waitForLoadersToDisappear(); +}; diff --git a/client/global.d.ts b/client/global.d.ts index b6f360662f..d234d3b8b3 100644 --- a/client/global.d.ts +++ b/client/global.d.ts @@ -1,4 +1,4 @@ -import { QueryClient } from '@tanstack/react-query'; +import { QueryClient, UseMutateAsyncFunction } from '@tanstack/react-query'; import { WINDOW_PROJECTS_LOADED_ARCHIVED_EPOCHS_NUMBER, @@ -11,5 +11,13 @@ export declare global { [WINDOW_PROJECTS_LOADED_ARCHIVED_EPOCHS_NUMBER]?: number; [WINDOW_PROJECTS_SCROLL_Y]?: number; clientReactQuery?: QueryClient; + mutateAsyncMakeSnapshot: (type: 'pending' | 'finalized') => Promise; + mutateAsyncMoveToDecisionWindowClosed: UseMutateAsyncFunction< + boolean, + unknown, + unknown, + unknown + >; + mutateAsyncMoveToDecisionWindowOpen: UseMutateAsyncFunction; } } diff --git a/client/src/App.tsx b/client/src/App.tsx index 7c7243bc50..957b6dcd73 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -9,8 +9,8 @@ import useCypressHelpers from 'hooks/helpers/useCypressHelpers'; import useIsProjectAdminMode from 'hooks/helpers/useIsProjectAdminMode'; import useManageTransactionsPending from 'hooks/helpers/useManageTransactionsPending'; import RootRoutes from 'routes/RootRoutes/RootRoutes'; -import 'react-toastify/dist/ReactToastify.css'; +import 'react-toastify/dist/ReactToastify.css'; import 'styles/index.scss'; import 'i18n'; @@ -24,7 +24,7 @@ const App = (): ReactElement => { const isProjectAdminMode = useIsProjectAdminMode(); // useCypressHelpers needs to be called after all the initial sets done above. - useCypressHelpers(); + const { isFetching: isFetchingCypressHelpers } = useCypressHelpers(); if (isLoading && !isSyncingInProgress) { return ; @@ -32,7 +32,7 @@ const App = (): ReactElement => { return ( - + {!isSyncingInProgress && !isProjectAdminMode && } ); diff --git a/client/src/api/calls/snapshots.ts b/client/src/api/calls/snapshots.ts new file mode 100644 index 0000000000..565b6de758 --- /dev/null +++ b/client/src/api/calls/snapshots.ts @@ -0,0 +1,10 @@ +import env from 'env'; +import apiService from 'services/apiService'; + +export async function apiPostSnapshotsPending(): Promise { + return apiService.post(`${env.serverEndpoint}snapshots/pending`) +} + +export async function apiPostSnapshotsFinalized(): Promise { + return apiService.post(`${env.serverEndpoint}snapshots/finalized`) +} diff --git a/client/src/api/queryKeys/index.ts b/client/src/api/queryKeys/index.ts index b8316ea973..fed8f60cdf 100644 --- a/client/src/api/queryKeys/index.ts +++ b/client/src/api/queryKeys/index.ts @@ -43,6 +43,7 @@ export const QUERY_KEYS: QueryKeys = { epochTimestampHappenedIn: timestamp => [ROOTS.epochTimestampHappenedIn, timestamp.toString()], epochUnusedRewards: epoch => [ROOTS.epochUnusedRewards, epoch.toString()], epochesEndTime: epochNumber => [ROOTS.epochesEndTime, epochNumber.toString()], + epochsIndexedBySubgraph: ['epochsIndexedBySubgraph'], estimatedEffectiveDeposit: userAddress => [ROOTS.estimatedEffectiveDeposit, userAddress], history: ['history'], individualProjectRewards: ['individualProjectRewards'], diff --git a/client/src/api/queryKeys/types.ts b/client/src/api/queryKeys/types.ts index 7b6f48d0c4..ea222b55c5 100644 --- a/client/src/api/queryKeys/types.ts +++ b/client/src/api/queryKeys/types.ts @@ -45,6 +45,7 @@ export type QueryKeys = { epochTimestampHappenedIn: (timestamp: number) => [Root['epochTimestampHappenedIn'], string]; epochUnusedRewards: (epoch: number) => [Root['epochUnusedRewards'], string]; epochesEndTime: (epochNumber: number) => [Root['epochesEndTime'], string]; + epochsIndexedBySubgraph: ['epochsIndexedBySubgraph']; estimatedEffectiveDeposit: (userAddress: string) => [Root['estimatedEffectiveDeposit'], string]; history: ['history']; individualProjectRewards: ['individualProjectRewards']; diff --git a/client/src/components/Allocation/AllocationItem/AllocationItem.tsx b/client/src/components/Allocation/AllocationItem/AllocationItem.tsx index bb14cc4bc1..0601316df6 100644 --- a/client/src/components/Allocation/AllocationItem/AllocationItem.tsx +++ b/client/src/components/Allocation/AllocationItem/AllocationItem.tsx @@ -225,11 +225,13 @@ const AllocationItem: FC = ({
`${element}${profileImageSmall}`)} />
-
{name}
+
+ {name} +
= ({ = ({ isDonationAboveThreshold && styles.isDonationAboveThreshold, )} + data-test="AllocationItemRewards" onClick={onClick} onMouseLeave={() => setIsSimulateVisible(false)} // eslint-disable-next-line jsx-a11y/mouse-events-have-key-events diff --git a/client/src/components/ui/InputText/InputText.tsx b/client/src/components/ui/InputText/InputText.tsx index 1a41abf43f..838e2b88e5 100644 --- a/client/src/components/ui/InputText/InputText.tsx +++ b/client/src/components/ui/InputText/InputText.tsx @@ -98,6 +98,7 @@ const InputText = forwardRef( !!error && styles.isError, suffixClassName, )} + data-test={`${dataTest}__suffix`} > {suffix}
diff --git a/client/src/gql/gql.ts b/client/src/gql/gql.ts index bc1bef80a6..02d68ced80 100644 --- a/client/src/gql/gql.ts +++ b/client/src/gql/gql.ts @@ -19,6 +19,7 @@ const documents = { types.GetBlockNumberDocument, '\n query GetEpochTimestampHappenedIn($timestamp: BigInt) {\n epoches(where: { fromTs_lte: $timestamp, toTs_gte: $timestamp }) {\n epoch\n }\n }\n': types.GetEpochTimestampHappenedInDocument, + '\n query GetEpoches {\n epoches {\n epoch\n }\n }\n': types.GetEpochesDocument, '\n query GetEpochsStartEndTime($lastEpoch: Int) {\n epoches(first: $lastEpoch) {\n epoch\n toTs\n fromTs\n decisionWindow\n }\n }\n': types.GetEpochsStartEndTimeDocument, '\n query GetLargestLockedAmount {\n lockeds(orderBy: amount, orderDirection: desc, first: 1) {\n amount\n }\n }\n': @@ -67,6 +68,12 @@ export function graphql( export function graphql( source: '\n query GetEpochTimestampHappenedIn($timestamp: BigInt) {\n epoches(where: { fromTs_lte: $timestamp, toTs_gte: $timestamp }) {\n epoch\n }\n }\n', ): (typeof documents)['\n query GetEpochTimestampHappenedIn($timestamp: BigInt) {\n epoches(where: { fromTs_lte: $timestamp, toTs_gte: $timestamp }) {\n epoch\n }\n }\n']; +/** + * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. + */ +export function graphql( + source: '\n query GetEpoches {\n epoches {\n epoch\n }\n }\n', +): (typeof documents)['\n query GetEpoches {\n epoches {\n epoch\n }\n }\n']; /** * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ diff --git a/client/src/gql/graphql.ts b/client/src/gql/graphql.ts index 9ba8409ef6..da404e0813 100644 --- a/client/src/gql/graphql.ts +++ b/client/src/gql/graphql.ts @@ -1160,6 +1160,13 @@ export type GetEpochTimestampHappenedInQuery = { epoches: Array<{ __typename?: 'Epoch'; epoch: number }>; }; +export type GetEpochesQueryVariables = Exact<{ [key: string]: never }>; + +export type GetEpochesQuery = { + __typename?: 'Query'; + epoches: Array<{ __typename?: 'Epoch'; epoch: number }>; +}; + export type GetEpochsStartEndTimeQueryVariables = Exact<{ lastEpoch?: InputMaybe; }>; @@ -1350,6 +1357,29 @@ export const GetEpochTimestampHappenedInDocument = { GetEpochTimestampHappenedInQuery, GetEpochTimestampHappenedInQueryVariables >; +export const GetEpochesDocument = { + kind: 'Document', + definitions: [ + { + kind: 'OperationDefinition', + operation: 'query', + name: { kind: 'Name', value: 'GetEpoches' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: { kind: 'Name', value: 'epoches' }, + selectionSet: { + kind: 'SelectionSet', + selections: [{ kind: 'Field', name: { kind: 'Name', value: 'epoch' } }], + }, + }, + ], + }, + }, + ], +} as unknown as DocumentNode; export const GetEpochsStartEndTimeDocument = { kind: 'Document', definitions: [ diff --git a/client/src/hooks/helpers/useCypressHelpers.ts b/client/src/hooks/helpers/useCypressHelpers.ts index 7c5baf95d9..5a5533b063 100644 --- a/client/src/hooks/helpers/useCypressHelpers.ts +++ b/client/src/hooks/helpers/useCypressHelpers.ts @@ -1,9 +1,38 @@ -import { useEffect } from 'react'; +import { useEffect, useState } from 'react'; -import useCypressMoveEpoch from 'hooks/mutations/useCypressMoveEpoch'; +import env from 'env'; +import useCypressMakeSnapshot from 'hooks/mutations/useCypressMakeSnapshot'; +import useCypressMoveToDecisionWindowClosed from 'hooks/mutations/useCypressMoveToDecisionWindowClosed'; +import useCypressMoveToDecisionWindowOpen from 'hooks/mutations/useCypressMoveToDecisionWindowOpen'; +import useCurrentEpoch from 'hooks/queries/useCurrentEpoch'; +import useIsDecisionWindowOpen from 'hooks/queries/useIsDecisionWindowOpen'; +import useEpochsIndexedBySubgraph from 'hooks/subgraph/useEpochsIndexedBySubgraph'; -export default function useCypressHelpers(): void { - const { mutateAsync: mutateAsyncMoveEpoch } = useCypressMoveEpoch(); +export default function useCypressHelpers(): { isFetching: boolean } { + const [isRefetchingEpochs, setIsRefetchingEpochs] = useState(false); + + const isHookEnabled = !!window.Cypress || env.network === 'Local'; + + const { + mutateAsync: mutateAsyncMoveToDecisionWindowOpen, + isPending: isPendingMoveToDecisionWindowOpen, + } = useCypressMoveToDecisionWindowOpen(); + const { + mutateAsync: mutateAsyncMoveToDecisionWindowClosed, + isPending: isPendingMoveToDecisionWindowClosed, + } = useCypressMoveToDecisionWindowClosed(); + const { mutateAsync: mutateAsyncMakeSnapshot, isPending: isPendingMakeSnapshot } = + useCypressMakeSnapshot(); + useIsDecisionWindowOpen({ refetchInterval: isHookEnabled ? 1000 : false }); + const { data: currentEpoch } = useCurrentEpoch({ refetchInterval: isHookEnabled ? 1000 : false }); + const { data: epochs } = useEpochsIndexedBySubgraph(isHookEnabled && isRefetchingEpochs); + + const isEpochAlreadyIndexedBySubgraph = + epochs !== undefined && currentEpoch !== undefined && epochs.includes(currentEpoch); + + useEffect(() => { + setIsRefetchingEpochs(!isEpochAlreadyIndexedBySubgraph); + }, [isEpochAlreadyIndexedBySubgraph]); useEffect(() => { /** @@ -18,10 +47,20 @@ export default function useCypressHelpers(): void { * * (1) History of commits here: https://github.com/golemfoundation/octant/pull/13. */ - if (window.Cypress) { - // @ts-expect-error Left for debug purposes. - window.mutateAsyncMoveEpoch = mutateAsyncMoveEpoch; + if (isHookEnabled) { + window.mutateAsyncMoveToDecisionWindowOpen = mutateAsyncMoveToDecisionWindowOpen; + window.mutateAsyncMoveToDecisionWindowClosed = mutateAsyncMoveToDecisionWindowClosed; + window.mutateAsyncMakeSnapshot = mutateAsyncMakeSnapshot; } // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + + return { + isFetching: + isHookEnabled && + (!isEpochAlreadyIndexedBySubgraph || + isPendingMoveToDecisionWindowOpen || + isPendingMoveToDecisionWindowClosed || + isPendingMakeSnapshot), + }; } diff --git a/client/src/hooks/mutations/useCypressMakeSnapshot.ts b/client/src/hooks/mutations/useCypressMakeSnapshot.ts new file mode 100644 index 0000000000..d910c808a2 --- /dev/null +++ b/client/src/hooks/mutations/useCypressMakeSnapshot.ts @@ -0,0 +1,11 @@ +import { useMutation, UseMutationResult } from '@tanstack/react-query'; + +import { apiPostSnapshotsPending, apiPostSnapshotsFinalized } from 'api/calls/snapshots'; + +export default function useCypressMakeSnapshot(): UseMutationResult { + return useMutation({ + mutationFn: (type: 'pending' | 'finalized') => type === 'pending' + ? apiPostSnapshotsPending() + : apiPostSnapshotsFinalized(), + }) +} diff --git a/client/src/hooks/mutations/useCypressMoveToDecisionWindowClosed.ts b/client/src/hooks/mutations/useCypressMoveToDecisionWindowClosed.ts new file mode 100644 index 0000000000..525a2a4b91 --- /dev/null +++ b/client/src/hooks/mutations/useCypressMoveToDecisionWindowClosed.ts @@ -0,0 +1,89 @@ +import { useMutation, UseMutationResult, useQueryClient } from '@tanstack/react-query'; +import { useConfig } from 'wagmi'; + +import { QUERY_KEYS } from 'api/queryKeys'; +import { readContractEpochs } from 'hooks/contracts/readContracts'; + +export default function useCypressMoveToDecisionWindowClosed(): UseMutationResult { + const queryClient = useQueryClient(); + const wagmiConfig = useConfig(); + + return useMutation({ + mutationFn: () => { + // eslint-disable-next-line no-async-promise-executor + return new Promise(async (resolve, reject) => { + if (!window.Cypress) { + reject(new Error('useCypressMoveToDecisionWindowOpen was called outside Cypress.')); + } + + const currentEpochPromise = queryClient.fetchQuery({ + queryFn: () => + readContractEpochs({ + functionName: 'getCurrentEpoch', + publicClient: wagmiConfig.publicClient, + }), + queryKey: QUERY_KEYS.currentEpoch, + }); + + const blockPromise = wagmiConfig.publicClient.getBlock(); + + const currentEpochEndPromise = queryClient.fetchQuery({ + queryFn: () => + readContractEpochs({ + functionName: 'getCurrentEpochEnd', + publicClient: wagmiConfig.publicClient, + }), + queryKey: QUERY_KEYS.currentEpochEnd, + }); + + const currentEpochPropsPromise = queryClient.fetchQuery({ + queryFn: () => + readContractEpochs({ + functionName: 'getCurrentEpochProps', + publicClient: wagmiConfig.publicClient, + }), + queryKey: QUERY_KEYS.currentEpochProps, + }); + + const [block, currentEpochEnd, currentEpoch, currentEpochProps] = await Promise.all([ + blockPromise, + currentEpochEndPromise, + currentEpochPromise, + currentEpochPropsPromise, + ]); + + if ( + [block, currentEpoch, currentEpochEnd, currentEpochProps].some( + element => element === undefined, + ) + ) { + // eslint-disable-next-line prefer-promise-reject-errors + reject( + new Error( + 'useCypressMoveEpoch fetched undefined block or currentEpoch or currentEpochEnd or currentEpochProps.', + ), + ); + } + + const timeToIncrease = Number(currentEpochProps.decisionWindow) + 10; // [s] + await wagmiConfig.publicClient.request({ + method: 'evm_increaseTime' as any, + params: [timeToIncrease] as any, + }); + await wagmiConfig.publicClient.request({ method: 'evm_mine' as any, params: [] as any }); + + const isDecisionWindowOpenAfter = await queryClient.fetchQuery({ + queryFn: () => + readContractEpochs({ + functionName: 'isDecisionWindowOpen', + publicClient: wagmiConfig.publicClient, + }), + queryKey: QUERY_KEYS.isDecisionWindowOpen, + }); + + // isEpochChanged + resolve(isDecisionWindowOpenAfter === false); + }); + }, + }); +} diff --git a/client/src/hooks/mutations/useCypressMoveEpoch.ts b/client/src/hooks/mutations/useCypressMoveToDecisionWindowOpen.ts similarity index 92% rename from client/src/hooks/mutations/useCypressMoveEpoch.ts rename to client/src/hooks/mutations/useCypressMoveToDecisionWindowOpen.ts index d5b1ea9a81..83c1977475 100644 --- a/client/src/hooks/mutations/useCypressMoveEpoch.ts +++ b/client/src/hooks/mutations/useCypressMoveToDecisionWindowOpen.ts @@ -4,7 +4,7 @@ import { useConfig } from 'wagmi'; import { QUERY_KEYS } from 'api/queryKeys'; import { readContractEpochs } from 'hooks/contracts/readContracts'; -export default function useCypressMoveEpoch(): UseMutationResult { +export default function useCypressMoveToDecisionWindowOpen(): UseMutationResult { const queryClient = useQueryClient(); const wagmiConfig = useConfig(); @@ -13,7 +13,7 @@ export default function useCypressMoveEpoch(): UseMutationResult { if (!window.Cypress) { - reject(new Error('useCypressMoveEpoch was called outside Cypress.')); + reject(new Error('useCypressMoveToDecisionWindowOpen was called outside Cypress.')); } const currentEpochPromise = queryClient.fetchQuery({ diff --git a/client/src/hooks/subgraph/useEpochsIndexedBySubgraph.ts b/client/src/hooks/subgraph/useEpochsIndexedBySubgraph.ts new file mode 100644 index 0000000000..4834b58fa4 --- /dev/null +++ b/client/src/hooks/subgraph/useEpochsIndexedBySubgraph.ts @@ -0,0 +1,27 @@ +import { useQuery, UseQueryResult } from '@tanstack/react-query'; +import request from 'graphql-request'; + +import { QUERY_KEYS } from 'api/queryKeys'; +import env from 'env'; +import { graphql } from 'gql/gql'; +import { GetEpochesQuery } from 'gql/graphql'; + +const GET_EPOCHS = graphql(` + query GetEpoches { + epoches { + epoch + } + } +`); + +export default function useEpochsIndexedBySubgraph(isEnabled?: boolean): UseQueryResult { + const { subgraphAddress } = env; + + return useQuery({ + enabled: isEnabled, + queryFn: async () => request(subgraphAddress, GET_EPOCHS), + queryKey: QUERY_KEYS.epochsIndexedBySubgraph, + refetchInterval: isEnabled ? 2000 : false, + select: data => data.epoches.map(({ epoch }) => epoch), + }); +} diff --git a/client/src/routes/RootRoutes/RootRoutes.tsx b/client/src/routes/RootRoutes/RootRoutes.tsx index bc46797f50..83689c19b9 100644 --- a/client/src/routes/RootRoutes/RootRoutes.tsx +++ b/client/src/routes/RootRoutes/RootRoutes.tsx @@ -1,6 +1,7 @@ import React, { Fragment, FC } from 'react'; import { Navigate, Route, Routes } from 'react-router-dom'; +import env from 'env'; import useIsProjectAdminMode from 'hooks/helpers/useIsProjectAdminMode'; import useCurrentEpoch from 'hooks/queries/useCurrentEpoch'; import useIsPatronMode from 'hooks/queries/useIsPatronMode'; @@ -8,6 +9,7 @@ import getIsPreLaunch from 'utils/getIsPreLaunch'; import AllocationView from 'views/AllocationView/AllocationView'; import EarnView from 'views/EarnView/EarnView'; import MetricsView from 'views/MetricsView/MetricsView'; +import PlaygroundView from 'views/PlaygroundView/PlaygroundView'; import ProjectsView from 'views/ProjectsView/ProjectsView'; import ProjectView from 'views/ProjectView/ProjectView'; import SettingsView from 'views/SettingsView/SettingsView'; @@ -85,6 +87,16 @@ const RootRoutes: FC = props => { } path={`${ROOT_ROUTES.earn.relative}/*`} /> + {(window.Cypress || env.network === 'Local') && ( + + + + } + path={`${ROOT_ROUTES.playground.relative}/*`} + /> + )}
Playground
; + +export default PlaygroundView;