diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 20a1f9e..899af8a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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: @@ -83,4 +99,4 @@ jobs: env: GITHUB_TOKEN: ${{ github.token }} with: - asset_paths: '["./tradeUI.zip"]' \ No newline at end of file + asset_paths: '["./tradeUI.zip"]' diff --git a/.gitignore b/.gitignore index e97b670..c35ef46 100644 --- a/.gitignore +++ b/.gitignore @@ -34,3 +34,7 @@ yarn-error.log* components.d.ts +/test-results/ +/playwright-report/ +/blob-report/ +/playwright/.cache/ diff --git a/e2e/helpers.ts b/e2e/helpers.ts new file mode 100644 index 0000000..797be20 --- /dev/null +++ b/e2e/helpers.ts @@ -0,0 +1,51 @@ +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'); + }); +} + +export async function defaultMocks(page: Page) { + page.route('**/api/v1/**', (route) => { + route.fulfill({ + headers: { 'access-control-allow-origin': '*' }, + json: {}, + }); + }); + + await page.route('**/api/v1/ping', (route) => { + return route.fulfill({ path: './cypress/fixtures/ping.json' }); + }); + await page.route('**/api/v1/show_config', (route) => { + return route.fulfill({ path: './cypress/fixtures/show_config.json' }); + }); + await page.route('**/api/v1/pair_candles?*', (route) => { + return route.fulfill({ path: './cypress/fixtures/pair_candles_btc_1m.json' }); + }); +} + +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); +} diff --git a/e2e/logs.spec.ts b/e2e/logs.spec.ts new file mode 100644 index 0000000..d06512a --- /dev/null +++ b/e2e/logs.spec.ts @@ -0,0 +1,32 @@ +import { test, expect } from '@playwright/test'; + +import { setLoginInfo, defaultMocks, getWaitForResponse } from './helpers'; + +test.describe('Logs', () => { + test('Displays and reloads logs', async ({ page }) => { + /// + await defaultMocks(page); + await setLoginInfo(page); + // const pingPromise = page.route('**/*ping*', + + // const logsPromise = page.waitForResponse('**/api/v1/logs'); + await page.route('**/api/v1/logs', (route) => { + return route.fulfill({ path: './cypress/fixtures/logs.json' }); + }); + + const logs = getWaitForResponse(page, '@Logs'); + const ping = getWaitForResponse(page, '@ShowConf'); + await page.goto('/logs', { waitUntil: 'networkidle' }); + await Promise.all([logs, ping]); + + await expect(page.locator('span', { hasText: 'Checking exchange' })).toBeVisible(); + await expect(page.locator('span', { hasText: 'Checking exchange' })).toHaveText( + /Checking exchange.../, + {}, + ); + // const logsPromise = page.waitForResponse('**/api/v1/logs'); + const logsPromise = getWaitForResponse(page, '@Logs'); + await page.getByRole('button', { name: 'Reload Logs' }).click(); + await logsPromise; + }); +}); diff --git a/e2e/trade.spec.ts b/e2e/trade.spec.ts new file mode 100644 index 0000000..5bb5230 --- /dev/null +++ b/e2e/trade.spec.ts @@ -0,0 +1,151 @@ +import { test, expect } from '@playwright/test'; + +import { setLoginInfo, defaultMocks } from './helpers'; + +function tradeMocks(page) { + const mapping = [ + { 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: '@Whitelist', url: '**/api/v1/whitelist', fixture: 'whitelist.json' }, + { name: '@Blacklist', url: '**/api/v1/blacklist', fixture: 'blacklist.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', + }, + ]; + mapping.forEach((item) => { + page.route(item.url, (route) => { + return route.fulfill({ path: `./cypress/fixtures/${item.fixture}` }); + }); + }); +} + +test.describe('Trade', () => { + test.beforeEach(async ({ page }) => { + await defaultMocks(page); + await setLoginInfo(page); + + await tradeMocks(page); + }); + test('Trade page', async ({ page }) => { + await Promise.all([ + page.goto('/trade'), + // Wait for network requests + // page.waitForResponse('**/ping'), + page.waitForResponse('**/status'), + page.waitForResponse('**/profit'), + page.waitForResponse('**/balance'), + // page.waitForResponse('**/trades'), + page.waitForResponse('**/whitelist'), + page.waitForResponse('**/blacklist'), + page.waitForResponse('**/locks'), + ]); + + // // Check visibility of elements + await expect(page.locator('.drag-header', { hasText: 'Multi Pane' })).toBeInViewport(); + await expect(page.locator('.drag-header', { hasText: 'Chart' })).toBeInViewport(); + // Pairlist elements + await expect(page.locator('button', { hasText: 'BTC/USDT' })).toBeInViewport(); + await expect(page.locator('button', { hasText: 'ETH/USDT' })).toBeInViewport(); + + // // Click on Performance button and wait for response + await Promise.all([ + page.waitForResponse('**/performance'), + page.click('button:has-text("Performance")'), + ]); + + // // Check visibility of Profit USDT + await expect(page.locator('th:has-text("Profit USDT")')).toBeInViewport(); + + // // Test messageBox behavior + + const dialogModal = page.getByRole('dialog'); + const modalButton = page.locator( + '#MsgBoxModal .modal-dialog > .modal-content > .modal-footer > .btn-secondary:has-text("Cancel")', + ); + await expect(dialogModal).not.toBeVisible(); + await expect(dialogModal).not.toBeInViewport(); + + await expect(modalButton).not.toBeVisible(); + + await page.getByRole('button', { name: 'Stop Trading - Also stops' }).click(); + + // Modal open + await expect(dialogModal).toBeVisible(); + await expect(dialogModal).toBeInViewport(); + await expect(modalButton).toBeInViewport(); + + // // Close modal + await modalButton.click(); + + // // Modal closed + await expect(modalButton).not.toBeVisible(); + await expect(modalButton).not.toBeInViewport(); + + // // Click on General tab + const performancePair = page.locator('td:has-text("XRP/USDT")'); + await expect(performancePair).toBeInViewport(); + await page.click('button[role="tab"]:has-text("General")'); + + // // Check visibility of elements + await expect(performancePair).not.toBeInViewport(); + const openTrades = page.locator('.drag-header:has-text("Open Trades")'); + openTrades.scrollIntoViewIfNeeded(); + await expect(openTrades).toBeInViewport(); + const closedTrades = page.locator('.drag-header:has-text("Closed Trades")'); + closedTrades.scrollIntoViewIfNeeded(); + await expect(closedTrades).toBeInViewport(); + await expect(page.locator('span:has-text("TRX/USDT")')).toBeInViewport(); + await expect(page.locator('td:has-text("8070.5")')).toBeInViewport(); + + // Scroll to top + const multiPane = page.locator('.drag-header', { hasText: 'Multi Pane' }); + await expect(multiPane).toBeVisible(); + await multiPane.scrollIntoViewIfNeeded(); + await expect(multiPane).toBeInViewport(); + + // // Click on Reload Config button + await page.getByRole('button', { name: 'Reload Config' }).click(); + // await page.locator('button[title*="Reload Config "]').click(); + await expect(dialogModal).toBeVisible(); + await expect(dialogModal).toBeInViewport(); + + const modalOkButton = page.locator( + '#MsgBoxModal .modal-dialog > .modal-content > .modal-footer > .btn-primary:has-text("Ok")', + ); + await expect(modalOkButton).toBeVisible(); + await modalOkButton.click(); + + await expect(page.getByText('Config reloaded successfully.')).toBeInViewport(); + }); + test('Trade page - drag and drop', async ({ page }) => { + await page.goto('/trade'); + + await page.locator('#avatar-drop').click(); + const multiPane = page.locator('.drag-header', { hasText: 'Multi Pane' }); + + await page.getByLabel('Lock layout').uncheck(); + + const chartHeader = await page.locator('.drag-header:has-text("Chart")'); + await expect(multiPane).toBeInViewport(); + await expect(chartHeader).toBeInViewport(); + + // Test drag and drop functionality + const chartHeaderbb = await chartHeader.boundingBox(); + if (chartHeaderbb) { + await chartHeader.hover(); + await page.mouse.down(); + + await page.mouse.move(chartHeaderbb?.x + chartHeaderbb.width / 2, chartHeaderbb?.y + 200); + await page.mouse.up(); + await expect(multiPane).toBeInViewport(); + await expect(chartHeader).toBeInViewport(); + } + }); +}); diff --git a/package.json b/package.json index 0d8cc05..bf188c1 100644 --- a/package.json +++ b/package.json @@ -13,12 +13,15 @@ "cy:open": "cypress open", "cy:run": "cypress run", "cy:open-ct": "cypress open-ct", - "cy:run-ct": "cypress run --component" + "cy:run-ct": "cypress run --component", + "test:e2e": "yarn playwright test", + "test:e2e-chromium": "yarn playwright test --project=chromium", + "test:e2e-msedge": "yarn playwright test --project=msedge" }, "dependencies": { - "@noction/vue-draggable-grid": "1.10.1", + "@noction/vue-draggable-grid": "1.9.16", "@popperjs/core": "^2.11.8", - "@vuepic/vue-datepicker": "^8.3.1", + "@vuepic/vue-datepicker": "^8.3.2", "@vueuse/core": "^10.9.0", "@vueuse/integrations": "^10.9.0", "axios": "^1.6.8", @@ -44,16 +47,18 @@ "@cypress/vite-dev-server": "^5.0.7", "@cypress/vue": "^6.0.0", "@iconify-json/mdi": "^1.1.64", + "@playwright/test": "^1.40.0", "@types/echarts": "^4.9.22", - "@typescript-eslint/eslint-plugin": "^7.4.0", - "@typescript-eslint/parser": "^7.4.0", + "@types/node": "^20.9.2", + "@typescript-eslint/eslint-plugin": "^7.5.0", + "@typescript-eslint/parser": "^7.5.0", "@vitejs/plugin-vue": "^5.0.4", "@vue/compiler-sfc": "3.4.21", "@vue/eslint-config-prettier": "^9.0.0", "@vue/eslint-config-typescript": "^13.0.0", "@vue/runtime-dom": "^3.4.21", "@vue/test-utils": "^2.4.5", - "cypress": "^13.7.1", + "cypress": "^13.7.2", "eslint": "^8.57.0", "eslint-plugin-prettier": "^5.1.3", "eslint-plugin-vue": "^9.24.0", diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..0b7c6bc --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,80 @@ +import { defineConfig, devices } from '@playwright/test'; + +/** + * Read environment variables from file. + * https://github.com/motdotla/dotenv + */ +// require('dotenv').config(); + +/** + * See https://playwright.dev/docs/test-configuration. + */ +export default defineConfig({ + testDir: './e2e', + /* Run tests in files in parallel */ + fullyParallel: true, + /* Fail the build on CI if you accidentally left test.only in the source code. */ + forbidOnly: !!process.env.CI, + /* Retry on CI only */ + retries: process.env.CI ? 2 : 0, + /* Opt out of parallel tests on CI. */ + workers: process.env.CI ? 1 : undefined, + /* Reporter to use. See https://playwright.dev/docs/test-reporters */ + reporter: 'html', + /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ + use: { + /* Base URL to use in actions like `await page.goto('/')`. */ + baseURL: 'http://localhost:3000', + + /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ + trace: 'on-first-retry', + }, + + /* Configure projects for major browsers */ + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'], channel: 'chrome' }, + }, + { + name: 'msedge', + use: { ...devices['Desktop Edge'], channel: 'msedge' }, // or "msedge-beta" or 'msedge-dev' + }, + { + name: 'firefox', + use: { ...devices['Desktop Firefox'] }, + }, + + { + name: 'webkit', + use: { ...devices['Desktop Safari'] }, + }, + + /* Test against mobile viewports. */ + // { + // name: 'Mobile Chrome', + // use: { ...devices['Pixel 5'] }, + // }, + // { + // name: 'Mobile Safari', + // use: { ...devices['iPhone 12'] }, + // }, + + /* Test against branded browsers. */ + // { + // name: 'Microsoft Edge', + // use: { ...devices['Desktop Edge'], channel: 'msedge' }, + // }, + // { + // name: 'Google Chrome', + // use: { ...devices['Desktop Chrome'], channel: 'chrome' }, + // }, + ], + + /* Run your local dev server before starting the tests */ + webServer: { + command: 'yarn dev', + url: 'http://localhost:3000', + reuseExistingServer: !process.env.CI, + }, +}); diff --git a/src/components/general/MessageBox.vue b/src/components/general/MessageBox.vue index 413bd13..63795c6 100644 --- a/src/components/general/MessageBox.vue +++ b/src/components/general/MessageBox.vue @@ -1,5 +1,6 @@