-
-
Notifications
You must be signed in to change notification settings - Fork 285
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #1823 from freqtrade/playwright
Playwright
- Loading branch information
Showing
17 changed files
with
832 additions
and
3 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -34,3 +34,7 @@ yarn-error.log* | |
|
||
|
||
components.d.ts | ||
/test-results/ | ||
/playwright-report/ | ||
/blob-report/ | ||
/playwright/.cache/ |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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(); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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'); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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(); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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(); | ||
}); | ||
}); |
Oops, something went wrong.