diff --git a/src/frontend/playwright.config.ts b/src/frontend/playwright.config.ts index e84e49110c..eeafe96df8 100644 --- a/src/frontend/playwright.config.ts +++ b/src/frontend/playwright.config.ts @@ -3,9 +3,6 @@ import { defineConfig, devices } from '@playwright/test'; // Detect if running in CI const IS_CI = !!process.env.CI; -console.log('Running Playwright tests:'); -console.log(` - CI Mode: ${IS_CI}`); - const MAX_WORKERS: number = 3; const MAX_RETRIES: number = 3; @@ -20,7 +17,6 @@ const MAX_RETRIES: number = 3; * - In CI (GitHub actions), we run "vite build" to generate a production build * - This build is then served by a local server for testing * - This allows the tests to run much faster and with parallel workers - * - Run a Gunicorn multi-threaded web server to handle multiple requests * - WORKERS = MAX_WORKERS (to speed up the tests) * * CI Mode (Coverage): @@ -30,11 +26,13 @@ const MAX_RETRIES: number = 3; * - WORKERS = 1 (to avoid conflicts with HMR) */ -// Command to spin-up the backend server -// In production mode, we want a stronger webserver to handle multiple requests -const WEB_SERVER_CMD: string = IS_CI - ? 'gunicorn --chdir ../backend/InvenTree --workers 8 --thread 8 --bind 127.0.0.1:8000 InvenTree.wsgi' - : 'invoke dev.server -a 127.0.0.1:8000'; +const BASE_URL: string = IS_CI + ? 'http://localhost:8000' + : 'http://localhost:5173'; + +console.log('Running Playwright Tests:'); +console.log(`- CI Mode: ${IS_CI}`); +console.log('- Base URL:', BASE_URL); export default defineConfig({ testDir: './tests', @@ -72,15 +70,16 @@ export default defineConfig({ timeout: 120 * 1000 }, { - command: WEB_SERVER_CMD, + command: 'invoke dev.server', env: { INVENTREE_DEBUG: 'True', + INVENTREE_LOG_LEVEL: 'WARNING', INVENTREE_PLUGINS_ENABLED: 'True', INVENTREE_ADMIN_URL: 'test-admin', INVENTREE_SITE_URL: 'http://localhost:8000', INVENTREE_FRONTEND_API_HOST: 'http://localhost:8000', INVENTREE_CORS_ORIGIN_ALLOW_ALL: 'True', - INVENTREE_COOKIE_SAMESITE: 'Lax', + INVENTREE_COOKIE_SAMESITE: 'False', INVENTREE_LOGIN_ATTEMPTS: '100' }, url: 'http://localhost:8000/api/', @@ -92,7 +91,7 @@ export default defineConfig({ ], globalSetup: './playwright/global-setup.ts', use: { - baseURL: 'http://localhost:5173', + baseURL: BASE_URL, headless: IS_CI ? true : undefined, trace: 'on-first-retry', contextOptions: { diff --git a/src/frontend/playwright/global-setup.ts b/src/frontend/playwright/global-setup.ts index 64503127d1..6386959cbf 100644 --- a/src/frontend/playwright/global-setup.ts +++ b/src/frontend/playwright/global-setup.ts @@ -1,7 +1,8 @@ -import { type FullConfig, chromium } from '@playwright/test'; +import { type FullConfig, chromium, request } from '@playwright/test'; import fs from 'node:fs'; import path from 'node:path'; +import { apiUrl } from '../tests/defaults'; import { doCachedLogin } from '../tests/login'; async function globalSetup(config: FullConfig) { @@ -18,27 +19,53 @@ async function globalSetup(config: FullConfig) { }); } - // Perform login for each user - const browser = await chromium.launch(); + const baseUrl = config.projects[0].use?.baseURL || 'http://localhost:5173'; + const apiContext = await request.newContext(); - await doCachedLogin(browser, { + let tries = 100; + let success = false; + + // Wait for the web server to actually be started + while (tries--) { + // Perform GET request to the API URL + const response = await apiContext + .get(apiUrl, { timeout: 5000 }) + .catch(() => {}); + + if (!!response && response?.ok() && response?.status() === 200) { + success = true; + break; + } + console.log(`... waiting for API to be available at ${apiUrl}`); + } + + if (!success) { + throw new Error(`Failed to connect to API at ${apiUrl} after 100 attempts`); + } + + // Perform login for each user (each in a separate browser instance) + await doCachedLogin(await chromium.launch(), { username: 'admin', - password: 'inventree' + password: 'inventree', + baseUrl: baseUrl }); - await doCachedLogin(browser, { + await doCachedLogin(await chromium.launch(), { username: 'allaccess', - password: 'nolimits' + password: 'nolimits', + baseUrl: baseUrl }); - await doCachedLogin(browser, { + await doCachedLogin(await chromium.launch(), { username: 'reader', - password: 'readonly' + password: 'readonly', + baseUrl: baseUrl }); - await doCachedLogin(browser, { + await doCachedLogin(await chromium.launch(), { username: 'steven', - password: 'wizardstaff' + password: 'wizardstaff', + baseUrl: baseUrl }); } diff --git a/src/frontend/tests/baseFixtures.ts b/src/frontend/tests/baseFixtures.ts index f7885824a3..d43b0fb44e 100644 --- a/src/frontend/tests/baseFixtures.ts +++ b/src/frontend/tests/baseFixtures.ts @@ -52,7 +52,7 @@ export const test = baseTest.extend({ } }, // Ensure no errors are thrown in the console - page: async ({ baseURL, page }, use) => { + page: async ({ page }, use) => { const messages = []; page.on('console', (msg) => { const url = msg.location().url; @@ -67,20 +67,20 @@ export const test = baseTest.extend({ ) < 0 && msg.text() != 'Failed to load resource: the server responded with a status of 400 (Bad Request)' && - !msg.text().includes('http://localhost:8000/this/does/not/exist.js') && - url != 'http://localhost:8000/this/does/not/exist.js' && - url != 'http://localhost:8000/api/user/me/' && - url != 'http://localhost:8000/api/user/token/' && - url != 'http://localhost:8000/api/auth/v1/auth/login' && - url != 'http://localhost:8000/api/auth/v1/auth/session' && - url != 'http://localhost:8000/api/auth/v1/account/password/change' && - url != 'http://localhost:8000/api/barcode/' && - url != 'https://docs.inventree.org/en/versions.json' && - url != 'http://localhost:5173/favicon.ico' && + !msg.text().includes('/this/does/not/exist.js') && + !url.includes('/this/does/not/exist.js') && + !url.includes('/api/user/me/') && + !url.includes('/api/user/token/') && + !url.includes('/api/auth/v1/auth/login') && + !url.includes('/api/auth/v1/auth/session') && + !url.includes('/api/auth/v1/account/password/change') && + !url.includes('/api/barcode/') && + !url.includes('/favicon.ico') && !url.startsWith('https://api.github.com/repos/inventree') && - !url.startsWith('http://localhost:8000/api/news/') && - !url.startsWith('http://localhost:8000/api/notifications/') && + !url.includes('/api/news/') && + !url.includes('/api/notifications/') && !url.startsWith('chrome://') && + url != 'https://docs.inventree.org/en/versions.json' && url.indexOf('99999') < 0 ) messages.push(msg); diff --git a/src/frontend/tests/defaults.ts b/src/frontend/tests/defaults.ts index 836f7135e1..887083c270 100644 --- a/src/frontend/tests/defaults.ts +++ b/src/frontend/tests/defaults.ts @@ -1,10 +1,11 @@ -export const classicUrl = 'http://127.0.0.1:8000'; +export const webUrl = '/web'; -export const apiUrl = `${classicUrl}/api`; -export const baseUrl = './web'; -export const loginUrl = `${baseUrl}/login`; -export const logoutUrl = `${baseUrl}/logout`; -export const homeUrl = `${baseUrl}/home`; +// Note: API requests are handled by the backend server +export const apiUrl = 'http://localhost:8000/api/'; + +export const homeUrl = `${webUrl}/home`; +export const loginUrl = `${webUrl}/login`; +export const logoutUrl = `${webUrl}/logout`; export const user = { name: 'Ally Access', diff --git a/src/frontend/tests/helpers.ts b/src/frontend/tests/helpers.ts index 443eff8dee..cf270e5112 100644 --- a/src/frontend/tests/helpers.ts +++ b/src/frontend/tests/helpers.ts @@ -1,5 +1,3 @@ -import { baseUrl } from './defaults'; - /** * Open the filter drawer for the currently visible table * @param page - The page object @@ -73,21 +71,30 @@ export const clickOnRowMenu = async (cell) => { await row.getByLabel(/row-action-menu-/i).click(); }; +interface NavigateOptions { + waitUntil?: 'load' | 'domcontentloaded' | 'networkidle'; + baseUrl?: string; +} + /** * Navigate to the provided page, and wait for loading to complete * @param page * @param url */ -export const navigate = async (page, url: string) => { - if (!url.startsWith(baseUrl)) { - if (url.startsWith('/')) { - url = url.slice(1); - } - - url = `${baseUrl}/${url}`; +export const navigate = async ( + page, + url: string, + options?: NavigateOptions +) => { + if (!url.startsWith('http') && !url.includes('web')) { + url = `/web/${url}`.replaceAll('//', '/'); } - await page.goto(url); + const path: string = options?.baseUrl + ? new URL(url, options.baseUrl).toString() + : url; + + await page.goto(path, { waitUntil: options?.waitUntil ?? 'load' }); }; /** diff --git a/src/frontend/tests/login.ts b/src/frontend/tests/login.ts index f69bbcd80b..2f4945ef7f 100644 --- a/src/frontend/tests/login.ts +++ b/src/frontend/tests/login.ts @@ -1,33 +1,53 @@ import type { Browser, Page } from '@playwright/test'; -import { expect } from './baseFixtures.js'; -import { user } from './defaults'; -import { navigate } from './helpers.js'; +import { loginUrl, logoutUrl, user, webUrl } from './defaults'; import fs from 'node:fs'; import path from 'node:path'; +import { navigate } from './helpers.js'; + +interface LoginOptions { + username?: string; + password?: string; + baseUrl?: string; +} /* * Perform form based login operation from the "login" URL */ -export const doLogin = async (page, username?: string, password?: string) => { - username = username ?? user.username; - password = password ?? user.password; +export const doLogin = async (page, options?: LoginOptions) => { + const username: string = options?.username ?? user.username; + const password: string = options?.password ?? user.password; - await page.goto('http://localhost:8000/web/logout', { waituntil: 'load' }); + console.log('- Logging in with username:', username); + + await navigate(page, loginUrl, { + baseUrl: options?.baseUrl, + waitUntil: 'networkidle' + }); - await expect(page).toHaveTitle(/^InvenTree.*$/); await page.waitForURL('**/web/login'); + await page.getByLabel('username').fill(username); await page.getByLabel('password').fill(password); + + await page.waitForTimeout(100); + await page.getByRole('button', { name: 'Log in' }).click(); - await page.waitForURL('**/web/home'); - await page.waitForTimeout(250); + + await page.waitForTimeout(100); + await page.waitForLoadState('networkidle'); + + await page.getByRole('link', { name: 'Dashboard' }).waitFor(); + await page.getByRole('button', { name: 'navigation-menu' }).waitFor(); + await page.waitForURL(/\/web(\/home)?/); + await page.waitForLoadState('networkidle'); }; export interface CachedLoginOptions { username?: string; password?: string; url?: string; + baseUrl?: string; } // Set of users allowed to do cached login @@ -58,9 +78,17 @@ export const doCachedLogin = async ( storageState: fn }); console.log(`Using cached login state for ${username}`); - await navigate(page, url); - await page.waitForURL('**/web/**'); - await page.waitForLoadState('load'); + + await navigate(page, url ?? webUrl, { + baseUrl: options?.baseUrl, + waitUntil: 'networkidle' + }); + + await page.getByRole('link', { name: 'Dashboard' }).waitFor(); + await page.getByRole('button', { name: 'navigation-menu' }).waitFor(); + await page.waitForURL(/\/web(\/home)?/); + await page.waitForLoadState('networkidle'); + return page; } @@ -69,29 +97,37 @@ export const doCachedLogin = async ( console.log(`No cache found - logging in for ${username}`); - // Ensure we start from the login page - await page.goto('http://localhost:8000/web/', { waitUntil: 'load' }); + // Completely clear the browser cache and cookies, etc + await page.context().clearCookies(); + await page.context().clearPermissions(); + + await doLogin(page, { + username: username, + password: password, + baseUrl: options?.baseUrl + }); - await doLogin(page, username, password); await page.getByLabel('navigation-menu').waitFor({ timeout: 5000 }); - await page.getByText(/InvenTree Demo Server -/).waitFor(); - await page.waitForURL('**/web/**'); - - // Wait for the dashboard to load - //await page.getByText('No widgets selected').waitFor() await page.waitForLoadState('load'); // Cache the login state await page.context().storageState({ path: fn }); if (url) { - await navigate(page, url); + await navigate(page, url, { baseUrl: options?.baseUrl }); } return page; }; -export const doLogout = async (page) => { - await page.goto('http://localhost:8000/web/logout', { waitUntil: 'load' }); +interface LogoutOptions { + baseUrl?: string; +} + +export const doLogout = async (page, options?: LogoutOptions) => { + await navigate(page, logoutUrl, { + baseUrl: options?.baseUrl, + waitUntil: 'load' + }); await page.waitForURL('**/web/login'); }; diff --git a/src/frontend/tests/pages/pui_part.spec.ts b/src/frontend/tests/pages/pui_part.spec.ts index fa81010734..4a3f037994 100644 --- a/src/frontend/tests/pages/pui_part.spec.ts +++ b/src/frontend/tests/pages/pui_part.spec.ts @@ -265,7 +265,7 @@ test('Parts - Pricing (Nothing, BOM)', async ({ browser }) => { await page.getByRole('button', { name: 'Supplier Pricing' }).isDisabled(); // Part with history - await navigate(page, 'part/108/pricing'); + await navigate(page, 'part/108/pricing', { waitUntil: 'networkidle' }); await page.getByText('A chair - with blue paint').waitFor(); await loadTab(page, 'Part Pricing'); await page.getByLabel('Part Pricing').getByText('Part Pricing').waitFor(); diff --git a/src/frontend/tests/pui_login.spec.ts b/src/frontend/tests/pui_login.spec.ts index 32bb5dfbe3..93b6388a71 100644 --- a/src/frontend/tests/pui_login.spec.ts +++ b/src/frontend/tests/pui_login.spec.ts @@ -45,13 +45,13 @@ test('Login - Failures', async ({ page }) => { }); test('Login - Change Password', async ({ page }) => { - await doLogin(page, 'noaccess', 'youshallnotpass'); - await page.waitForLoadState('networkidle'); + await doLogin(page, { + username: 'noaccess', + password: 'youshallnotpass' + }); // Navigate to the 'change password' page - await navigate(page, 'settings/user/account'); - await page.waitForLoadState('networkidle'); - + await navigate(page, 'settings/user/account', { waitUntil: 'networkidle' }); await page.getByLabel('action-menu-account-actions').click(); await page.getByLabel('action-menu-account-actions-change-password').click(); @@ -69,9 +69,16 @@ test('Login - Change Password', async ({ page }) => { await page.getByText('This password is too short').waitFor(); await page.getByText('This password is entirely numeric').waitFor(); - await page.getByLabel('input-password-1').fill('youshallnotpass'); + await page.waitForTimeout(250); + + await page.getByLabel('password', { exact: true }).clear(); + await page.getByLabel('input-password-1').clear(); await page.getByLabel('input-password-2').clear(); + + await page.getByLabel('password', { exact: true }).fill('youshallnotpass'); + await page.getByLabel('input-password-1').fill('youshallnotpass'); await page.getByLabel('input-password-2').fill('youshallnotpass'); + await page.getByRole('button', { name: 'Confirm' }).click(); await page.getByText('Password Changed').waitFor(); diff --git a/src/frontend/tests/pui_settings.spec.ts b/src/frontend/tests/pui_settings.spec.ts index f4d27f2d32..e8a706f299 100644 --- a/src/frontend/tests/pui_settings.spec.ts +++ b/src/frontend/tests/pui_settings.spec.ts @@ -198,7 +198,8 @@ test('Settings - Admin - Barcode History', async ({ browser, request }) => { const barcodes = ['ABC1234', 'XYZ5678', 'QRS9012']; barcodes.forEach(async (barcode) => { - await request.post(`${apiUrl}/barcode/`, { + const url = new URL('barcode/', apiUrl).toString(); + await request.post(url, { data: { barcode: barcode }, diff --git a/src/frontend/tests/settings.ts b/src/frontend/tests/settings.ts index 8b035a67f1..2d828f2012 100644 --- a/src/frontend/tests/settings.ts +++ b/src/frontend/tests/settings.ts @@ -14,7 +14,7 @@ export const setSettingState = async ({ setting: string; value: any; }) => { - const url = `${apiUrl}/settings/global/${setting}/`; + const url = new URL(`settings/global/${setting}/`, apiUrl).toString(); const response = await request.patch(url, { data: { @@ -38,7 +38,7 @@ export const setPluginState = async ({ plugin: string; state: boolean; }) => { - const url = `${apiUrl}/plugins/${plugin}/activate/`; + const url = new URL(`plugins/${plugin}/activate/`, apiUrl).toString(); const response = await request.patch(url, { data: { diff --git a/src/frontend/vite.config.ts b/src/frontend/vite.config.ts index d8c4a4e6d7..888cc0b727 100644 --- a/src/frontend/vite.config.ts +++ b/src/frontend/vite.config.ts @@ -21,6 +21,10 @@ const OUTPUT_DIR = '../../src/backend/InvenTree/web/static/web'; // https://vitejs.dev/config/ export default defineConfig(({ command, mode }) => { + // In 'build' mode, we want to use an empty base URL (for static file generation) + const baseUrl: string | undefined = command === 'build' ? '' : undefined; + console.log(`Running Vite in '${command}' mode -> base URL: ${baseUrl}`); + return { plugins: [ react({ @@ -57,7 +61,7 @@ export default defineConfig(({ command, mode }) => { ], // When building, set the base path to an empty string // This is required to ensure that the static path prefix is observed - base: command == 'build' ? '' : undefined, + base: baseUrl, build: { manifest: true, outDir: OUTPUT_DIR,