mirror of
https://github.com/inventree/InvenTree.git
synced 2026-09-02 18:58:37 +00:00
[UI] Page Load Improvements (#12334)
* Parallel initial requests * Remove debouncing * Refactor lazy loading of desktop / mobile view * Reduce initial waiting for loadable components * Fix duplication of API calls * Further reduce duplicate calls * Combine user roles into /user/me/ endpoint * lazy load global import drawer * lazy load table in plugin context * Patch ScanButton * Added playwright tests for login * Adjust thresholds
This commit is contained in:
@@ -38,3 +38,8 @@ export const noaccessuser: UserType = {
|
||||
username: 'noaccess',
|
||||
testcred: 'youshallnotpass'
|
||||
};
|
||||
|
||||
export const engineeruser: UserType = {
|
||||
username: 'engineer',
|
||||
testcred: 'partsonly'
|
||||
};
|
||||
|
||||
@@ -1,9 +1,61 @@
|
||||
import type { Page } from '@playwright/test';
|
||||
import { TOTP } from 'otpauth';
|
||||
import { expect, test } from './baseFixtures.js';
|
||||
import { logoutUrl, noaccessuser } from './defaults.js';
|
||||
import { engineeruser, logoutUrl, noaccessuser } from './defaults.js';
|
||||
import { navigate, openDetailAction } from './helpers.js';
|
||||
import { doLogin } from './login.js';
|
||||
|
||||
import { TOTP } from 'otpauth';
|
||||
const stripQueryAndHash = (url: string): string => {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
return `${parsed.origin}${parsed.pathname}`;
|
||||
} catch {
|
||||
return url.split('?')[0].split('#')[0];
|
||||
}
|
||||
};
|
||||
|
||||
const isScriptOrStyle = (resourceType: string): boolean => {
|
||||
return resourceType === 'script' || resourceType === 'stylesheet';
|
||||
};
|
||||
|
||||
const isCriticalBundle = (url: string): boolean => {
|
||||
if (!/\.(js|css)$/i.test(url)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (/\.map$/i.test(url)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (/(@vite\/client|hot-update)/i.test(url)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return /\/(assets|static)\//i.test(url);
|
||||
};
|
||||
|
||||
const loginAndMeasure = async (page: Page): Promise<number> => {
|
||||
await navigate(page, logoutUrl, { waitUntil: 'load' });
|
||||
await page.waitForURL('**/web/login');
|
||||
|
||||
await page.getByLabel('login-username').fill(noaccessuser.username);
|
||||
await page.getByLabel('login-password').fill(noaccessuser.testcred);
|
||||
|
||||
const start = Date.now();
|
||||
await page.getByRole('button', { name: 'Log In' }).click();
|
||||
|
||||
await page.getByRole('link', { name: 'Dashboard' }).waitFor();
|
||||
await page.getByRole('button', { name: 'navigation-menu' }).waitFor();
|
||||
await page.waitForURL(/\/web(\/home)?/);
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// Ensure dashboard has completely loaded
|
||||
await page.getByText('No Widgets Selected').waitFor();
|
||||
await page.getByRole('button', { name: 'Norman Nothington' }).waitFor();
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
return Date.now() - start;
|
||||
};
|
||||
|
||||
/**
|
||||
* Test various types of login failure
|
||||
@@ -90,6 +142,256 @@ test('Login - Failures', async ({ page }) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Check that page load times do not exceed thresholds for cold/warm/hot login scenarios
|
||||
test('Login - Cold vs Warm vs Hot Load', async ({ page }) => {
|
||||
// Ensure a fresh state for the cold login measurement.
|
||||
await page.context().clearCookies();
|
||||
await navigate(page, logoutUrl, { waitUntil: 'load' });
|
||||
await page.waitForURL('**/web/login');
|
||||
await page.evaluate(() => {
|
||||
localStorage.clear();
|
||||
sessionStorage.clear();
|
||||
});
|
||||
|
||||
// Page load threshold values
|
||||
// Note: Vite server in dev mode is significantly slower than production build
|
||||
const COLD_MS_THRESHOLD: number = 5000;
|
||||
const WARM_MS_THRESHOLD: number = 4000;
|
||||
const HOT_MS_THRESHOLD: number = 3000;
|
||||
|
||||
const coldMs = await loginAndMeasure(page);
|
||||
|
||||
await navigate(page, logoutUrl, { waitUntil: 'load' });
|
||||
await page.waitForURL('**/web/login');
|
||||
|
||||
const warmMs = await loginAndMeasure(page);
|
||||
|
||||
console.log('Cold MS:', coldMs, 'Warm MS:', warmMs);
|
||||
expect(coldMs).toBeLessThan(COLD_MS_THRESHOLD);
|
||||
expect(warmMs).toBeLessThan(WARM_MS_THRESHOLD);
|
||||
|
||||
// Perform a "hot" reload of the dashboard page, which should be faster than the warm login.
|
||||
const start = Date.now();
|
||||
await page.reload();
|
||||
// Ensure dashboard has completely loaded
|
||||
await page.getByText('No Widgets Selected').waitFor();
|
||||
await page.getByRole('button', { name: 'Norman Nothington' }).waitFor();
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
const hotMs = Date.now() - start;
|
||||
|
||||
console.log('Hot MS:', hotMs);
|
||||
expect(hotMs).toBeLessThan(HOT_MS_THRESHOLD);
|
||||
});
|
||||
|
||||
// Check for JS/CSS request failures and duplicate critical bundles during login boot
|
||||
test('Login - JS/CSS Boot Checks', async ({ page }) => {
|
||||
const failedResources: string[] = [];
|
||||
const criticalResourceCounts = new Map<string, number>();
|
||||
|
||||
page.on('requestfailed', (request) => {
|
||||
if (!isScriptOrStyle(request.resourceType())) {
|
||||
return;
|
||||
}
|
||||
|
||||
failedResources.push(
|
||||
`${request.resourceType()} ${request.url()} (${request.failure()?.errorText ?? 'request failed'})`
|
||||
);
|
||||
});
|
||||
|
||||
page.on('requestfinished', async (request) => {
|
||||
if (!isScriptOrStyle(request.resourceType())) {
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await request.response();
|
||||
|
||||
if (!response) {
|
||||
return;
|
||||
}
|
||||
|
||||
const status = response.status();
|
||||
|
||||
if (status >= 400) {
|
||||
failedResources.push(
|
||||
`${request.resourceType()} ${request.url()} (HTTP ${status})`
|
||||
);
|
||||
}
|
||||
|
||||
const normalizedUrl = stripQueryAndHash(request.url());
|
||||
|
||||
if (isCriticalBundle(normalizedUrl)) {
|
||||
const count = criticalResourceCounts.get(normalizedUrl) ?? 0;
|
||||
criticalResourceCounts.set(normalizedUrl, count + 1);
|
||||
}
|
||||
});
|
||||
|
||||
await loginAndMeasure(page);
|
||||
|
||||
const duplicateCriticalBundles = [...criticalResourceCounts.entries()]
|
||||
.filter(([, count]) => count > 1)
|
||||
.map(([url, count]) => `${url} x${count}`);
|
||||
|
||||
expect(
|
||||
failedResources,
|
||||
`JS/CSS failures during login boot:\n${failedResources.join('\n')}`
|
||||
).toEqual([]);
|
||||
expect(
|
||||
duplicateCriticalBundles,
|
||||
`Duplicate critical bundles during login boot:\n${duplicateCriticalBundles.join('\n')}`
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
// Check page redirect after login
|
||||
test('Login - Redirect on Login', async ({ page }) => {
|
||||
await navigate(page, logoutUrl, { waitUntil: 'load' });
|
||||
await page.waitForURL('**/web/login');
|
||||
|
||||
await navigate(page, 'settings/user/account', { waitUntil: 'load' });
|
||||
await page.waitForURL('**/web/login');
|
||||
|
||||
await page.getByLabel('login-username').fill(engineeruser.username);
|
||||
await page.getByLabel('login-password').fill(engineeruser.testcred);
|
||||
await page.getByRole('button', { name: 'Log In' }).click();
|
||||
|
||||
await page.waitForURL('**/web/settings/user/account');
|
||||
await page.getByRole('button', { name: 'action-menu-account' }).waitFor();
|
||||
await page.getByRole('button', { name: 'Robert Shuruncle' }).waitFor();
|
||||
});
|
||||
|
||||
// Test that login session persists across page reload
|
||||
test('Login - Session Persistence', async ({ page }) => {
|
||||
await doLogin(page, {
|
||||
user: engineeruser
|
||||
});
|
||||
|
||||
await page.getByText('Use the menu to add widgets').waitFor();
|
||||
await page.reload();
|
||||
await page.getByRole('button', { name: 'navigation-menu' }).waitFor();
|
||||
|
||||
// Once we logout, the user session has been invalidated
|
||||
await navigate(page, logoutUrl, { waitUntil: 'load' });
|
||||
await page.waitForURL('**/web/login');
|
||||
|
||||
await page.goBack();
|
||||
await page.waitForURL('**/web/login');
|
||||
await page.getByLabel('login-username').waitFor();
|
||||
});
|
||||
|
||||
// Test login session with forced network errors
|
||||
test('Login - Network Errors & Retry', async ({ page }) => {
|
||||
await navigate(page, logoutUrl, { waitUntil: 'load' });
|
||||
await page.waitForURL('**/web/login');
|
||||
|
||||
await page.getByLabel('login-username').fill(engineeruser.username);
|
||||
await page.getByLabel('login-password').fill(engineeruser.testcred);
|
||||
|
||||
const loginEndpoint = /auth\/login/;
|
||||
|
||||
await page.route(loginEndpoint, (route) => {
|
||||
route.fulfill({
|
||||
status: 500,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ detail: 'Simulated server failure' })
|
||||
});
|
||||
});
|
||||
|
||||
const loginButton = page.getByRole('button', { name: 'Log In' });
|
||||
await loginButton.click();
|
||||
|
||||
await page.getByText('Login failed (500)').waitFor();
|
||||
await page.getByText('Simulated server failure').waitFor();
|
||||
await expect(loginButton).toBeEnabled();
|
||||
|
||||
await page.unroute(loginEndpoint);
|
||||
await loginButton.click();
|
||||
await page.getByRole('button', { name: 'navigation-menu' }).waitFor();
|
||||
|
||||
await navigate(page, logoutUrl, { waitUntil: 'load' });
|
||||
await page.waitForURL('**/web/login');
|
||||
await page.getByLabel('login-username').fill(noaccessuser.username);
|
||||
await page.getByLabel('login-password').fill(noaccessuser.testcred);
|
||||
|
||||
await page.route(loginEndpoint, (route) => {
|
||||
route.abort('internetdisconnected');
|
||||
});
|
||||
|
||||
await loginButton.click();
|
||||
await page.getByText('Login failed').first().waitFor();
|
||||
await page.getByText('No response from server.').waitFor();
|
||||
await expect(loginButton).toBeEnabled();
|
||||
await page.getByLabel('login-username').waitFor();
|
||||
|
||||
await page.unroute(loginEndpoint);
|
||||
await loginButton.click();
|
||||
await page.getByRole('button', { name: 'navigation-menu' }).waitFor();
|
||||
});
|
||||
|
||||
// Check for exposed tokens or cookies after login, and ensure session cookie is secure
|
||||
test('Login - Security Regression Checks', async ({ page }) => {
|
||||
await doLogin(page, {
|
||||
user: noaccessuser
|
||||
});
|
||||
|
||||
const url = page.url();
|
||||
|
||||
expect(url).not.toMatch(/[?&](token|access_token|refresh_token|jwt|auth)=/i);
|
||||
|
||||
const storageData = await page.evaluate(() => {
|
||||
return {
|
||||
localStorageEntries: Object.entries(localStorage),
|
||||
sessionStorageEntries: Object.entries(sessionStorage)
|
||||
};
|
||||
});
|
||||
|
||||
const suspiciousStorageEntries = [
|
||||
...storageData.localStorageEntries,
|
||||
...storageData.sessionStorageEntries
|
||||
].filter(([key, value]) => {
|
||||
const combined = `${key} ${value}`;
|
||||
|
||||
return (
|
||||
/(access_token|refresh_token|jwt|bearer)/i.test(combined) ||
|
||||
/^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/.test(value)
|
||||
);
|
||||
});
|
||||
|
||||
expect(suspiciousStorageEntries).toEqual([]);
|
||||
|
||||
const cookies = await page.context().cookies();
|
||||
const sessionCookie = cookies.find((cookie) => cookie.name === 'sessionid');
|
||||
|
||||
expect(sessionCookie).toBeDefined();
|
||||
expect(sessionCookie?.httpOnly).toBeTruthy();
|
||||
expect(['Lax', 'None', 'Strict']).toContain(sessionCookie?.sameSite ?? 'Lax');
|
||||
});
|
||||
|
||||
// Check keyboard navigation of the login screen
|
||||
test('Login - Keyboard Focus', async ({ page }) => {
|
||||
await navigate(page, logoutUrl, { waitUntil: 'load' });
|
||||
await page.waitForURL('**/web/login');
|
||||
|
||||
const username = page.getByLabel('login-username');
|
||||
const password = page.getByLabel('login-password');
|
||||
|
||||
await expect(username).toBeVisible();
|
||||
await expect(password).toBeVisible();
|
||||
|
||||
await username.focus();
|
||||
await page.keyboard.type(noaccessuser.username);
|
||||
await page.keyboard.press('Tab');
|
||||
await expect(password).toBeFocused();
|
||||
await page.keyboard.type(noaccessuser.testcred);
|
||||
await page.keyboard.press('Enter');
|
||||
|
||||
await page.getByRole('button', { name: 'navigation-menu' }).waitFor();
|
||||
|
||||
await navigate(page, logoutUrl, { waitUntil: 'load' });
|
||||
await page.waitForURL('**/web/login');
|
||||
await page.getByRole('button', { name: 'Log In' }).click();
|
||||
await expect(page.getByLabel('login-username')).toBeFocused();
|
||||
});
|
||||
|
||||
test('Login - Change Password', async ({ page }) => {
|
||||
await doLogin(page, {
|
||||
user: noaccessuser
|
||||
|
||||
Reference in New Issue
Block a user