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