From 533a17e88ebbe907045dde67c8cbc1b5454dd646 Mon Sep 17 00:00:00 2001 From: Satoshi Qazi Muhammed Date: Sat, 10 Oct 2026 09:10:20 +0400 Subject: [PATCH] Integration specs: node:test module mocks instead of jest.mock The account, address, sidebar and bounties specs replaced hooks with jest.mock, which the node:test runner does not have, so all five failed to load and were excluded from yarn test. They now register mocks with mock.module from node:test and import the page under test afterwards, since Node only swaps a module that has not been loaded yet. - test-support/mocks: mockPageHooks() replaces the hooks a Page reads. An ES module mock replaces every export, so each mock lists them all (useBestNumber's useBestNumberRelay included). The real useAccountInfo is wrapped, typed now, with the fixture identity merged in a typed helper. - The page-specific mocks live with their page: useMultisigApprovals in page-accounts/test, useAddresses in page-addresses/test. - jest-dom matchers were never registered for this runner; specs and page elements use assertHasClass, assertTextContent and assertVisible from test-support, with the same meaning. - Page's mock api gains apiIdentity, which useAccountInfo reads. - The bounties voting tooltip renders hidden and fades in, so the spec waits for it to become visible. - jest/expect-expect counts the assert* helpers as assertions; the disable comments that only existed for that are removed. Create and Bounties run green. Accounts, Addresses and Sidebar load and stay skipped as upstream left them (their FIXMEs are about the reworked tables). Needs @pezkuwi/dev 0.86.6 for the module mocks flag and the fuller browser env. --- eslint.config.js | 8 ++ .../page-accounts/src/Accounts/index.spec.ts | 52 ++++---- .../page-accounts/src/modals/Create.spec.tsx | 11 +- .../test/mockAccountsPageHooks.ts | 19 +++ .../test/pageElements/AccountRow.tsx | 5 +- .../page-addresses/src/Contacts/index.spec.ts | 15 ++- .../test/mockAddressesPageHooks.ts | 25 ++++ .../test/pages/addressesPage.tsx | 11 -- packages/page-bounties/src/Bounties.spec.ts | 54 ++++---- .../page-bounties/test/pages/bountiesPage.tsx | 9 +- .../src/AccountSidebar/Sidebar.spec.tsx | 26 ++-- packages/test-support/src/mocks/index.ts | 118 ++++++++++++++++++ packages/test-support/src/pages/Page.tsx | 85 +------------ .../test-support/src/pagesElements/Row.ts | 11 +- .../test-support/src/pagesElements/Sidebar.ts | 7 +- .../test-support/src/pagesElements/Table.ts | 3 +- .../test-support/src/utils/domAssertions.ts | 55 ++++++++ packages/test-support/src/utils/index.ts | 1 + .../src/utils/renderedScreenUtils.ts | 6 +- tsconfig.base.json | 1 + 20 files changed, 330 insertions(+), 192 deletions(-) create mode 100644 packages/page-accounts/test/mockAccountsPageHooks.ts create mode 100644 packages/page-addresses/test/mockAddressesPageHooks.ts create mode 100644 packages/test-support/src/mocks/index.ts create mode 100644 packages/test-support/src/utils/domAssertions.ts 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"],