Skip to content

Commit

Permalink
feat(e2e): Add test.step decorator to all page object methods (#16148)
Browse files Browse the repository at this point in the history
* feat(e2e): Add test.step decorator to all page object methods

* feat(e2e): guideline entry about step decorators
  • Loading branch information
Vere-Grey authored Jan 3, 2025
1 parent 17cd6f1 commit a2ffd0a
Show file tree
Hide file tree
Showing 14 changed files with 116 additions and 22 deletions.
15 changes: 12 additions & 3 deletions docs/tests/e2e-playwright-contribution-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,25 +3,29 @@
## Page Actions

We use `page actions` pattern to encapsulate all UI elements and operations.
Furthermore, every method in the page object class should have `step` decorator. This decorator wraps the method into playwright `test.step()`. This vastly improves readability of test report.

Example:

```typescript
import { step } from '../common';

export class WalletActions {
private readonly window: Page;
readonly searchInput: Locator;
readonly accountChevron: Locator;

constructor(window: Page) {
this.window = window;
constructor(private readonly page: Page) {
this.searchInput = this.window.getByTestId('@wallet/accounts/search-icon');
this.accountChevron = this.window.getByTestId('@account-menu/arrow');
}

@step()
async filterTransactions(transaction: string) {
await this.searchInput.click();
await this.searchInput.fill(transaction, { force: true });
}

@step()
async expandAllAccountsInMenu() {
for (const chevron of await this.accountChevron.all()) {
await chevron.click();
Expand All @@ -30,8 +34,11 @@ export class WalletActions {
```
❌ Never pass `Page` instance as a method argument.
✅ Always create a construtor to pass the `Page` instance to the page action.
✅ Always add an descriptor `@step()` before every `Page` object method.
## Fixtures
To further improve test readability we want to use fixtures to inject our `page actions` into the tests.
Expand Down Expand Up @@ -99,11 +106,13 @@ export class SuiteGuide {
);
}

@step()
async openPanel() {
await this.guideButton.click();
await expect(this.guidePanel).toBeVisible();
}

