Skip to content

Commit

Permalink
Merge pull request #1823 from freqtrade/playwright
Browse files Browse the repository at this point in the history
Playwright
  • Loading branch information
xmatthias authored Apr 9, 2024
2 parents 4d1dcd9 + d6e4b58 commit 270004f
Show file tree
Hide file tree
Showing 17 changed files with 832 additions and 3 deletions.
16 changes: 16 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,22 @@ jobs:
- name: Run Tests
run: yarn test:unit

# Playwright section
- name: Install Playwright Browsers
run: yarn playwright install --with-deps

- name: Run Playwright tests
run: yarn playwright test

- uses: actions/upload-artifact@v4
if: ${{ !cancelled() }}
with:
name: playwright-report-${{ matrix.node }}
path: playwright-report/
retention-days: 30

# End Playwright section

- name: Run Component tests
uses: cypress-io/github-action@v6
with:
Expand Down
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -34,3 +34,7 @@ yarn-error.log*


components.d.ts
/test-results/
/playwright-report/
/blob-report/
/playwright/.cache/
51 changes: 51 additions & 0 deletions e2e/backtest.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { test, expect } from '@playwright/test';
import { setLoginInfo, defaultMocks } from './helpers';

test.describe('Backtesting', () => {
test.beforeEach(async ({ page }) => {
await defaultMocks(page);
page.route('**/api/v1/show_config', (route) => {
return route.fulfill({ path: `./cypress/fixtures/backtest/show_config_webserver.json` });
});
page.route('**/api/v1/strategies', (route) => {
return route.fulfill({ path: `./cypress/fixtures/backtest/strategies.json` });
});

await page.route('**/api/v1/backtest', (route) => {
if (route.request().method() === 'POST') {
route.fulfill({
path: './cypress/fixtures/backtest/backtest_post_start.json',
});
} else if (route.request().method() === 'GET') {
route.fulfill({
path: './cypress/fixtures/backtest/backtest_get_end.json',
});
}
});

await setLoginInfo(page);
});
test('Starts webserver mode', async ({ page }) => {
await page.goto('/backtest');

await expect(page.locator('a', { hasText: 'Backtest' })).toBeInViewport();
await expect(page.getByText('Run backtest')).toBeInViewport();
await expect(page.getByText('Strategy', { exact: true })).toBeInViewport();

const strategySelect = page.locator('select[id="strategy-select"]');
await expect(strategySelect).toBeVisible();
await expect(strategySelect).toBeInViewport();

await strategySelect.selectOption('SampleStrategy');
const option = page.locator('option[value="SampleStrategy"]');
await expect(option).toBeAttached();
const analyzeButton = page.locator('[id="bt-analyze-btn"]');
await expect(analyzeButton).toBeDisabled();

const startBacktestButton = page.locator('button[id="start-backtest"]');
await Promise.all([startBacktestButton.click(), page.waitForResponse('**/api/v1/backtest')]);

// All buttons are now enabled
await expect(analyzeButton).toBeEnabled();
});
});
40 changes: 40 additions & 0 deletions e2e/chart.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { test, expect } from '@playwright/test';
import { setLoginInfo, defaultMocks } from './helpers';

test.describe('Chart', () => {
test.beforeEach(async ({ page }) => {
await defaultMocks(page);
await setLoginInfo(page);
});
test('Chart page', async ({ page }) => {
await Promise.all([
page.goto('/graph'),
page.waitForResponse('**/whitelist'),
page.waitForResponse('**/blacklist'),
]);

// await page.waitForResponse('**/pair_candles');
await page.locator('input[title="AutoRefresh"]').click();
// await page.click('input[title="AutoRefresh"]');

await page.waitForSelector('span:has-text("NoActionStrategyFut | 1m")');

await page.click('.form-check:has-text("Heikin Ashi")');

// Reload triggers a new request
await Promise.all([
page.getByRole('button', { name: 'Refresh chart' }).click(),

page.waitForResponse('**/pair_candles?*'),
]);
// Disable Heikin Ashi
await page.locator('.form-check:has-text("Heikin Ashi")').click();
// Default plotconfig exists
await expect(
page
.locator('div')
.filter({ hasText: /^Heikin Ashidefault$/ })
.locator('#plotConfigSelect'),
).toHaveValue('default');
});
});
39 changes: 39 additions & 0 deletions e2e/dashboard.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { test, expect } from '@playwright/test';
import { setLoginInfo, defaultMocks, tradeMocks } from './helpers';

