From 69a54a16e45cf0228879e39a5441a1b4c84ba35f Mon Sep 17 00:00:00 2001 From: Matiss Janis Aboltins <matiss@mja.lv> Date: Sun, 26 Feb 2023 15:25:53 +0000 Subject: [PATCH] :white_check_mark: (e2e) adding e2e tests for accounts: creating & closing (#695) --- packages/desktop-client/e2e/accounts.test.js | 52 +++++++++++++ .../e2e/page-models/account-page.js | 25 ++++-- .../e2e/page-models/close-account-modal.js | 13 ++++ .../e2e/page-models/navigation.js | 51 ++++++++++++ .../e2e/page-models/reports-page.js | 17 ++++ .../e2e/page-models/rules-page.js | 78 +++++++++++++++++++ .../e2e/page-models/schedules-page.js | 12 +-- .../e2e/page-models/settings-page.js | 9 +++ packages/desktop-client/e2e/reports.test.js | 35 +++++++++ packages/desktop-client/e2e/rules.test.js | 64 +++++++++++++++ packages/desktop-client/e2e/settings.test.js | 40 ++++++++++ .../src/components/ManageRules.js | 11 ++- .../src/components/accounts/Account.js | 3 +- .../src/components/modals/EditRule.js | 20 +++-- .../src/components/reports/Overview.js | 2 + .../src/components/reports/index.js | 2 +- packages/loot-design/src/components/Stack.js | 2 + 17 files changed, 413 insertions(+), 23 deletions(-) create mode 100644 packages/desktop-client/e2e/accounts.test.js create mode 100644 packages/desktop-client/e2e/page-models/close-account-modal.js create mode 100644 packages/desktop-client/e2e/page-models/reports-page.js create mode 100644 packages/desktop-client/e2e/page-models/rules-page.js create mode 100644 packages/desktop-client/e2e/page-models/settings-page.js create mode 100644 packages/desktop-client/e2e/reports.test.js create mode 100644 packages/desktop-client/e2e/rules.test.js create mode 100644 packages/desktop-client/e2e/settings.test.js diff --git a/packages/desktop-client/e2e/accounts.test.js b/packages/desktop-client/e2e/accounts.test.js new file mode 100644 index 000000000..da28d63c1 --- /dev/null +++ b/packages/desktop-client/e2e/accounts.test.js @@ -0,0 +1,52 @@ +import { test, expect } from '@playwright/test'; + +import { ConfigurationPage } from './page-models/configuration-page'; +import { Navigation } from './page-models/navigation'; + +test.describe('Accounts', () => { + let page; + let navigation; + let configurationPage; + + test.beforeAll(async ({ browser }) => { + page = await browser.newPage(); + navigation = new Navigation(page); + configurationPage = new ConfigurationPage(page); + + await page.goto('/'); + await configurationPage.createTestFile(); + }); + + test.afterAll(async () => { + await page.close(); + }); + + test('creates a new account and views the initial balance transaction', async () => { + const accountPage = await navigation.createAccount({ + name: 'New Account', + type: 'Checking / Cash', + offBudget: false, + balance: 100, + }); + + expect(await accountPage.getNthTransaction(0)).toMatchObject({ + payee: 'Starting Balance', + notes: '', + category: 'Starting Balances', + debit: '', + credit: '100.00', + }); + }); + + test('closes an account', async () => { + const accountPage = await navigation.goToAccountPage('Roth IRA'); + + await expect(accountPage.accountName).toHaveText('Roth IRA'); + + const modal = await accountPage.clickCloseAccount(); + await modal.selectTransferAccount('Vanguard 401k'); + await modal.closeAccount(); + + await expect(accountPage.accountName).toHaveText('Closed: Roth IRA'); + }); +}); diff --git a/packages/desktop-client/e2e/page-models/account-page.js b/packages/desktop-client/e2e/page-models/account-page.js index 88bdfbc1d..fac86780d 100644 --- a/packages/desktop-client/e2e/page-models/account-page.js +++ b/packages/desktop-client/e2e/page-models/account-page.js @@ -1,16 +1,22 @@ +import { CloseAccountModal } from './close-account-modal'; + export class AccountPage { constructor(page) { this.page = page; + this.accountName = this.page.getByTestId('account-name'); this.addNewTransactionButton = this.page.getByRole('button', { - name: 'Add New' + name: 'Add New', }); this.newTransactionRow = this.page .getByTestId('new-transaction') .getByTestId('row'); this.addTransactionButton = this.page.getByTestId('add-button'); this.cancelTransactionButton = this.page.getByRole('button', { - name: 'Cancel' + name: 'Cancel', + }); + this.menuButton = this.page.getByRole('button', { + name: 'Menu', }); this.transactionTableRow = this.page @@ -40,14 +46,14 @@ export class AccountPage { const transactionRow = this.newTransactionRow.first(); await this._fillTransactionFields(transactionRow, { ...rootTransaction, - category: 'split' + category: 'split', }); // Child transactions for (let i = 0; i < transactions.length; i++) { await this._fillTransactionFields( this.newTransactionRow.nth(i + 1), - transactions[i] + transactions[i], ); if (i + 1 < transactions.length) { @@ -71,10 +77,19 @@ export class AccountPage { notes: await row.getByTestId('notes').textContent(), category: await row.getByTestId('category').textContent(), debit: await row.getByTestId('debit').textContent(), - credit: await row.getByTestId('credit').textContent() + credit: await row.getByTestId('credit').textContent(), }; } + /** + * Open the modal for closing the account. + */ + async clickCloseAccount() { + await this.menuButton.click(); + await this.page.getByRole('button', { name: 'Close Account' }).click(); + return new CloseAccountModal(this.page.locator('css=[aria-modal]')); + } + async _fillTransactionFields(transactionRow, transaction) { if (transaction.payee) { await transactionRow.getByTestId('payee').click(); diff --git a/packages/desktop-client/e2e/page-models/close-account-modal.js b/packages/desktop-client/e2e/page-models/close-account-modal.js new file mode 100644 index 000000000..9b9236026 --- /dev/null +++ b/packages/desktop-client/e2e/page-models/close-account-modal.js @@ -0,0 +1,13 @@ +export class CloseAccountModal { + constructor(page) { + this.page = page; + } + + async selectTransferAccount(accountName) { + await this.page.getByRole('combobox').selectOption({ label: accountName }); + } + + async closeAccount() { + await this.page.getByRole('button', { name: 'Close account' }).click(); + } +} diff --git a/packages/desktop-client/e2e/page-models/navigation.js b/packages/desktop-client/e2e/page-models/navigation.js index af5ba6acb..d256c5202 100644 --- a/packages/desktop-client/e2e/page-models/navigation.js +++ b/packages/desktop-client/e2e/page-models/navigation.js @@ -1,5 +1,8 @@ import { AccountPage } from './account-page'; +import { ReportsPage } from './reports-page'; +import { RulesPage } from './rules-page'; import { SchedulesPage } from './schedules-page'; +import { SettingsPage } from './settings-page'; export class Navigation { constructor(page) { @@ -14,9 +17,57 @@ export class Navigation { return new AccountPage(this.page); } + async goToReportsPage() { + await this.page.getByRole('link', { name: 'Reports' }).click(); + + return new ReportsPage(this.page); + } + async goToSchedulesPage() { await this.page.getByRole('link', { name: 'Schedules' }).click(); return new SchedulesPage(this.page); } + + async goToRulesPage() { + const rulesLink = this.page.getByRole('link', { name: 'Rules' }); + + // Expand the "more" menu only if it is not already expanded + if (!(await rulesLink.isVisible())) { + await this.page.getByRole('button', { name: 'More' }).click(); + } + + await rulesLink.click(); + + return new RulesPage(this.page); + } + + async goToSettingsPage() { + const settingsLink = this.page.getByRole('link', { name: 'Settings' }); + + // Expand the "more" menu only if it is not already expanded + if (!(await settingsLink.isVisible())) { + await this.page.getByRole('button', { name: 'More' }).click(); + } + + await settingsLink.click(); + + return new SettingsPage(this.page); + } + + async createAccount(data) { + await this.page.getByRole('button', { name: 'Add account' }).click(); + + // Fill the form + await this.page.getByLabel('Name:').fill(data.name); + await this.page.getByLabel('Type:').selectOption({ label: data.type }); + await this.page.getByLabel('Balance:').fill(String(data.balance)); + + if (data.offBudget) { + await this.page.getByLabel('Off-budget').click(); + } + + await this.page.getByRole('button', { name: 'Create' }).click(); + return new AccountPage(this.page); + } } diff --git a/packages/desktop-client/e2e/page-models/reports-page.js b/packages/desktop-client/e2e/page-models/reports-page.js new file mode 100644 index 000000000..5ebbc4284 --- /dev/null +++ b/packages/desktop-client/e2e/page-models/reports-page.js @@ -0,0 +1,17 @@ +export class ReportsPage { + constructor(page) { + this.page = page; + this.pageContent = page.getByTestId('reports-page'); + } + + async waitToLoad() { + return this.pageContent.getByRole('link', { name: /^Net/ }).waitFor(); + } + + async getAvailableReportList() { + return this.pageContent + .getByRole('link') + .getByRole('heading') + .allTextContents(); + } +} diff --git a/packages/desktop-client/e2e/page-models/rules-page.js b/packages/desktop-client/e2e/page-models/rules-page.js new file mode 100644 index 000000000..bd5824599 --- /dev/null +++ b/packages/desktop-client/e2e/page-models/rules-page.js @@ -0,0 +1,78 @@ +export class RulesPage { + constructor(page) { + this.page = page; + } + + /** + * Create a new rule + */ + async createRule(data) { + await this.page + .getByRole('button', { + name: 'Create new rule', + }) + .click(); + + await this._fillRuleFields(data); + + await this.page.getByRole('button', { name: 'Save' }).click(); + } + + /** + * Retrieve the data for the nth-rule. + * 0-based index + */ + async getNthRule(index) { + const row = this.page.getByTestId('table').getByTestId('row').nth(index); + + return { + conditions: await row.getByTestId('conditions').textContent(), + actions: await row.getByTestId('actions').textContent(), + }; + } + + async _fillRuleFields(data) { + if (data.conditions) { + await this._fillEditorFields( + data.conditions, + this.page.getByTestId('condition-list'), + ); + } + + if (data.actions) { + await this._fillEditorFields( + data.actions, + this.page.getByTestId('action-list'), + ); + } + } + + async _fillEditorFields(data, rootElement) { + for (const idx in data) { + const { field, op, value } = data[idx]; + + const row = rootElement.getByTestId('editor-row').nth(idx); + + if (!(await row.isVisible())) { + await rootElement.getByRole('button', { name: 'Add entry' }).click(); + } + + if (field) { + await row.getByRole('button').first().click(); + await this.page + .getByRole('option', { exact: true, name: field }) + .click(); + } + + if (op) { + await row.getByRole('button', { name: 'is' }).click(); + await this.page.getByRole('option', { name: op }).click(); + } + + if (value) { + await row.getByRole('combobox').fill(value); + await this.page.keyboard.press('Enter'); + } + } + } +} diff --git a/packages/desktop-client/e2e/page-models/schedules-page.js b/packages/desktop-client/e2e/page-models/schedules-page.js index c61dbad38..401fea1cb 100644 --- a/packages/desktop-client/e2e/page-models/schedules-page.js +++ b/packages/desktop-client/e2e/page-models/schedules-page.js @@ -71,23 +71,17 @@ export class SchedulesPage { async _fillScheduleFields(data) { if (data.payee) { - await this.page.getByLabel('Payee').click(); - await this.page.keyboard.type(data.payee); + await this.page.getByLabel('Payee').fill(data.payee); await this.page.keyboard.press('Enter'); } if (data.account) { - await this.page.getByLabel('Account').click(); - await this.page.keyboard.type(data.account); + await this.page.getByLabel('Account').fill(data.account); await this.page.keyboard.press('Enter'); } if (data.amount) { - await this.page.getByLabel('Amount').click(); - await this.page.keyboard.press('Control+A'); - await this.page.keyboard.press('Delete'); - await this.page.keyboard.type(String(data.amount)); - await this.page.keyboard.press('Enter'); + await this.page.getByLabel('Amount').fill(String(data.amount)); } } } diff --git a/packages/desktop-client/e2e/page-models/settings-page.js b/packages/desktop-client/e2e/page-models/settings-page.js new file mode 100644 index 000000000..b24bb18b0 --- /dev/null +++ b/packages/desktop-client/e2e/page-models/settings-page.js @@ -0,0 +1,9 @@ +export class SettingsPage { + constructor(page) { + this.page = page; + } + + async exportData() { + await this.page.getByRole('button', { name: 'Export data' }).click(); + } +} diff --git a/packages/desktop-client/e2e/reports.test.js b/packages/desktop-client/e2e/reports.test.js new file mode 100644 index 000000000..554b203d4 --- /dev/null +++ b/packages/desktop-client/e2e/reports.test.js @@ -0,0 +1,35 @@ +import { test, expect } from '@playwright/test'; + +import { ConfigurationPage } from './page-models/configuration-page'; +import { Navigation } from './page-models/navigation'; + +test.describe('Reports', () => { + let page; + let navigation; + let reportsPage; + let configurationPage; + + test.beforeAll(async ({ browser }) => { + page = await browser.newPage(); + navigation = new Navigation(page); + configurationPage = new ConfigurationPage(page); + + await page.goto('/'); + await configurationPage.createTestFile(); + }); + + test.afterAll(async () => { + await page.close(); + }); + + test.beforeEach(async () => { + reportsPage = await navigation.goToReportsPage(); + await reportsPage.waitToLoad(); + }); + + test('loads net worth and cash flow reports', async () => { + const reports = await reportsPage.getAvailableReportList(); + + expect(reports).toEqual(['Net Worth', 'Cash Flow']); + }); +}); diff --git a/packages/desktop-client/e2e/rules.test.js b/packages/desktop-client/e2e/rules.test.js new file mode 100644 index 000000000..383b19a31 --- /dev/null +++ b/packages/desktop-client/e2e/rules.test.js @@ -0,0 +1,64 @@ +import { test, expect } from '@playwright/test'; + +import { ConfigurationPage } from './page-models/configuration-page'; +import { Navigation } from './page-models/navigation'; + +test.describe('Rules', () => { + let page; + let navigation; + let rulesPage; + let configurationPage; + + test.beforeAll(async ({ browser }) => { + page = await browser.newPage(); + navigation = new Navigation(page); + configurationPage = new ConfigurationPage(page); + + await page.goto('/'); + await configurationPage.createTestFile(); + }); + + test.afterAll(async () => { + await page.close(); + }); + + test.beforeEach(async () => { + rulesPage = await navigation.goToRulesPage(); + }); + + test('creates a rule and makes sure it is applied when creating a transaction', async () => { + await rulesPage.createRule({ + conditions: [ + { + field: 'payee', + op: 'is', + value: 'Fast Internet', + }, + ], + actions: [ + { + field: 'category', + value: 'General', + }, + ], + }); + + expect(await rulesPage.getNthRule(0)).toMatchObject({ + conditions: 'payee is Fast Internet', + actions: 'set category to General', + }); + + const accountPage = await navigation.goToAccountPage('Bank of America'); + + await accountPage.createSingleTransaction({ + payee: 'Fast Internet', + debit: '12.34', + }); + + expect(await accountPage.getNthTransaction(0)).toMatchObject({ + payee: 'Fast Internet', + category: 'General', + debit: '12.34', + }); + }); +}); diff --git a/packages/desktop-client/e2e/settings.test.js b/packages/desktop-client/e2e/settings.test.js new file mode 100644 index 000000000..058f9a861 --- /dev/null +++ b/packages/desktop-client/e2e/settings.test.js @@ -0,0 +1,40 @@ +import { test, expect } from '@playwright/test'; + +import { ConfigurationPage } from './page-models/configuration-page'; +import { Navigation } from './page-models/navigation'; + +test.describe('Settings', () => { + let page; + let navigation; + let settingsPage; + let configurationPage; + + test.beforeAll(async ({ browser }) => { + page = await browser.newPage(); + navigation = new Navigation(page); + configurationPage = new ConfigurationPage(page); + + await page.goto('/'); + await configurationPage.createTestFile(); + }); + + test.afterAll(async () => { + await page.close(); + }); + + test.beforeEach(async () => { + settingsPage = await navigation.goToSettingsPage(); + }); + + test('downloads the export of the budget', async () => { + const downloadPromise = page.waitForEvent('download'); + + await settingsPage.exportData(); + + const download = await downloadPromise; + + expect(await download.suggestedFilename()).toMatch( + /^\d{4}-\d{2}-\d{2}-.*.zip$/, + ); + }); +}); diff --git a/packages/desktop-client/src/components/ManageRules.js b/packages/desktop-client/src/components/ManageRules.js index b4fba6868..072303d47 100644 --- a/packages/desktop-client/src/components/ManageRules.js +++ b/packages/desktop-client/src/components/ManageRules.js @@ -356,7 +356,10 @@ let Rule = React.memo( <Field width="flex" style={{ padding: '15px 0' }} truncate={false}> <Stack direction="row" align="center"> - <View style={{ flex: 1, alignItems: 'flex-start' }}> + <View + style={{ flex: 1, alignItems: 'flex-start' }} + data-testid="conditions" + > {rule.conditions.map((cond, i) => ( <ConditionExpression key={i} @@ -374,7 +377,10 @@ let Rule = React.memo( <ArrowRight color={colors.n4} style={{ width: 12, height: 12 }} /> </Text> - <View style={{ flex: 1, alignItems: 'flex-start' }}> + <View + style={{ flex: 1, alignItems: 'flex-start' }} + data-testid="actions" + > {rule.actions.map((action, i) => ( <ActionExpression key={i} @@ -446,6 +452,7 @@ let SimpleTable = React.forwardRef( style, ]} tabIndex="1" + data-testid="table" {...getNavigatorProps(props)} > <View diff --git a/packages/desktop-client/src/components/accounts/Account.js b/packages/desktop-client/src/components/accounts/Account.js index 2a5508e04..a3b58d6fb 100644 --- a/packages/desktop-client/src/components/accounts/Account.js +++ b/packages/desktop-client/src/components/accounts/Account.js @@ -224,7 +224,7 @@ function ReconcileTooltip({ account, onReconcile, onClose }) { function MenuButton({ onClick }) { return ( - <Button bare onClick={onClick}> + <Button bare onClick={onClick} aria-label="Menu"> <DotsHorizontalTriple width={15} height={15} @@ -724,6 +724,7 @@ const AccountHeader = React.memo( marginRight: 5, marginBottom: 5, }} + data-testid="account-name" > {account && account.closed ? 'Closed: ' + accountName diff --git a/packages/desktop-client/src/components/modals/EditRule.js b/packages/desktop-client/src/components/modals/EditRule.js index b8ad4c479..856beefee 100644 --- a/packages/desktop-client/src/components/modals/EditRule.js +++ b/packages/desktop-client/src/components/modals/EditRule.js @@ -121,12 +121,22 @@ function EditorButtons({ onAdd, onDelete, style }) { return ( <> {onDelete && ( - <Button bare onClick={onDelete} style={{ padding: 7 }}> + <Button + bare + onClick={onDelete} + style={{ padding: 7 }} + aria-label="Delete entry" + > <SubtractIcon style={{ width: 8, height: 8 }} /> </Button> )} {onAdd && ( - <Button bare onClick={onAdd} style={{ padding: 7 }}> + <Button + bare + onClick={onAdd} + style={{ padding: 7 }} + aria-label="Add entry" + > <AddIcon style={{ width: 10, height: 10 }} /> </Button> )} @@ -151,7 +161,7 @@ function FieldError({ type }) { function Editor({ error, style, children }) { return ( - <View style={style}> + <View style={style} data-testid="editor-row"> <Stack direction="row" align="center" @@ -506,7 +516,7 @@ export function ConditionsList({ Add condition </Button> ) : ( - <Stack spacing={2}> + <Stack spacing={2} data-testid="condition-list"> {conditions.map((cond, i) => { let ops = TYPE_INFO[cond.type].ops; @@ -794,7 +804,7 @@ export default function EditRule({ Add action </Button> ) : ( - <Stack spacing={2}> + <Stack spacing={2} data-testid="action-list"> {actions.map((action, i) => ( <View key={i}> <ActionEditor diff --git a/packages/desktop-client/src/components/reports/Overview.js b/packages/desktop-client/src/components/reports/Overview.js index 5f15bf807..4270d0bcc 100644 --- a/packages/desktop-client/src/components/reports/Overview.js +++ b/packages/desktop-client/src/components/reports/Overview.js @@ -79,6 +79,7 @@ function NetWorthCard({ accounts }) { <View style={{ flex: 1 }}> <Block style={[styles.mediumText, { fontWeight: 500, marginBottom: 5 }]} + role="heading" > Net Worth </Block> @@ -132,6 +133,7 @@ function CashFlowCard() { <View style={{ flex: 1 }}> <Block style={[styles.mediumText, { fontWeight: 500, marginBottom: 5 }]} + role="heading" > Cash Flow </Block> diff --git a/packages/desktop-client/src/components/reports/index.js b/packages/desktop-client/src/components/reports/index.js index 2c50538f0..9c6054282 100644 --- a/packages/desktop-client/src/components/reports/index.js +++ b/packages/desktop-client/src/components/reports/index.js @@ -10,7 +10,7 @@ import Overview from './Overview'; class Reports extends React.Component { render() { return ( - <View style={{ flex: 1 }}> + <View style={{ flex: 1 }} data-testid="reports-page"> <Route path="/reports" exact component={Overview} /> <Route path="/reports/net-worth" exact component={NetWorth} /> <Route path="/reports/cash-flow" exact component={CashFlow} /> diff --git a/packages/loot-design/src/components/Stack.js b/packages/loot-design/src/components/Stack.js index 1c23ee2a8..d984668bc 100644 --- a/packages/loot-design/src/components/Stack.js +++ b/packages/loot-design/src/components/Stack.js @@ -26,6 +26,7 @@ const Stack = React.forwardRef( children, debug, style, + ...props }, ref, ) => { @@ -44,6 +45,7 @@ const Stack = React.forwardRef( style, ]} innerRef={ref} + {...props} > {validChildren.map(({ key, child }, index) => { let isLastChild = validChildren.length === index + 1; -- GitLab