diff --git a/packages/desktop-client/e2e/budget.test.js b/packages/desktop-client/e2e/budget.test.js new file mode 100644 index 0000000000000000000000000000000000000000..95da06367e976013157c40d731d43d2d5a72a859 --- /dev/null +++ b/packages/desktop-client/e2e/budget.test.js @@ -0,0 +1,54 @@ +import { test, expect } from '@playwright/test'; + +import { ConfigurationPage } from './page-models/configuration-page'; +import { Navigation } from './page-models/navigation'; + +test.describe('Budget', () => { + let page; + let navigation; + let configurationPage; + let budgetPage; + + test.beforeAll(async ({ browser }) => { + page = await browser.newPage(); + navigation = new Navigation(page); + configurationPage = new ConfigurationPage(page); + + await page.goto('/'); + budgetPage = await configurationPage.createTestFile(); + }); + + test.afterAll(async () => { + await page.close(); + }); + + test('renders the summary information: available funds, overspent, budgeted and for next month', async () => { + const summary = budgetPage.budgetSummary.first(); + + await expect(summary.getByText('Available Funds')).toBeVisible(); + await expect(summary.getByText(/^Overspent in /)).toBeVisible(); + await expect(summary.getByText('Budgeted')).toBeVisible(); + await expect(summary.getByText('For Next Month')).toBeVisible(); + }); + + test('transfer funds to another category', async () => { + const currentFundsA = await budgetPage.getBalanceForRow(1); + const currentFundsB = await budgetPage.getBalanceForRow(2); + + await budgetPage.transferAllBalance(1, 2); + await page.waitForTimeout(1000); + + expect(await budgetPage.getBalanceForRow(2)).toEqual( + currentFundsA + currentFundsB, + ); + }); + + test('budget table is rendered', async () => { + await expect(budgetPage.budgetTable).toBeVisible(); + expect(await budgetPage.getTableTotals()).toEqual({ + budgeted: expect.any(Number), + spent: expect.any(Number), + balance: expect.any(Number), + }); + }); +}); diff --git a/packages/desktop-client/e2e/data/actual-demo-budget.zip b/packages/desktop-client/e2e/data/actual-demo-budget.zip new file mode 100644 index 0000000000000000000000000000000000000000..9a35f3f0ae90d9f4f8538e80279ed4d84e0c9955 Binary files /dev/null and b/packages/desktop-client/e2e/data/actual-demo-budget.zip differ diff --git a/packages/desktop-client/e2e/data/ynab4-demo-budget.zip b/packages/desktop-client/e2e/data/ynab4-demo-budget.zip new file mode 100644 index 0000000000000000000000000000000000000000..13db0b90e0bfaf2a4da82a72962dc5651c3da842 Binary files /dev/null and b/packages/desktop-client/e2e/data/ynab4-demo-budget.zip differ diff --git a/packages/desktop-client/e2e/onboarding.test.js b/packages/desktop-client/e2e/onboarding.test.js new file mode 100644 index 0000000000000000000000000000000000000000..3295fd81a6676e163354c8b8b248fc35c8dd3621 --- /dev/null +++ b/packages/desktop-client/e2e/onboarding.test.js @@ -0,0 +1,71 @@ +import path from 'path'; +import { test, expect } from '@playwright/test'; + +import { AccountPage } from './page-models/account-page'; +import { ConfigurationPage } from './page-models/configuration-page'; +import { Navigation } from './page-models/navigation'; + +test.describe('Onboarding', () => { + let page; + let navigation; + let configurationPage; + + test.beforeEach(async ({ browser }) => { + page = await browser.newPage(); + navigation = new Navigation(page); + configurationPage = new ConfigurationPage(page); + + await page.goto('/'); + }); + + test.afterEach(async () => { + await page.close(); + }); + + test('creates a new budget file by importing YNAB4 budget', async () => { + await configurationPage.clickOnNoServer(); + const budgetPage = await configurationPage.importBudget( + 'YNAB4', + path.resolve(__dirname, 'data/ynab4-demo-budget.zip'), + ); + + await expect(budgetPage.budgetTable).toBeVisible(); + + const accountPage = await navigation.goToAccountPage( + 'Account1 with Starting Balance', + ); + await expect(accountPage.accountBalance).toHaveText('-400.00'); + + await navigation.goToAccountPage('Account2 no Starting Balance'); + await expect(accountPage.accountBalance).toHaveText('2,607.00'); + }); + + // TODO: implement this test once we have an example nYNAB file + // test('creates a new budget file by importing nYNAB budget'); + + test('creates a new budget file by importing Actual budget', async () => { + await configurationPage.clickOnNoServer(); + const budgetPage = await configurationPage.importBudget( + 'Actual', + path.resolve(__dirname, 'data/actual-demo-budget.zip'), + ); + + await expect(budgetPage.budgetTable).toBeVisible(); + + const accountPage = await navigation.goToAccountPage('Ally Savings'); + await expect(accountPage.accountBalance).toHaveText('1,772.80'); + + await navigation.goToAccountPage('Roth IRA'); + await expect(accountPage.accountBalance).toHaveText('2,745.81'); + }); + + test('creates a new empty budget file', async () => { + await configurationPage.clickOnNoServer(); + await configurationPage.startFresh(); + + const accountPage = new AccountPage(page); + await expect(accountPage.accountName).toBeVisible(); + await expect(accountPage.accountName).toHaveText('All Accounts'); + await expect(accountPage.accountBalance).toHaveText('0.00'); + }); +}); diff --git a/packages/desktop-client/e2e/page-models/account-page.js b/packages/desktop-client/e2e/page-models/account-page.js index fac86780db30cf484a7ea4924073ae1f11da0dce..37398971a19c3858641a055c9c78753da9e3aec5 100644 --- a/packages/desktop-client/e2e/page-models/account-page.js +++ b/packages/desktop-client/e2e/page-models/account-page.js @@ -5,6 +5,7 @@ export class AccountPage { this.page = page; this.accountName = this.page.getByTestId('account-name'); + this.accountBalance = this.page.getByTestId('account-balance'); this.addNewTransactionButton = this.page.getByRole('button', { name: 'Add New', }); diff --git a/packages/desktop-client/e2e/page-models/budget-page.js b/packages/desktop-client/e2e/page-models/budget-page.js new file mode 100644 index 0000000000000000000000000000000000000000..dfbe2bfb8225cce37b76b6cdf49e579f51bf4b9e --- /dev/null +++ b/packages/desktop-client/e2e/page-models/budget-page.js @@ -0,0 +1,72 @@ +export class BudgetPage { + constructor(page) { + this.page = page; + + this.budgetSummary = page.getByTestId('budget-summary'); + this.budgetTable = page.getByTestId('budget-table'); + this.budgetTableTotals = this.budgetTable.getByTestId('budget-totals'); + } + + async getTableTotals() { + return { + budgeted: parseInt( + await this.budgetTableTotals + .getByTestId(/total-budgeted$/) + .textContent(), + 10, + ), + spent: parseInt( + await this.budgetTableTotals.getByTestId(/total-spent$/).textContent(), + 10, + ), + balance: parseInt( + await this.budgetTableTotals + .getByTestId(/total-leftover$/) + .textContent(), + 10, + ), + }; + } + + async showMoreMonths() { + await this.page.getByTestId('calendar-icon').first().click(); + } + + async getBalanceForRow(idx) { + return Math.round( + parseFloat( + await this.budgetTable + .getByTestId('row') + .nth(idx) + .getByTestId('balance') + .textContent(), + ) * 100, + ); + } + + async transferAllBalance(fromIdx, toIdx) { + const toName = await this.budgetTable + .getByTestId('row') + .nth(toIdx) + .getByTestId('category-name') + .textContent(); + + await this.budgetTable + .getByTestId('row') + .nth(fromIdx) + .getByTestId('balance') + .getByTestId(/^budget/) + .click(); + + await this.page + .getByRole('button', { name: 'Transfer to another category' }) + .click(); + + await this.page.getByPlaceholder('(none)').click(); + + await this.page.keyboard.type(toName); + await this.page.keyboard.press('Enter'); + + await this.page.getByRole('button', { name: 'Transfer' }).click(); + } +} diff --git a/packages/desktop-client/e2e/page-models/configuration-page.js b/packages/desktop-client/e2e/page-models/configuration-page.js index 56a64bcaaa2dc2427d222406723fe825d43fdca6..ecaaa8a42eb3881bc1579b813c238d2514c1d326 100644 --- a/packages/desktop-client/e2e/page-models/configuration-page.js +++ b/packages/desktop-client/e2e/page-models/configuration-page.js @@ -1,3 +1,5 @@ +import { BudgetPage } from './budget-page'; + export class ConfigurationPage { constructor(page) { this.page = page; @@ -5,5 +7,56 @@ export class ConfigurationPage { async createTestFile() { await this.page.getByRole('button', { name: 'Create test file' }).click(); + return new BudgetPage(this.page); + } + + async clickOnNoServer() { + await this.page.getByRole('button', { name: 'Don’t use a server' }).click(); + } + + async startFresh() { + await this.page.getByRole('button', { name: 'Start fresh' }).click(); + } + + async importBudget(type, file) { + const fileChooserPromise = this.page.waitForEvent('filechooser'); + await this.page.getByRole('button', { name: 'Import my budget' }).click(); + + switch (type) { + case 'YNAB4': + await this.page + .getByRole('button', { + name: 'YNAB4 The old unsupported desktop app', + }) + .click(); + await this.page + .getByRole('button', { name: 'Select zip file...' }) + .click(); + break; + + case 'nYNAB': + await this.page + .getByRole('button', { name: 'nYNAB The newer web app' }) + .click(); + await this.page.getByRole('button', { name: 'Select file...' }).click(); + break; + + case 'Actual': + await this.page + .getByRole('button', { + name: 'Actual Import a file exported from Actual', + }) + .click(); + await this.page.getByRole('button', { name: 'Select file...' }).click(); + break; + + default: + throw new Error(`Unrecognized import type: ${type}`); + } + + const fileChooser = await fileChooserPromise; + await fileChooser.setFiles(file); + + return new BudgetPage(this.page); } } diff --git a/packages/desktop-client/src/components/accounts/Account.js b/packages/desktop-client/src/components/accounts/Account.js index 28e364f9b5ab78b253a898a0973a5b2ceab63c89..ca69fe7b04ec6889dec55ecfdb916b8eea528e2d 100644 --- a/packages/desktop-client/src/components/accounts/Account.js +++ b/packages/desktop-client/src/components/accounts/Account.js @@ -401,6 +401,7 @@ function Balances({ balanceQuery, showExtraBalances, onToggleExtraBalances }) { }} > <Button + data-testid="account-balance" bare onClick={onToggleExtraBalances} style={{ @@ -767,6 +768,7 @@ const AccountHeader = React.memo( ) : ( <View style={{ fontSize: 25, fontWeight: 500, marginBottom: 5 }} + data-testid="account-name" > {account && account.closed ? 'Closed: ' + accountName diff --git a/packages/desktop-client/src/components/budget/misc.js b/packages/desktop-client/src/components/budget/misc.js index 6e0f752597bcb77f618b8f56972c30b5b2b6f954..b5dfd75bdebc0c241983855d3467ec751cea0d2b 100644 --- a/packages/desktop-client/src/components/budget/misc.js +++ b/packages/desktop-client/src/components/budget/misc.js @@ -186,6 +186,7 @@ export class BudgetTable extends React.Component { return ( <View + data-testid="budget-table" style={[ { flex: 1 }, styles.lightScrollbar && { @@ -309,6 +310,7 @@ export function SidebarCategory({ }} > <div + data-testid="category-name" style={{ textOverflow: 'ellipsis', whiteSpace: 'nowrap', @@ -607,6 +609,7 @@ function RenderMonths({ component: Component, editingIndex, args, style }) { const BudgetTotals = React.memo(function BudgetTotals({ MonthComponent }) { return ( <View + data-testid="budget-totals" style={{ backgroundColor: 'white', flexDirection: 'row', diff --git a/packages/desktop-client/src/components/budget/rollover/BudgetSummary.js b/packages/desktop-client/src/components/budget/rollover/BudgetSummary.js index 1dff564dc505f6a58973c06fa57a6672ba37cac3..69c0e194189420aeba19be2b741602475dc1ae05 100644 --- a/packages/desktop-client/src/components/budget/rollover/BudgetSummary.js +++ b/packages/desktop-client/src/components/budget/rollover/BudgetSummary.js @@ -277,6 +277,7 @@ export function BudgetSummary({ return ( <View + data-testid="budget-summary" style={{ backgroundColor: 'white', boxShadow: MONTH_BOX_SHADOW, diff --git a/packages/desktop-client/src/components/budget/rollover/TransferTooltip.js b/packages/desktop-client/src/components/budget/rollover/TransferTooltip.js index afedb001a3adc5c44c9d7aa91756d0681a1c28ba..9f948f9612b1c9975c384d4edd3cf9e42beed4e5 100644 --- a/packages/desktop-client/src/components/budget/rollover/TransferTooltip.js +++ b/packages/desktop-client/src/components/budget/rollover/TransferTooltip.js @@ -88,7 +88,7 @@ export default function TransferTooltip({ openOnFocus={true} onUpdate={id => {}} onSelect={id => setCategory(id)} - inputProps={{ onEnter: submit }} + inputProps={{ onEnter: submit, placeholder: '(none)' }} /> <View diff --git a/packages/loot-core/src/client/actions/budgets.js b/packages/loot-core/src/client/actions/budgets.js index 351ff749b5fd8bd01b90a8d4893920b565a4ec33..24a7671d21788dab938edb94ef3fe6d48732d883 100644 --- a/packages/loot-core/src/client/actions/budgets.js +++ b/packages/loot-core/src/client/actions/budgets.js @@ -165,6 +165,7 @@ export function importBudget(filepath, type) { dispatch(closeModal()); await dispatch(loadPrefs()); + window.__history.push('/budget'); }; } diff --git a/upcoming-release-notes/813.md b/upcoming-release-notes/813.md new file mode 100644 index 0000000000000000000000000000000000000000..5a36b2ad38777120f4485218dbbb4934a8b84237 --- /dev/null +++ b/upcoming-release-notes/813.md @@ -0,0 +1,6 @@ +--- +category: Maintenance +authors: [MatissJanis] +--- + +Added onboarding and budget e2e tests