Files
trezor-suite/suite/e2e/support/common.ts
2026-01-29 15:36:31 +01:00

223 lines
7.7 KiB
TypeScript

import test, { Locator, Page, TestInfo } from '@playwright/test';
import { isEqual, omit } from 'lodash';
import { readdirSync } from 'node:fs';
import path from 'node:path';
import { validJws } from '@suite-common/message-system/src/__fixtures__/messageSystemActions';
import { TradingCountryCode, regional } from '@suite-common/trading';
import { getAccountDecimals, localizeNumber } from '@suite-common/wallet-utils';
import { Model } from '@trezor/trezor-user-env-link';
import { BigNumber, splitStringEveryNCharacters } from '@trezor/utils';
import { PlaywrightTarget } from './testExtends/suiteTestOptions';
import { PercentageOfBalanceParams } from './types';
import releases from '../../../submodules/trezor-common/releases.json';
export const isDesktopProject = (target: PlaywrightTarget) => target === PlaywrightTarget.Desktop;
export const isWebProject = (target: PlaywrightTarget) => target === PlaywrightTarget.Web;
export const getUrl = (testInfo: TestInfo, target: PlaywrightTarget) => {
const electronApiURL = 'file:///';
const apiURL = isDesktopProject(target) ? electronApiURL : testInfo.project.use.baseURL;
if (!apiURL) {
throw new Error('apiURL is not defined');
}
return apiURL;
};
// Wraps whole page object methods with test.step
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)}`;
const params = args.map((arg: any) => JSON.stringify(arg)).join(', '); // Serialize arguments
return test.step(`${name}(${params})`, async () => await target.call(this, ...args));
};
};
/* eslint-enable @typescript-eslint/no-unsafe-function-type */
}
export const isEqualWithOmit = (param: { object1: any; object2: any; mask: string[] }) =>
isEqual(omit(param.object1, param.mask), omit(param.object2, param.mask));
export const formatAddress = (address: string) => splitStringEveryNCharacters(address, 4).join(' ');
const REGEXP_ADDRESS_CHUNKS = /((?:\S+\s){3}\S+)\s/g;
const formatEvmAddress = (address: string) => {
if (!address.startsWith('0x')) {
return formatAddress(address);
}
const spacedBody = splitStringEveryNCharacters(address.slice(2), 4).join(' ');
return spacedBody ? `0x${spacedBody}` : address;
};
export const formatAddressWithNewlines = (address: string) =>
formatEvmAddress(address).replace(REGEXP_ADDRESS_CHUNKS, '$1\n');
// This function is used to override automatic fixtures that we want to skip in specific tests.
/* eslint-disable react-hooks/rules-of-hooks */
export async function skipFixture({}, use: (r: void) => Promise<void>) {
await use();
}
/* eslint-enable react-hooks/rules-of-hooks */
export const getVideoPath = (videoFolder: string): string | false => {
const videoFilenames = readdirSync(videoFolder).filter(file => file.endsWith('.webm'));
if (videoFilenames.length < 1) {
console.error(
`Test teardown error: No test video files found in the output directory: ${videoFolder}`,
);
return false;
}
if (videoFilenames.length > 1) {
console.warn(
`Test teardown warning: Multiple test video files found in the output directory: ${videoFilenames}.\nUsing the first one: ${videoFilenames[0]}`,
);
}
return path.join(videoFolder, videoFilenames[0]);
};
export const findLatestVersionForModel = (model: Model): string => {
const firmwareVersions = releases.firmware;
const versions = Object.keys(firmwareVersions);
// Sort versions in descending order
versions.sort((a, b) => (a > b ? -1 : 1));
// Find the latest version supporting our model
for (const version of versions) {
if (firmwareVersions[version as keyof typeof firmwareVersions].includes(model)) {
return version;
}
}
throw new Error(`No firmware version found for model ${model}`);
};
export const getCountryLabel = (country: TradingCountryCode) => {
const countryOption = regional.countriesOptionsMap.get(country);
if (!countryOption) {
throw new Error(`Country ${country} not found in the countries map`);
}
return countryOption.label.substring(countryOption.label.indexOf(' ') + 1);
};
export const calculatePercentageOfBalance = (params: PercentageOfBalanceParams) => {
if (params.balance === null) {
throw new Error('Account balance is null');
}
const fraction = (parseFloat(params.balance) * params.percentage) / 100;
const maxDecimals = getAccountDecimals(params.symbol);
return localizeNumber(fraction, 'en-US', 0, maxDecimals);
};
export const countDecimalPlaces = (value: string | number) => {
if (typeof value === 'string' && isNaN(Number(value))) {
throw new Error('Value is not a valid number string');
}
return value.toString().split('.')[1].length || 0;
};
export const getBigNumberFromBalance = async (locator: Locator) => {
let originalBalanceText = await locator.textContent();
if (!originalBalanceText) {
throw new Error('Balance text content is empty');
}
const hasEllipsis = originalBalanceText?.includes('…');
if (hasEllipsis) {
originalBalanceText = originalBalanceText.slice(0, -1);
}
const originalBalance = BigNumber(originalBalanceText);
return { originalBalance, hasEllipsis };
};
/**
* Mocks remote message-system with an empty JWS config signed by develop key.
*/
export const mockRemoteMessageSystem = async (page: Page): Promise<void> =>
await page.route('**/config.v1.jws', async route => {
await route.fulfill({ status: 200, body: validJws });
});
export const normalizeWhitespace = (obj: any): any => {
if (typeof obj === 'string') {
// Normalize whitespace: \u00A0 is non-breaking space, \u0020 is regular space.
return obj.replace(/[\u00A0\u0020]/g, ' ');
}
if (Array.isArray(obj)) {
return obj.map(normalizeWhitespace);
}
if (obj && typeof obj === 'object') {
const result: any = {};
for (const [key, value] of Object.entries(obj)) {
result[key] = normalizeWhitespace(value);
}
return result;
}
return obj;
};
export const analyzeObject = (obj: any): any => {
const padding = 3; // Padding for character codes
const analyzeText = (text: string) => {
const chars = Array.from(text);
const paddedChars = chars.map(char => char.padStart(padding, ' '));
const paddedCodes = chars.map(char => char.charCodeAt(0).toString().padStart(padding, ' '));
return {
chars: paddedChars.join(' '),
codes: paddedCodes.join(' '),
};
};
if (typeof obj === 'string') {
return analyzeText(obj);
}
if (Array.isArray(obj)) {
return obj.map(analyzeObject);
}
if (obj && typeof obj === 'object') {
const result: any = {};
for (const [key, value] of Object.entries(obj)) {
result[key] = analyzeObject(value);
}
return result;
}
return obj;
};
export const sanitizeAndStringifyLogFields = (fields: Record<string, unknown>) =>
JSON.stringify(
Object.fromEntries(
Object.entries(fields).map(([key, value]) => [
key,
typeof value === 'undefined' ? 'warning: undefined' : value,
]),
),
null,
2,
);
export const toADA = (lovelace: number, options?: { maxDecimals?: number }) =>
`${localizeNumber(lovelace / 1000000, 'en-US', 0, options?.maxDecimals ?? 6)} ADA`;