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:
@ -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: {
|
||||
|
@ -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
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -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);
|
||||
|
@ -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',
|
||||
|
@ -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' });
|
||||
};
|
||||
|
||||
/**
|
||||
|
@ -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');
|
||||
};
|
||||
|
@ -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();
|
||||
|
@ -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();
|
||||
|
@ -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
|
||||
},
|
||||
|
@ -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: {
|
||||
|
@ -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,
|
||||
|
Reference in New Issue
Block a user