(e2e) adding e2e tests for accounts: creating & closing (#695)

This commit is contained in:
Matiss Janis Aboltins
2023-02-26 15:25:53 +00:00
committed by GitHub
parent 42b5208b8b
commit 69a54a16e4
17 changed files with 413 additions and 23 deletions

View File

@@ -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');
});
});

View File

@@ -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();

View File

@@ -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();
}
}

View File

@@ -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);
}
}

View File

@@ -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();
}
}

View File

@@ -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');
}
}
}
}

View File

@@ -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));
}
}
}

View File

@@ -0,0 +1,9 @@
export class SettingsPage {
constructor(page) {
this.page = page;
}
async exportData() {
await this.page.getByRole('button', { name: 'Export data' }).click();
}
}

View File

@@ -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']);
});
});

View File

@@ -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',
});
});
});

View File

@@ -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$/,
);
});
});

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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>

View File

@@ -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} />

View File

@@ -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;