Merge branch 'master' into block-notes

This commit is contained in:
Oliver 2026-06-06 09:10:15 +10:00 committed by GitHub
commit 002a0b32bc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
26 changed files with 635 additions and 323 deletions

View File

@ -106,7 +106,7 @@ runs:
- name: Run invoke update
if: ${{ inputs.update == 'true' }}
shell: bash
run: invoke update --skip-backup --skip-static
run: invoke update --skip-backup --skip-static --no-frontend
- name: Collect static files
if: ${{ inputs.static == 'true' }}
shell: bash

306
.github/workflows/frontend.yaml vendored Normal file
View File

@ -0,0 +1,306 @@
# Playwright testing for frontend code
# Runs the following tests:
# - Playwright tests in Firefox (against compiled frontend code)
# - Playwright tests in Chromium (coverage enabled, against vite frontend code)
# - Build frontend code and upload as artifact
name: Frontend
on:
push:
branches-ignore: ["l10*"]
pull_request:
branches-ignore: ["l10*"]
env:
python_version: 3.11
node_version: 24
# The OS version must be set per job
server_start_sleep: 60
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
INVENTREE_DB_ENGINE: postgresql
INVENTREE_DB_NAME: inventree
INVENTREE_DB_HOST: "127.0.0.1"
INVENTREE_DB_PORT: 5432
INVENTREE_DB_USER: inventree_user
INVENTREE_DB_PASSWORD: inventree_password
INVENTREE_DEBUG: true
INVENTREE_PLUGINS_ENABLED: false
INVENTREE_MEDIA_ROOT: /home/runner/work/InvenTree/test_inventree_media
INVENTREE_STATIC_ROOT: /home/runner/work/InvenTree/test_inventree_static
INVENTREE_BACKUP_DIR: /home/runner/work/InvenTree/test_inventree_backup
INVENTREE_SITE_URL: http://localhost:8000
permissions:
contents: read
jobs:
paths-filter:
name: Filter
runs-on: ubuntu-latest
outputs:
frontend: ${{ steps.filter.outputs.frontend }}
force: ${{ steps.force.outputs.force }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # pin@v6.0.2
with:
persist-credentials: false
- uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # pin@v4.0.1
id: filter
with:
filters: |
frontend:
- 'src/frontend/**'
- name: Is CI being forced?
run: echo "force=true" >> $GITHUB_OUTPUT
id: force
if: |
contains(github.event.pull_request.labels.*.name, 'dependency') ||
contains(github.event.pull_request.labels.*.name, 'full-run')
build:
name: Build
runs-on: ubuntu-24.04
timeout-minutes: 60
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # pin@v6.0.2
with:
persist-credentials: false
- name: Environment Setup
uses: ./.github/actions/setup
with:
npm: true
- name: Install dependencies
run: cd src/frontend && yarn install
- name: Build frontend
run: cd src/frontend && yarn run compile && yarn run lib && yarn run build
- name: Write version file - SHA
run: cd src/backend/InvenTree/web/static/web/.vite && echo "$GITHUB_SHA" > sha.txt
- name: Zip frontend
run: |
cd src/backend/InvenTree/web/static
zip -r frontend-build.zip web/ web/.vite
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # pin@v7.0.1
with:
name: frontend-build
path: src/backend/InvenTree/web/static/web
include-hidden-files: true
firefox:
name: Tests [Firefox ${{ matrix.shard }} / 2]
runs-on: ubuntu-24.04
timeout-minutes: 60
needs: ["build", "paths-filter"]
if: github.ref == 'refs/heads/master' || needs.paths-filter.outputs.frontend == 'true' || needs.paths-filter.outputs.force == 'true'
strategy:
fail-fast: false
matrix:
shard: [1, 2]
services:
postgres:
image: postgres:17
env:
POSTGRES_DB: inventree
POSTGRES_USER: inventree_user
POSTGRES_PASSWORD: inventree_password
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U testuser"
--health-interval 10s
--health-timeout 5s
--health-retries 5
env:
VITE_COVERAGE: false
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # pin@v6.0.2
with:
persist-credentials: false
- name: Environment Setup
uses: ./.github/actions/setup
with:
npm: true
install: true
update: true
apt-dependency: gettext postgresql-client libpq-dev
pip-dependency: psycopg2
- name: Set up test data
run: |
invoke dev.setup-test -iv
invoke int.rebuild-thumbnails
- name: Install dependencies
run: invoke int.frontend-compile --extract
- name: Cache Playwright browsers
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # pin@v4.3.0
id: playwright-cache
with:
path: ~/.cache/ms-playwright
key: ${{ runner.os }}-playwright-${{ hashFiles('src/frontend/yarn.lock') }}
- name: Install Playwright browsers
if: steps.playwright-cache.outputs.cache-hit != 'true'
run: cd src/frontend && npx playwright install --with-deps
- name: Install Playwright OS dependencies
if: steps.playwright-cache.outputs.cache-hit == 'true'
run: cd src/frontend && npx playwright install-deps
- name: Install Sample Plugin
run: |
pip install -U inventree-plugin-creator
create-inventree-plugin --default
cd MyCustomPlugin && pip install -e . && cd frontend && npm install && npm run translate && npm run build
- name: Run Playwright tests
id: tests
run: |
cd src/frontend
cp ./tests/fixtures/playwright_custom_logo.png ../backend/InvenTree/InvenTree/static/img/playwright_custom_logo.png
cp ./tests/fixtures/playwright_custom_splash.png ../backend/InvenTree/InvenTree/static/img/playwright_custom_splash.png
invoke static
env INVENTREE_CUSTOM_SPLASH="img/playwright_custom_splash.png" INVENTREE_CUSTOM_LOGO="img/playwright_custom_logo.png" PLAYWRIGHT_BASE_URL=http://localhost:8000 npx playwright test --project=firefox --shard=${{ matrix.shard }}/2
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # pin@v7.0.1
if: ${{ !cancelled() && steps.tests.outcome == 'failure' }}
with:
name: playwright-report-firefox-${{ matrix.shard }}
path: src/frontend/playwright-report/
retention-days: 14
chromium:
name: Tests [Chromium ${{ matrix.shard }} / 4]
runs-on: ubuntu-24.04
timeout-minutes: 60
needs: ["build", "paths-filter"]
if: github.ref == 'refs/heads/master' || needs.paths-filter.outputs.frontend == 'true' || needs.paths-filter.outputs.force == 'true'
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3, 4]
services:
postgres:
image: postgres:17
env:
POSTGRES_DB: inventree
POSTGRES_USER: inventree_user
POSTGRES_PASSWORD: inventree_password
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U testuser"
--health-interval 10s
--health-timeout 5s
--health-retries 5
env:
COVERAGE: true
VITE_COVERAGE: true
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # pin@v6.0.2
with:
persist-credentials: false
- name: Environment Setup
uses: ./.github/actions/setup
with:
npm: true
install: true
update: true
apt-dependency: gettext postgresql-client libpq-dev
pip-dependency: psycopg2
- name: Set up test data
run: |
invoke dev.setup-test -iv
invoke int.rebuild-thumbnails
- name: Install dependencies
run: invoke int.frontend-compile --extract
- name: Cache Playwright browsers
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # pin@v4.3.0
id: playwright-cache
with:
path: ~/.cache/ms-playwright
key: ${{ runner.os }}-playwright-${{ hashFiles('src/frontend/yarn.lock') }}
- name: Install Playwright browsers
if: steps.playwright-cache.outputs.cache-hit != 'true'
run: cd src/frontend && npx playwright install --with-deps
- name: Install Playwright OS dependencies
if: steps.playwright-cache.outputs.cache-hit == 'true'
run: cd src/frontend && npx playwright install-deps
- name: Install Sample Plugin
run: |
pip install -U inventree-plugin-creator
create-inventree-plugin --default
cd MyCustomPlugin && pip install -e . && cd frontend && npm install && npm run translate && npm run build
- name: Playwright [${{ matrix.shard }} / 4]
id: tests
run: |
cd src/frontend
npx nyc playwright test --project=chromium --shard=${{ matrix.shard }}/4
- name: Playwright Report [${{ matrix.shard }} / 4]
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # pin@v7.0.1
if: ${{ !cancelled() && steps.tests.outcome == 'failure' }}
with:
name: playwright-report-chromium-${{ matrix.shard }}
path: src/frontend/playwright-report/
if-no-files-found: error
retention-days: 7
- name: Upload Coverage Artifact [${{ matrix.shard }} / 4]
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # pin@v7.0.1
id: coverage-upload
if: ${{ !cancelled() && steps.tests.outcome != 'failure' }}
with:
name: coverage-${{ matrix.shard }}
path: src/frontend/.nyc_output/
if-no-files-found: error
include-hidden-files: true
retention-days: 1
# Recombine the coverage reports from the 4 shards, and upload to Codecov
coverage:
name: Merge coverage reports and upload to Codecov
runs-on: ubuntu-latest
needs: chromium
timeout-minutes: 30
if: always()
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # pin@v6.0.2
with:
persist-credentials: false
- name: Environment Setup
uses: ./.github/actions/setup
with:
npm: true
install: false
update: false
- name: Download Coverage Artifacts
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # pin@v8.0.1
with:
pattern: coverage-*
path: all-coverage/
merge-multiple: true
- name: Merge Coverage Reports
run: |
mkdir -p .nyc_output
cp all-coverage/*.json .nyc_output/ 2>/dev/null || true
npx nyc merge .nyc_output merged-coverage.json
npx nyc report \
--tempdir .nyc_output \
--reporter=lcov \
--reporter=text-summary \
--report-dir ./coverage \
- name: Upload coverage reports to Codecov
if: ${{ !cancelled() && github.ref == 'refs/heads/master' }}
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # pin@v6.0.1
with:
token: ${{ secrets.CODECOV_TOKEN }}
slug: inventree/InvenTree
flags: web
files: ./coverage/lcov.info

View File

@ -664,133 +664,6 @@ jobs:
chmod +rw /home/runner/work/InvenTree/db.sqlite3
invoke migrate
web_ui:
name: Tests - Web UI
runs-on: ubuntu-24.04
timeout-minutes: 60
needs: ["code-style", "paths-filter"]
if: needs.paths-filter.outputs.frontend == 'true' || needs.paths-filter.outputs.force == 'true'
services:
postgres:
image: postgres:17
env:
POSTGRES_DB: inventree
POSTGRES_USER: inventree_user
POSTGRES_PASSWORD: inventree_password
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U testuser"
--health-interval 10s
--health-timeout 5s
--health-retries 5
env:
INVENTREE_DB_ENGINE: postgresql
INVENTREE_DB_NAME: inventree
INVENTREE_DB_HOST: "127.0.0.1"
INVENTREE_DB_PORT: 5432
INVENTREE_DB_USER: inventree_user
INVENTREE_DB_PASSWORD: inventree_password
INVENTREE_DEBUG: true
INVENTREE_PLUGINS_ENABLED: false
VITE_COVERAGE_BUILD: true
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # pin@v6.0.2
with:
persist-credentials: false
- name: Environment Setup
uses: ./.github/actions/setup
with:
npm: true
install: true
update: true
apt-dependency: gettext postgresql-client libpq-dev
pip-dependency: psycopg2
- name: Set up test data
run: |
invoke dev.setup-test -iv
invoke int.rebuild-thumbnails
- name: Install dependencies
run: invoke int.frontend-compile --extract
- name: Cache Playwright browsers
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # pin@v5.0.5
id: playwright-cache
with:
path: ~/.cache/ms-playwright
key: ${{ runner.os }}-playwright-${{ hashFiles('src/frontend/yarn.lock') }}
- name: Install Playwright browsers
if: steps.playwright-cache.outputs.cache-hit != 'true'
run: cd src/frontend && npx playwright install --with-deps
- name: Install Playwright OS dependencies
if: steps.playwright-cache.outputs.cache-hit == 'true'
run: cd src/frontend && npx playwright install-deps
- name: Install Sample Plugin
run: |
pip install -U inventree-plugin-creator
create-inventree-plugin --default
cd MyCustomPlugin && pip install -e . && cd frontend && npm install && npm run translate && npm run build
- name: Run Playwright tests
id: tests
run: |
cd src/frontend
cp ./tests/fixtures/playwright_custom_logo.png ../backend/InvenTree/InvenTree/static/img/playwright_custom_logo.png
cp ./tests/fixtures/playwright_custom_splash.png ../backend/InvenTree/InvenTree/static/img/playwright_custom_splash.png
invoke static
env INVENTREE_CUSTOM_SPLASH="img/playwright_custom_splash.png" INVENTREE_CUSTOM_LOGO="img/playwright_custom_logo.png" npx nyc playwright test --project=customization
npx nyc playwright test --project=chromium --project=firefox
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # pin@v7.0.1
if: ${{ !cancelled() && steps.tests.outcome == 'failure' }}
with:
name: playwright-report
path: src/frontend/playwright-report/
retention-days: 14
- name: Report coverage
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@e79a6962e0d4c0c17b229090214935d2e33f8354 # pin@v6.0.1
with:
token: ${{ secrets.CODECOV_TOKEN }}
slug: inventree/InvenTree
flags: web
- name: Upload bundler info
env:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
run: |
cd src/frontend
yarn install
yarn run build
web_ui_build:
name: Build - Web UI
runs-on: ubuntu-24.04
timeout-minutes: 60
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # pin@v6.0.2
with:
persist-credentials: false
- name: Environment Setup
uses: ./.github/actions/setup
with:
npm: true
- name: Install dependencies
run: cd src/frontend && yarn install
- name: Build frontend
run: cd src/frontend && yarn run compile && yarn run lib && yarn run build
- name: Write version file - SHA
run: cd src/backend/InvenTree/web/static/web/.vite && echo "$GITHUB_SHA" > sha.txt
- name: Zip frontend
run: |
cd src/backend/InvenTree/web/static
zip -r frontend-build.zip web/ web/.vite
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # pin@v7.0.1
with:
name: frontend-build
path: src/backend/InvenTree/web/static/web
include-hidden-files: true
zizmor:
name: Security [Zizmor]
runs-on: ubuntu-24.04

View File

@ -43,8 +43,10 @@ component_management:
name: Backend Apps
paths:
- src/backend/InvenTree/build/**
- src/backend/InvenTree/common/**
- src/backend/InvenTree/company/**
- src/backend/InvenTree/data_exporter/**
- src/backend/InvenTree/generic/**
- src/backend/InvenTree/importer/**
- src/backend/InvenTree/machine/**
- src/backend/InvenTree/order/**
@ -70,7 +72,8 @@ component_management:
- component_id: web
name: Frontend
paths:
- src/frontend/**
- src/frontend/lib/**
- src/frontend/src/**
statuses:
- type: project
target: 68% # 90%

View File

@ -6,9 +6,9 @@ asgiref==3.11.1 \
# via
# -c src/backend/requirements.txt
# django
django==5.2.14 \
--hash=sha256:58a63ba841662e5c686b57ba1fec52ddd68c0b93bd96ac3029d55728f00bf8a2 \
--hash=sha256:6f712143bd3064310d1f50fac859c3e9a274bdcfc9595339853be7779297fc76
django==5.2.15 \
--hash=sha256:0eb4a9bb1853a35b0286dbc6d916bd352c8c2687195a7f2d6f80cefd840e4970 \
--hash=sha256:5154a9bf84ac01dde011e367f355c07dbb329532e06810dcf3ef2af269e236e7
# via
# -c src/backend/requirements.txt
# -r contrib/container/requirements.in

View File

@ -214,10 +214,10 @@ The frontend code for your plugin is located in the `frontend/src` directory. Yo
Refer to the `./frontend/src/Panel.tsx` file as a starting point. This is where the custom panel for the part detail page is implemented. You can modify this file to change the content and behavior of the panel.
While the `npm dev` server is running, any changes you make to the frontend code will be automatically reloaded allowing for rapid development and testing of your plugin's frontend features. This avoids the need to rebuild the frontend code every time you make a change.
While the `npm dev` server is running, any changes to the frontend are reflected in the browser using React Fast Refresh, allowing for rapid development without rebuilding the frontend with every change.
!!! info "Page Reload"
Due to the way the InvenTree frontend is structured, you will need to manually refresh the page in your browser to see changes to the frontend code. The development server will automatically reload the frontend code, but the InvenTree server needs to be aware of the changes.
All exports in plugin modules that export React components must start with a capital letter. Otherwise, React Fast Refresh will fall back to a full page reload instead of performing a component-level update. Additionally, any render functions referenced from Python must also be capitalized.
## Build Plugin

View File

@ -119,7 +119,7 @@ function AttachmentCarouselPanel({context}: {context: InvenTreePluginContext;})
}
// This is the function which is called by InvenTree to render the actual panel component
export function renderAttachmentCarouselPanel(context: InvenTreePluginContext) {
export function RenderAttachmentCarouselPanel(context: InvenTreePluginContext) {
checkPluginVersion(context);
return <AttachmentCarouselPanel context={context} />;
}
@ -300,7 +300,7 @@ Back to the walkthrough, open `core.py` in the `attachment_carousel` folder and
'title': 'Attachment Carousel',
'description': 'Custom panel description',
'icon': 'ti:carousel-horizontal:outline',
'source': self.plugin_static_file('Panel.js:renderAttachmentCarouselPanel'),
'source': self.plugin_static_file('Panel.js:RenderAttachmentCarouselPanel'),
'context': {
# Provide additional context data to the panel
'settings': self.get_settings_dict(),
@ -385,7 +385,7 @@ panels.append({
'description': 'Custom panel description',
- 'icon': 'ti:mood-smile:outline',
+ 'icon': 'ti:carousel-horizontal:outline',
'source': self.plugin_static_file('Panel.js:renderAttachmentCarouselPanel'),
'source': self.plugin_static_file('Panel.js:RenderAttachmentCarouselPanel'),
'context': {
# Provide additional context data to the panel
'settings': self.get_settings_dict(),
@ -519,7 +519,7 @@ panels.append({
'title': 'Attachment Carousel',
'description': 'Custom panel description',
'icon': 'ti:carousel-horizontal:outline',
'source': self.plugin_static_file('Panel.js:renderAttachmentCarouselPanel'),
'source': self.plugin_static_file('Panel.js:RenderAttachmentCarouselPanel'),
'context': {
# Provide additional context data to the panel
'settings': self.get_settings_dict(),

View File

@ -520,9 +520,9 @@ defusedxml==0.7.1 \
# via
# -c src/backend/requirements.txt
# python3-openid
django==5.2.14 \
--hash=sha256:58a63ba841662e5c686b57ba1fec52ddd68c0b93bd96ac3029d55728f00bf8a2 \
--hash=sha256:6f712143bd3064310d1f50fac859c3e9a274bdcfc9595339853be7779297fc76
django==5.2.15 \
--hash=sha256:0eb4a9bb1853a35b0286dbc6d916bd352c8c2687195a7f2d6f80cefd840e4970 \
--hash=sha256:5154a9bf84ac01dde011e367f355c07dbb329532e06810dcf3ef2af269e236e7
# via
# -c src/backend/requirements.txt
# -r src/backend/requirements.in

View File

@ -408,9 +408,9 @@ cryptography==48.0.0 \
# -c src/backend/requirements-3.14.txt
# -c src/backend/requirements.txt
# pdfminer-six
django==5.2.14 \
--hash=sha256:58a63ba841662e5c686b57ba1fec52ddd68c0b93bd96ac3029d55728f00bf8a2 \
--hash=sha256:6f712143bd3064310d1f50fac859c3e9a274bdcfc9595339853be7779297fc76
django==5.2.15 \
--hash=sha256:0eb4a9bb1853a35b0286dbc6d916bd352c8c2687195a7f2d6f80cefd840e4970 \
--hash=sha256:5154a9bf84ac01dde011e367f355c07dbb329532e06810dcf3ef2af269e236e7
# via
# -c src/backend/requirements-3.14.txt
# -c src/backend/requirements.txt

View File

@ -403,9 +403,9 @@ cryptography==48.0.0 \
# via
# -c src/backend/requirements.txt
# pdfminer-six
django==5.2.14 \
--hash=sha256:58a63ba841662e5c686b57ba1fec52ddd68c0b93bd96ac3029d55728f00bf8a2 \
--hash=sha256:6f712143bd3064310d1f50fac859c3e9a274bdcfc9595339853be7779297fc76
django==5.2.15 \
--hash=sha256:0eb4a9bb1853a35b0286dbc6d916bd352c8c2687195a7f2d6f80cefd840e4970 \
--hash=sha256:5154a9bf84ac01dde011e367f355c07dbb329532e06810dcf3ef2af269e236e7
# via
# -c src/backend/requirements.txt
# django-silk

View File

@ -501,9 +501,9 @@ defusedxml==0.7.1 \
--hash=sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69 \
--hash=sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61
# via python3-openid
django==5.2.14 \
--hash=sha256:58a63ba841662e5c686b57ba1fec52ddd68c0b93bd96ac3029d55728f00bf8a2 \
--hash=sha256:6f712143bd3064310d1f50fac859c3e9a274bdcfc9595339853be7779297fc76
django==5.2.15 \
--hash=sha256:0eb4a9bb1853a35b0286dbc6d916bd352c8c2687195a7f2d6f80cefd840e4970 \
--hash=sha256:5154a9bf84ac01dde011e367f355c07dbb329532e06810dcf3ef2af269e236e7
# via
# -r src/backend/requirements.in
# django-allauth

View File

@ -27,6 +27,10 @@ Exposes sub-components related to DetailDrawer rendering:
- `DetailDrawerComponent`
- `useLocalLibState`
#### Plugin System
Enable React Fast Refresh support for plugin frontend development. Plugin modules exporting React components must start with a capital letter; otherwise, a full page reload occurs instead of a component-level update.
### 0.11.3 - April 2026
Exposes additional type definitions related to rendering drawers from tables:

View File

@ -3,9 +3,6 @@ import { defineConfig, devices } from '@playwright/test';
// Detect if running in CI
const IS_CI = !!process.env.CI;
const MAX_WORKERS: number = 3;
const MAX_RETRIES: number = 3;
/* We optionally spin-up services based on the testing mode:
*
* Local Development:
@ -26,21 +23,28 @@ const MAX_RETRIES: number = 3;
* - WORKERS = 1 (to avoid conflicts with HMR)
*/
const BASE_URL: string = IS_CI
? 'http://localhost:8000'
: 'http://localhost:5173';
const BASE_URL: string =
process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:5173';
// If running in "production" mode, we can use multiple workers to speed up the tests
const MAX_WORKERS: number = BASE_URL.endsWith('8000') ? 3 : 1;
const MAX_RETRIES: number = IS_CI ? 1 : 2;
console.log('Running Playwright Tests:');
console.log('- Base URL:', BASE_URL);
console.log('- Max Workers:', MAX_WORKERS);
console.log('- Max Retries:', MAX_RETRIES);
export default defineConfig({
testDir: './tests',
fullyParallel: false,
timeout: 90000,
forbidOnly: !!IS_CI,
retries: IS_CI ? MAX_RETRIES : 0,
workers: IS_CI ? MAX_WORKERS : 1,
reporter: IS_CI ? [['html', { open: 'never' }], ['github']] : 'list',
retries: MAX_RETRIES,
workers: MAX_WORKERS,
reporter: IS_CI
? [['html', { open: 'never' }], ['blob'], ['github']]
: 'list',
/* Configure projects for major browsers */
projects: [
@ -57,13 +61,6 @@ export default defineConfig({
...devices['Desktop Firefox']
},
testIgnore: /customization/ // Ignore all tests in the "customization" folder for this project
},
{
name: 'customization',
use: {
...devices['Desktop Firefox']
},
testIgnore: /pui_.*\.spec\.ts/ // Ignore all "pui_*.spec.ts" tests for this project
}
],
@ -89,7 +86,9 @@ export default defineConfig({
INVENTREE_CORS_ORIGIN_ALLOW_ALL: 'True',
INVENTREE_COOKIE_SAMESITE: 'False',
INVENTREE_LOGIN_ATTEMPTS: '100',
INVENTREE_PLUGINS_MANDATORY: 'samplelocate'
INVENTREE_PLUGINS_MANDATORY: 'samplelocate',
INVENTREE_CUSTOM_SPLASH: 'img/playwright_custom_splash.png',
INVENTREE_CUSTOM_LOGO: 'img/playwright_custom_logo.png'
},
url: 'http://localhost:8000/api/',
reuseExistingServer: IS_CI,

View File

@ -1,17 +1,12 @@
import { t } from '@lingui/core/macro';
import { Alert, MantineProvider, Stack, Text } from '@mantine/core';
import { IconExclamationCircle } from '@tabler/icons-react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Alert, Stack, Text } from '@mantine/core';
import { useRef } from 'react';
import { Boundary } from '@lib/components/Boundary';
import { identifierString } from '@lib/functions/Conversion';
import type { InvenTreePluginContext } from '@lib/types/Plugins';
import { type Root, createRoot } from 'react-dom/client';
import { api, queryClient } from '../../App';
import { ApiProvider } from '../../contexts/ApiContext';
import { LanguageContext } from '../../contexts/LanguageContext';
import { useLocalState } from '../../states/LocalState';
import { findExternalPluginFunction } from './PluginSource';
import { IconExclamationCircle } from '@tabler/icons-react';
import { useRemotePlugin } from '../../hooks/UseRemotePlugin';
/**
* A remote component which can be used to display plugin content.
@ -31,125 +26,39 @@ export default function RemoteComponent({
defaultFunctionName: string;
context: InvenTreePluginContext;
}>) {
const componentRef = useRef<HTMLDivElement | null>(null);
const rootElement = useRef<Root | null>(null);
const containerRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
if (componentRef.current && rootElement.current === null) {
rootElement.current = createRoot(componentRef.current);
}
}, [rootElement]);
const { componentFn, errorMsg, exportName, pluginContext, remountKey } =
useRemotePlugin({
context,
source,
defaultFunctionName,
containerRef
});
const [renderingError, setRenderingError] = useState<string | undefined>(
undefined
const content = componentFn ? (
componentFn(pluginContext)
) : (
<div ref={containerRef} />
);
const func: string = useMemo(() => {
// Attempt to extract the function name from the source
const { getHost } = useLocalState.getState();
const url = new URL(source, getHost());
if (url.pathname.includes(':')) {
const parts = url.pathname.split(':');
return parts[1] || defaultFunctionName; // Use the second part as the function name, or fallback to default
} else {
return defaultFunctionName;
}
}, [source, defaultFunctionName]);
const reloadPluginContent = useCallback(() => {
if (!rootElement.current) {
return;
}
const ctx: InvenTreePluginContext = {
...context,
reloadContent: reloadPluginContent
};
if (source && defaultFunctionName) {
findExternalPluginFunction(source, func)
.then((func) => {
if (!!func) {
try {
if (func.length > 1) {
// Support "legacy" plugin functions which call createRoot() internally
// Ref: https://github.com/inventree/InvenTree/pull/9439/
func(componentRef.current, ctx);
} else {
// Render the plugin component into the target element
// Note that we have to provide the right context(s) to the component
// This approach ensures that the component is rendered in the correct context tree
rootElement.current?.render(
<ApiProvider client={queryClient} api={api}>
<MantineProvider
theme={ctx.theme}
defaultColorScheme={ctx.colorScheme}
>
<LanguageContext>{func(ctx)}</LanguageContext>
</MantineProvider>
</ApiProvider>
);
}
setRenderingError('');
} catch (error) {
setRenderingError(`${error}`);
console.error(error);
}
} else {
setRenderingError(`${source} / ${func}`);
}
})
.catch((_error) => {
console.error(
`ERR: Failed to load remote plugin function: ${source} /${func}`
);
});
} else {
setRenderingError(
`${t`Invalid source or function name`} - ${source} /${func}`
);
}
}, [
componentRef.current,
rootElement.current,
source,
defaultFunctionName,
context
]);
// Reload the plugin content dynamically
useEffect(() => {
reloadPluginContent();
}, [
func,
rootElement.current,
context.id,
context.model,
context.instance,
context.user,
context.colorScheme,
context.locale,
context.context
]);
return (
<Boundary label={identifierString(`RemoteComponent-${func}`)}>
<Stack gap='xs'>
{renderingError && (
<Alert
color='red'
title={t`Error Loading Content`}
icon={<IconExclamationCircle />}
>
<Text>
{t`Error occurred while loading plugin content`}: {renderingError}
</Text>
</Alert>
)}
{componentRef && <div ref={componentRef as any} />}
</Stack>
</Boundary>
<Stack>
{errorMsg && (
<Alert
color='red'
title={t`Error Loading Plugin Content`}
icon={<IconExclamationCircle />}
>
<Text>{errorMsg}</Text>
</Alert>
)}
<Boundary
key={remountKey}
label={identifierString(`RemoteComponent-${exportName}`)}
>
{content}
</Boundary>
</Stack>
);
}

View File

@ -0,0 +1,157 @@
import type { InvenTreePluginContext } from '@lib/types/Plugins';
import { t } from '@lingui/core/macro';
import { useCallback, useEffect, useMemo, useState } from 'react';
import type { ReactElement } from 'react';
import { useLocalState } from '../states/LocalState';
type LegacyPluginEntryFn = (
container: HTMLDivElement,
ctx: InvenTreePluginContext
) => void;
type PluginEntryFn = (ctx: InvenTreePluginContext) => ReactElement;
type UseRemotePluginOptions = {
context: InvenTreePluginContext;
source: string;
defaultFunctionName: string;
containerRef: React.RefObject<HTMLDivElement | null>;
};
type UsePluginSourceOptions = {
source: string;
defaultFunctionName?: string;
};
type UseRemotePluginReturn = {
componentFn: PluginEntryFn | null;
errorMsg: string | null;
exportName: string;
pluginContext: InvenTreePluginContext;
remountKey: number;
};
function usePluginSource({
source,
defaultFunctionName
}: UsePluginSourceOptions) {
const { getHost } = useLocalState.getState();
const { moduleUrl, exportName } = useMemo(() => {
const url = new URL(source, getHost());
const parts = url.pathname.split(':');
return {
exportName: parts[1] || defaultFunctionName || 'default',
moduleUrl: url.origin + parts[0]
};
}, [source, defaultFunctionName, getHost]);
return { moduleUrl, exportName };
}
function getHmrCallbacks(url: string) {
const w = window as any;
w.__plugin_hmr_callbacks ??= {};
w.__plugin_hmr_callbacks[url] ??= new Set<Function>();
return w.__plugin_hmr_callbacks[url];
}
const hasHmr = import.meta.hot !== undefined;
export function useRemotePlugin({
context,
source,
defaultFunctionName,
containerRef
}: UseRemotePluginOptions): UseRemotePluginReturn {
const { moduleUrl, exportName } = usePluginSource({
source,
defaultFunctionName
});
const [remoteModule, setRemoteModule] = useState<Record<
string,
unknown
> | null>(null);
const [reloadVersion, setReloadVersion] = useState(0);
const [errorMsg, setErrorMsg] = useState<string | null>(null);
const reloadContent = useCallback(() => setReloadVersion((v) => v + 1), []);
const hmrSetModule = useCallback(
(newRemoteModule: Record<string, unknown> | null) => {
if (!hasHmr) return;
setRemoteModule(newRemoteModule);
},
[]
);
useEffect(() => {
let cancelled = false;
setErrorMsg(null);
const loadModule = async () => {
try {
const mod = await import(/* @vite-ignore */ moduleUrl);
if (!cancelled) setRemoteModule(mod);
} catch (err) {
if (!cancelled) {
console.error(`ERR: Failed to load module: ${moduleUrl}:\n${err}`);
setErrorMsg(t`Failed to load module: ${moduleUrl}`);
}
}
};
loadModule();
return () => {
cancelled = true;
};
}, [moduleUrl]);
const [legacyRenderFn, componentFn, error] = useMemo(() => {
if (!remoteModule) return [null, null, null];
let err: string | null = null;
const func = remoteModule[exportName];
if (typeof func === 'function') {
if (func.length === 2) {
return [func as LegacyPluginEntryFn, null, null];
} else if (func.length === 1) {
return [null, func as PluginEntryFn, null];
} else {
err = `Entrypoint ${exportName} in ${moduleUrl} must accept 1-2 arguments`;
}
} else if (func !== undefined) {
err = t`Export ${exportName} in ${moduleUrl} is not a function (found type ${typeof func}).`;
} else {
err = t`Plugin entrypoint ${exportName} does not exist in ${moduleUrl}.`;
}
return [null, null, err];
}, [remoteModule, exportName]);
useEffect(() => {
if (legacyRenderFn && containerRef.current) {
containerRef.current.innerHTML = '';
legacyRenderFn(containerRef.current, context);
if (hasHmr) getHmrCallbacks(moduleUrl)?.add(hmrSetModule);
}
return () => {
if (hasHmr) getHmrCallbacks(moduleUrl)?.delete(hmrSetModule);
};
}, [moduleUrl, legacyRenderFn, context, hmrSetModule]);
return {
componentFn: componentFn,
errorMsg: error ?? errorMsg,
exportName: exportName,
pluginContext: { ...context, reloadContent: reloadContent },
remountKey: reloadVersion
};
}

