From 7f5a447769f09a5b2e9156e9303ebbf23469659f Mon Sep 17 00:00:00 2001 From: Oliver Date: Sun, 30 Mar 2025 14:12:48 +1100 Subject: [PATCH] [CI] Playwright improvements (#9395) * Allow port 4173 (vite preview) * Change 'base' attr based on vite command * Allow api_host to be specified separately * Harden API host functionality * Adjust server selections * Cleanup vite.config.ts * Adjust playwright configuration - Allow to run in "production" mode - Builds the code first - Runs only the backend web server - Not suitable for coverage * Tweak github actions * Tweak QC file * Reduce number of steps * Tweak CI file * Fix typo * Ensure translation before build * Fix hard-coded test * Test tweaks * uncomment * Revert some changes * Run with gunicorn, single worker * Reduce log output in DEBUG mode * Update deps * Add global-setup func * Fix for .gitignore file * Cached auth state * Tweak login func * Updated tests * Enable parallel workers again * Simplify config * Try with a single worker again * Single retry mode * Run auth setup first - Prevent issues with parallel test doing login * Improve test setup process * Tweaks * Bump to 3 workers * Tweak playwright settings * Revert change * Revert change --- .github/workflows/qc_checks.yaml | 28 ++-- src/backend/InvenTree/InvenTree/config.py | 11 +- src/backend/InvenTree/InvenTree/settings.py | 1 + src/backend/InvenTree/InvenTree/status.py | 10 +- src/frontend/package.json | 4 +- src/frontend/playwright.config.ts | 125 +++++++++++++----- src/frontend/playwright/.gitignore | 3 + src/frontend/playwright/global-setup.ts | 45 +++++++ src/frontend/src/App.tsx | 4 +- .../src/components/buttons/EditButton.tsx | 25 ---- .../src/components/forms/InstanceOptions.tsx | 54 +++++--- .../src/components/images/ApiImage.tsx | 6 +- src/frontend/src/functions/auth.tsx | 8 +- src/frontend/src/functions/urls.tsx | 4 +- src/frontend/src/main.tsx | 27 ++-- src/frontend/src/states/LocalState.tsx | 23 ++++ src/frontend/tests/helpers.ts | 5 +- src/frontend/tests/login.ts | 78 ++++++++--- src/frontend/tests/modals.spec.ts | 52 -------- src/frontend/tests/pages/pui_build.spec.ts | 34 ++--- src/frontend/tests/pages/pui_company.spec.ts | 6 +- src/frontend/tests/pages/pui_core.spec.ts | 8 +- .../tests/pages/pui_dashboard.spec.ts | 10 +- src/frontend/tests/pages/pui_part.spec.ts | 103 ++++++--------- .../tests/pages/pui_purchase_order.spec.ts | 53 +++++--- .../tests/pages/pui_sales_order.spec.ts | 29 ++-- src/frontend/tests/pages/pui_scan.spec.ts | 51 +++---- src/frontend/tests/pages/pui_stock.spec.ts | 37 +++--- src/frontend/tests/pui_exporting.spec.ts | 20 +-- src/frontend/tests/pui_forms.spec.ts | 21 ++- src/frontend/tests/pui_general.spec.ts | 20 ++- src/frontend/tests/pui_login.spec.ts | 42 +----- ...pui_command.spec.ts => pui_modals.spec.ts} | 78 +++++++++-- src/frontend/tests/pui_plugins.spec.ts | 44 ++++-- src/frontend/tests/pui_printing.spec.ts | 22 +-- src/frontend/tests/pui_settings.spec.ts | 100 +++++++------- src/frontend/tests/pui_tables.spec.ts | 18 +-- .../tests/settings/selectionList.spec.ts | 9 +- src/frontend/vite.config.ts | 113 +++++++++------- src/frontend/yarn.lock | 38 +++++- 40 files changed, 794 insertions(+), 575 deletions(-) create mode 100644 src/frontend/playwright/.gitignore create mode 100644 src/frontend/playwright/global-setup.ts delete mode 100644 src/frontend/src/components/buttons/EditButton.tsx delete mode 100644 src/frontend/tests/modals.spec.ts rename src/frontend/tests/{pui_command.spec.ts => pui_modals.spec.ts} (55%) diff --git a/.github/workflows/qc_checks.yaml b/.github/workflows/qc_checks.yaml index 989fe0d8cf..458fa9a25a 100644 --- a/.github/workflows/qc_checks.yaml +++ b/.github/workflows/qc_checks.yaml @@ -554,7 +554,7 @@ jobs: chmod +rw /home/runner/work/InvenTree/db.sqlite3 invoke migrate - platform_ui: + web_ui: name: Tests - Web UI runs-on: ubuntu-24.04 timeout-minutes: 60 @@ -584,7 +584,6 @@ jobs: INVENTREE_DB_PASSWORD: inventree_password INVENTREE_DEBUG: true INVENTREE_PLUGINS_ENABLED: false - VITE_COVERAGE: true steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # pin@v4.2.2 @@ -599,13 +598,19 @@ jobs: apt-dependency: postgresql-client libpq-dev pip-dependency: psycopg2 - name: Set up test data - run: invoke dev.setup-test -iv - - name: Rebuild thumbnails - run: invoke int.rebuild-thumbnails + run: | + invoke dev.setup-test -iv + invoke int.rebuild-thumbnails - name: Install dependencies - run: invoke int.frontend-compile - - name: Install Playwright Browsers - run: cd src/frontend && npx playwright install --with-deps + run: | + invoke int.frontend-compile + cd src/frontend && npx playwright install --with-deps + - name: Set Production Mode + if: github.event_name == 'pull_request' + run: echo "VITE_PRODUCTION_BUILD=true" >> $GITHUB_ENV + - name: Set Coverage Mode + if: github.event_name != 'pull_request' + run: echo "VITE_COVERAGE_BUILD=true" >> $GITHUB_ENV - name: Run Playwright tests id: tests run: cd src/frontend && npx nyc playwright test @@ -616,16 +621,17 @@ jobs: path: src/frontend/playwright-report/ retention-days: 14 - name: Report coverage - if: always() + if: github.event_name != 'pull_request' run: cd src/frontend && npx nyc report --report-dir ./coverage --temp-dir .nyc_output --reporter=lcov --exclude-after-remap false - name: Upload coverage reports to Codecov uses: codecov/codecov-action@0565863a31f2c772f9f0395002a31e3f06189574 # pin@v5.4.0 - if: always() + if: github.event_name != 'pull_request' with: token: ${{ secrets.CODECOV_TOKEN }} slug: inventree/InvenTree flags: web - name: Upload bundler info + if: github.event_name != 'pull_request' env: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} run: | @@ -633,7 +639,7 @@ jobs: yarn install yarn run build - platform_ui_build: + web_ui_build: name: Build - Web UI runs-on: ubuntu-24.04 timeout-minutes: 60 diff --git a/src/backend/InvenTree/InvenTree/config.py b/src/backend/InvenTree/InvenTree/config.py index 7a0d42ce83..5e2ee8eddb 100644 --- a/src/backend/InvenTree/InvenTree/config.py +++ b/src/backend/InvenTree/InvenTree/config.py @@ -406,13 +406,22 @@ def get_frontend_settings(debug=True): 'INVENTREE_FRONTEND_SETTINGS', 'frontend_settings', {}, typecast=dict ) - # Set the base URL + # Set the base URL for the user interface + # This is the UI path e.g. '/web/' if 'base_url' not in frontend_settings: frontend_settings['base_url'] = ( get_setting('INVENTREE_FRONTEND_URL_BASE', 'frontend_url_base', 'web') or 'web' ) + # If provided, specify the API host + api_host = frontend_settings.get('api_host', None) or get_setting( + 'INVENTREE_FRONTEND_API_HOST', 'frontend_api_host', None + ) + + if api_host: + frontend_settings['api_host'] = api_host + # Set the server list frontend_settings['server_list'] = frontend_settings.get('server_list', []) diff --git a/src/backend/InvenTree/InvenTree/settings.py b/src/backend/InvenTree/InvenTree/settings.py index 617a39a028..348830bc02 100644 --- a/src/backend/InvenTree/InvenTree/settings.py +++ b/src/backend/InvenTree/InvenTree/settings.py @@ -1088,6 +1088,7 @@ if DEBUG: 'http://localhost', 'http://*.localhost', 'http://*localhost:8000', + 'http://*localhost:4173', 'http://*localhost:5173', ]: if origin not in CSRF_TRUSTED_ORIGINS: diff --git a/src/backend/InvenTree/InvenTree/status.py b/src/backend/InvenTree/InvenTree/status.py index c2fd3943b7..b143ac0ee8 100644 --- a/src/backend/InvenTree/InvenTree/status.py +++ b/src/backend/InvenTree/InvenTree/status.py @@ -2,6 +2,7 @@ from datetime import timedelta +from django.conf import settings from django.utils import timezone import structlog @@ -60,13 +61,16 @@ def check_system_health(**kwargs): if not is_worker_running(**kwargs): # pragma: no cover result = False - logger.warning('Background worker check failed') + if not settings.DEBUG: + logger.warning('Background worker check failed') if not InvenTree.helpers_email.is_email_configured(): # pragma: no cover result = False - logger.warning('Email backend not configured') + if not settings.DEBUG: + logger.warning('Email backend not configured') if not result: # pragma: no cover - logger.warning('InvenTree system health checks failed') + if not settings.DEBUG: + logger.warning('InvenTree system health checks failed') return result diff --git a/src/frontend/package.json b/src/frontend/package.json index 9da89d07e3..e8d7c3961e 100644 --- a/src/frontend/package.json +++ b/src/frontend/package.json @@ -41,6 +41,7 @@ "@mantine/notifications": "^7.16.0", "@mantine/spotlight": "^7.16.0", "@mantine/vanilla-extract": "^7.16.0", + "@messageformat/date-skeleton": "^1.1.0", "@sentry/react": "^8.43.0", "@tabler/icons-react": "^3.17.0", "@tanstack/react-query": "^5.56.2", @@ -83,7 +84,7 @@ "@lingui/cli": "^5.3.0", "@lingui/macro": "^5.3.0", "@playwright/test": "^1.49.1", - "@types/node": "^22.6.0", + "@types/node": "^22.13.14", "@types/qrcode": "^1.5.5", "@types/react": "^18.3.8", "@types/react-dom": "^18.3.0", @@ -94,6 +95,7 @@ "@vitejs/plugin-react": "^4.3.4", "babel-plugin-macros": "^3.1.0", "nyc": "^17.1.0", + "path": "^0.12.7", "rollup": "^4.0.0", "rollup-plugin-license": "^3.5.3", "typescript": "^5.8.2", diff --git a/src/frontend/playwright.config.ts b/src/frontend/playwright.config.ts index c7f9bf6e4a..e65b8c11d5 100644 --- a/src/frontend/playwright.config.ts +++ b/src/frontend/playwright.config.ts @@ -1,13 +1,96 @@ import { defineConfig, devices } from '@playwright/test'; +import type TestConfigWebServer from '@playwright/test'; + +// Detect if running in CI +const IS_CI = !!process.env.CI; +const IS_COVERAGE = !!process.env.VITE_COVERAGE_BUILD; + +// If specified, tests will be run against the production build +const IS_PRODUCTION = !!process.env.VITE_PRODUCTION_BUILD; + +console.log('Running Playwright tests:'); +console.log(` - CI Mode: ${IS_CI}`); +console.log(` - Coverage Mode: ${IS_COVERAGE}`); +console.log(` - Production Mode: ${IS_PRODUCTION}`); + +const MAX_WORKERS: number = 3; +const MAX_RETRIES: number = 3; + +/* We optionally spin-up services based on the testing mode: + * + * Local Development: + * - If running locally (developer mode), we run "vite dev" with HMR enabled + * - This allows playwright to monitor the code for changes + * - WORKERS = 1 (to avoid conflicts with HMR) + * + * CI Mode (Production): + * - 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): + * - In coverage mode (GitHub actions), we cannot compile the code first + * - This is because we need to compile coverage report against ./src directory + * - So, we run "vite dev" with coverage enabled (similar to the local dev mode) + * - WORKERS = 1 (to avoid conflicts with HMR) + */ + +const devServer: TestConfigWebServer = { + command: 'yarn run dev --host --port 5173', + url: 'http://localhost:5173', + reuseExistingServer: IS_CI, + stdout: 'pipe', + stderr: 'pipe', + timeout: 120 * 1000 +}; + +const WEB_BUILD_CMD: string = + 'yarn run extract && yarn run compile && yarn run build'; + +// 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_PRODUCTION + ? `${WEB_BUILD_CMD} && 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 webServer: TestConfigWebServer = { + // If running in production mode, we need to build the frontend first + command: WEB_SERVER_CMD, + env: { + INVENTREE_DEBUG: 'True', + 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_LOGIN_ATTEMPTS: '100' + }, + url: 'http://localhost:8000/api/', + reuseExistingServer: IS_CI, + stdout: 'pipe', + stderr: 'pipe', + timeout: 120 * 1000 +}; + +const serverList: TestConfigWebServer[] = []; + +if (!IS_PRODUCTION) { + serverList.push(devServer); +} + +serverList.push(webServer); export default defineConfig({ testDir: './tests', - fullyParallel: true, + fullyParallel: false, timeout: 90000, - forbidOnly: !!process.env.CI, - retries: process.env.CI ? 3 : 0, - workers: 1, // process.env.CI ? 3 : undefined, - reporter: process.env.CI ? [['html', { open: 'never' }], ['github']] : 'list', + forbidOnly: !!IS_CI, + retries: IS_CI ? MAX_RETRIES : 0, + workers: IS_PRODUCTION ? MAX_WORKERS : 1, + reporter: IS_CI ? [['html', { open: 'never' }], ['github']] : 'list', /* Configure projects for major browsers */ projects: [ @@ -26,35 +109,11 @@ export default defineConfig({ ], /* Run your local dev server before starting the tests */ - webServer: [ - { - command: 'yarn run dev --host', - url: 'http://localhost:5173', - reuseExistingServer: !process.env.CI, - stdout: 'pipe', - stderr: 'pipe', - timeout: 120 * 1000 - }, - { - command: 'invoke dev.server -a 127.0.0.1:8000', - env: { - INVENTREE_DEBUG: 'True', - INVENTREE_PLUGINS_ENABLED: 'True', - INVENTREE_ADMIN_URL: 'test-admin', - INVENTREE_SITE_URL: 'http://localhost:8000', - INVENTREE_CORS_ORIGIN_ALLOW_ALL: 'True', - INVENTREE_COOKIE_SAMESITE: 'Lax', - INVENTREE_LOGIN_ATTEMPTS: '100' - }, - url: 'http://127.0.0.1:8000/api/', - reuseExistingServer: !process.env.CI, - stdout: 'pipe', - stderr: 'pipe', - timeout: 120 * 1000 - } - ], + webServer: serverList, + globalSetup: './playwright/global-setup.ts', use: { - baseURL: 'http://localhost:5173', + baseURL: IS_PRODUCTION ? 'http://localhost:8000' : 'http://localhost:5173', + headless: IS_PRODUCTION ? true : undefined, trace: 'on-first-retry' } }); diff --git a/src/frontend/playwright/.gitignore b/src/frontend/playwright/.gitignore new file mode 100644 index 0000000000..6f8ac65511 --- /dev/null +++ b/src/frontend/playwright/.gitignore @@ -0,0 +1,3 @@ +# Ignore auth cache +auth/ +*.json diff --git a/src/frontend/playwright/global-setup.ts b/src/frontend/playwright/global-setup.ts new file mode 100644 index 0000000000..64503127d1 --- /dev/null +++ b/src/frontend/playwright/global-setup.ts @@ -0,0 +1,45 @@ +import { type FullConfig, chromium } from '@playwright/test'; + +import fs from 'node:fs'; +import path from 'node:path'; +import { doCachedLogin } from '../tests/login'; + +async function globalSetup(config: FullConfig) { + const authDir = path.resolve('./playwright/auth'); + + if (fs.existsSync(authDir)) { + // Clear out the cached authentication states + fs.rm(path.resolve('./playwright/auth'), { recursive: true }, (err) => { + if (err) { + console.error('Failed to clear out cached authentication states:', err); + } else { + console.log('Removed cached authentication states'); + } + }); + } + + // Perform login for each user + const browser = await chromium.launch(); + + await doCachedLogin(browser, { + username: 'admin', + password: 'inventree' + }); + + await doCachedLogin(browser, { + username: 'allaccess', + password: 'nolimits' + }); + + await doCachedLogin(browser, { + username: 'reader', + password: 'readonly' + }); + + await doCachedLogin(browser, { + username: 'steven', + password: 'wizardstaff' + }); +} + +export default globalSetup; diff --git a/src/frontend/src/App.tsx b/src/frontend/src/App.tsx index da346b7bf0..cbaae4965a 100644 --- a/src/frontend/src/App.tsx +++ b/src/frontend/src/App.tsx @@ -10,9 +10,9 @@ export const api = axios.create({}); * Setup default settings for the Axios API instance. */ export function setApiDefaults() { - const { host } = useLocalState.getState(); + const { getHost } = useLocalState.getState(); - api.defaults.baseURL = host; + api.defaults.baseURL = getHost(); api.defaults.timeout = 5000; api.defaults.withCredentials = true; diff --git a/src/frontend/src/components/buttons/EditButton.tsx b/src/frontend/src/components/buttons/EditButton.tsx deleted file mode 100644 index 7a941d3bb3..0000000000 --- a/src/frontend/src/components/buttons/EditButton.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import { ActionIcon } from '@mantine/core'; -import { IconDeviceFloppy, IconEdit } from '@tabler/icons-react'; - -export function EditButton({ - setEditing, - editing, - disabled, - saveIcon -}: Readonly<{ - setEditing: (value?: React.SetStateAction | undefined) => void; - editing: boolean; - disabled?: boolean; - saveIcon?: JSX.Element; -}>) { - saveIcon = saveIcon || ; - return ( - setEditing()} - disabled={disabled} - variant='default' - > - {editing ? saveIcon : } - - ); -} diff --git a/src/frontend/src/components/forms/InstanceOptions.tsx b/src/frontend/src/components/forms/InstanceOptions.tsx index e5b125c03a..0fe361333d 100644 --- a/src/frontend/src/components/forms/InstanceOptions.tsx +++ b/src/frontend/src/components/forms/InstanceOptions.tsx @@ -1,10 +1,19 @@ import { t } from '@lingui/core/macro'; import { Trans } from '@lingui/react/macro'; -import { ActionIcon, Divider, Group, Select, Table, Text } from '@mantine/core'; +import { + ActionIcon, + Divider, + Group, + Select, + Table, + Text, + Tooltip +} from '@mantine/core'; import { useToggle } from '@mantine/hooks'; import { IconApi, - IconCheck, + IconCircleCheck, + IconEdit, IconInfoCircle, IconPlugConnected, IconServer, @@ -15,7 +24,7 @@ import { Wrapper } from '../../pages/Auth/Layout'; import { useServerApiState } from '../../states/ApiState'; import { useLocalState } from '../../states/LocalState'; import type { HostList } from '../../states/states'; -import { EditButton } from '../buttons/EditButton'; +import { ActionButton } from '../buttons/ActionButton'; import { HostOptionsForm } from './HostOptionsForm'; export function InstanceOptions({ @@ -27,7 +36,7 @@ export function InstanceOptions({ ChangeHost: (newHost: string | null) => void; setHostEdit: () => void; }>) { - const [HostListEdit, setHostListEdit] = useToggle([false, true] as const); + const [hostListEdit, setHostListEdit] = useToggle([false, true] as const); const [setHost, setHostList, hostList] = useLocalState((state) => [ state.setHost, state.setHostList, @@ -48,27 +57,36 @@ export function InstanceOptions({ return ( - +