2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-07-07 14:10:55 +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
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: {

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 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
});
}

View File

@ -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);

View File

@ -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',

View File

@ -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' });
};
/**

View File

@ -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');
};

View File

@ -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();

View File

@ -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();

View File

@ -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
},

View File

@ -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: {

View File

@ -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,