View File

@ -2,9 +2,14 @@ import * as crypto from 'node:crypto';
import * as fs from 'node:fs';
import os from 'node:os';
import * as path from 'node:path';
import { test as baseTest } from '@playwright/test';
import { fileURLToPath } from 'node:url';
import { type BrowserContext, test as baseTest } from '@playwright/test';
const istanbulCLIOutput = path.join(process.cwd(), '.nyc_output');
const frontendDir = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
'..'
);
const istanbulCLIOutput = path.join(frontendDir, '.nyc_output');
const platform = os.platform();
let systemKeyVar: string;
if (platform === 'darwin') {
@ -19,16 +24,16 @@ export function generateUUID(): string {
return crypto.randomBytes(16).toString('hex');
}
export const test = baseTest.extend({
context: async ({ context }, use) => {
await context.addInitScript(() =>
window.addEventListener('beforeunload', () =>
(window as any).collectIstanbulCoverage(
JSON.stringify((window as any).__coverage__)
)
async function setupCoverageCollection(context: BrowserContext) {
await context.addInitScript(() =>
window.addEventListener('beforeunload', () =>
(window as any).collectIstanbulCoverage?.(
JSON.stringify((window as any).__coverage__)
)
);
await fs.promises.mkdir(istanbulCLIOutput, { recursive: true });
)
);
await fs.promises.mkdir(istanbulCLIOutput, { recursive: true });
try {
await context.exposeFunction(
'collectIstanbulCoverage',
(coverageJSON: string) => {
@ -42,18 +47,68 @@ export const test = baseTest.extend({
);
}
);
} catch {
// already exposed on this context (e.g. called twice for same context)
}
}
async function collectCoverageFromContext(context: BrowserContext) {
await Promise.allSettled(
context.pages().map(async (page) => {
try {
await Promise.race([
page.evaluate(() =>
(window as any).collectIstanbulCoverage?.(
JSON.stringify((window as any).__coverage__)
)
),
new Promise((_, reject) =>
setTimeout(
() => reject(new Error('Coverage collection timeout')),
2000
)
)
]);
} catch {
// page may already be closed or script execution can be blocked during teardown
}
})
);
}
export const test = baseTest.extend<{}, {}>({
// Wrap browser.newPage so contexts created via doCachedLogin also get coverage
browser: [
async ({ browser }, use) => {
const origNewPage = browser.newPage.bind(browser);
(browser as any).newPage = async (
options?: Parameters<typeof browser.newPage>[0]
) => {
const page = await origNewPage(options);
await setupCoverageCollection(page.context());
return page;
};
try {
await use(browser);
} finally {
(browser as any).newPage = origNewPage;
for (const context of browser.contexts()) {
await collectCoverageFromContext(context);
}
}
},
{ scope: 'worker' }
],
context: async ({ context }, use) => {
await setupCoverageCollection(context);
await use(context);
for (const page of context.pages()) {
await page.evaluate(() =>
(window as any).collectIstanbulCoverage(
JSON.stringify((window as any).__coverage__)
)
);
}
await collectCoverageFromContext(context);
},
// Ensure no errors are thrown in the console
page: async ({ page }, use) => {
const messages = [];
const messages: any[] = [];
page.on('console', (msg) => {
const url = msg.location().url;
if (

View File

@ -1,11 +1,16 @@
import test, { expect } from '@playwright/test';
import { expect, test } from '../baseFixtures';
import { navigate } from '../helpers';
/**
* Tests for user interface customization functionality.
*
* Note: The correct environment variables must be set for these tests to work correctly. See "playwright.config.ts" for details.
* These tests are designed to run in CI environments where specific environment variables are set to enable custom logos and splash screens. The tests verify that these customizations are correctly applied in the user interface.
* These tests are designed to run in CI environments where specific environment variables are set to enable custom logos and splash screens.
* The tests verify that these customizations are correctly applied in the user interface.
*
* If you are running these tests locally, ensure that you have the appropriate environment variables set to enable the customizations.
* You may need to modify the "webServer" configuration in "playwright.config.ts" to include the necessary environment variables for local testing.
*
*/
test('Customization - Splash', async ({ page }) => {

View File

@ -1,4 +1,4 @@
import test from '@playwright/test';
import { test } from '../baseFixtures';
import { loadTab } from '../helpers';
import { doCachedLogin } from '../login';

View File

@ -1,4 +1,4 @@
import test from '@playwright/test';
import { test } from './baseFixtures';
import { stevenuser } from './defaults';
import { globalSearch, loadTab, navigate } from './helpers';
import { doCachedLogin } from './login';

View File

@ -1,6 +1,6 @@
/** Unit tests for form validation, rendering, etc */
import { expect, test } from 'playwright/test';
import { createApi } from './api';
/** Unit tests for form validation, rendering, etc */
import { expect, test } from './baseFixtures';
import { stevenuser } from './defaults';
import { navigate } from './helpers';
import { doCachedLogin } from './login';

View File

@ -1,4 +1,4 @@
import test from '@playwright/test';
import { test } from './baseFixtures';
import { stevenuser } from './defaults';
import { doCachedLogin } from './login';

View File

@ -13,7 +13,8 @@ test('Login - Failures', async ({ page }) => {
await page.getByRole('button', { name: 'Log In' }).click();
await page.getByText('Login failed', { exact: true }).waitFor();
await page.getByText('Check your input and try again').first().waitFor();
await page.locator('#login').getByRole('button').click();
await page.reload();
};
// Navigate to the 'login' page

View File

@ -1,4 +1,4 @@
import test from 'playwright/test';
import { test } from './baseFixtures';
import { adminuser } from './defaults';
import { clickOnRowMenu, navigate } from './helpers';
import { doCachedLogin } from './login';

View File

@ -2,7 +2,7 @@
* Tests for UI permissions checks
*/
import test from '@playwright/test';
import { test } from './baseFixtures';
import { adminuser, readeruser } from './defaults';
import { clickOnRowMenu, loadTab } from './helpers';
import { doCachedLogin } from './login';

View File

@ -1,4 +1,4 @@
import test from 'playwright/test';
import { test } from './baseFixtures';
import { adminuser } from './defaults.js';
import {
@ -174,7 +174,7 @@ test('Plugins - Panels', async ({ browser }) => {
// Check out each of the plugin panels
await loadTab(page, 'Broken Panel');
await page.getByText('Error occurred while loading plugin content').waitFor();
await page.getByText('Error Loading Plugin Content').waitFor();
await loadTab(page, 'Dynamic Panel');
await page.getByText('Instance ID: 69');
await page

View File

@ -47,7 +47,7 @@ export default defineConfig(({ command, mode }) => {
}),
istanbul({
include: ['src/*', 'lib/*'],
exclude: ['node_modules', 'test/'],
exclude: ['node_modules/', 'playwright/', 'tests/'],
extension: ['.js', '.ts', '.tsx'],
requireEnv: true
}),