diff --git a/eslint.config.js b/eslint.config.js index 5a94f22..5bf92b0 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -20,5 +20,13 @@ export default [ '@typescript-eslint/no-unsafe-return': 'off', '@typescript-eslint/unbound-method': 'off' } + }, + { + // the same files the base config enables the jest plugin for + files: ['**/*.spec.ts', '**/*.spec.tsx'], + rules: { + // the specs assert through expect() and the assert* helpers in test-support + 'jest/expect-expect': ['warn', { assertFunctionNames: ['expect', 'assert*', '*.assert*', '*.expect*'] }] + } } ]; diff --git a/packages/page-accounts/src/Accounts/index.spec.ts b/packages/page-accounts/src/Accounts/index.spec.ts index 96118f1..2438b48 100644 --- a/packages/page-accounts/src/Accounts/index.spec.ts +++ b/packages/page-accounts/src/Accounts/index.spec.ts @@ -17,17 +17,21 @@ import { toShortAddress } from '@pezkuwi/react-components/util'; import { anAccountWithBalance, anAccountWithBalanceAndMeta, anAccountWithInfo, anAccountWithInfoAndMeta, anAccountWithMeta, anAccountWithStaking } from '@pezkuwi/test-support/creation/account'; import { makeStakingLedger as ledger } from '@pezkuwi/test-support/creation/staking'; import { alice, bob, MemoryStore } from '@pezkuwi/test-support/keyring'; -import { balance, mockApiHooks, showBalance } from '@pezkuwi/test-support/utils'; +import { assertHasClass, assertTextContent, balance, mockApiHooks, showBalance } from '@pezkuwi/test-support/utils'; import { TypeRegistry } from '@pezkuwi/types/create'; import { keyring } from '@pezkuwi/ui-keyring'; import { BN } from '@pezkuwi/util'; -import { AccountsPage } from '../../test/pages/accountsPage.js'; +import { mockAccountsPageHooks } from '../../test/mockAccountsPageHooks.js'; + +await mockAccountsPageHooks(); + +const { AccountsPage } = await import('../../test/pages/accountsPage.js'); // FIXME isSplit Table // eslint-disable-next-line jest/no-disabled-tests describe.skip('Accounts page', () => { - let accountsPage: AccountsPage; + let accountsPage: InstanceType; beforeAll(async () => { await i18next.changeLanguage('en'); @@ -64,7 +68,6 @@ describe.skip('Accounts page', () => { expect(accountRows).toHaveLength(0); }); - // eslint-disable-next-line jest/expect-expect it('the accounts table contains a message about no accounts available', async () => { const noAccountsMessage = 'You don\'t have any accounts. Some features are currently hidden and will only become available once you have accounts.'; const accountsTable = await accountsPage.getTable(); @@ -132,7 +135,7 @@ describe.skip('Accounts page', () => { }); // FIXME broken after column rework - // eslint-disable-next-line jest/no-disabled-tests, jest/expect-expect + // eslint-disable-next-line jest/no-disabled-tests it.skip('a separate column for parent account is not displayed', async () => { accountsPage.renderDefaultAccounts(1); const accountsTable = await accountsPage.getTable(); @@ -176,11 +179,11 @@ describe.skip('Accounts page', () => { accountsPage.renderDefaultAccounts(1); const row = (await accountsPage.getAccountRows())[0]; - expect(row.detailsRow).toHaveClass('isCollapsed'); + assertHasClass(row.detailsRow, 'isCollapsed'); await row.expand(); - expect(row.detailsRow).toHaveClass('isExpanded'); + assertHasClass(row.detailsRow, 'isExpanded'); }); it('displays some summary', () => { @@ -202,7 +205,7 @@ describe.skip('Accounts page', () => { const summary = await screen.findByTestId(/card-summary:(total )?balance/i); - expect(summary).toHaveTextContent(showBalance(500 + 200 + 150)); + assertTextContent(summary, showBalance(500 + 200 + 150)); }); it('displays transferable summary', async () => { @@ -213,7 +216,7 @@ describe.skip('Accounts page', () => { const summary = await screen.findByTestId(/card-summary:(total )?transferable/i); - expect(summary).toHaveTextContent(showBalance(400 + 600)); + assertTextContent(summary, showBalance(400 + 600)); }); it('displays locked summary', async () => { @@ -224,7 +227,7 @@ describe.skip('Accounts page', () => { const summary = await screen.findByTestId(/card-summary:(total )?locked/i); - expect(summary).toHaveTextContent(showBalance(400 + 600)); + assertTextContent(summary, showBalance(400 + 600)); }); it('displays bonded summary', async () => { @@ -235,7 +238,7 @@ describe.skip('Accounts page', () => { const summary = await screen.findByTestId(/card-summary:(total )?bonded/i); - expect(summary).toHaveTextContent(showBalance(70 + 20)); + assertTextContent(summary, showBalance(70 + 20)); }); it('displays unbonding summary', async () => { @@ -276,7 +279,7 @@ describe.skip('Accounts page', () => { const summary = await screen.findByTestId(/card-summary:(total )?unbonding/i); - expect(summary).toHaveTextContent(showBalance(200 + 300 + 400 + 100 + 200 + 300)); + assertTextContent(summary, showBalance(200 + 300 + 400 + 100 + 200 + 300)); }); it('displays redeemable summary', async () => { @@ -287,7 +290,7 @@ describe.skip('Accounts page', () => { const summary = await screen.findByTestId(/card-summary:(total )?redeemable/i); - expect(summary).toHaveTextContent(showBalance(4000 + 5000)); + assertTextContent(summary, showBalance(4000 + 5000)); }); it('sorts accounts by date by default', async () => { @@ -296,7 +299,7 @@ describe.skip('Accounts page', () => { anAccountWithBalanceAndMeta({ freeBalance: balance(2) }, { whenCreated: 300 }), anAccountWithBalanceAndMeta({ freeBalance: balance(3) }, { whenCreated: 100 }) ); - expect(await accountsPage.getCurrentSortCategory()).toHaveTextContent('date'); + assertTextContent(await accountsPage.getCurrentSortCategory(), 'date'); const accountsTable = await accountsPage.getTable(); @@ -326,35 +329,29 @@ describe.skip('Accounts page', () => { it('changes default dropdown value', async () => { await accountsPage.sortBy('balances'); - expect(await accountsPage.getCurrentSortCategory()) - .toHaveTextContent('balances'); + assertTextContent(await accountsPage.getCurrentSortCategory(), 'balances'); }); - // eslint-disable-next-line jest/expect-expect it('sorts by parent if asked', async () => { await accountsPage.sortBy('parent'); await accountsTable.assertRowsOrder([3, 1, 2]); }); - // eslint-disable-next-line jest/expect-expect it('sorts by name if asked', async () => { await accountsPage.sortBy('name'); await accountsTable.assertRowsOrder([3, 2, 1]); }); - // eslint-disable-next-line jest/expect-expect it('sorts by date if asked', async () => { await accountsPage.sortBy('date'); await accountsTable.assertRowsOrder([3, 1, 2]); }); - // eslint-disable-next-line jest/expect-expect it('sorts by balances if asked', async () => { await accountsPage.sortBy('balances'); await accountsTable.assertRowsOrder([1, 2, 3]); }); - // eslint-disable-next-line jest/expect-expect it('implements stable sort', async () => { await accountsPage.sortBy('name'); await accountsTable.assertRowsOrder([3, 2, 1]); @@ -362,7 +359,6 @@ describe.skip('Accounts page', () => { await accountsTable.assertRowsOrder([1, 2, 3]); }); - // eslint-disable-next-line jest/expect-expect it('respects reverse button', async () => { await accountsPage.sortBy('name'); await accountsTable.assertRowsOrder([3, 2, 1]); @@ -410,17 +406,14 @@ describe.skip('Accounts page', () => { await aliceRow.assertAccountName('ALICE'); }); - // eslint-disable-next-line jest/expect-expect it('the development badge is displayed', async () => { await aliceRow.assertBadge('wrench-badge'); }); - // eslint-disable-next-line jest/expect-expect it('the all networks badge is not displayed', () => { aliceRow.assertNoBadge('exclamation-triangle-badge'); }); - // eslint-disable-next-line jest/expect-expect it('the regular badge is not displayed', () => { aliceRow.assertNoBadge('transparent-badge'); }); @@ -434,17 +427,14 @@ describe.skip('Accounts page', () => { await bobRow.assertAccountName('BOB'); }); - // eslint-disable-next-line jest/expect-expect it('the development badge is not displayed', () => { bobRow.assertNoBadge('wrench-badge'); }); - // eslint-disable-next-line jest/expect-expect it('the all networks badge is displayed', async () => { await bobRow.assertBadge('exclamation-triangle-badge'); }); - // eslint-disable-next-line jest/expect-expect it('the regular badge is not displayed', () => { bobRow.assertNoBadge('transparent-badge'); }); @@ -500,7 +490,7 @@ describe.skip('Accounts page', () => { const modal = await screen.findByTestId('modal'); within(modal).getByText('Pending call hashes'); - expect(approvalsModalToggle).toHaveClass('purpleColor'); + assertHasClass(approvalsModalToggle, 'purpleColor'); }); it('delegate democracy vote', async () => { @@ -512,7 +502,7 @@ describe.skip('Accounts page', () => { const modal = await screen.findByTestId('modal'); within(modal).getByText('democracy vote delegation'); - expect(delegateModalToggle).toHaveClass('normalColor'); + assertHasClass(delegateModalToggle, 'normalColor'); }); it('proxy overview', async () => { @@ -524,7 +514,7 @@ describe.skip('Accounts page', () => { const modal = await screen.findByTestId('modal'); within(modal).getByText('Proxy overview'); - expect(proxyOverviewToggle).toHaveClass('normalColor'); + assertHasClass(proxyOverviewToggle, 'normalColor'); }); afterEach(() => { diff --git a/packages/page-accounts/src/modals/Create.spec.tsx b/packages/page-accounts/src/modals/Create.spec.tsx index 0e4a97d..9ac309f 100644 --- a/packages/page-accounts/src/modals/Create.spec.tsx +++ b/packages/page-accounts/src/modals/Create.spec.tsx @@ -11,7 +11,11 @@ import { assertButtonDisabled, assertText, clickButton, fillInput } from '@pezku import { keyring } from '@pezkuwi/ui-keyring'; import { cryptoWaitReady } from '@pezkuwi/util-crypto'; -import { AccountsPage } from '../../test/pages/accountsPage.js'; +import { mockAccountsPageHooks } from '../../test/mockAccountsPageHooks.js'; + +await mockAccountsPageHooks(); + +const { AccountsPage } = await import('../../test/pages/accountsPage.js'); const spy = jest.spyOn(keyring, 'addUri'); @@ -19,7 +23,7 @@ const newAccountName = 'NEW ACCOUNT NAME'; const newAccountPassword = 'mySecretPassword'; describe('Create an account modal', () => { - let accountsPage: AccountsPage; + let accountsPage: InstanceType; beforeAll(async () => { await cryptoWaitReady(); @@ -33,7 +37,6 @@ describe('Create an account modal', () => { accountsPage = new AccountsPage(); }); - // eslint-disable-next-line jest/expect-expect it('creates an account', async () => { await accountsPage.enterCreateAccountModal(); @@ -53,7 +56,6 @@ describe('Create an account modal', () => { expectCreateAnAccountCall(); }); - // eslint-disable-next-line jest/expect-expect it('navigates through the modal flow with enter key', async () => { await accountsPage.enterCreateAccountModal(); @@ -74,7 +76,6 @@ describe('Create an account modal', () => { expectCreateAnAccountCall(); }); - // eslint-disable-next-line jest/expect-expect it('gives an error message when entering invalid derivation path', async () => { await accountsPage.enterCreateAccountModal(); diff --git a/packages/page-accounts/test/mockAccountsPageHooks.ts b/packages/page-accounts/test/mockAccountsPageHooks.ts new file mode 100644 index 0000000..469e26e --- /dev/null +++ b/packages/page-accounts/test/mockAccountsPageHooks.ts @@ -0,0 +1,19 @@ +// Copyright 2017-2026 @pezkuwi/app-accounts authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import { mock } from 'node:test'; + +import { mockPageHooks } from '@pezkuwi/test-support/mocks'; +import { mockApiHooks } from '@pezkuwi/test-support/utils'; + +/** + * The shared page hooks, plus the multisig lookup that an account row makes. + * Await it before importing AccountsPage. + */ +export async function mockAccountsPageHooks (): Promise { + await mockPageHooks(); + + mock.module('../src/Accounts/useMultisigApprovals', { + defaultExport: () => mockApiHooks.multisigApprovals + }); +} diff --git a/packages/page-accounts/test/pageElements/AccountRow.tsx b/packages/page-accounts/test/pageElements/AccountRow.tsx index d934ebb..cc3072a 100644 --- a/packages/page-accounts/test/pageElements/AccountRow.tsx +++ b/packages/page-accounts/test/pageElements/AccountRow.tsx @@ -1,16 +1,15 @@ // Copyright 2017-2026 @pezkuwi/app-accounts authors & contributors // SPDX-License-Identifier: Apache-2.0 -/* global expect */ - import { within } from '@testing-library/react'; import { Row } from '@pezkuwi/test-support/pagesElements'; +import { assertTextContent } from '@pezkuwi/test-support/utils'; export class AccountRow extends Row { async assertParentAccountName (expectedParentAccount: string): Promise { const parentAccount = await within(this.primaryRow).findByTestId('parent'); - expect(parentAccount).toHaveTextContent(expectedParentAccount); + assertTextContent(parentAccount, expectedParentAccount); } } diff --git a/packages/page-addresses/src/Contacts/index.spec.ts b/packages/page-addresses/src/Contacts/index.spec.ts index 8fce74d..935f55e 100644 --- a/packages/page-addresses/src/Contacts/index.spec.ts +++ b/packages/page-addresses/src/Contacts/index.spec.ts @@ -10,15 +10,19 @@ import { screen } from '@testing-library/react'; import i18next from '@pezkuwi/react-components/i18n'; import { aContactWithBalance } from '@pezkuwi/test-support/creation/contact'; import { MemoryStore } from '@pezkuwi/test-support/keyring'; -import { balance } from '@pezkuwi/test-support/utils'; +import { assertHasClass, balance } from '@pezkuwi/test-support/utils'; import { keyring } from '@pezkuwi/ui-keyring'; -import { AddressesPage } from '../../test/pages/addressesPage.js'; +import { mockAddressesPageHooks } from '../../test/mockAddressesPageHooks.js'; + +await mockAddressesPageHooks(); + +const { AddressesPage } = await import('../../test/pages/addressesPage.js'); // FIXME isSplit Table // eslint-disable-next-line jest/no-disabled-tests describe.skip('Addresses page', () => { - let addressesPage: AddressesPage; + let addressesPage: InstanceType; beforeAll(async () => { await i18next.changeLanguage('en'); @@ -49,7 +53,6 @@ describe.skip('Addresses page', () => { expect(await addressesTable.getRows()).toHaveLength(0); }); - // eslint-disable-next-line jest/expect-expect it('the contacts table contains a message about no contacts available', async () => { const noContactsMessage = 'no addresses saved yet, add any existing address'; @@ -128,11 +131,11 @@ describe.skip('Addresses page', () => { addressesPage.renderDefaultContacts(1); const row = (await addressesPage.getAddressesRows())[0]; - expect(row.detailsRow).toHaveClass('isCollapsed'); + assertHasClass(row.detailsRow, 'isCollapsed'); await row.expand(); - expect(row.detailsRow).toHaveClass('isExpanded'); + assertHasClass(row.detailsRow, 'isExpanded'); }); }); }); diff --git a/packages/page-addresses/test/mockAddressesPageHooks.ts b/packages/page-addresses/test/mockAddressesPageHooks.ts new file mode 100644 index 0000000..a04f2c7 --- /dev/null +++ b/packages/page-addresses/test/mockAddressesPageHooks.ts @@ -0,0 +1,25 @@ +// Copyright 2017-2026 @pezkuwi/app-addresses authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import { mock } from 'node:test'; + +import { mockPageHooks } from '@pezkuwi/test-support/mocks'; +import { mockAccountHooks } from '@pezkuwi/test-support/utils'; + +/** + * The shared page hooks, with the contacts served from the same fixture as + * the accounts. Await it before importing AddressesPage. + */ +export async function mockAddressesPageHooks (): Promise { + await mockPageHooks(); + + mock.module('@pezkuwi/react-hooks/useAddresses', { + namedExports: { + useAddresses: () => ({ + allAddresses: mockAccountHooks.useAccounts.allAccounts, + hasAddresses: mockAccountHooks.useAccounts.hasAccounts, + isAddress: true + }) + } + }); +} diff --git a/packages/page-addresses/test/pages/addressesPage.tsx b/packages/page-addresses/test/pages/addressesPage.tsx index 39ef9e5..fb10906 100644 --- a/packages/page-addresses/test/pages/addressesPage.tsx +++ b/packages/page-addresses/test/pages/addressesPage.tsx @@ -1,8 +1,6 @@ // Copyright 2017-2026 @pezkuwi/app-addresses authors & contributors // SPDX-License-Identifier: Apache-2.0 -/* global jest */ - import type { Row } from '@pezkuwi/test-support/pagesElements'; import type { AccountOverrides as ContactOverrides } from '@pezkuwi/test-support/types'; @@ -10,20 +8,11 @@ import React from 'react'; import { aContact } from '@pezkuwi/test-support/creation/contact'; import { Page } from '@pezkuwi/test-support/pages/Page'; -import { mockAccountHooks } from '@pezkuwi/test-support/utils'; import AddressOverview from '../../src/Contacts/index.js'; const NOOP_CHANGE = () => undefined; -jest.mock('@pezkuwi/react-hooks/useAddresses', () => ({ - useAddresses: () => ({ - allAddresses: mockAccountHooks.useAccounts.allAccounts, - hasAddresses: mockAccountHooks.useAccounts.hasAccounts, - isAddress: true - }) -})); - export class AddressesPage extends Page { constructor () { super( diff --git a/packages/page-bounties/src/Bounties.spec.ts b/packages/page-bounties/src/Bounties.spec.ts index 21bbf60..a06726a 100644 --- a/packages/page-bounties/src/Bounties.spec.ts +++ b/packages/page-bounties/src/Bounties.spec.ts @@ -3,14 +3,13 @@ /// -/* eslint-disable jest/expect-expect */ - import type { SubmittableExtrinsic } from '@pezkuwi/api/types'; import type { DeriveCollectiveProposal } from '@pezkuwi/api-derive/types'; import type { BountyIndex } from '@pezkuwi/types/interfaces'; import type { PezpalletBountiesBounty, PezpalletBountiesBountyStatus } from '@pezkuwi/types/lookup'; import { fireEvent } from '@testing-library/react'; +import { mock } from 'node:test'; import i18next from '@pezkuwi/react-components/i18n'; import { createAugmentedApi } from '@pezkuwi/test-support/api'; @@ -19,36 +18,43 @@ import { BountyFactory } from '@pezkuwi/test-support/creation/bounties'; import { proposalFactory } from '@pezkuwi/test-support/creation/treasury'; import { mockHooks } from '@pezkuwi/test-support/hooks'; import { alice, bob, MemoryStore } from '@pezkuwi/test-support/keyring'; +import { assertHasClass } from '@pezkuwi/test-support/utils'; import { keyring } from '@pezkuwi/ui-keyring'; import { BN } from '@pezkuwi/util'; import { defaultBountyUpdatePeriod, mockBountyHooks } from '../test/hooks/defaults.js'; -import { BountiesPage } from '../test/pages/bountiesPage.js'; -import { BLOCKS_PERCENTAGE_LEFT_TO_SHOW_WARNING } from './BountyNextActionInfo/BountyActionMessage.js'; -jest.mock('@pezkuwi/react-hooks/useTreasury', () => ({ - useTreasury: () => mockHooks.treasury -})); +// Node only swaps a module that has not been loaded yet, so the page and +// anything that loads @pezkuwi/react-hooks are imported after these. A module +// mock replaces every export, so each one lists them all. +mock.module('@pezkuwi/react-hooks/useTreasury', { + namedExports: { useTreasury: () => mockHooks.treasury } +}); -jest.mock('@pezkuwi/react-hooks/useCollectiveInstance', () => ({ - useCollectiveInstance: () => 'council' -})); +mock.module('@pezkuwi/react-hooks/useCollectiveInstance', { + namedExports: { useCollectiveInstance: () => 'council' } +}); -jest.mock('@pezkuwi/react-hooks/useCollectiveMembers', () => ({ - useCollectiveMembers: () => mockHooks.members -})); +mock.module('@pezkuwi/react-hooks/useCollectiveMembers', { + namedExports: { useCollectiveMembers: () => mockHooks.members } +}); -jest.mock('@pezkuwi/react-hooks/useBlockTime', () => ({ - useBlockTime: () => mockHooks.blockTime -})); +const { calcBlockTime } = await import('@pezkuwi/react-hooks/useBlockTime'); -jest.mock('./hooks/useBalance', () => ({ - useBalance: () => mockBountyHooks.balance -})); +mock.module('@pezkuwi/react-hooks/useBlockTime', { + namedExports: { calcBlockTime, useBlockTime: () => mockHooks.blockTime } +}); -jest.mock('./hooks/useBounties', () => ({ - useBounties: () => mockBountyHooks.bountyApi -})); +mock.module('./hooks/useBalance', { + namedExports: { useBalance: () => mockBountyHooks.balance } +}); + +mock.module('./hooks/useBounties', { + namedExports: { useBounties: () => mockBountyHooks.bountyApi } +}); + +const { BountiesPage } = await import('../test/pages/bountiesPage.js'); +const { BLOCKS_PERCENTAGE_LEFT_TO_SHOW_WARNING } = await import('./BountyNextActionInfo/BountyActionMessage.js'); let aProposal: (extrinsic: SubmittableExtrinsic<'promise'>, ayes?: string[], nays?: string[]) => DeriveCollectiveProposal; let augmentedApi: any; @@ -58,7 +64,7 @@ let bountyStatusWith: ({ curator, status, updateDue }: { curator?: string, statu let bountyWith: ({ status, value }: { status?: string, value?: number }) => PezpalletBountiesBounty; describe('Bounties', () => { - let bountiesPage: BountiesPage; + let bountiesPage: InstanceType; beforeAll(async () => { await i18next.changeLanguage('en'); @@ -309,7 +315,7 @@ describe('Bounties', () => { it('disables Assign Curator button if validation fails', async () => { await bountiesPage.enterCuratorsFee('6'); - expect(await bountiesPage.assignCuratorButton()).toHaveClass('isDisabled'); + assertHasClass(await bountiesPage.assignCuratorButton(), 'isDisabled'); }); it('queues propose extrinsic on submit', async () => { diff --git a/packages/page-bounties/test/pages/bountiesPage.tsx b/packages/page-bounties/test/pages/bountiesPage.tsx index 4a149b8..079dc71 100644 --- a/packages/page-bounties/test/pages/bountiesPage.tsx +++ b/packages/page-bounties/test/pages/bountiesPage.tsx @@ -12,7 +12,7 @@ import type { BountyIndex } from '@pezkuwi/types/interfaces'; import type { PezpalletBountiesBounty, PezpalletBountiesBountyStatus } from '@pezkuwi/types/lookup'; import type { BountyApi } from '../../src/hooks/index.js'; -import { fireEvent, render, within } from '@testing-library/react'; +import { fireEvent, render, waitFor, within } from '@testing-library/react'; import React, { Suspense } from 'react'; import { MemoryRouter } from 'react-router-dom'; import { ThemeProvider } from 'styled-components'; @@ -24,6 +24,7 @@ import { ApiCtx } from '@pezkuwi/react-hooks/ctx/Api'; import { QueueCtx } from '@pezkuwi/react-hooks/ctx/Queue'; import { balanceOf } from '@pezkuwi/test-support/creation/balance'; import { BountyFactory } from '@pezkuwi/test-support/creation/bounties'; +import { assertVisible } from '@pezkuwi/test-support/utils'; import { TypeRegistry } from '@pezkuwi/types/create'; import Bounties from '../../src/Bounties.js'; @@ -330,6 +331,10 @@ export class BountiesPage { const icon = await within(votingInfo).findByTestId('question-circle'); fireEvent.mouseEnter(icon); - expect(await this.findByText(description)).toBeVisible(); + + const text = await this.findByText(description); + + // the tooltip renders hidden, then fades in + await waitFor(() => assertVisible(text)); } } diff --git a/packages/react-components/src/AccountSidebar/Sidebar.spec.tsx b/packages/react-components/src/AccountSidebar/Sidebar.spec.tsx index 74714c2..5fcdb50 100644 --- a/packages/react-components/src/AccountSidebar/Sidebar.spec.tsx +++ b/packages/react-components/src/AccountSidebar/Sidebar.spec.tsx @@ -12,16 +12,20 @@ import { fireEvent, screen, waitFor, within } from '@testing-library/react'; import i18next from '@pezkuwi/react-components/i18n'; import { anAccount, anAccountWithInfo, anAccountWithMeta } from '@pezkuwi/test-support/creation/account'; import { alice, bob, MemoryStore } from '@pezkuwi/test-support/keyring'; -import { charlieShortAddress, ferdieShortAddress, mockRegistration, registrars } from '@pezkuwi/test-support/mockData'; -import { mockApiHooks } from '@pezkuwi/test-support/utils'; +import { assertHasClass, assertTextContent, mockApiHooks } from '@pezkuwi/test-support/utils'; import { keyring } from '@pezkuwi/ui-keyring'; -import { AccountsPage } from '../../../page-accounts/test/pages/accountsPage.js'; +import { mockAccountsPageHooks } from '../../../page-accounts/test/mockAccountsPageHooks.js'; + +await mockAccountsPageHooks(); + +const { charlieShortAddress, ferdieShortAddress, mockRegistration, registrars } = await import('@pezkuwi/test-support/mockData'); +const { AccountsPage } = await import('../../../page-accounts/test/pages/accountsPage.js'); // FIXME: these all need to be wrapped in waitFor .... // eslint-disable-next-line jest/no-disabled-tests describe.skip('Sidebar', () => { - let accountsPage: AccountsPage; + let accountsPage: InstanceType; let sideBar: Sidebar; beforeAll(async () => { @@ -62,7 +66,6 @@ describe.skip('Sidebar', () => { expect(changedAccount?.meta?.name).toEqual(newName); }); - // eslint-disable-next-line jest/expect-expect it('within sidebar', async () => { await sideBar.assertAccountName(newName); }); @@ -105,7 +108,6 @@ describe.skip('Sidebar', () => { sideBar.edit(); }); - // eslint-disable-next-line jest/expect-expect it('restores tags and name to state from keyring', async () => { await sideBar.typeAccountName(newName); await sideBar.selectTag(defaultTag); @@ -149,7 +151,6 @@ describe.skip('Sidebar', () => { expect(sideBar.queryByRole('button', { name: 'Cancel' })).toBeTruthy(); }); - // eslint-disable-next-line jest/expect-expect it('cancels editing and changes name when opening sidebar for another account', async () => { await waitFor(() => sideBar.assertAccountInput('alice')); @@ -188,12 +189,11 @@ describe.skip('Sidebar', () => { const subsNumber = subs[0].childNodes[0]; const subAccount = subs[0].childNodes[1]; - expect(subsNumber).toHaveClass('subs-number'); - expect(subsNumber).toHaveTextContent('1'); - expect(subAccount).toHaveTextContent('BOB'); + assertHasClass(subsNumber, 'subs-number'); + assertTextContent(subsNumber, '1'); + assertTextContent(subAccount, 'BOB'); }); - // eslint-disable-next-line jest/expect-expect it('displays picked sub in sidebar', async () => { const subAccount = subs[0].childNodes[1]; @@ -205,7 +205,6 @@ describe.skip('Sidebar', () => { }); describe('judgements', () => { - // eslint-disable-next-line jest/expect-expect it('displays several judgements', async () => { mockApiHooks.setJudgements(mockRegistration.judgements as RegistrationJudgement[]); accountsPage.renderDefaultAccounts(1); @@ -216,7 +215,6 @@ describe.skip('Sidebar', () => { await sideBar.assertJudgement('1 Erroneous'); }); - // eslint-disable-next-line jest/expect-expect it('displays no judgements', async () => { accountsPage.renderDefaultAccounts(1); sideBar = await accountsPage.openSidebarForRow(0); @@ -235,14 +233,12 @@ describe.skip('Sidebar', () => { sideBar = await accountsPage.openSidebarForRow(0); }); - // eslint-disable-next-line jest/expect-expect it('singular registrar', async () => { const judgementTag = await sideBar.getJudgement('1 Known good'); await judgementTag.assertRegistrars([charlieShortAddress]); }); - // eslint-disable-next-line jest/expect-expect it('multiple registrars', async () => { const judgementTag = await sideBar.getJudgement('2 Reasonable'); diff --git a/packages/test-support/src/mocks/index.ts b/packages/test-support/src/mocks/index.ts new file mode 100644 index 0000000..ab83627 --- /dev/null +++ b/packages/test-support/src/mocks/index.ts @@ -0,0 +1,118 @@ +// Copyright 2017-2026 @pezkuwi/test-support authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import type { AddressIdentity, UseAccountInfo } from '@pezkuwi/react-hooks/types'; + +import { mock } from 'node:test'; + +import { mockAccountHooks } from '../utils/accountDefaults.js'; +import { mockApiHooks } from '../utils/mockApiHooks.js'; + +/** + * The fixture identity over the real one, with the fixture judgements added + * to the real ones + */ +function withMockIdentity (actual?: AddressIdentity, mocked?: AddressIdentity): AddressIdentity | undefined { + const identity = mocked + ? { ...actual, ...mocked } + : actual; + + return identity && { + ...identity, + judgements: [ + ...(actual?.judgements || []), + ...(mockApiHooks.judgements || []) + ] + }; +} + +/** + * Replaces the hooks that a Page reads with the shared fixtures in + * mockAccountHooks and mockApiHooks. + * + * Node only swaps a module that has not been loaded yet, so a spec awaits + * this before anything that loads @pezkuwi/react-hooks, and then imports + * the page under test with a dynamic import(). Needs node's + * --experimental-test-module-mocks flag. + * + * A module mock replaces every export of the module, and the barrel in + * @pezkuwi/react-hooks re-exports them by name, so each mock lists all of + * them. Loading the real module to copy the rest is not an option: some of + * them load the barrel, which would bind it before the mocks are in place. + */ +export async function mockPageHooks (): Promise { + mock.module('@pezkuwi/react-hooks/useAccounts', { + namedExports: { useAccounts: () => mockAccountHooks.useAccounts } + }); + + mock.module('@pezkuwi/react-hooks/useNextTick', { + namedExports: { useNextTick: () => true } + }); + + mock.module('@pezkuwi/react-hooks/useBalancesAll', { + namedExports: { useBalancesAll: (address: string) => mockAccountHooks.accountsMap[address].balance } + }); + + mock.module('@pezkuwi/react-hooks/useStakingInfo', { + namedExports: { useStakingInfo: (address: string) => mockAccountHooks.accountsMap[address].staking } + }); + + mock.module('@pezkuwi/react-hooks/useBestNumber', { + namedExports: { + useBestNumber: () => 1, + useBestNumberRelay: () => 1 + } + }); + + mock.module('@pezkuwi/react-hooks/useSubidentities', { + namedExports: { useSubidentities: () => mockApiHooks.subs } + }); + + mock.module('@pezkuwi/react-hooks/useDelegations', { + namedExports: { useDelegations: () => mockApiHooks.delegations } + }); + + mock.module('@pezkuwi/react-hooks/useProxies', { + namedExports: { useProxies: () => mockApiHooks.proxies } + }); + + mock.module('@pezkuwi/react-hooks/useRegistrars', { + namedExports: { + useRegistrars: () => ({ + isRegistrar: false, + registrars: mockApiHooks.registrars + }) + } + }); + + mock.module('@pezkuwi/react-hooks/useTheme', { + namedExports: { + useTheme: () => ({ + theme: 'light', + themeClassName: 'theme--light' + }) + } + }); + + // The fixture is layered over the real hook. It does not load the barrel, + // so it can be loaded here, once the hooks above have been replaced. + const { useAccountInfo: useActualAccountInfo } = await import('@pezkuwi/react-hooks/useAccountInfo'); + + mock.module('@pezkuwi/react-hooks/useAccountInfo', { + namedExports: { + useAccountInfo: (address: string): UseAccountInfo => { + const mockInfo = mockAccountHooks.accountsMap[address]; + const info = useActualAccountInfo(address); + + return mockInfo + ? { + ...info, + flags: { ...info.flags, ...(mockInfo.info.flags) }, + identity: withMockIdentity(info.identity, mockInfo.info.identity), + tags: [...info.tags, ...(mockInfo.info.tags)] + } + : info; + } + } + }); +} diff --git a/packages/test-support/src/pages/Page.tsx b/packages/test-support/src/pages/Page.tsx index b1b10ef..92da09b 100644 --- a/packages/test-support/src/pages/Page.tsx +++ b/packages/test-support/src/pages/Page.tsx @@ -6,7 +6,6 @@ import type { RenderResult } from '@testing-library/react'; import type { ApiProps } from '@pezkuwi/react-api/types'; import type { PartialQueueTxExtrinsic, QueueProps, QueueTxExtrinsicAdd } from '@pezkuwi/react-components/Status/types'; -import type { UseAccountInfo } from '@pezkuwi/react-hooks/types'; import type { AccountOverrides } from '../utils/accountDefaults.js'; import { queryByAttribute, render, screen } from '@testing-library/react'; @@ -25,94 +24,12 @@ import { BN } from '@pezkuwi/util'; import { alice, bob, charlie, ferdie } from '../keyring/index.js'; import { Table } from '../pagesElements/index.js'; import { mockAccountHooks } from '../utils/accountDefaults.js'; -import { mockApiHooks } from '../utils/mockApiHooks.js'; let queueExtrinsic: (value: PartialQueueTxExtrinsic) => void; class NotYetRendered extends Error { } -jest.mock('@pezkuwi/react-hooks/useAccounts', () => ({ - useAccounts: () => mockAccountHooks.useAccounts -})); - -jest.mock('@pezkuwi/react-hooks/useAccountInfo', () => { - const actual = jest.requireActual<{ useAccountInfo: (address: string) => UseAccountInfo }>('@pezkuwi/react-hooks/useAccountInfo'); - - return ({ - useAccountInfo: (address: string) => { - const mockInfo = mockAccountHooks.accountsMap[address]; - - return mockInfo - ? { - ...actual.useAccountInfo(address), - flags: { ...actual.useAccountInfo(address).flags, ...(mockInfo.info.flags) }, - identity: { - ...actual.useAccountInfo(address).identity, - ...(mockInfo.info.identity), - judgements: [ - ...(actual.useAccountInfo(address).identity?.judgements || []), - ...(mockApiHooks.judgements || []) - ] - }, - tags: [...actual.useAccountInfo(address).tags, ...(mockInfo.info.tags)] - } - : actual.useAccountInfo(address); - } - }); -}); - -jest.mock('@pezkuwi/react-hooks/useNextTick', () => ({ - useNextTick: () => true -})); - -jest.mock('@pezkuwi/react-hooks/useBalancesAll', () => ({ - useBalancesAll: (address: string) => mockAccountHooks.accountsMap[address].balance -})); - -jest.mock('@pezkuwi/react-hooks/useStakingInfo', () => ({ - useStakingInfo: (address: string) => mockAccountHooks.accountsMap[address].staking -})); - -jest.mock('@pezkuwi/react-hooks/useBestNumber', () => ({ - useBestNumber: () => 1 -})); - -jest.mock('@pezkuwi/react-hooks/useSubidentities', () => ({ - useSubidentities: () => mockApiHooks.subs -})); - -jest.mock('@pezkuwi/app-accounts/Accounts/useMultisigApprovals', () => ({ - __esModule: true, - default: () => mockApiHooks.multisigApprovals -})); - -jest.mock('@pezkuwi/react-hooks/useDelegations', () => ({ - useDelegations: () => mockApiHooks.delegations -})); - -jest.mock('@pezkuwi/react-hooks/useProxies', () => ({ - useProxies: () => mockApiHooks.proxies -})); - -jest.mock('@pezkuwi/react-hooks/useSubidentities', () => ({ - useSubidentities: () => mockApiHooks.subs -})); - -jest.mock('@pezkuwi/react-hooks/useRegistrars', () => ({ - useRegistrars: () => ({ - isRegistrar: false, - registrars: mockApiHooks.registrars - }) -})); - -jest.mock('@pezkuwi/react-hooks/useTheme', () => ({ - useTheme: () => ({ - theme: 'light', - themeClassName: 'theme--light' - }) -})); - export abstract class Page { private renderResult?: RenderResult; protected readonly defaultAddresses = [alice, bob, charlie, ferdie]; @@ -198,6 +115,8 @@ export abstract class Page { }; const mockApi: ApiProps = { api, + // identities live on the people chain; here both are the same mock + apiIdentity: api, apiSystem: { ...api, isReady: Promise.resolve(api) diff --git a/packages/test-support/src/pagesElements/Row.ts b/packages/test-support/src/pagesElements/Row.ts index 359a194..033cb69 100644 --- a/packages/test-support/src/pagesElements/Row.ts +++ b/packages/test-support/src/pagesElements/Row.ts @@ -8,6 +8,7 @@ import type { Balance } from '@pezkuwi/types/interfaces'; import { fireEvent, screen, within } from '@testing-library/react'; import { format } from '../utils/balance.js'; +import { assertTextContent } from '../utils/domAssertions.js'; import { Sidebar } from './Sidebar.js'; // utility wrapper over an account item in accounts table, serves basic assertions about an account row @@ -24,7 +25,7 @@ export class Row { const actualBalanceText = await this.getBalanceSummary(); const expectedBalanceText = format(expectedTotalBalance); - expect(actualBalanceText).toHaveTextContent(expectedBalanceText); + assertTextContent(actualBalanceText, expectedBalanceText); } async getBalanceSummary (): Promise { @@ -34,7 +35,7 @@ export class Row { async assertAccountName (expectedName: string): Promise { const accountName = await this.getAccountName(); - expect(accountName).toHaveTextContent(expectedName); + assertTextContent(accountName, expectedName); } async assertBalancesDetails (expectedBalanceComponents: { name: string, amount: Balance }[]): Promise { @@ -54,13 +55,13 @@ export class Row { async assertTags (expectedTagsContent: string): Promise { const actualTags = await within(this.detailsRow).findByTestId('tags'); - expect(actualTags).toHaveTextContent(expectedTagsContent); + assertTextContent(actualTags, expectedTagsContent); } async assertShortAddress (expectedShortAddress: string): Promise { const actualShortAddress = await within(this.primaryRow).findByTestId('short-address'); - expect(actualShortAddress).toHaveTextContent(expectedShortAddress); + assertTextContent(actualShortAddress, expectedShortAddress); } async expand (): Promise { @@ -85,7 +86,7 @@ export class Row { const balanceElement = await this.getBalanceElementByLabelName(expectedBalanceComponent.name); const balanceText = format(expectedBalanceComponent.amount); - expect(balanceElement).toHaveTextContent(balanceText); + assertTextContent(balanceElement, balanceText); } private async getBalanceElementByLabelName (labelName: string): Promise { diff --git a/packages/test-support/src/pagesElements/Sidebar.ts b/packages/test-support/src/pagesElements/Sidebar.ts index 89b4b25..c75208a 100644 --- a/packages/test-support/src/pagesElements/Sidebar.ts +++ b/packages/test-support/src/pagesElements/Sidebar.ts @@ -5,6 +5,7 @@ import { fireEvent, screen, within } from '@testing-library/react'; +import { assertTextContent } from '../utils/domAssertions.js'; import { JudgementTag } from './JudgementTag.js'; export class Sidebar { @@ -48,19 +49,19 @@ export class Sidebar { const sideBarAddressSection = await this.findByTestId('sidebar-address-menu'); const sideBarName = await within(sideBarAddressSection).findByTestId('account-name'); - expect(sideBarName).toHaveTextContent(expectedAccountName); + assertTextContent(sideBarName, expectedAccountName); } async assertJudgement (judgement: string): Promise { const judgementsSection = await this.findByTestId('judgements'); - expect(judgementsSection).toHaveTextContent(judgement); + assertTextContent(judgementsSection, judgement); } async assertTags (tagsContent: string): Promise { const sideBarTags = await this.findByTestId('sidebar-tags'); - expect(sideBarTags).toHaveTextContent(tagsContent); + assertTextContent(sideBarTags, tagsContent); } close (): Promise { diff --git a/packages/test-support/src/pagesElements/Table.ts b/packages/test-support/src/pagesElements/Table.ts index 24477a4..45b0b90 100644 --- a/packages/test-support/src/pagesElements/Table.ts +++ b/packages/test-support/src/pagesElements/Table.ts @@ -6,6 +6,7 @@ import { within } from '@testing-library/react'; import { showBalance } from '../utils/balance.js'; +import { assertTextContent } from '../utils/domAssertions.js'; import { Row } from './Row.js'; export class Table { @@ -21,7 +22,7 @@ export class Table { const row = orderedRows[index]; const expectedBalanceTextContent = showBalance(balancesExpectedOrder[index]); - expect(await row.getBalanceSummary()).toHaveTextContent(expectedBalanceTextContent); + assertTextContent(await row.getBalanceSummary(), expectedBalanceTextContent); } } diff --git a/packages/test-support/src/utils/domAssertions.ts b/packages/test-support/src/utils/domAssertions.ts new file mode 100644 index 0000000..d34ef48 --- /dev/null +++ b/packages/test-support/src/utils/domAssertions.ts @@ -0,0 +1,55 @@ +// Copyright 2017-2026 @pezkuwi/test-supports authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +// DOM checks with the meaning of the matching @testing-library/jest-dom +// matchers, which the node:test based runner does not provide + +import assert from 'node:assert/strict'; + +function describeElement (node: Node): string { + const html = (node as Partial).outerHTML ?? node.nodeName; + + return html.length > 200 + ? `${html.slice(0, 200)}...` + : html; +} + +/** As toHaveClass: the element carries the class */ +export function assertHasClass (node: Node | null, className: string): void { + assert.ok(node, `Expected an element with class "${className}", found none`); + + const classList = (node as Partial).classList; + + assert.ok(classList, `Expected an element with class "${className}", found ${describeElement(node)}`); + assert.ok(classList.contains(className), `Expected class "${className}" on ${describeElement(node)}`); +} + +/** As toHaveTextContent: the whitespace-normalized text contains the string, or matches the pattern */ +export function assertTextContent (element: Node | null, expected: string | RegExp): void { + assert.ok(element, `Expected an element with text ${String(expected)}, found none`); + + const text = (element.textContent ?? '').replace(/\s+/g, ' ').trim(); + + if (typeof expected === 'string') { + assert.ok(text.includes(expected), `Expected text containing "${expected}", found "${text}"`); + } else { + assert.match(text, expected); + } +} + +/** As toBeVisible: in the document, and neither it nor an ancestor is hidden */ +export function assertVisible (element: Element | null): void { + assert.ok(element, 'Expected a visible element, found none'); + assert.ok(element.isConnected, `Expected ${describeElement(element)} to be in the document`); + + for (let current: Element | null = element; current; current = current.parentElement) { + const style = current.ownerDocument.defaultView?.getComputedStyle(current); + const isHidden = current.hasAttribute('hidden') || + style?.display === 'none' || + style?.visibility === 'hidden' || + style?.visibility === 'collapse' || + style?.opacity === '0'; + + assert.ok(!isHidden, `Expected ${describeElement(element)} to be visible, hidden by ${describeElement(current)}`); + } +} diff --git a/packages/test-support/src/utils/index.ts b/packages/test-support/src/utils/index.ts index 64108a5..4be2aa3 100644 --- a/packages/test-support/src/utils/index.ts +++ b/packages/test-support/src/utils/index.ts @@ -3,6 +3,7 @@ export * from './accountDefaults.js'; export * from './balance.js'; +export * from './domAssertions.js'; export * from './mockApiHooks.js'; export * from './renderedScreenUtils.js'; export * from './waitFor.js'; diff --git a/packages/test-support/src/utils/renderedScreenUtils.ts b/packages/test-support/src/utils/renderedScreenUtils.ts index a741408..b909ee8 100644 --- a/packages/test-support/src/utils/renderedScreenUtils.ts +++ b/packages/test-support/src/utils/renderedScreenUtils.ts @@ -1,10 +1,10 @@ // Copyright 2017-2026 @pezkuwi/test-supports authors & contributors // SPDX-License-Identifier: Apache-2.0 -/* global expect */ - import { fireEvent, screen } from '@testing-library/react'; +import { assertHasClass } from './domAssertions.js'; + export const clickButton = async (buttonName: string): Promise => { const button = await screen.findByRole('button', { name: buttonName }); @@ -24,5 +24,5 @@ export const fillInput = (inputTestId: string, value: string): void => { export const assertButtonDisabled = (buttonName: string): void => { const button = screen.getByRole('button', { name: buttonName }); - expect(button).toHaveClass('isDisabled'); + assertHasClass(button, 'isDisabled'); }; diff --git a/tsconfig.base.json b/tsconfig.base.json index 61fcc6b..4160f76 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -127,6 +127,7 @@ "@pezkuwi/test-support/hooks": ["test-support/src/hooks/index.ts"], "@pezkuwi/test-support/keyring": ["test-support/src/keyring/index.ts"], "@pezkuwi/test-support/mockData": ["test-support/src/mockData/index.ts"], + "@pezkuwi/test-support/mocks": ["test-support/src/mocks/index.ts"], "@pezkuwi/test-support/pages/*": ["test-support/src/pages/*.tsx"], "@pezkuwi/test-support/pagesElements": ["test-support/src/pagesElements/index.ts"], "@pezkuwi/test-support/react": ["test-support/src/react/index.ts"],