2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-07-07 06:00:57 +00:00

[bug] Playwright fixes (#9933)

* Fixes for playwright testing

- Ensure cookies are completely cleaned  between sessions
- Fix base URL based on vite command
- Fix samesite cookie mode
- Prevent /static/ files being served by web server on :8000

* Remove gunicorn option

* Readjust base URL

* Simplify doCachedLogin

* Fix logic func

* Revert webserver cmd

* Set base URL in playwrightconfig file

* Fix URL checks

* Fix URL definitions

* adjust playwright base URL

* Tweak for URL helper

* Further login tweaks

* Tweak test

* wait for API before starting tests

* Handle error

* Adjust login functions

* Don't use gunicorn

- But still use the webserver to serve static files in CI

* Enhanced login functions

* Tweak login tests

* Fix broken test

* Flipped the flippies
This commit is contained in:
Oliver
2025-07-02 22:12:17 +10:00
committed by GitHub
parent 2ce7e225ad
commit 3f14ae3f7d
11 changed files with 169 additions and 87 deletions

View File

@ -3,9 +3,6 @@ import { defineConfig, devices } from '@playwright/test';
// Detect if running in CI // Detect if running in CI
const IS_CI = !!process.env.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_WORKERS: number = 3;
const MAX_RETRIES: 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 * - 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 build is then served by a local server for testing
* - This allows the tests to run much faster and with parallel workers * - 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) * - WORKERS = MAX_WORKERS (to speed up the tests)
* *
* CI Mode (Coverage): * CI Mode (Coverage):
@ -30,11 +26,13 @@ const MAX_RETRIES: number = 3;
* - WORKERS = 1 (to avoid conflicts with HMR) * - WORKERS = 1 (to avoid conflicts with HMR)
*/ */
// Command to spin-up the backend server const BASE_URL: string = IS_CI
// In production mode, we want a stronger webserver to handle multiple requests ? 'http://localhost:8000'
const WEB_SERVER_CMD: string = IS_CI : 'http://localhost:5173';
? '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'; console.log('Running Playwright Tests:');
console.log(`- CI Mode: ${IS_CI}`);
console.log('- Base URL:', BASE_URL);
export default defineConfig({ export default defineConfig({
testDir: './tests', testDir: './tests',
@ -72,15 +70,16 @@ export default defineConfig({
timeout: 120 * 1000 timeout: 120 * 1000
}, },
{ {
command: WEB_SERVER_CMD, command: 'invoke dev.server',
env: { env: {
INVENTREE_DEBUG: 'True', INVENTREE_DEBUG: 'True',
INVENTREE_LOG_LEVEL: 'WARNING',
INVENTREE_PLUGINS_ENABLED: 'True', INVENTREE_PLUGINS_ENABLED: 'True',
INVENTREE_ADMIN_URL: 'test-admin', INVENTREE_ADMIN_URL: 'test-admin',
INVENTREE_SITE_URL: 'http://localhost:8000', INVENTREE_SITE_URL: 'http://localhost:8000',
INVENTREE_FRONTEND_API_HOST: 'http://localhost:8000', INVENTREE_FRONTEND_API_HOST: 'http://localhost:8000',
INVENTREE_CORS_ORIGIN_ALLOW_ALL: 'True', INVENTREE_CORS_ORIGIN_ALLOW_ALL: 'True',
INVENTREE_COOKIE_SAMESITE: 'Lax', INVENTREE_COOKIE_SAMESITE: 'False',
INVENTREE_LOGIN_ATTEMPTS: '100' INVENTREE_LOGIN_ATTEMPTS: '100'
}, },
url: 'http://localhost:8000/api/', url: 'http://localhost:8000/api/',
@ -92,7 +91,7 @@ export default defineConfig({
], ],
globalSetup: './playwright/global-setup.ts', globalSetup: './playwright/global-setup.ts',
use: { use: {
baseURL: 'http://localhost:5173', baseURL: BASE_URL,
headless: IS_CI ? true : undefined, headless: IS_CI ? true : undefined,
trace: 'on-first-retry', trace: 'on-first-retry',
contextOptions: { contextOptions: {

View File

@ -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 fs from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import { apiUrl } from '../tests/defaults';
import { doCachedLogin } from '../tests/login'; import { doCachedLogin } from '../tests/login';
async function globalSetup(config: FullConfig) { async function globalSetup(config: FullConfig) {
@ -18,27 +19,53 @@ async function globalSetup(config: FullConfig) {
}); });
} }
// Perform login for each user const baseUrl = config.projects[0].use?.baseURL || 'http://localhost:5173';
const browser = await chromium.launch(); 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', username: 'admin',
password: 'inventree' password: 'inventree',
baseUrl: baseUrl
}); });
await doCachedLogin(browser, { await doCachedLogin(await chromium.launch(), {
username: 'allaccess', username: 'allaccess',
password: 'nolimits' password: 'nolimits',
baseUrl: baseUrl
}); });
await doCachedLogin(browser, { await doCachedLogin(await chromium.launch(), {
username: 'reader', username: 'reader',
password: 'readonly' password: 'readonly',
baseUrl: baseUrl
}); });
await doCachedLogin(browser, { await doCachedLogin(await chromium.launch(), {
username: 'steven', username: 'steven',
password: 'wizardstaff' password: 'wizardstaff',
baseUrl: baseUrl
}); });
} }

View File

@ -52,7 +52,7 @@ export const test = baseTest.extend({
} }
}, },
// Ensure no errors are thrown in the console // Ensure no errors are thrown in the console
page: async ({ baseURL, page }, use) => { page: async ({ page }, use) => {
const messages = []; const messages = [];
page.on('console', (msg) => { page.on('console', (msg) => {
const url = msg.location().url; const url = msg.location().url;
@ -67,20 +67,20 @@ export const test = baseTest.extend({
) < 0 && ) < 0 &&
msg.text() != msg.text() !=
'Failed to load resource: the server responded with a status of 400 (Bad Request)' && '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') && !msg.text().includes('/this/does/not/exist.js') &&
url != 'http://localhost:8000/this/does/not/exist.js' && !url.includes('/this/does/not/exist.js') &&
url != 'http://localhost:8000/api/user/me/' && !url.includes('/api/user/me/') &&
url != 'http://localhost:8000/api/user/token/' && !url.includes('/api/user/token/') &&
url != 'http://localhost:8000/api/auth/v1/auth/login' && !url.includes('/api/auth/v1/auth/login') &&
url != 'http://localhost:8000/api/auth/v1/auth/session' && !url.includes('/api/auth/v1/auth/session') &&
url != 'http://localhost:8000/api/auth/v1/account/password/change' && !url.includes('/api/auth/v1/account/password/change') &&
url != 'http://localhost:8000/api/barcode/' && !url.includes('/api/barcode/') &&
url != 'https://docs.inventree.org/en/versions.json' && !url.includes('/favicon.ico') &&
url != 'http://localhost:5173/favicon.ico' &&
!url.startsWith('https://api.github.com/repos/inventree') && !url.startsWith('https://api.github.com/repos/inventree') &&
!url.startsWith('http://localhost:8000/api/news/') && !url.includes('/api/news/') &&
!url.startsWith('http://localhost:8000/api/notifications/') && !url.includes('/api/notifications/') &&
!url.startsWith('chrome://') && !url.startsWith('chrome://') &&
url != 'https://docs.inventree.org/en/versions.json' &&
url.indexOf('99999') < 0 url.indexOf('99999') < 0
) )
messages.push(msg); messages.push(msg);

View File

@ -1,10 +1,11 @@
export const classicUrl = 'http://127.0.0.1:8000'; export const webUrl = '/web';
export const apiUrl = `${classicUrl}/api`; // Note: API requests are handled by the backend server
export const baseUrl = './web'; export const apiUrl = 'http://localhost:8000/api/';
export const loginUrl = `${baseUrl}/login`;
export const logoutUrl = `${baseUrl}/logout`; export const homeUrl = `${webUrl}/home`;
export const homeUrl = `${baseUrl}/home`; export const loginUrl = `${webUrl}/login`;
export const logoutUrl = `${webUrl}/logout`;
export const user = { export const user = {
name: 'Ally Access', name: 'Ally Access',

View File

@ -1,5 +1,3 @@
import { baseUrl } from './defaults';
/** /**
* Open the filter drawer for the currently visible table * Open the filter drawer for the currently visible table
* @param page - The page object * @param page - The page object
@ -73,21 +71,30 @@ export const clickOnRowMenu = async (cell) => {
await row.getByLabel(/row-action-menu-/i).click(); 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 * Navigate to the provided page, and wait for loading to complete
* @param page * @param page
* @param url * @param url
*/ */
export const navigate = async (page, url: string) => { export const navigate = async (
if (!url.startsWith(baseUrl)) { page,
if (url.startsWith('/')) { url: string,
url = url.slice(1); options?: NavigateOptions
) => {
if (!url.startsWith('http') && !url.includes('web')) {
url = `/web/${url}`.replaceAll('//', '/');
} }
url = `${baseUrl}/${url}`; const path: string = options?.baseUrl
} ? new URL(url, options.baseUrl).toString()
: url;
await page.goto(url); await page.goto(path, { waitUntil: options?.waitUntil ?? 'load' });
}; };
/** /**

View File

@ -1,33 +1,53 @@
import type { Browser, Page } from '@playwright/test'; import type { Browser, Page } from '@playwright/test';
import { expect } from './baseFixtures.js'; import { loginUrl, logoutUrl, user, webUrl } from './defaults';
import { user } from './defaults';
import { navigate } from './helpers.js';
import fs from 'node:fs'; import fs from 'node:fs';
import path from 'node:path'; 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 * Perform form based login operation from the "login" URL
*/ */
export const doLogin = async (page, username?: string, password?: string) => { export const doLogin = async (page, options?: LoginOptions) => {
username = username ?? user.username; const username: string = options?.username ?? user.username;
password = password ?? user.password; 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.waitForURL('**/web/login');
await page.getByLabel('username').fill(username); await page.getByLabel('username').fill(username);
await page.getByLabel('password').fill(password); await page.getByLabel('password').fill(password);
await page.waitForTimeout(100);
await page.getByRole('button', { name: 'Log in' }).click(); 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 { export interface CachedLoginOptions {
username?: string; username?: string;
password?: string; password?: string;
url?: string; url?: string;
baseUrl?: string;
} }
// Set of users allowed to do cached login // Set of users allowed to do cached login
@ -58,9 +78,17 @@ export const doCachedLogin = async (
storageState: fn storageState: fn
}); });
console.log(`Using cached login state for ${username}`); console.log(`Using cached login state for ${username}`);
await navigate(page, url);
await page.waitForURL('**/web/**'); await navigate(page, url ?? webUrl, {
await page.waitForLoadState('load'); 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; return page;
} }
@ -69,29 +97,37 @@ export const doCachedLogin = async (
console.log(`No cache found - logging in for ${username}`); console.log(`No cache found - logging in for ${username}`);
// Ensure we start from the login page // Completely clear the browser cache and cookies, etc
await page.goto('http://localhost:8000/web/', { waitUntil: 'load' }); 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.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'); await page.waitForLoadState('load');
// Cache the login state // Cache the login state
await page.context().storageState({ path: fn }); await page.context().storageState({ path: fn });
if (url) { if (url) {
await navigate(page, url); await navigate(page, url, { baseUrl: options?.baseUrl });
} }
return page; return page;
}; };
export const doLogout = async (page) => { interface LogoutOptions {
await page.goto('http://localhost:8000/web/logout', { waitUntil: 'load' }); baseUrl?: string;
}
export const doLogout = async (page, options?: LogoutOptions) => {
await navigate(page, logoutUrl, {
baseUrl: options?.baseUrl,
waitUntil: 'load'
});
await page.waitForURL('**/web/login'); await page.waitForURL('**/web/login');
}; };

View File

@ -265,7 +265,7 @@ test('Parts - Pricing (Nothing, BOM)', async ({ browser }) => {
await page.getByRole('button', { name: 'Supplier Pricing' }).isDisabled(); await page.getByRole('button', { name: 'Supplier Pricing' }).isDisabled();
// Part with history // 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 page.getByText('A chair - with blue paint').waitFor();
await loadTab(page, 'Part Pricing'); await loadTab(page, 'Part Pricing');
await page.getByLabel('Part Pricing').getByText('Part Pricing').waitFor(); await page.getByLabel('Part Pricing').getByText('Part Pricing').waitFor();

View File

@ -45,13 +45,13 @@ test('Login - Failures', async ({ page }) => {
}); });
test('Login - Change Password', async ({ page }) => { test('Login - Change Password', async ({ page }) => {
await doLogin(page, 'noaccess', 'youshallnotpass'); await doLogin(page, {
await page.waitForLoadState('networkidle'); username: 'noaccess',
password: 'youshallnotpass'
});
// Navigate to the 'change password' page // Navigate to the 'change password' page
await navigate(page, 'settings/user/account'); await navigate(page, 'settings/user/account', { waitUntil: 'networkidle' });
await page.waitForLoadState('networkidle');
await page.getByLabel('action-menu-account-actions').click(); await page.getByLabel('action-menu-account-actions').click();
await page.getByLabel('action-menu-account-actions-change-password').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 too short').waitFor();
await page.getByText('This password is entirely numeric').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('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.getByLabel('input-password-2').fill('youshallnotpass');
await page.getByRole('button', { name: 'Confirm' }).click(); await page.getByRole('button', { name: 'Confirm' }).click();
await page.getByText('Password Changed').waitFor(); await page.getByText('Password Changed').waitFor();

View File

@ -198,7 +198,8 @@ test('Settings - Admin - Barcode History', async ({ browser, request }) => {
const barcodes = ['ABC1234', 'XYZ5678', 'QRS9012']; const barcodes = ['ABC1234', 'XYZ5678', 'QRS9012'];
barcodes.forEach(async (barcode) => { barcodes.forEach(async (barcode) => {
await request.post(`${apiUrl}/barcode/`, { const url = new URL('barcode/', apiUrl).toString();
await request.post(url, {
data: { data: {
barcode: barcode barcode: barcode
}, },

View File

@ -14,7 +14,7 @@ export const setSettingState = async ({
setting: string; setting: string;
value: any; value: any;
}) => { }) => {
const url = `${apiUrl}/settings/global/${setting}/`; const url = new URL(`settings/global/${setting}/`, apiUrl).toString();
const response = await request.patch(url, { const response = await request.patch(url, {
data: { data: {
@ -38,7 +38,7 @@ export const setPluginState = async ({
plugin: string; plugin: string;
state: boolean; state: boolean;
}) => { }) => {
const url = `${apiUrl}/plugins/${plugin}/activate/`; const url = new URL(`plugins/${plugin}/activate/`, apiUrl).toString();
const response = await request.patch(url, { const response = await request.patch(url, {
data: { data: {

View File

@ -21,6 +21,10 @@ const OUTPUT_DIR = '../../src/backend/InvenTree/web/static/web';
// https://vitejs.dev/config/ // https://vitejs.dev/config/
export default defineConfig(({ command, mode }) => { 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 { return {
plugins: [ plugins: [
react({ react({
@ -57,7 +61,7 @@ export default defineConfig(({ command, mode }) => {
], ],
// When building, set the base path to an empty string // When building, set the base path to an empty string
// This is required to ensure that the static path prefix is observed // This is required to ensure that the static path prefix is observed
base: command == 'build' ? '' : undefined, base: baseUrl,
build: { build: {
manifest: true, manifest: true,
outDir: OUTPUT_DIR, outDir: OUTPUT_DIR,