[TypeScript] Convert test page models to TS (#4218)

* Dummy commit

* Delete js snapshots

* Move extended expect and test to fixtures

* Fix wrong commit

* Update VRT

* Dummy commit to run GH actions

* Convert test page models to TS

* Release notes

* Fix typecheck errors

* New page models to TS

* Fix typecheck error

* Fix page name

* Put awaits on getTableTotals

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
This commit is contained in:
Joel Jeremy Marquez
2025-02-16 11:24:38 -07:00
committed by GitHub
parent 04f8140d26
commit 0504becaf5
27 changed files with 606 additions and 280 deletions

View File

@@ -60,6 +60,9 @@ async function setBudgetAverage(
await budgetPage.goToPreviousMonth();
const spentButton = await budgetPage.getButtonForSpent(categoryName);
const spent = await spentButton.textContent();
if (!spent) {
throw new Error('Failed to get spent amount');
}
totalSpent += currencyToAmount(spent) ?? 0;
}
@@ -280,6 +283,10 @@ budgetTypes.forEach(budgetType => {
const lastMonthBudget = await budgetedButton.textContent();
if (!lastMonthBudget) {
throw new Error('Failed to get last month budget');
}
await budgetPage.goToNextMonth();
await copyLastMonthBudget(budgetPage, categoryName);

View File

@@ -1,7 +1,33 @@
import { type Locator, type Page } from '@playwright/test';
import { CloseAccountModal } from './close-account-modal';
type TransactionEntry = {
debit?: string;
credit?: string;
account?: string;
payee?: string;
notes?: string;
category?: string;
};
export class AccountPage {
constructor(page) {
readonly page: Page;
readonly accountName: Locator;
readonly accountBalance: Locator;
readonly addNewTransactionButton: Locator;
readonly newTransactionRow: Locator;
readonly addTransactionButton: Locator;
readonly cancelTransactionButton: Locator;
readonly accountMenuButton: Locator;
readonly transactionTable: Locator;
readonly transactionTableRow: Locator;
readonly filterButton: Locator;
readonly filterSelectTooltip: Locator;
readonly selectButton: Locator;
readonly selectTooltip: Locator;
constructor(page: Page) {
this.page = page;
this.accountName = this.page.getByTestId('account-name');
@@ -30,14 +56,14 @@ export class AccountPage {
this.selectTooltip = this.page.getByTestId('transactions-select-tooltip');
}
async waitFor() {
await this.transactionTable.waitFor();
async waitFor(...options: Parameters<Locator['waitFor']>) {
await this.transactionTable.waitFor(...options);
}
/**
* Enter details of a transaction
*/
async enterSingleTransaction(transaction) {
async enterSingleTransaction(transaction: TransactionEntry) {
await this.addNewTransactionButton.click();
await this._fillTransactionFields(this.newTransactionRow, transaction);
}
@@ -53,7 +79,7 @@ export class AccountPage {
/**
* Create a single transaction
*/
async createSingleTransaction(transaction) {
async createSingleTransaction(transaction: TransactionEntry) {
await this.enterSingleTransaction(transaction);
await this.addEnteredTransaction();
}
@@ -61,7 +87,10 @@ export class AccountPage {
/**
* Create split transactions
*/
async createSplitTransaction([rootTransaction, ...transactions]) {
async createSplitTransaction([
rootTransaction,
...transactions
]: TransactionEntry[]) {
await this.addNewTransactionButton.click();
// Root transaction
@@ -87,7 +116,7 @@ export class AccountPage {
await this.cancelTransactionButton.click();
}
async selectNthTransaction(index) {
async selectNthTransaction(index: number) {
const row = this.transactionTableRow.nth(index);
await row.getByTestId('select').click();
}
@@ -96,7 +125,7 @@ export class AccountPage {
* Retrieve the data for the nth-transaction.
* 0-based index
*/
getNthTransaction(index) {
getNthTransaction(index: number) {
const row = this.transactionTableRow.nth(index);
return this._getTransactionDetails(row);
@@ -106,11 +135,9 @@ export class AccountPage {
return this._getTransactionDetails(this.newTransactionRow);
}
_getTransactionDetails(row) {
const account = row.getByTestId('account');
_getTransactionDetails(row: Locator) {
return {
...(account ? { account } : {}),
account: row.getByTestId('account'),
payee: row.getByTestId('payee'),
notes: row.getByTestId('notes'),
category: row.getByTestId('category'),
@@ -119,7 +146,7 @@ export class AccountPage {
};
}
async clickSelectAction(action) {
async clickSelectAction(action: string | RegExp) {
await this.selectButton.click();
await this.selectTooltip.getByRole('button', { name: action }).click();
}
@@ -130,16 +157,13 @@ export class AccountPage {
async clickCloseAccount() {
await this.accountMenuButton.click();
await this.page.getByRole('button', { name: 'Close Account' }).click();
return new CloseAccountModal(
this.page,
this.page.getByTestId('close-account-modal'),
);
return new CloseAccountModal(this.page.getByTestId('close-account-modal'));
}
/**
* Open the filtering popover.
*/
async filterBy(field) {
async filterBy(field: string | RegExp) {
await this.filterButton.click();
await this.filterSelectTooltip.getByRole('button', { name: field }).click();
@@ -149,7 +173,7 @@ export class AccountPage {
/**
* Filter to a specific note
*/
async filterByNote(note) {
async filterByNote(note: string) {
const filterTooltip = await this.filterBy('Note');
await this.page.keyboard.type(note);
await filterTooltip.applyButton.click();
@@ -158,14 +182,17 @@ export class AccountPage {
/**
* Remove the nth filter
*/
async removeFilter(idx) {
async removeFilter(idx: number) {
await this.page
.getByRole('button', { name: 'Delete filter' })
.nth(idx)
.click();
}
async _fillTransactionFields(transactionRow, transaction) {
async _fillTransactionFields(
transactionRow: Locator,
transaction: TransactionEntry,
) {
if (transaction.debit) {
await transactionRow.getByTestId('debit').click();
await this.page.keyboard.type(transaction.debit);
@@ -210,8 +237,11 @@ export class AccountPage {
}
class FilterTooltip {
constructor(page) {
this.page = page;
this.applyButton = page.getByRole('button', { name: 'Apply' });
readonly locator: Locator;
readonly applyButton: Locator;
constructor(locator: Locator) {
this.locator = locator;
this.applyButton = locator.getByRole('button', { name: 'Apply' });
}
}

View File

@@ -1,89 +0,0 @@
import { AccountPage } from './account-page';
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()
).replace(/,/g, ''),
) * 100,
);
}
async getCategoryNameForRow(idx) {
return this.budgetTable
.getByTestId('row')
.nth(idx)
.getByTestId('category-name')
.textContent();
}
async clickOnSpentAmountForRow(idx) {
await this.budgetTable
.getByTestId('row')
.nth(idx)
.getByTestId('category-month-spent')
.click();
return new AccountPage(this.page);
}
async transferAllBalance(fromIdx, toIdx) {
const toName = await this.getCategoryNameForRow(toIdx);
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();
}
}

View File

@@ -0,0 +1,128 @@
import { type Locator, type Page } from '@playwright/test';
import { AccountPage } from './account-page';
export class BudgetPage {
readonly page: Page;
readonly budgetSummary: Locator;
readonly budgetTable: Locator;
readonly budgetTableTotals: Locator;
constructor(page: Page) {
this.page = page;
this.budgetSummary = page.getByTestId('budget-summary');
this.budgetTable = page.getByTestId('budget-table');
this.budgetTableTotals = this.budgetTable.getByTestId('budget-totals');
}
async getTotalBudgeted() {
const totalBudgetedText = await this.budgetTableTotals
.getByTestId(/total-budgeted$/)
.textContent();
if (!totalBudgetedText) {
throw new Error('Failed to get total budgeted.');
}
return parseInt(totalBudgetedText, 10);
}
async getTotalSpent() {
const totalSpentText = await this.budgetTableTotals
.getByTestId(/total-spent$/)
.textContent();
if (!totalSpentText) {
throw new Error('Failed to get total spent.');
}
return parseInt(totalSpentText, 10);
}
async getTotalLeftover() {
const totalLeftoverText = await this.budgetTableTotals
.getByTestId(/total-leftover$/)
.textContent();
if (!totalLeftoverText) {
throw new Error('Failed to get total leftover.');
}
return parseInt(totalLeftoverText, 10);
}
async getTableTotals() {
return {
budgeted: await this.getTotalBudgeted(),
spent: await this.getTotalSpent(),
balance: await this.getTotalLeftover(),
};
}
async showMoreMonths() {
await this.page.getByTestId('calendar-icon').first().click();
}
async getBalanceForRow(idx: number) {
const balanceText = await this.budgetTable
.getByTestId('row')
.nth(idx)
.getByTestId('balance')
.textContent();
if (!balanceText) {
throw new Error(`Failed to get balance on row index ${idx}.`);
}
return Math.round(parseFloat(balanceText.replace(/,/g, '')) * 100);
}
async getCategoryNameForRow(idx: number) {
const categoryNameText = this.budgetTable
.getByTestId('row')
.nth(idx)
.getByTestId('category-name')
.textContent();
if (!categoryNameText) {
throw new Error(`Failed to get category name on row index ${idx}.`);
}
return categoryNameText;
}
async clickOnSpentAmountForRow(idx: number) {
await this.budgetTable
.getByTestId('row')
.nth(idx)
.getByTestId('category-month-spent')
.click();
return new AccountPage(this.page);
}
async transferAllBalance(fromIdx: number, toIdx: number) {
const toName = await this.getCategoryNameForRow(toIdx);
if (!toName) {
throw new Error(`Unable to get category name of row index ${toIdx}.`);
}
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();
}
}

View File

@@ -1,10 +1,15 @@
import { type Locator, type Page } from '@playwright/test';
export class CloseAccountModal {
constructor(page, locator) {
this.page = page;
readonly locator: Locator;
readonly page: Page;
constructor(locator: Locator) {
this.locator = locator;
this.page = locator.page();
}
async selectTransferAccount(accountName) {
async selectTransferAccount(accountName: string) {
await this.locator.getByPlaceholder('Select account...').fill(accountName);
await this.page.keyboard.press('Enter');
}

View File

@@ -1,8 +1,13 @@
import { type Locator, type Page } from '@playwright/test';
import { AccountPage } from './account-page';
import { BudgetPage } from './budget-page';
export class ConfigurationPage {
constructor(page) {
readonly page: Page;
readonly heading: Locator;
constructor(page: Page) {
this.page = page;
this.heading = page.getByRole('heading');
@@ -23,7 +28,7 @@ export class ConfigurationPage {
return new AccountPage(this.page);
}
async importBudget(type, file) {
async importBudget(type: 'YNAB4' | 'nYNAB' | 'Actual', file: string) {
const fileChooserPromise = this.page.waitForEvent('filechooser');
await this.page.getByRole('button', { name: 'Import my budget' }).click();

View File

@@ -1,5 +1,13 @@
import { type Locator, type Page } from '@playwright/test';
export class CustomReportPage {
constructor(page) {
readonly page: Page;
readonly pageContent: Locator;
readonly showLegendButton: Locator;
readonly showSummaryButton: Locator;
readonly showLabelsButton: Locator;
constructor(page: Page) {
this.page = page;
this.pageContent = page.getByTestId('reports-page');
@@ -14,11 +22,11 @@ export class CustomReportPage {
});
}
async selectViz(vizName) {
async selectViz(vizName: string | RegExp) {
await this.pageContent.getByRole('button', { name: vizName }).click();
}
async selectMode(mode) {
async selectMode(mode: 'total' | 'time') {
switch (mode) {
case 'total':
await this.pageContent.getByRole('button', { name: 'Total' }).click();

View File

@@ -1,7 +1,18 @@
import { type Locator, type Page } from '@playwright/test';
import { MobileTransactionEntryPage } from './mobile-transaction-entry-page';
export class MobileAccountPage {
constructor(page) {
readonly page: Page;
readonly heading: Locator;
readonly balance: Locator;
readonly noTransactionsMessage: Locator;
readonly searchBox: Locator;
readonly transactionList: Locator;
readonly transactions: Locator;
readonly createTransactionButton: Locator;
constructor(page: Page) {
this.page = page;
this.heading = page.getByRole('heading');
@@ -15,21 +26,25 @@ export class MobileAccountPage {
});
}
async waitFor() {
await this.transactionList.waitFor();
async waitFor(...options: Parameters<Locator['waitFor']>) {
await this.transactionList.waitFor(...options);
}
/**
* Retrieve the balance of the account as a number
*/
async getBalance() {
return parseInt(await this.balance.textContent(), 10);
const balanceText = await this.balance.textContent();
if (!balanceText) {
throw new Error('Failed to get balance.');
}
return parseInt(balanceText, 10);
}
/**
* Search by the given term
*/
async searchByText(term) {
async searchByText(term: string) {
await this.searchBox.fill(term);
}

View File

@@ -1,21 +1,27 @@
import { type Locator, type Page } from '@playwright/test';
import { MobileAccountPage } from './mobile-account-page';
export class MobileAccountsPage {
constructor(page) {
readonly page: Page;
readonly accountList: Locator;
readonly accountListItems: Locator;
constructor(page: Page) {
this.page = page;
this.accountList = this.page.getByLabel('Account list');
this.accountListItems = this.accountList.getByTestId('account-list-item');
}
async waitFor() {
await this.accountList.waitFor();
async waitFor(...options: Parameters<Locator['waitFor']>) {
await this.accountList.waitFor(...options);
}
/**
* Get the name and balance of the nth account
*/
async getNthAccount(idx) {
async getNthAccount(idx: number) {
const accountRow = this.accountListItems.nth(idx);
return {
@@ -27,7 +33,7 @@ export class MobileAccountsPage {
/**
* Click on the n-th account to open it up
*/
async openNthAccount(idx) {
async openNthAccount(idx: number) {
await this.accountListItems.nth(idx).click();
return new MobileAccountPage(this.page);

View File

@@ -1,7 +1,18 @@
import { type Page, type Locator } from '@playwright/test';
export class BalanceMenuModal {
constructor(page, locator) {
this.page = page;
readonly page: Page;
readonly locator: Locator;
readonly heading: Locator;
readonly balanceAmountInput: Locator;
readonly transferToAnotherCategoryButton: Locator;
readonly coverOverspendingButton: Locator;
readonly rolloverOverspendingButton: Locator;
readonly removeOverspendingRolloverButton: Locator;
constructor(locator: Locator) {
this.locator = locator;
this.page = locator.page();
this.heading = locator.getByRole('heading');
this.balanceAmountInput = locator.getByTestId('amount-input');

View File

@@ -1,7 +1,19 @@
import { type Locator, type Page } from '@playwright/test';
export class BudgetMenuModal {
constructor(page, locator) {
this.page = page;
readonly page: Page;
readonly locator: Locator;
readonly heading: Locator;
readonly budgetAmountInput: Locator;
readonly copyLastMonthBudgetButton: Locator;
readonly setTo3MonthAverageButton: Locator;
readonly setTo6MonthAverageButton: Locator;
readonly setToYearlyAverageButton: Locator;
readonly applyBudgetTemplateButton: Locator;
constructor(locator: Locator) {
this.locator = locator;
this.page = locator.page();
this.heading = locator.getByRole('heading');
this.budgetAmountInput = locator.getByTestId('amount-input');
@@ -26,8 +38,8 @@ export class BudgetMenuModal {
await this.heading.getByRole('button', { name: 'Close' }).click();
}
async setBudgetAmount(newAmount) {
await this.budgetAmountInput.fill(String(newAmount));
async setBudgetAmount(newAmount: string) {
await this.budgetAmountInput.fill(newAmount);
await this.budgetAmountInput.blur();
await this.close();
}

View File

@@ -1,3 +1,5 @@
import { type Locator, type Page } from '@playwright/test';
import { MobileAccountPage } from './mobile-account-page';
import { BalanceMenuModal } from './mobile-balance-menu-modal';
import { BudgetMenuModal } from './mobile-budget-menu-modal';
@@ -6,24 +8,47 @@ import { EnvelopeBudgetSummaryModal } from './mobile-envelope-budget-summary-mod
import { TrackingBudgetSummaryModal } from './mobile-tracking-budget-summary-modal';
export class MobileBudgetPage {
MONTH_HEADER_DATE_FORMAT = 'MMMM yy';
readonly MONTH_HEADER_DATE_FORMAT = 'MMMM yy';
constructor(page) {
readonly page: Page;
readonly heading: Locator;
readonly previousMonthButton: Locator;
readonly selectedBudgetMonthButton: Locator;
readonly nextMonthButton: Locator;
readonly budgetPageMenuButton: Locator;
readonly budgetTableHeader: Locator;
readonly toBudgetButton: Locator;
readonly overbudgetedButton: Locator;
readonly savedButton: Locator;
readonly projectedSavingsButton: Locator;
readonly overspentButton: Locator;
readonly budgetedHeaderButton: Locator;
readonly spentHeaderButton: Locator;
readonly budgetTable: Locator;
readonly categoryRows: Locator;
readonly categoryNames: Locator;
readonly categoryGroupRows: Locator;
readonly categoryGroupNames: Locator;
constructor(page: Page) {
this.page = page;
this.#initializePageHeaderLocators(page);
this.#initializeBudgetTableLocators(page);
}
// Page header locators
async determineBudgetType() {
return (await this.#getButtonForEnvelopeBudgetSummary({
throwIfNotFound: false,
})) !== null
? 'Envelope'
: 'Tracking';
}
this.heading = page.getByRole('heading');
this.previousMonthButton = this.heading.getByRole('button', {
name: 'Previous month',
});
this.selectedBudgetMonthButton = this.heading.locator('button[data-month]');
this.nextMonthButton = this.heading.getByRole('button', {
name: 'Next month',
});
this.budgetPageMenuButton = page.getByRole('button', {
name: 'Budget page menu',
});
// Budget table locators
#initializeBudgetTableLocators(page) {
this.budgetTableHeader = page.getByTestId('budget-table-header');
// Envelope budget summary buttons
@@ -69,25 +94,21 @@ export class MobileBudgetPage {
);
}
#initializePageHeaderLocators(page) {
this.heading = page.getByRole('heading');
this.previousMonthButton = this.heading.getByRole('button', {
name: 'Previous month',
});
this.selectedBudgetMonthButton = this.heading.locator('button[data-month]');
this.nextMonthButton = this.heading.getByRole('button', {
name: 'Next month',
});
this.budgetPageMenuButton = page.getByRole('button', {
name: 'Budget page menu',
});
async determineBudgetType() {
return (await this.#getButtonForEnvelopeBudgetSummary({
throwIfNotFound: false,
})) !== null
? 'Envelope'
: 'Tracking';
}
async waitFor(options) {
await this.budgetTable.waitFor(options);
async waitFor(...options: Parameters<Locator['waitFor']>) {
await this.budgetTable.waitFor(...options);
}
async toggleVisibleColumns(maxAttempts = 3) {
async toggleVisibleColumns({
maxAttempts = 3,
}: { maxAttempts?: number } = {}) {
for (let i = 0; i < maxAttempts; i++) {
if (await this.budgetedHeaderButton.isVisible()) {
await this.budgetedHeaderButton.click();
@@ -100,55 +121,72 @@ export class MobileBudgetPage {
await this.page.waitForTimeout(1000);
}
throw new Error('Budgeted/Spent columns could not be located on the page');
throw new Error('Budgeted/Spent columns could not be located on the page.');
}
async getSelectedMonth() {
return await this.heading
const selectedMonth = await this.heading
.locator('[data-month]')
.getAttribute('data-month');
if (!selectedMonth) {
throw new Error('Failed to get the selected month.');
}
return selectedMonth;
}
async openBudgetPageMenu() {
await this.budgetPageMenuButton.click();
}
async getCategoryGroupNameForRow(idx) {
return this.categoryGroupNames.nth(idx).textContent();
async getCategoryGroupNameForRow(idx: number) {
const groupNameText = await this.categoryGroupNames.nth(idx).textContent();
if (!groupNameText) {
throw new Error(`Failed to get category group name for row ${idx}.`);
}
return groupNameText;
}
#getButtonForCategoryGroup(categoryGroupName) {
#getButtonForCategoryGroup(categoryGroupName: string | RegExp) {
return this.categoryGroupRows.getByRole('button', {
name: categoryGroupName,
exact: true,
});
}
async openCategoryGroupMenu(categoryGroupName) {
async openCategoryGroupMenu(categoryGroupName: string | RegExp) {
const categoryGroupButton =
await this.#getButtonForCategoryGroup(categoryGroupName);
await categoryGroupButton.click();
}
async getCategoryNameForRow(idx) {
return this.categoryNames.nth(idx).textContent();
async getCategoryNameForRow(idx: number) {
const categoryNameText = await this.categoryNames.nth(idx).textContent();
if (!categoryNameText) {
throw new Error(`Failed to get category name for row ${idx}.`);
}
return categoryNameText;
}
#getButtonForCategory(categoryName) {
#getButtonForCategory(categoryName: string | RegExp) {
return this.categoryRows.getByRole('button', {
name: categoryName,
exact: true,
});
}
async openCategoryMenu(categoryName) {
async openCategoryMenu(categoryName: string | RegExp) {
const categoryButton = await this.#getButtonForCategory(categoryName);
await categoryButton.click();
return new CategoryMenuModal(this.page, this.page.getByRole('dialog'));
return new CategoryMenuModal(this.page.getByRole('dialog'));
}
async #getButtonForCell(buttonType, categoryName) {
async #getButtonForCell(
buttonType: 'Budgeted' | 'Spent',
categoryName: string,
) {
const buttonSelector =
buttonType === 'Budgeted'
? `Open budget menu for ${categoryName} category`
@@ -168,43 +206,43 @@ export class MobileBudgetPage {
}
throw new Error(
`${buttonType} button for category ${categoryName} could not be located on the page`,
`${buttonType} button for category ${categoryName} could not be located on the page.`,
);
}
async getButtonForBudgeted(categoryName) {
async getButtonForBudgeted(categoryName: string) {
return await this.#getButtonForCell('Budgeted', categoryName);
}
async getButtonForSpent(categoryName) {
async getButtonForSpent(categoryName: string) {
return await this.#getButtonForCell('Spent', categoryName);
}
async openBudgetMenu(categoryName) {
async openBudgetMenu(categoryName: string) {
const budgetedButton = await this.getButtonForBudgeted(categoryName);
await budgetedButton.click();
return new BudgetMenuModal(this.page, this.page.getByRole('dialog'));
return new BudgetMenuModal(this.page.getByRole('dialog'));
}
async openSpentPage(categoryName) {
async openSpentPage(categoryName: string) {
const spentButton = await this.getButtonForSpent(categoryName);
await spentButton.click();
return new MobileAccountPage(this.page);
}
async openBalanceMenu(categoryName) {
async openBalanceMenu(categoryName: string) {
const balanceButton = this.budgetTable.getByRole('button', {
name: `Open balance menu for ${categoryName} category`,
});
if (await balanceButton.isVisible()) {
await balanceButton.click();
return new BalanceMenuModal(this.page, this.page.getByRole('dialog'));
return new BalanceMenuModal(this.page.getByRole('dialog'));
} else {
throw new Error(
`Balance button for category ${categoryName} not found or not visible`,
`Balance button for category ${categoryName} not found or not visible.`,
);
}
}
@@ -213,7 +251,11 @@ export class MobileBudgetPage {
currentMonth,
errorMessage,
maxAttempts = 3,
} = {}) {
}: {
currentMonth: string;
errorMessage: string;
maxAttempts: number;
}) {
for (let attempt = 0; attempt < maxAttempts; attempt++) {
const newMonth = await this.getSelectedMonth();
if (newMonth !== currentMonth) {
@@ -225,7 +267,7 @@ export class MobileBudgetPage {
throw new Error(errorMessage);
}
async goToPreviousMonth({ maxAttempts = 3 } = {}) {
async goToPreviousMonth({ maxAttempts = 3 }: { maxAttempts?: number } = {}) {
const currentMonth = await this.getSelectedMonth();
await this.previousMonthButton.click();
@@ -234,7 +276,7 @@ export class MobileBudgetPage {
currentMonth,
maxAttempts,
errorMessage:
'Failed to navigate to the previous month after maximum attempts',
'Failed to navigate to the previous month after maximum attempts.',
});
}
@@ -242,7 +284,7 @@ export class MobileBudgetPage {
await this.selectedBudgetMonthButton.click();
}
async goToNextMonth({ maxAttempts = 3 } = {}) {
async goToNextMonth({ maxAttempts = 3 }: { maxAttempts?: number } = {}) {
const currentMonth = await this.getSelectedMonth();
await this.nextMonthButton.click();
@@ -251,11 +293,13 @@ export class MobileBudgetPage {
currentMonth,
maxAttempts,
errorMessage:
'Failed to navigate to the next month after maximum attempts',
'Failed to navigate to the next month after maximum attempts.',
});
}
async #getButtonForEnvelopeBudgetSummary({ throwIfNotFound = true } = {}) {
async #getButtonForEnvelopeBudgetSummary({
throwIfNotFound = true,
}: { throwIfNotFound?: boolean } = {}) {
if (await this.toBudgetButton.isVisible()) {
return this.toBudgetButton;
}
@@ -269,21 +313,23 @@ export class MobileBudgetPage {
}
throw new Error(
'Neither “To Budget” nor “Overbudgeted” button could be located on the page',
'Neither “To Budget” nor “Overbudgeted” button could be located on the page.',
);
}
async openEnvelopeBudgetSummary() {
const budgetSummaryButton = await this.#getButtonForEnvelopeBudgetSummary();
if (!budgetSummaryButton) {
throw new Error('Envelope budget summary button not found.');
}
await budgetSummaryButton.click();
return new EnvelopeBudgetSummaryModal(
this.page,
this.page.getByRole('dialog'),
);
return new EnvelopeBudgetSummaryModal(this.page.getByRole('dialog'));
}
async #getButtonForTrackingBudgetSummary({ throwIfNotFound = true } = {}) {
async #getButtonForTrackingBudgetSummary({
throwIfNotFound = true,
}: { throwIfNotFound?: boolean } = {}) {
if (await this.savedButton.isVisible()) {
return this.savedButton;
}
@@ -301,17 +347,17 @@ export class MobileBudgetPage {
}
throw new Error(
'None of “Saved”, “Projected savings”, or “Overspent” buttons could be located on the page',
'None of “Saved”, “Projected savings”, or “Overspent” buttons could be located on the page.',
);
}
async openTrackingBudgetSummary() {
const budgetSummaryButton = await this.#getButtonForTrackingBudgetSummary();
if (!budgetSummaryButton) {
throw new Error('Tracking budget summary button not found.');
}
await budgetSummaryButton.click();
return new TrackingBudgetSummaryModal(
this.page,
this.page.getByRole('dialog'),
);
return new TrackingBudgetSummaryModal(this.page.getByRole('dialog'));
}
}

View File

@@ -1,9 +1,17 @@
import { type Locator, type Page } from '@playwright/test';
import { EditNotesModal } from './mobile-edit-notes-modal';
export class CategoryMenuModal {
constructor(page, locator) {
this.page = page;
readonly page: Page;
readonly locator: Locator;
readonly heading: Locator;
readonly budgetAmountInput: Locator;
readonly editNotesButton: Locator;
constructor(locator: Locator) {
this.locator = locator;
this.page = locator.page();
this.heading = locator.getByRole('heading');
this.budgetAmountInput = locator.getByTestId('amount-input');
@@ -17,6 +25,6 @@ export class CategoryMenuModal {
async editNotes() {
await this.editNotesButton.click();
return new EditNotesModal(this.page, this.page.getByRole('dialog'));
return new EditNotesModal(this.page.getByRole('dialog'));
}
}

View File

@@ -1,7 +1,15 @@
import { type Locator, type Page } from '@playwright/test';
export class EditNotesModal {
constructor(page, locator) {
this.page = page;
readonly page: Page;
readonly locator: Locator;
readonly heading: Locator;
readonly textArea: Locator;
readonly saveNotesButton: Locator;
constructor(locator: Locator) {
this.locator = locator;
this.page = locator.page();
this.heading = locator.getByRole('heading');
this.textArea = locator.getByRole('textbox');
@@ -12,7 +20,7 @@ export class EditNotesModal {
await this.heading.getByRole('button', { name: 'Close' }).click();
}
async updateNotes(notes) {
async updateNotes(notes: string) {
await this.textArea.fill(notes);
await this.saveNotesButton.click();
}

View File

@@ -1,7 +1,13 @@
import { type Locator, type Page } from '@playwright/test';
export class EnvelopeBudgetSummaryModal {
constructor(page, locator) {
this.page = page;
readonly page: Page;
readonly locator: Locator;
readonly heading: Locator;
constructor(locator: Locator) {
this.locator = locator;
this.page = locator.page();
this.heading = locator.getByRole('heading');
}

View File

@@ -1,3 +1,5 @@
import { type Locator, type Page } from '@playwright/test';
import { MobileAccountPage } from './mobile-account-page';
import { MobileAccountsPage } from './mobile-accounts-page';
import { MobileBudgetPage } from './mobile-budget-page';
@@ -5,8 +7,30 @@ import { MobileReportsPage } from './mobile-reports-page';
import { MobileTransactionEntryPage } from './mobile-transaction-entry-page';
import { SettingsPage } from './settings-page';
const NAVBAR_ROWS = 3;
const NAV_LINKS_HIDDEN_BY_DEFAULT = [
'Reports',
'Schedules',
'Payees',
'Rules',
'Settings',
];
const ROUTES_BY_PAGE = {
Budget: '/budget',
Accounts: '/accounts',
Transaction: '/transactions/new',
Reports: '/reports',
Settings: '/settings',
};
export class MobileNavigation {
constructor(page) {
readonly page: Page;
readonly heading: Locator;
readonly navbar: Locator;
readonly mainContentSelector: string;
readonly navbarSelector: string;
constructor(page: Page) {
this.page = page;
this.heading = page.getByRole('heading');
this.navbar = page.getByRole('navigation');
@@ -14,31 +38,23 @@ export class MobileNavigation {
this.navbarSelector = '[role=navigation]';
}
static #NAVBAR_ROWS = 3;
static #NAV_LINKS_HIDDEN_BY_DEFAULT = [
'Reports',
'Schedules',
'Payees',
'Rules',
'Settings',
];
static #ROUTES_BY_PAGE = {
Budget: '/budget',
Accounts: '/accounts',
Transactions: '/transactions/new',
Reports: '/reports',
Settings: '/settings',
};
async dragNavbarUp() {
const mainContentBoundingBox = await this.page
.locator(this.mainContentSelector)
.boundingBox();
if (!mainContentBoundingBox) {
throw new Error('Unable to get bounding box of main content.');
}
const navbarBoundingBox = await this.page
.locator(this.navbarSelector)
.boundingBox();
if (!navbarBoundingBox) {
throw new Error('Unable to get bounding box of navbar.');
}
await this.page.dragAndDrop(this.navbarSelector, this.mainContentSelector, {
sourcePosition: { x: 1, y: 0 },
targetPosition: {
@@ -53,39 +69,47 @@ export class MobileNavigation {
.locator(this.navbarSelector)
.boundingBox();
if (!boundingBox) {
throw new Error('Unable to get bounding box of navbar.');
}
await this.page.dragAndDrop(this.navbarSelector, this.navbarSelector, {
sourcePosition: { x: 1, y: 0 },
targetPosition: {
x: 1,
// Only scroll until bottom of screen i.e. bottom of first navbar row.
y: boundingBox.height / MobileNavigation.#NAVBAR_ROWS,
y: boundingBox.height / NAVBAR_ROWS,
},
});
}
async hasNavbarState(...states) {
async hasNavbarState(...states: string[]) {
if ((await this.navbar.count()) === 0) {
// No navbar on page.
return false;
}
const dataNavbarState = await this.navbar.getAttribute('data-navbar-state');
if (!dataNavbarState) {
throw new Error('Navbar does not have data-navbar-state attribute.');
}
return states.includes(dataNavbarState);
}
async navigateToPage(pageName, pageModelFactory) {
async navigateToPage<T extends { waitFor: Locator['waitFor'] }>(
pageName: keyof typeof ROUTES_BY_PAGE,
pageModelFactory: () => T,
): Promise<T> {
const pageInstance = pageModelFactory();
if (this.page.url().endsWith(MobileNavigation.#ROUTES_BY_PAGE[pageName])) {
if (this.page.url().endsWith(ROUTES_BY_PAGE[pageName])) {
// Already on the page.
return pageInstance;
}
await this.navbar.waitFor();
const navbarStates = MobileNavigation.#NAV_LINKS_HIDDEN_BY_DEFAULT.includes(
pageName,
)
const navbarStates = NAV_LINKS_HIDDEN_BY_DEFAULT.includes(pageName)
? ['default', 'hidden']
: ['hidden'];

View File

@@ -1,11 +0,0 @@
export class MobileReportsPage {
constructor(page) {
this.page = page;
this.overview = page.getByTestId('reports-overview');
}
async waitFor(options) {
await this.overview.waitFor(options);
}
}

View File

@@ -0,0 +1,16 @@
import { type Locator, type Page } from '@playwright/test';
export class MobileReportsPage {
readonly page: Page;
readonly overview: Locator;
constructor(page: Page) {
this.page = page;
this.overview = page.getByTestId('reports-overview');
}
async waitFor(...options: Parameters<Locator['waitFor']>) {
await this.overview.waitFor(...options);
}
}

View File

@@ -1,7 +1,13 @@
import { type Locator, type Page } from '@playwright/test';
export class TrackingBudgetSummaryModal {
constructor(page, locator) {
this.page = page;
readonly page: Page;
readonly locator: Locator;
readonly heading: Locator;
constructor(locator: Locator) {
this.locator = locator;
this.page = locator.page();
this.heading = locator.getByRole('heading');
}

View File

@@ -1,7 +1,16 @@
import { type Locator, type Page } from '@playwright/test';
import { MobileAccountPage } from './mobile-account-page';
export class MobileTransactionEntryPage {
constructor(page) {
readonly page: Page;
readonly header: Locator;
readonly amountField: Locator;
readonly transactionForm: Locator;
readonly footer: Locator;
readonly addTransactionButton: Locator;
constructor(page: Page) {
this.page = page;
this.header = page.getByRole('heading');
this.transactionForm = page.getByTestId('transaction-form');
@@ -12,11 +21,11 @@ export class MobileTransactionEntryPage {
});
}
async waitFor(options) {
await this.transactionForm.waitFor(options);
async waitFor(...options: Parameters<Locator['waitFor']>) {
await this.transactionForm.waitFor(...options);
}
async fillField(fieldLocator, content) {
async fillField(fieldLocator: Locator, content: string) {
await fieldLocator.click();
await this.page.locator('css=[role=combobox] input').fill(content);
await this.page.keyboard.press('Enter');

View File

@@ -1,15 +1,25 @@
import { type Page } from '@playwright/test';
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';
type AccountEntry = {
name: string;
balance: number;
offBudget: boolean;
};
export class Navigation {
constructor(page) {
readonly page: Page;
constructor(page: Page) {
this.page = page;
}
async goToAccountPage(accountName) {
async goToAccountPage(accountName: string) {
await this.page
.getByRole('link', { name: new RegExp(`^${accountName}`) })
.click();
@@ -55,7 +65,7 @@ export class Navigation {
return new SettingsPage(this.page);
}
async createAccount(data) {
async createAccount(data: AccountEntry) {
await this.page.getByRole('button', { name: 'Add account' }).click();
await this.page
.getByRole('button', { name: 'Create a local account' })

View File

@@ -1,7 +1,12 @@
import { type Locator, type Page } from '@playwright/test';
import { CustomReportPage } from './custom-report-page';
export class ReportsPage {
constructor(page) {
readonly page: Page;
readonly pageContent: Locator;
constructor(page: Page) {
this.page = page;
this.pageContent = page.getByTestId('reports-page');
}

View File

@@ -1,5 +1,35 @@
import { type Locator, type Page } from '@playwright/test';
type ConditionsEntry = {
field: string;
op: string;
value: string;
};
type ActionsEntry = {
field: string;
op?: string;
value: string;
};
type SplitsEntry = {
field: string;
op?: string;
value?: string;
};
type RuleEntry = {
conditionsOp?: string | RegExp;
conditions?: ConditionsEntry[];
actions?: ActionsEntry[];
splits?: Array<SplitsEntry[]>;
};
export class RulesPage {
constructor(page) {
readonly page: Page;
readonly searchBox: Locator;
constructor(page: Page) {
this.page = page;
this.searchBox = page.getByPlaceholder('Filter rules...');
}
@@ -7,7 +37,7 @@ export class RulesPage {
/**
* Create a new rule
*/
async createRule(data) {
async createRule(data: RuleEntry) {
await this.page
.getByRole('button', {
name: 'Create new rule',
@@ -23,7 +53,7 @@ export class RulesPage {
* Retrieve the data for the nth-rule.
* 0-based index
*/
getNthRule(index) {
getNthRule(index: number) {
const row = this.page.getByTestId('table').getByTestId('row').nth(index);
return {
@@ -32,11 +62,11 @@ export class RulesPage {
};
}
async searchFor(text) {
async searchFor(text: string) {
await this.searchBox.fill(text);
}
async _fillRuleFields(data) {
async _fillRuleFields(data: RuleEntry) {
if (data.conditionsOp) {
await this.page
.getByTestId('conditions-op')
@@ -76,9 +106,13 @@ export class RulesPage {
}
}
async _fillEditorFields(data, rootElement, fieldFirst = false) {
for (const idx in data) {
const { field, op, value } = data[idx];
async _fillEditorFields(
data: Array<ConditionsEntry | ActionsEntry | SplitsEntry>,
rootElement: Locator,
fieldFirst = false,
) {
for (const [idx, entry] of data.entries()) {
const { field, op, value } = entry;
const row = rootElement.getByTestId('editor-row').nth(idx);

View File

@@ -1,5 +1,17 @@
import { type Locator, type Page } from '@playwright/test';
type ScheduleEntry = {
payee?: string;
account?: string;
amount?: number;
};
export class SchedulesPage {
constructor(page) {
readonly page: Page;
readonly addNewScheduleButton: Locator;
readonly schedulesTableRow: Locator;
constructor(page: Page) {
this.page = page;
this.addNewScheduleButton = this.page.getByRole('button', {
@@ -11,7 +23,7 @@ export class SchedulesPage {
/**
* Add a new schedule
*/
async addNewSchedule(data) {
async addNewSchedule(data: ScheduleEntry) {
await this.addNewScheduleButton.click();
await this._fillScheduleFields(data);
@@ -23,7 +35,7 @@ export class SchedulesPage {
* Retrieve the row element for the nth-schedule.
* 0-based index
*/
getNthScheduleRow(index) {
getNthScheduleRow(index: number) {
return this.schedulesTableRow.nth(index);
}
@@ -31,7 +43,7 @@ export class SchedulesPage {
* Retrieve the data for the nth-schedule.
* 0-based index
*/
getNthSchedule(index) {
getNthSchedule(index: number) {
const row = this.getNthScheduleRow(index);
return {
@@ -47,7 +59,7 @@ export class SchedulesPage {
* Create a transaction for the nth-schedule.
* 0-based index
*/
async postNthSchedule(index) {
async postNthSchedule(index: number) {
await this._performNthAction(index, 'Post transaction today');
await this.page.waitForTimeout(1000);
}
@@ -56,12 +68,12 @@ export class SchedulesPage {
* Complete the nth-schedule.
* 0-based index
*/
async completeNthSchedule(index) {
async completeNthSchedule(index: number) {
await this._performNthAction(index, 'Complete');
await this.page.waitForTimeout(1000);
}
async _performNthAction(index, actionName) {
async _performNthAction(index: number, actionName: string | RegExp) {
const row = this.getNthScheduleRow(index);
const actions = row.getByTestId('actions');
@@ -69,7 +81,7 @@ export class SchedulesPage {
await this.page.getByRole('button', { name: actionName }).click();
}
async _fillScheduleFields(data) {
async _fillScheduleFields(data: ScheduleEntry) {
if (data.payee) {
await this.page.getByRole('textbox', { name: 'Payee' }).fill(data.payee);
await this.page.keyboard.press('Enter');

View File

@@ -1,5 +1,14 @@
import { type Locator, type Page } from '@playwright/test';
export class SettingsPage {
constructor(page) {
readonly page: Page;
readonly settings: Locator;
readonly exportDataButton: Locator;
readonly switchBudgetTypeButton: Locator;
readonly advancedSettingsButton: Locator;
readonly experimentalSettingsButton: Locator;
constructor(page: Page) {
this.page = page;
this.settings = page.getByTestId('settings');
this.exportDataButton = this.settings.getByRole('button', {
@@ -15,24 +24,24 @@ export class SettingsPage {
);
}
async waitFor(options) {
await this.settings.waitFor(options);
async waitFor(...options: Parameters<Locator['waitFor']>) {
await this.settings.waitFor(...options);
}
async exportData() {
await this.exportDataButton.click();
}
async useBudgetType(budgetType) {
async useBudgetType(budgetType: 'Envelope' | 'Tracking') {
await this.switchBudgetTypeButton.waitFor();
const buttonText = await this.switchBudgetTypeButton.textContent();
if (buttonText.includes(budgetType.toLowerCase())) {
if (buttonText?.includes(budgetType.toLowerCase())) {
await this.switchBudgetTypeButton.click();
}
}
async enableExperimentalFeature(featureName) {
async enableExperimentalFeature(featureName: string) {
if (await this.advancedSettingsButton.isVisible()) {
await this.advancedSettingsButton.click();
}

View File

@@ -40,7 +40,7 @@ test.describe('Transactions', () => {
test('by date', async () => {
const filterTooltip = await accountPage.filterBy('Date');
await expect(filterTooltip.page).toMatchThemeScreenshots();
await expect(filterTooltip.locator).toMatchThemeScreenshots();
// Open datepicker
await page.keyboard.press('Space');
@@ -58,7 +58,7 @@ test.describe('Transactions', () => {
test('by category', async () => {
const filterTooltip = await accountPage.filterBy('Category');
await expect(filterTooltip.page).toMatchThemeScreenshots();
await expect(filterTooltip.locator).toMatchThemeScreenshots();
// Type in the autocomplete box
const autocomplete = page.getByTestId('autocomplete');

View File

@@ -0,0 +1,6 @@
---
category: Maintenance
authors: [joel-jeremy]
---
Convert playwright page models to TypeScript.