test.describe('Dashboard', () => {
test.beforeEach(async ({ page }) => {
await defaultMocks(page);
await tradeMocks(page);
await setLoginInfo(page);
});
test('Dashboard Page', async ({ page }) => {
await Promise.all([
page.goto('/dashboard'),
page.waitForResponse('**/status'),
page.waitForResponse('**/profit'),
page.waitForResponse('**/balance'),
// page.waitForResponse('**/trades'),
page.waitForResponse('**/whitelist'),
page.waitForResponse('**/blacklist'),
page.waitForResponse('**/locks'),
]);
await expect(page.locator('.drag-header', { hasText: 'Bot comparison' })).toBeVisible();
await expect(page.locator('.drag-header', { hasText: 'Bot comparison' })).toBeInViewport();
await expect(page.locator('.drag-header', { hasText: 'Daily Profit' })).toBeVisible();
await expect(page.locator('.drag-header', { hasText: 'Daily Profit' })).toBeInViewport();
await expect(page.locator('.drag-header', { hasText: 'Open trades' })).toBeVisible();
await expect(page.locator('.drag-header', { hasText: 'Open trades' })).toBeInViewport();
await expect(page.locator('.drag-header', { hasText: 'Cumulative Profit' })).toBeVisible();
await expect(page.locator('.drag-header', { hasText: 'Cumulative Profit' })).toBeInViewport();

await expect(page.locator('span', { hasText: 'TestBot' })).toBeVisible();
await expect(page.locator('span', { hasText: 'Summary' })).toBeVisible();
// Scroll to bottom
await page.locator('.drag-header', { hasText: 'Trades Log' }).scrollIntoViewIfNeeded();
await expect(page.locator('.drag-header', { hasText: 'Closed Trades' })).toBeInViewport();
await expect(page.locator('.drag-header', { hasText: 'Profit Distribution' })).toBeInViewport();

await expect(page.locator('.drag-header', { hasText: 'Trades Log' })).toBeInViewport();
});
});
84 changes: 84 additions & 0 deletions e2e/helpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { Page } from '@playwright/test';

export async function setLoginInfo(page) {
await page.goto('/');
await page.evaluate(() => {
localStorage.setItem(
'ftAuthLoginInfo',
JSON.stringify({
'ftbot.0': {
botName: 'TestBot',
apiUrl: 'http://localhost:3000',
accessToken: 'access_token_tesst',
refreshToken: 'refresh_test',
autoRefresh: true,
},
}),
);
localStorage.setItem('ftSelectedBot', 'ftbot.0');
});
}

interface mockArray {
name: string;
url: string;
fixture: string;
method?: string;
}

function mockRequests(page, mocks: mockArray[]) {
mocks.forEach((item) => {
page.route(item.url, (route) => {
return route.fulfill({ path: `./cypress/fixtures/${item.fixture}` });
});
});
}

export async function defaultMocks(page: Page) {
page.route('**/api/v1/**', (route) => {
route.fulfill({
headers: { 'access-control-allow-origin': '*' },
json: {},
});
});

const mapping: mockArray[] = [
{ name: '@Ping', url: '**/api/v1/ping', fixture: 'ping.json' },
{ name: '@Ping', url: '**/api/v1/show_config', fixture: 'show_config.json' },
{ name: '@Ping', url: '**/api/v1/pair_candles?*', fixture: 'pair_candles_btc_1m.json' },
{ name: '@Whitelist', url: '**/api/v1/whitelist', fixture: 'whitelist.json' },
{ name: '@Blacklist', url: '**/api/v1/blacklist', fixture: 'blacklist.json' },
];

mockRequests(page, mapping);
}

export function tradeMocks(page) {
const mapping: mockArray[] = [
{ name: '@Status', url: '**/api/v1/status', fixture: 'status_empty.json' },
{ name: '@Profit', url: '**/api/v1/profit', fixture: 'profit.json' },
{ name: '@Trades', url: '**/api/v1/trades*', fixture: 'trades.json' },
{ name: '@Balance', url: '**/api/v1/balance', fixture: 'balance.json' },
{ name: '@Locks', url: '**/api/v1/locks', fixture: 'locks_empty.json' },
{ name: '@Performance', url: '**/api/v1/performance', fixture: 'performance.json' },
{
name: '@ReloadConfig',
method: 'POST',
url: '**/api/v1/reload_config',
fixture: 'reload_config.json',
},
];
mockRequests(page, mapping);
}

export function getWaitForResponse(page: Page, url: string) {
const urlMapping = {
'@Ping': '**/api/v1/ping',
'@ShowConf': '**/api/v1/show_config',
'@PairCandles': '**/api/v1/pair_candles',
'@Logs': '**/api/v1/logs',
};
const urlMap = urlMapping[url] ?? url;

return page.waitForResponse(urlMap);
}
125 changes: 125 additions & 0 deletions e2e/login.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import { test, expect } from '@playwright/test';
import { setLoginInfo, defaultMocks, tradeMocks } from './helpers';