@step()
async selectBugLocation(location: FeedbackCategory) {
await this.bugLocationDropdown.click();
await this.bugLocationDropdownOption(location).click();
Expand Down
7 changes: 5 additions & 2 deletions packages/suite-desktop-core/e2e/support/analytics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import { SuiteAnalyticsEvent } from '@trezor/suite-analytics';
import { Requests, EventPayload } from '@trezor/suite-web/e2e/support/types';
import { urlSearchParams } from '@trezor/suite/src/utils/suite/metadata';

import { step } from './common';

export class AnalyticsFixture {
private page: Page;
requests: Requests = [];
Expand All @@ -13,17 +15,18 @@ export class AnalyticsFixture {
}

//TODO: #15811 To be refactored
findAnalyticsEventByType = <T extends SuiteAnalyticsEvent>(eventType: T['type']) => {
findAnalyticsEventByType<T extends SuiteAnalyticsEvent>(eventType: T['type']) {
const event = this.requests.find(req => req.c_type === eventType) as EventPayload<T>;

if (!event) {
throw new Error(`Event with type ${eventType} not found.`);
}

return event;
};
}

//TODO: #15811 To be refactored
@step()
async interceptAnalytics() {
await this.page.route('**://data.trezor.io/suite/log/**', route => {
const url = route.request().url();
Expand Down
16 changes: 15 additions & 1 deletion packages/suite-desktop-core/e2e/support/common.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/* eslint-disable no-console */

import { _electron as electron, TestInfo } from '@playwright/test';
import test, { _electron as electron, TestInfo } from '@playwright/test';
import path from 'path';
import { readdirSync, removeSync } from 'fs-extra';

Expand Down Expand Up @@ -133,3 +133,17 @@ export const getElectronVideoPath = (videoFolder: string) => {

return path.join(videoFolder, videoFilenames[0]);
};

export function step(stepName?: string) {
/* eslint-disable @typescript-eslint/no-unsafe-function-type */
return function decorator(target: Function, context: ClassMethodDecoratorContext) {
return function replacementMethod(this: any, ...args: any) {
const name = stepName || `${this.constructor.name + '.' + (context.name as string)}`;

return test.step(name, async () => {
return await target.call(this, ...args);
});
};
};
/* eslint-enable @typescript-eslint/no-unsafe-function-type */
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { Locator, Page } from '@playwright/test';

import { step } from '../common';

export class AnalyticsActions {
readonly heading: Locator;
readonly continueButton: Locator;
Expand All @@ -9,6 +11,7 @@ export class AnalyticsActions {
this.heading = page.getByTestId('@analytics/consent/heading');
}

@step()
async passThroughAnalytics() {
await this.continueButton.click();
await this.continueButton.click();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { Locator, Page, expect } from '@playwright/test';
import { TrezorUserEnvLink } from '@trezor/trezor-user-env-link';

import { DevicePromptActions } from './devicePromptActions';
import { step } from '../common';

export class BackupActions {
readonly backupStartButton: Locator;
Expand All @@ -24,6 +25,7 @@ export class BackupActions {
this.backupCloseButton = page.getByTestId('@backup/close-button');
}

@step()
async passThroughShamirBackup(shares: number, threshold: number) {
// Backup button should be disabled until all checkboxes are checked
await expect(this.backupStartButton).toBeDisabled();
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { Locator, Page, expect } from '@playwright/test';

import { step } from '../common';

export class DashboardActions {
readonly dashboardMenuButton: Locator;
readonly discoveryHeader: Locator;
Expand Down Expand Up @@ -32,28 +34,33 @@ export class DashboardActions {
this.portfolioFiatAmount = this.page.getByTestId('@dashboard/portfolio/fiat-amount');
}

@step()
async navigateTo() {
await this.dashboardMenuButton.click();
await expect(this.discoveryHeader).toBeVisible();
}

@step()
async discoveryShouldFinish() {
await this.discoveryBar.waitFor({ state: 'attached', timeout: 10_000 });
await this.discoveryBar.waitFor({ state: 'detached', timeout: 120_000 });
await expect(this.dashboardGraph).toBeVisible();
}

@step()
async openDeviceSwitcher() {
await this.deviceSwitchingOpenButton.click();
await expect(this.modal).toBeVisible();
}

@step()
async ejectWallet(walletIndex: number = 0) {
await this.walletAtIndexEjectButton(walletIndex).click();
await this.confirmDeviceEjectButton.click();
await this.walletAtIndex(walletIndex).waitFor({ state: 'detached' });
}

@step()
async addStandardWallet() {
await this.addStandardWalletButton.click();
await this.modal.waitFor({ state: 'detached' });
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { Locator, Page, expect } from '@playwright/test';

import { step } from '../common';

export class DevicePromptActions {
private readonly confirmOnDevicePrompt: Locator;
private readonly connectDevicePrompt: Locator;
Expand All @@ -11,10 +13,12 @@ export class DevicePromptActions {
this.modal = page.getByTestId('@modal');
}

@step()
async confirmOnDevicePromptIsShown() {
await expect(this.confirmOnDevicePrompt).toBeVisible();
}

@step()
async connectDevicePromptIsShown() {
await expect(this.connectDevicePrompt).toBeVisible();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import { TrezorUserEnvLink } from '@trezor/trezor-user-env-link';
import { FiatCurrencyCode } from '@suite-common/suite-config';
import regional from '@trezor/suite/src/constants/wallet/coinmarket/regional';

import { step } from '../common';

const getCountryLabel = (country: string) => {
const labelWithFlag = regional.countriesMap.get(country);
if (!labelWithFlag) {
Expand Down Expand Up @@ -88,14 +90,16 @@ export class MarketActions {
this.exchangeFeeDetails = this.page.getByTestId('@wallet/fee-details');
}

waitForOffersSyncToFinish = async () => {
@step()
async waitForOffersSyncToFinish() {
await expect(this.offerSpinner).toBeHidden({ timeout: 30000 });
//Even though the offer sync is finished, the best offer might not be displayed correctly yet and show 0 BTC
await expect(this.bestOfferAmount).not.toHaveText('0 BTC');
await expect(this.buyBestOfferButton).toBeEnabled();
};
}

selectCountryOfResidence = async (countryCode: string) => {
@step()
async selectCountryOfResidence(countryCode: string) {
const countryLabel = getCountryLabel(countryCode);
const currentCountry = await this.countryOfResidenceDropdown.textContent();
if (currentCountry === countryLabel) {
Expand All @@ -104,41 +108,45 @@ export class MarketActions {
await this.countryOfResidenceDropdown.click();
await this.countryOfResidenceDropdown.getByRole('combobox').fill(countryLabel);
await this.countryOfResidenceOption(countryCode).click();
};
}

selectFiatCurrency = async (currencyCode: FiatCurrencyCode) => {
@step()
async selectFiatCurrency(currencyCode: FiatCurrencyCode) {
const currentCurrency = await this.youPayCurrencyDropdown.textContent();
if (currentCurrency === currencyCode.toUpperCase()) {
return;
}
await this.youPayCurrencyDropdown.click();
await this.youPayCurrencyOption(currencyCode).click();
};
}

setYouPayAmount = async (
@step()
async setYouPayAmount(
amount: string,
currency: FiatCurrencyCode = 'czk',
country: string = 'CZ',
) => {
//Warning: the field is initialized empty and gets default value after the first offer sync
) {
// Warning: the field is initialized empty and gets default value after the first offer sync
await expect(this.youPayInput).not.toHaveValue('');
await this.selectCountryOfResidence(country);
await this.selectFiatCurrency(currency);
await this.youPayInput.fill(amount);
//Warning: Bug #16054, as a workaround we wait for offer sync after setting the amount
// Warning: Bug #16054, as a workaround we wait for offer sync after setting the amount
await this.waitForOffersSyncToFinish();
};
}

confirmTrade = async () => {
@step()
async confirmTrade() {
await expect(this.modal).toBeVisible();
await this.buyTermsConfirmButton.click();
await this.confirmOnTrezorButton.click();
await expect(this.confirmOnDevicePrompt).toBeVisible();
await TrezorUserEnvLink.pressYes();
await expect(this.confirmOnDevicePrompt).not.toBeVisible();
};
}

readBestOfferValues = async () => {
@step()
async readBestOfferValues() {
await expect(this.bestOfferAmount).not.toHaveText('0 BTC');
const amount = await this.bestOfferAmount.textContent();
const provider = await this.bestOfferProvider.textContent();
Expand All @@ -149,5 +157,5 @@ export class MarketActions {
}

return { amount, provider };
};
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { Model, TrezorUserEnvLink } from '@trezor/trezor-user-env-link';
import { SUITE as SuiteActions } from '@trezor/suite/src/actions/suite/constants';

import { AnalyticsActions } from './analyticsActions';
import { isWebProject } from '../common';
import { isWebProject, step } from '../common';

export class OnboardingActions {
readonly welcomeTitle: Locator;
Expand Down Expand Up @@ -58,6 +58,7 @@ export class OnboardingActions {
this.finalTitle = this.page.getByTestId('@onboarding/final');
}

@step()
async optionallyDismissFwHashCheckError() {
await expect(this.welcomeTitle).toBeVisible({ timeout: 10000 });
// dismisses the error modal only if it appears (handle it async in parallel, not necessary to block the rest of the flow)
Expand All @@ -66,6 +67,7 @@ export class OnboardingActions {
.then(dismissFwHashCheckButton => dismissFwHashCheckButton?.click());
}

@step()
async completeOnboarding({ enableViewOnly = false } = {}) {
await this.disableFirmwareHashCheck();
await this.optionallyDismissFwHashCheckError();
Expand All @@ -84,6 +86,7 @@ export class OnboardingActions {
await this.viewOnlyTooltipGotItButton.click();
}

@step()
async disableFirmwareHashCheck() {
// Desktop starts with already disabled firmware hash check. Web needs to disable it.
if (!isWebProject(this.testInfo)) {
Expand All @@ -104,11 +107,13 @@ export class OnboardingActions {
}, SuiteActions);
}

@step()
async skipFirmware() {
await this.skipFirmwareButton.click();
await this.skipConfirmButton.click();
}

@step()
async skipPin() {
await this.skipPinButton.click();
await this.skipConfirmButton.click();
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { Locator, Page } from '@playwright/test';

import { step } from '../common';

export class RecoveryActions {
readonly selectBasicRecoveryButton: Locator;
readonly userUnderstandsCheckbox: Locator;
Expand All @@ -13,10 +15,12 @@ export class RecoveryActions {
this.successTitle = page.getByTestId('@recovery/success-title');
}

@step()
async selectWordCount(number: 12 | 18 | 24) {
await this.page.getByTestId(`@recover/select-count/${number}`).click();
}

@step()
async initDryCheck(type: 'basic' | 'advanced', numberOfWords: 12 | 18 | 24) {
await this.userUnderstandsCheckbox.click();
await this.startButton.click();
Expand Down
Loading

0 comments on commit a2ffd0a

Please sign in to comment.