test.describe('Login', () => {
test('Is not logged in', async ({ page }) => {
await page.goto('/');
await expect(page.locator('button', { hasText: 'Login' })).toBeInViewport();

await page.locator('li', { hasText: 'No bot selected' });
await page.locator('button:has-text("Login")').click();
await page.locator('.modal-title:has-text("Login to your bot")');
// Test prefilled URL
await expect(page.locator('input[id=url-input]').inputValue()).resolves.toBe(
'http://localhost:3000',
);
await page.locator('#name-input').isVisible();
await page.locator('#username-input').isVisible();
await page.locator('#password-input').isVisible();
// Modal popup will use "OK" instead of "submit"
await expect(page.locator('button[type=submit]')).not.toBeVisible();
});

test('Explicit login page', async ({ page }) => {
await page.goto('/login');
await expect(page.locator('button', { hasText: 'Login' })).not.toBeInViewport();
await page.locator('li', { hasText: 'No bot selected' });
await page.locator('.card-header:has-text("Freqtrade bot Login")');
// Test prefilled URL
await expect(page.locator('input[id=url-input]').inputValue()).resolves.toBe(
'http://localhost:3000',
);
await page.locator('input[id=name-input]').isVisible();
await page.locator('input[id=username-input]').isVisible();
await page.locator('input[id=password-input]').isVisible();
await page.locator('button[type=submit]').isVisible();
});

test('Redirect when not logged in', async ({ page }) => {
await page.goto('/trade');
// await expect(page.locator('button', { hasText: 'Login' })).toBeInViewport();
await expect(page.locator('li', { hasText: 'No bot selected' }).first()).toBeInViewport();
await expect(page).toHaveURL(/.*\/login\?redirect=\/trade/);
});
test('Test Login', async ({ page }) => {
await defaultMocks(page);
await page.goto('/login');
await page.locator('.card-header:has-text("Freqtrade bot Login")');
await page.locator('input[id=name-input]').fill('TestBot');
await page.locator('input[id=username-input]').fill('Freqtrader');
await page.locator('input[id=password-input]').fill('SuperDuperBot');

await page.route('**/api/v1/token/login', (route) => {
return route.fulfill({
status: 200,
json: { access_token: 'access_token_tesst', refresh_token: 'refresh_test' },
headers: { 'access-control-allow-origin': '*' },
});
});
const loginButton = await page.locator('button[type=submit]');
await expect(loginButton).toBeVisible();
await expect(loginButton).toContainText('Submit');
await Promise.all([loginButton.click(), page.waitForResponse('**/api/v1/token/login')]);

await expect(page.locator('span', { hasText: 'TestBot' })).toBeVisible();
await expect(page.locator('button', { hasText: 'Add new Bot' })).toBeVisible();
await expect(page.locator('button', { hasText: 'Login' })).not.toBeVisible();
// Test logout
await page.locator('#avatar-drop').click();
await page.locator('a:visible', { hasText: 'Sign Out' }).click();
// Assert we're logged out again
await expect(page.locator('button', { hasText: 'Login' })).toBeVisible();
});

test('Test Login failed - wrong api url', async ({ page }) => {
await defaultMocks(page);
await page.goto('/login');
await page.locator('.card-header:has-text("Freqtrade bot Login")');
await page.locator('input[id=name-input]').fill('TestBot');
await page.locator('input[id=username-input]').fill('Freqtrader');
await page.locator('input[id=password-input]').fill('SuperDuperBot');

await page.route('**/api/v1/token/login', (route) => {
return route.fulfill({
status: 404,
json: { access_token: 'access_token_tesst', refresh_token: 'refresh_test' },
headers: { 'access-control-allow-origin': '*' },
});
});
const loginButton = await page.locator('button[type=submit]');
await expect(loginButton).toBeVisible();
await expect(loginButton).toContainText('Submit');
await Promise.all([loginButton.click(), page.waitForResponse('**/api/v1/token/login')]);
await expect(page.getByText('Login failed.')).toBeVisible();
await expect(page.getByText('API Url required')).toBeVisible();
});

test('Test Login failed - wrong password', async ({ page }) => {
await defaultMocks(page);
await page.goto('/login');
await page.locator('.card-header:has-text("Freqtrade bot Login")');
await page.locator('input[id=name-input]').fill('TestBot');
await page.locator('input[id=username-input]').fill('Freqtrader');
await page.locator('input[id=password-input]').fill('SuperDuperBot');

await page.route('**/api/v1/token/login', (route) => {
return route.fulfill({
status: 401,
json: { access_token: 'access_token_tesst', refresh_token: 'refresh_test' },
headers: { 'access-control-allow-origin': '*' },
});
});

const loginButton = await page.locator('button[type=submit]');
await expect(loginButton).toBeVisible();
await expect(loginButton).toContainText('Submit');
await expect(page.getByText('Name and Password are required.')).not.toBeVisible();
await expect(page.getByText('Connected to bot, however Login failed,')).not.toBeVisible();
await expect(page.getByText('Invalid Password')).not.toBeVisible();

await Promise.all([loginButton.click(), page.waitForResponse('**/api/v1/token/login')]);
await expect(page.getByText('Name and Password are required.')).toBeVisible();
await expect(page.getByText('Invalid Password')).toBeVisible();
await expect(page.getByText('Connected to bot, however Login failed,')).toBeVisible();
});
});
Loading

0 comments on commit 270004f

Please sign in to comment.