diff --git a/.github/workflows/frontend.yaml b/.github/workflows/frontend.yaml
index 24f4a9d103..07bd50d272 100644
--- a/.github/workflows/frontend.yaml
+++ b/.github/workflows/frontend.yaml
@@ -255,7 +255,8 @@ jobs:
- name: Upload Coverage Artifact [${{ matrix.shard }} / 4]
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
id: coverage-upload
- if: ${{ !cancelled() && steps.tests.outcome != 'failure' }}
+ # Coverage data is still valid if a (flaky) test fails - upload it so the shard is not dropped
+ if: ${{ !cancelled() }}
with:
name: coverage-${{ matrix.shard }}
path: src/frontend/.nyc_output/
@@ -291,16 +292,19 @@ jobs:
path: all-coverage/
merge-multiple: true
+ # Note: the raw istanbul JSON files are in *compiled* (post-babel / esbuild) coordinates.
+ # "nyc report" remaps them back to the original source files via the embedded source maps.
+ # Only the remapped lcov.info file may be uploaded to Codecov.
- name: Merge Coverage Reports
run: |
mkdir -p .nyc_output
cp all-coverage/*.json .nyc_output/ 2>/dev/null || true
- npx --ignore-scripts nyc merge .nyc_output merged-coverage.json
+ rm -rf all-coverage
npx --ignore-scripts nyc report \
- --tempdir .nyc_output \
+ --temp-dir .nyc_output \
--reporter=lcov \
--reporter=text-summary \
- --report-dir ./coverage \
+ --report-dir ./coverage
- name: Upload coverage reports to Codecov
if: ${{ !cancelled() && github.ref == 'refs/heads/master' }}
@@ -310,3 +314,6 @@ jobs:
slug: inventree/InvenTree
flags: web
files: ./coverage/lcov.info
+ # Prevent the Codecov CLI from also discovering (and uploading) the raw,
+ # un-remapped istanbul JSON files, which double-count every source file
+ disable_search: true
diff --git a/src/frontend/src/components/items/TransferList.tsx b/src/frontend/src/components/items/TransferList.tsx
index b9ae53a498..6510a57e0f 100644
--- a/src/frontend/src/components/items/TransferList.tsx
+++ b/src/frontend/src/components/items/TransferList.tsx
@@ -174,6 +174,7 @@ export function TransferList({
variant='outline'
size='md'
disabled={leftItems.length == 0}
+ aria-label='transfer-all-right'
onClick={() => transferLeftToRight(true)}
>
@@ -182,6 +183,7 @@ export function TransferList({
variant='outline'
size='md'
disabled={!leftSelected}
+ aria-label='transfer-selected-right'
onClick={() => transferLeftToRight(false)}
>
@@ -190,6 +192,7 @@ export function TransferList({
variant='outline'
size='md'
disabled={!rightSelected}
+ aria-label='transfer-selected-left'
onClick={() => transferRightToLeft(false)}
>
@@ -198,6 +201,7 @@ export function TransferList({
variant='outline'
size='md'
disabled={rightItems.length == 0}
+ aria-label='transfer-all-left'
onClick={() => transferRightToLeft(true)}
>
diff --git a/src/frontend/src/functions/auth.tsx b/src/frontend/src/functions/auth.tsx
index c9ef72decc..6a5a9f382a 100644
--- a/src/frontend/src/functions/auth.tsx
+++ b/src/frontend/src/functions/auth.tsx
@@ -346,28 +346,37 @@ export async function ensureCsrf() {
}
}
-export function handleReset(
+export async function handleReset(
navigate: NavigateFunction,
values: { email: string }
) {
- ensureCsrf();
- api.post(apiUrl(ApiEndpoints.user_reset), values).then((val) => {
- if (val.status === 200) {
- notifications.show({
- title: t`Mail delivery successful`,
- message: t`Check your inbox for a reset link. This only works if you have an account. Check in spam too.`,
- color: 'green',
- autoClose: false
- });
- navigate('/login');
- } else {
- notifications.show({
- title: t`Reset failed`,
- message: t`Check your input and try again.`,
- color: 'red'
- });
- }
- });
+ // Ensure the CSRF cookie is set *before* making the POST request
+ await ensureCsrf();
+
+ const showResetFailed = () => {
+ notifications.show({
+ title: t`Reset failed`,
+ message: t`Check your input and try again.`,
+ color: 'red'
+ });
+ };
+
+ api
+ .post(apiUrl(ApiEndpoints.user_reset), values)
+ .then((val) => {
+ if (val.status === 200) {
+ notifications.show({
+ title: t`Mail delivery successful`,
+ message: t`Check your inbox for a reset link. This only works if you have an account. Check in spam too.`,
+ color: 'green',
+ autoClose: false
+ });
+ navigate('/login');
+ } else {
+ showResetFailed();
+ }
+ })
+ .catch(showResetFailed);
}
export async function handleMfaLogin(
@@ -793,8 +802,10 @@ export async function handleWebauthnLogin(
}
try {
- const publicKey =
- PublicKeyCredential.parseRequestOptionsFromJSON(webauthn_challenge);
+ // The server returns the options wrapped as { publicKey: {...}, mediation: ... }
+ const publicKey = PublicKeyCredential.parseRequestOptionsFromJSON(
+ webauthn_challenge.publicKey ?? webauthn_challenge
+ );
const credential = await navigator.credentials.get({ publicKey });
await api
.post(apiUrl(ApiEndpoints.auth_webauthn_login), {
diff --git a/src/frontend/src/pages/Auth/MFA.tsx b/src/frontend/src/pages/Auth/MFA.tsx
index 06d7dadff7..5f7444d773 100644
--- a/src/frontend/src/pages/Auth/MFA.tsx
+++ b/src/frontend/src/pages/Auth/MFA.tsx
@@ -2,7 +2,7 @@ import { t } from '@lingui/core/macro';
import { Trans } from '@lingui/react/macro';
import { Button, Checkbox, TextInput } from '@mantine/core';
import { useForm } from '@mantine/form';
-import { useEffect, useState } from 'react';
+import { useEffect, useRef, useState } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { useShallow } from 'zustand/react/shallow';
import { handleMfaLogin, handleWebauthnLogin } from '../../functions/auth';
@@ -19,8 +19,18 @@ export default function Mfa() {
);
const mfa_types = mfa_context?.types || [];
+ // Only start the WebAuthn flow once per page load.
+ // The effect may re-run (e.g. when the auth context is updated mid-flow),
+ // which would otherwise start concurrent (conflicting) WebAuthn requests.
+ const webauthnStarted = useRef(false);
+
useEffect(() => {
+ if (webauthnStarted.current) {
+ return;
+ }
+
if (mfa_types.includes('webauthn') || mfa_types.includes('webauthn_2fa')) {
+ webauthnStarted.current = true;
handleWebauthnLogin(navigate, location);
}
}, [mfa_types]);
diff --git a/src/frontend/tests/baseFixtures.ts b/src/frontend/tests/baseFixtures.ts
index 3df74981f4..1c69dd5d68 100644
--- a/src/frontend/tests/baseFixtures.ts
+++ b/src/frontend/tests/baseFixtures.ts
@@ -164,9 +164,11 @@ export const test = baseTest.extend({
!url.includes('/api/auth/v1/auth/login') &&
!url.includes('/api/auth/v1/auth/session') &&
!url.includes('/api/auth/v1/auth/2fa/authenticate') &&
+ !url.includes('/api/auth/v1/auth/webauthn/authenticate') &&
!url.includes('/api/auth/v1/auth/2fa/reauthenticate') &&
!url.includes('/api/auth/v1/auth/provider/signup') &&
!url.includes('/api/auth/v1/account/authenticators/totp') &&
+ !url.includes('/api/auth/v1/account/authenticators/webauthn') &&
!url.includes('/api/auth/v1/account/password/change') &&
!url.includes('/api/barcode/') &&
!url.includes('/favicon.ico') &&
diff --git a/src/frontend/tests/helpers.ts b/src/frontend/tests/helpers.ts
index 5f7aae3f0f..829d7e51ef 100644
--- a/src/frontend/tests/helpers.ts
+++ b/src/frontend/tests/helpers.ts
@@ -198,11 +198,15 @@ export const deletePart = async (name: string) => {
.then((res) => res.json());
const existingPart = parts.find((p: any) => p.name === name);
if (existingPart) {
- await api.patch(`part/${existingPart.pk}/`, {
- data: { active: false }
+ // Parts must be inactive (and unlocked) before they can be deleted.
+ // Note: other tests may run concurrently against the same database
+ const patch = await api.patch(`part/${existingPart.pk}/`, {
+ data: { active: false, locked: false }
});
+ expect(patch.status(), await patch.text()).toBe(200);
+
const res = await api.delete(`part/${existingPart.pk}/`);
- expect(res.status()).toBe(204);
+ expect(res.status(), await res.text()).toBe(204);
}
};
diff --git a/src/frontend/tests/pages/pui_detail_tables.spec.ts b/src/frontend/tests/pages/pui_detail_tables.spec.ts
new file mode 100644
index 0000000000..45bdd6ea43
--- /dev/null
+++ b/src/frontend/tests/pages/pui_detail_tables.spec.ts
@@ -0,0 +1,207 @@
+/**
+ * Tests for detail page tables:
+ * - Attachment upload (drag and drop zone) / edit / delete
+ * - BOM table optional columns
+ * - Transfer order line item actions
+ */
+
+import { createApi } from '../api.js';
+import { expect, generateUUID, test } from '../baseFixtures.js';
+import { clickOnRowMenu, loadTab } from '../helpers.js';
+import { doCachedLogin } from '../login.js';
+
+test('Attachments - Upload, Edit and Delete', async ({ browser }) => {
+ const filename = `pw-upload-${generateUUID().slice(0, 8)}.txt`;
+
+ const page = await doCachedLogin(browser, { url: 'part/69/attachments' });
+
+ await page.getByText('Drag attachment file here to upload').waitFor();
+
+ // Upload a file via the dropzone
+ await page
+ .locator('input[type="file"]')
+ .first()
+ .setInputFiles({
+ name: filename,
+ mimeType: 'text/plain',
+ buffer: Buffer.from('Playwright attachment upload test')
+ });
+
+ await page.getByText(`File ${filename} uploaded successfully`).waitFor();
+
+ const cell = page.getByRole('cell', { name: filename });
+ await cell.waitFor();
+
+ // Edit the attachment
+ await clickOnRowMenu(cell);
+ await page.getByRole('menuitem', { name: 'Edit' }).click();
+ await page
+ .getByLabel('text-field-comment', { exact: true })
+ .fill('Uploaded by playwright');
+ await page.waitForTimeout(300);
+ await page.getByRole('button', { name: 'Submit' }).click();
+ await page.getByRole('cell', { name: 'Uploaded by playwright' }).waitFor();
+
+ // Delete the attachment
+ await clickOnRowMenu(cell);
+ await page.getByRole('menuitem', { name: 'Delete' }).click();
+ await page
+ .getByRole('dialog', { name: 'Delete Attachment' })
+ .getByRole('button', { name: 'Delete' })
+ .click();
+ await expect(page.getByRole('cell', { name: filename })).toHaveCount(0);
+});
+
+test('BOM - Optional Columns', async ({ browser }) => {
+ // "Widget Assembly Variant" - BOM items with setup quantity
+ const page = await doCachedLogin(browser, { url: 'part/81/bom' });
+ await page.waitForLoadState('networkidle');
+
+ const columns = [
+ 'Setup Quantity',
+ 'Attrition',
+ 'Rounding Multiple',
+ 'Piece Count',
+ 'Substitutes'
+ ];
+
+ // Enable the optional columns
+ await page.getByLabel('table-select-columns').click();
+ for (const column of columns) {
+ await page.getByRole('menuitem', { name: column, exact: true }).click();
+ }
+ await page.keyboard.press('Escape');
+ await page.waitForLoadState('networkidle');
+
+ // Check that the columns are displayed
+ for (const column of columns) {
+ await page.locator('table thead th', { hasText: column }).first().waitFor();
+ }
+
+ // Disable them again
+ await page.getByLabel('table-select-columns').click();
+ for (const column of columns) {
+ await page.getByRole('menuitem', { name: column, exact: true }).click();
+ }
+ await page.keyboard.press('Escape');
+});
+
+test('Transfer Orders - Line Item Actions', async ({ browser }) => {
+ const api = await createApi({});
+ const reference = `TO-${Math.floor(10000 + Math.random() * 89999)}`;
+
+ const response = await api.post('order/transfer-order/', {
+ data: {
+ reference: reference,
+ description: 'Playwright line item actions',
+ take_from: 7,
+ destination: 12
+ }
+ });
+ expect(response.status()).toBe(201);
+ const order = await response.json();
+
+ // Lines: purchaseable part, assembly part, trackable part
+ for (const part of [85, 108, 87]) {
+ const line = await api.post('order/transfer-order-line/', {
+ data: { order: order.pk, part: part, quantity: 2 }
+ });
+ expect(line.status()).toBe(201);
+ }
+
+ const page = await doCachedLogin(browser, {
+ url: `stock/transfer-order/${order.pk}/line-items`
+ });
+
+ await loadTab(page, 'Line Items');
+
+ const rows = page
+ .getByRole('tabpanel', { name: 'Line Items' })
+ .locator('table tbody tr');
+
+ const rowMenu = async (index: number, action: string) => {
+ await rows
+ .nth(index)
+ .getByLabel(/row-action-menu-/i)
+ .click();
+ await page.getByRole('menuitem', { name: action }).click();
+ };
+
+ await rows.nth(2).waitFor();
+
+ // Try the "extra" row actions available on each line
+ for (let idx = 0; idx < 3; idx++) {
+ await rows
+ .nth(idx)
+ .getByLabel(/row-action-menu-/i)
+ .click();
+
+ const menu = page.getByRole('menu');
+ await menu.waitFor();
+ await page.waitForTimeout(250);
+
+ const actions = await menu.getByRole('menuitem').allInnerTexts();
+ const names = actions.map((a) => a.trim());
+
+ if (names.includes('Order stock')) {
+ await menu.getByRole('menuitem', { name: 'Order stock' }).click();
+ const wizard = page.getByRole('dialog', { name: 'Order Parts' });
+ await wizard.waitFor();
+ await wizard.getByRole('banner').getByRole('button').click();
+ await expect(wizard).toHaveCount(0);
+ } else if (names.includes('Allocate serials')) {
+ await menu.getByRole('menuitem', { name: 'Allocate serials' }).click();
+ await page.getByRole('button', { name: 'Cancel' }).click();
+ } else if (names.includes('Build stock')) {
+ await menu.getByRole('menuitem', { name: 'Build stock' }).click();
+ await page.getByRole('button', { name: 'Cancel' }).click();
+ } else {
+ // Close the menu by clicking the toggle again
+ await rows
+ .nth(idx)
+ .getByLabel(/row-action-menu-/i)
+ .click();
+ }
+
+ await expect(page.getByRole('menu')).toHaveCount(0);
+ }
+
+ // The "build stock" action for the assembly line
+ for (let idx = 0; idx < 3; idx++) {
+ await rows
+ .nth(idx)
+ .getByLabel(/row-action-menu-/i)
+ .click();
+ const menu = page.getByRole('menu');
+ await menu.waitFor();
+ await page.waitForTimeout(250);
+ const build = menu.getByRole('menuitem', { name: 'Build stock' });
+
+ if ((await build.count()) > 0) {
+ await build.click();
+ await page.getByRole('button', { name: 'Cancel' }).click();
+ break;
+ }
+
+ await rows
+ .nth(idx)
+ .getByLabel(/row-action-menu-/i)
+ .click();
+ await expect(page.getByRole('menu')).toHaveCount(0);
+ }
+
+ // Edit, duplicate and delete the first line
+ await rowMenu(0, 'Edit');
+ await page.getByRole('button', { name: 'Cancel' }).click();
+ await rowMenu(0, 'Duplicate');
+ await page.getByRole('button', { name: 'Cancel' }).click();
+ await rowMenu(0, 'Delete');
+ await page
+ .getByRole('dialog')
+ .getByRole('button', { name: 'Delete', exact: true })
+ .click();
+ await page.waitForLoadState('networkidle');
+
+ // Cleanup - cancel the order
+ await api.post(`order/transfer-order/${order.pk}/cancel/`, {});
+});
diff --git a/src/frontend/tests/pages/pui_parametric.spec.ts b/src/frontend/tests/pages/pui_parametric.spec.ts
new file mode 100644
index 0000000000..6d047339ea
--- /dev/null
+++ b/src/frontend/tests/pages/pui_parametric.spec.ts
@@ -0,0 +1,120 @@
+/**
+ * Tests for the "parametric" table view (part parameters)
+ */
+
+import type { Page } from '@playwright/test';
+import { test } from '../baseFixtures.js';
+import {
+ clearTableFilters,
+ clickOnParamFilter,
+ showParametricView,
+ showTableView
+} from '../helpers.js';
+import { doCachedLogin } from '../login.js';
+
+// Click on the parameter cell for the given column (in the specified row)
+const clickParameterCell = async (page: Page, column: string, row = 0) => {
+ const headers = page.locator('table thead th');
+ const count = await headers.count();
+
+ for (let idx = 0; idx < count; idx++) {
+ const text = (await headers.nth(idx).innerText()).trim();
+ if (text.startsWith(column)) {
+ await page
+ .locator('table tbody tr')
+ .nth(row)
+ .locator('td')
+ .nth(idx)
+ .click();
+ return;
+ }
+ }
+
+ throw new Error(`Column ${column} not found`);
+};
+
+// Find (and click) the first empty cell in any of the provided columns
+const clickEmptyParameterCell = async (
+ page: Page,
+ columns: string[]
+): Promise => {
+ const headers = page.locator('table thead th');
+ const count = await headers.count();
+ const rows = page.locator('table tbody tr');
+ const rowCount = await rows.count();
+
+ for (let idx = 0; idx < count; idx++) {
+ const text = (await headers.nth(idx).innerText()).trim();
+
+ if (!columns.some((col) => text.startsWith(col))) continue;
+
+ for (let row = 0; row < rowCount; row++) {
+ const cell = rows.nth(row).locator('td').nth(idx);
+ if ((await cell.innerText()).trim() === '') {
+ await cell.click();
+ return true;
+ }
+ }
+ }
+
+ return false;
+};
+
+test('Parametric - Filters and Editing', async ({ browser }) => {
+ // "Resistors" category - parts with numeric parameters
+ const page = await doCachedLogin(browser, {
+ url: 'part/category/5/parts'
+ });
+
+ await showParametricView(page);
+ await clearTableFilters(page);
+
+ // Numeric filter, with a custom operator
+ await clickOnParamFilter(page, 'Resistance [ohms]');
+ await page
+ .getByRole('combobox', { name: 'filter-Resistance-operator' })
+ .last()
+ .click();
+ await page.getByRole('option', { name: '>', exact: true }).click();
+ await page.getByLabel('filter-Resistance', { exact: true }).last().fill('1k');
+ await page
+ .getByLabel('filter-Resistance', { exact: true })
+ .last()
+ .press('Enter');
+ await page.waitForLoadState('networkidle');
+
+ // Add a second filter on the same column
+ await clickOnParamFilter(page, 'Resistance [ohms]');
+ await page
+ .getByRole('combobox', { name: 'filter-Resistance-operator' })
+ .last()
+ .click();
+ await page.getByRole('option', { name: '<', exact: true }).click();
+ await page
+ .getByLabel('filter-Resistance', { exact: true })
+ .last()
+ .fill('100k');
+ await page
+ .getByLabel('filter-Resistance', { exact: true })
+ .last()
+ .press('Enter');
+ await page.waitForLoadState('networkidle');
+
+ // Clear the filter
+ await clickOnParamFilter(page, 'Resistance [ohms]');
+ await page.getByLabel('clear-filter-Resistance').first().click();
+ await page.waitForLoadState('networkidle');
+ await page.keyboard.press('Escape');
+
+ // Edit an existing parameter value
+ await clickParameterCell(page, 'Resistance');
+ await page.getByRole('button', { name: 'Submit' }).waitFor();
+ await page.getByRole('button', { name: 'Cancel' }).click();
+
+ // Add a parameter value which does not yet exist (find an empty cell)
+ if (await clickEmptyParameterCell(page, ['Power', 'Tolerance', 'Package'])) {
+ await page.getByRole('button', { name: 'Cancel' }).click();
+ }
+
+ await showTableView(page);
+});
diff --git a/src/frontend/tests/pages/pui_part_tests.spec.ts b/src/frontend/tests/pages/pui_part_tests.spec.ts
new file mode 100644
index 0000000000..b07b26fcdc
--- /dev/null
+++ b/src/frontend/tests/pages/pui_part_tests.spec.ts
@@ -0,0 +1,96 @@
+/**
+ * Tests for the "Test Templates" panel on the part detail page
+ */
+
+import { createApi } from '../api.js';
+import { expect, generateUUID, test } from '../baseFixtures.js';
+import {
+ clearTableFilters,
+ clickOnRowMenu,
+ loadTab,
+ navigate,
+ setTableChoiceFilter
+} from '../helpers.js';
+import { doCachedLogin } from '../login.js';
+
+test('Parts - Test Templates', async ({ browser }) => {
+ const testName = `PW Test ${generateUUID().slice(0, 8)}`;
+
+ // "Doohickey" - a testable part with its own test templates
+ const page = await doCachedLogin(browser, { url: 'part/87/' });
+ await loadTab(page, 'Test Templates');
+
+ // Exercise table filters
+ await setTableChoiceFilter(page, 'Required', 'Yes');
+ await clearTableFilters(page);
+ await setTableChoiceFilter(page, 'Has Results', 'No');
+ await clearTableFilters(page);
+
+ // Create a new test template
+ await page.getByLabel('action-button-add-test-template').click();
+ await page.getByLabel('text-field-test_name').fill(testName);
+ await page.getByLabel('text-field-description').fill('Playwright test');
+ await page.getByRole('switch', { name: 'boolean-field-required' }).click();
+ await page.getByRole('button', { name: 'Submit' }).click();
+
+ const cell = page.getByRole('cell', { name: testName });
+ await cell.waitFor();
+
+ // Hover to display template details
+ await page.getByText(testName, { exact: true }).hover();
+
+ // Edit the template
+ await clickOnRowMenu(cell);
+ await page.getByRole('menuitem', { name: 'Edit' }).click();
+ await page.getByLabel('text-field-description').fill('Edited description');
+ await page.getByRole('button', { name: 'Submit' }).click();
+ await page.getByRole('cell', { name: 'Edited description' }).waitFor();
+
+ // Delete the template
+ await clickOnRowMenu(page.getByRole('cell', { name: testName }));
+ await page.getByRole('menuitem', { name: 'Delete' }).click();
+ await page.getByText('This action cannot be reversed').waitFor();
+ await page.getByRole('button', { name: 'Delete' }).click();
+ await expect(page.getByRole('cell', { name: testName })).toHaveCount(0);
+
+ // "Widget Assembly Variant" - inherits tests from "Widget Assembly"
+ await navigate(page, 'part/81/');
+ await loadTab(page, 'Test Templates');
+
+ const inherited = page.getByText(
+ 'Test is defined for a parent template part'
+ );
+ const row = page.locator('table tbody tr').first();
+ await row.waitFor();
+ await row.locator('td').first().hover();
+
+ if (await inherited.first().isVisible()) {
+ await inherited.first().waitFor();
+ }
+
+ // Inherited tests only provide a "View Parent Part" action
+ await row.getByLabel(/row-action-menu-/i).click();
+ await page.getByRole('menuitem', { name: 'View Parent Part' }).click();
+ await page.waitForURL('**/part/77/**');
+});
+
+test('Parts - Test Templates (Locked)', async ({ browser }) => {
+ const api = await createApi({});
+
+ // Ensure part locking is enabled
+ await api.patch('settings/global/PART_ENABLE_LOCKING/', {
+ data: { value: true }
+ });
+
+ // "Test Board 2" - a locked part
+ const page = await doCachedLogin(browser, { url: 'part/111/' });
+ await loadTab(page, 'Test Templates');
+ await page.getByText('Part is Locked', { exact: true }).first().waitFor();
+ await page
+ .getByText('Part templates cannot be edited, as the part is locked')
+ .waitFor();
+
+ await expect(page.getByLabel('action-button-add-test-template')).toHaveCount(
+ 0
+ );
+});
diff --git a/src/frontend/tests/pages/pui_purchasing.spec.ts b/src/frontend/tests/pages/pui_purchasing.spec.ts
index 3049d9146f..bf4995582c 100644
--- a/src/frontend/tests/pages/pui_purchasing.spec.ts
+++ b/src/frontend/tests/pages/pui_purchasing.spec.ts
@@ -1,6 +1,6 @@
import { expect } from '@playwright/test';
import { createApi } from '../api.ts';
-import { test } from '../baseFixtures.ts';
+import { generateUUID, test } from '../baseFixtures.ts';
import { readeruser, stevenuser } from '../defaults.ts';
import {
activateCalendarView,
@@ -611,6 +611,8 @@ test('Purchase Orders - Receive Items', async ({ browser }) => {
});
test('Purchase Orders - Custom Location', async ({ browser }) => {
+ // Unique batch code, so that retries do not match items from earlier attempts
+ const batchCode = `po-custom-location-${generateUUID().slice(0, 8)}`;
const page = await doCachedLogin(browser);
await navigate(page, 'purchasing/purchase-order/14/line-items');
@@ -642,8 +644,20 @@ test('Purchase Orders - Custom Location', async ({ browser }) => {
await row.getByLabel(/row-action-menu-/i).click();
await page.getByRole('menuitem', { name: 'Receive line item' }).click();
- await page.getByLabel('tree-field-location').fill('storage room a');
- await page.getByText('Storage Room A (purple door)').click();
+ // Wait for the form to apply its initial data (the order's destination),
+ // otherwise it can overwrite the location selected below
+ const locationField = page.getByLabel('tree-field-location');
+ await expect(locationField).toHaveValue(/Mechanical Lab/);
+
+ await expect(async () => {
+ await locationField.fill('storage room a');
+ await page
+ .getByText('Storage Room A (purple door)')
+ .click({ timeout: 5000 });
+ await expect(locationField).toHaveValue(/Storage Room A/, {
+ timeout: 2000
+ });
+ }).toPass({ timeout: 30000 });
await page.getByLabel('number-field-quantity').fill('1');
await page.waitForTimeout(500);
@@ -651,11 +665,14 @@ test('Purchase Orders - Custom Location', async ({ browser }) => {
await page.getByLabel('action-button-assign-batch-').click();
await page
.getByLabel('text-field-line_batch_code', { exact: true })
- .fill('po-custom-location-test');
+ .fill(batchCode);
// Short timeout to allow for debouncing
await page.waitForTimeout(200);
+ // Ensure the selected location has not been reset
+ await expect(locationField).toHaveValue(/Storage Room A/);
+
await page.getByRole('button', { name: 'Submit' }).click();
await page.getByText('Items received').waitFor();
@@ -664,11 +681,11 @@ test('Purchase Orders - Custom Location', async ({ browser }) => {
await loadTab(page, 'Received Stock');
await clearTableFilters(page);
- await tableSearch(page, 'po-custom-location-test');
+ await tableSearch(page, batchCode);
const receivedRow = page
.getByRole('row')
- .filter({ hasText: 'po-custom-location-test' })
+ .filter({ hasText: batchCode })
.first();
await expect(receivedRow).toContainText('Storage Room A');
@@ -687,7 +704,7 @@ test('Purchase Orders - Custom Location', async ({ browser }) => {
expect(targetLocation).toBeTruthy();
const items = await api
- .get('stock/', { params: { batch: 'po-custom-location-test' } })
+ .get('stock/', { params: { batch: batchCode } })
.then((res) => res.json());
expect(items.length).toBeGreaterThan(0);
diff --git a/src/frontend/tests/pages/pui_stock_actions.spec.ts b/src/frontend/tests/pages/pui_stock_actions.spec.ts
new file mode 100644
index 0000000000..dc29ba9cdc
--- /dev/null
+++ b/src/frontend/tests/pages/pui_stock_actions.spec.ts
@@ -0,0 +1,243 @@
+/**
+ * Tests for stock adjustment forms which are not covered elsewhere:
+ * - Transfer to default location
+ * - Bulk actions from the stock table (merge / status / batch / assign / delete)
+ * - Find serial number (error handling)
+ * - Test results with templated choices
+ */
+
+import { createApi } from '../api.js';
+import { expect, generateUUID, test } from '../baseFixtures.js';
+import { navigate } from '../helpers.js';
+import { doCachedLogin } from '../login.js';
+
+// Part "530470210" - a simple (non-trackable) part
+const PART_ID = 69;
+
+// Create a new stock item via the API, and return its primary key
+const createStockItem = async (api, data: any): Promise => {
+ const response = await api.post('stock/', {
+ data: { part: PART_ID, quantity: 10, location: 7, ...data }
+ });
+ expect(response.status()).toBe(201);
+ const result = await response.json();
+ const item = Array.isArray(result) ? result[0] : result;
+ return item.pk ?? item.items?.[0]?.pk;
+};
+
+test('Stock - Transfer to Default Location', async ({ browser }) => {
+ const api = await createApi({});
+
+ // Set a default location for the part
+ await api.patch(`part/${PART_ID}/`, { data: { default_location: 12 } });
+
+ try {
+ const pk = await createStockItem(api, { quantity: 5 });
+
+ const page = await doCachedLogin(browser, {
+ url: `stock/item/${pk}/details`
+ });
+
+ const openTransfer = async () => {
+ await page.getByLabel('action-menu-stock-operations').click();
+ await page.getByLabel('action-menu-stock-operations-transfer').click();
+ };
+
+ // Move to the default location (confirmation dialog)
+ await openTransfer();
+ await page.getByLabel('action-button-move-to-default-location').click();
+ await page.getByText('Confirm Stock Transfer').waitFor();
+ await page
+ .getByRole('dialog', { name: 'Confirm Stock Transfer' })
+ .getByRole('button', { name: 'Submit' })
+ .click();
+ await page.waitForLoadState('networkidle');
+
+ // Close the transfer dialog (the item has been removed from the list)
+ await page.keyboard.press('Escape');
+
+ // Transfer again, toggling the extra row options
+ await navigate(page, `stock/item/${pk}/details`);
+ await openTransfer();
+ await page.getByLabel('action-button-change-status').click();
+ await page.getByLabel('action-button-adjust-packaging').click();
+ await page.getByLabel('action-button-merge-into-existing-stock').click();
+ await page.getByLabel('action-button-merge-into-existing-stock').click();
+ await page.getByRole('button', { name: 'Cancel' }).click();
+ } finally {
+ await api.patch(`part/${PART_ID}/`, { data: { default_location: null } });
+ }
+});
+
+test('Stock - Table Bulk Actions', async ({ browser }) => {
+ const api = await createApi({});
+ const batch = `PW-${generateUUID().slice(0, 6)}`;
+
+ const a = await createStockItem(api, { batch });
+ const b = await createStockItem(api, { batch });
+
+ const page = await doCachedLogin(browser, {
+ url: `part/${PART_ID}/stock`
+ });
+
+ // Filter the table to only show the new items
+ await page.getByLabel('table-search-input').fill(batch);
+ await page.waitForTimeout(600);
+ await page.waitForLoadState('networkidle');
+
+ const panel = page.getByRole('tabpanel', { name: 'Stock' });
+
+ // Wait for the filtered results to load
+ await expect(panel.getByRole('cell', { name: batch })).toHaveCount(2);
+
+ const actionMenu = panel.getByLabel('action-menu-stock-actions');
+
+ // Select the specified number of rows in the table
+ // Note: the table selection is reset (asynchronously) after each stock
+ // operation, when the table data is reloaded - so retry until it sticks
+ const selectRows = async (count = 2) => {
+ await expect(async () => {
+ // Wait for any modal (and its loading overlay) to close
+ await expect(page.getByRole('dialog')).toHaveCount(0, { timeout: 2000 });
+ await page.waitForLoadState('networkidle');
+
+ const boxes = [];
+ for (let idx = 1; idx <= count; idx++) {
+ const box = panel.getByLabel(`Select record ${idx}`, { exact: true });
+ await box.check({ timeout: 2000 });
+ boxes.push(box);
+ }
+
+ // Ensure that the selection is not cleared by a pending table reload
+ await page.waitForTimeout(500);
+ for (const box of boxes) {
+ await expect(box).toBeChecked({ timeout: 500 });
+ }
+ await expect(actionMenu).toBeEnabled({ timeout: 500 });
+ }).toPass({ timeout: 30000 });
+ };
+
+ const launch = async (action: string) => {
+ await actionMenu.click();
+ await page.getByLabel(`action-menu-stock-actions-${action}`).click();
+ };
+
+ // Change status
+ await selectRows();
+ await launch('change-status');
+ await page.getByLabel('choice-field-status').click();
+ await page.getByRole('option', { name: 'Damaged' }).click();
+ await page.getByRole('button', { name: 'Submit' }).click();
+ await page.waitForLoadState('networkidle');
+
+ // Change batch code (open, then cancel)
+ await selectRows();
+ await launch('change-batch-code');
+ await page.getByRole('button', { name: 'Cancel' }).click();
+
+ // Assign to customer (open, then cancel)
+ await selectRows();
+ await launch('assign-stock');
+ await page.getByRole('button', { name: 'Cancel' }).click();
+
+ // Merge the items
+ await selectRows();
+ await launch('merge-stock');
+ await page.getByRole('button', { name: 'Submit' }).click();
+ await page.waitForLoadState('networkidle');
+
+ // Delete whatever remains
+ await selectRows(1);
+ await launch('delete-stock');
+ await page.getByRole('button', { name: 'Delete' }).click();
+ await page.waitForLoadState('networkidle');
+
+ // Ensure the items have been removed
+ for (const pk of [a, b]) {
+ const response = await api.get(`stock/${pk}/`);
+ expect([404, 200]).toContain(response.status());
+ }
+});
+
+test('Stock - Find Serial Number', async ({ browser }) => {
+ // "Red Widget" variant with serialized stock
+ const page = await doCachedLogin(browser, { url: 'part/79/details' });
+
+ await page.getByLabel('action-menu-stock-actions').click();
+ await page.getByLabel('action-menu-stock-actions-search').click();
+
+ // Serial number which does not exist
+ await page.getByLabel('text-field-serial', { exact: true }).fill('99999999');
+ await page.getByRole('button', { name: 'Submit' }).click();
+ await page.getByText('No matching items').waitFor();
+
+ // Valid serial number
+ await page.getByLabel('text-field-serial', { exact: true }).fill('359');
+ await page.getByRole('button', { name: 'Submit' }).click();
+ await page.getByText('Serial Number: 359').waitFor();
+});
+
+test('Stock - Test Result Choices', async ({ browser }) => {
+ const api = await createApi({});
+ const testName = `PW Choice ${generateUUID().slice(0, 6)}`;
+
+ // "Doohickey" - create a test template with a set of choices
+ const response = await api.post('part/test-template/', {
+ data: {
+ part: 87,
+ test_name: testName,
+ description: 'Playwright test template',
+ choices: 'Pass, Fail, Retest',
+ requires_value: true
+ }
+ });
+ expect(response.status()).toBe(201);
+ const template = await response.json();
+
+ try {
+ // Serialized stock item for "Doohickey"
+ // Note: explicit timeouts are used so that any failure reports the
+ // offending step, rather than a generic test timeout
+ const page = await test.step('Open test results', async () => {
+ const page = await doCachedLogin(browser, { url: 'stock/item/409/' });
+ await page
+ .getByLabel(/panel-tabs-/)
+ .getByRole('tab', { name: 'Test Results' })
+ .click({ timeout: 15000 });
+ return page;
+ });
+
+ await test.step('Select test template', async () => {
+ await page
+ .getByLabel('action-button-add-test-result')
+ .click({ timeout: 15000 });
+
+ const option = page.getByRole('option', { name: new RegExp(testName) });
+
+ // Retry the search, in case the first query is debounced away
+ await expect(async () => {
+ await page
+ .getByLabel('related-field-template')
+ .fill(testName, { timeout: 5000 });
+ await option.click({ timeout: 5000 });
+ }).toPass({ timeout: 30000 });
+ });
+
+ await test.step('Select choice value', async () => {
+ // The "value" field is now a choice field
+ await page.getByLabel('choice-field-value').click({ timeout: 10000 });
+ await page
+ .getByRole('option', { name: 'Retest' })
+ .click({ timeout: 10000 });
+ await page
+ .getByRole('button', { name: 'Submit' })
+ .click({ timeout: 10000 });
+ await page
+ .getByRole('cell', { name: 'Retest' })
+ .first()
+ .waitFor({ timeout: 15000 });
+ });
+ } finally {
+ await api.delete(`part/test-template/${template.pk}/`);
+ }
+});
diff --git a/src/frontend/tests/pui_admin.spec.ts b/src/frontend/tests/pui_admin.spec.ts
new file mode 100644
index 0000000000..38f5852414
--- /dev/null
+++ b/src/frontend/tests/pui_admin.spec.ts
@@ -0,0 +1,287 @@
+/**
+ * Tests for the "Admin Center" management tables.
+ *
+ * These tests exercise the create / edit / delete flows (and detail drawers)
+ * for the user, group, custom state, email and identity management panels.
+ */
+
+import type { Page } from '@playwright/test';
+import { createApi } from './api.js';
+import { expect, generateUUID, test } from './baseFixtures.js';
+import { adminuser } from './defaults.js';
+import { clickOnRowMenu, loadTab, tableSearch } from './helpers.js';
+import { doCachedLogin } from './login.js';
+
+// Return the accordion control with the given title
+const accordion = (page: Page, name: string) =>
+ page.locator('.mantine-Accordion-control', {
+ has: page.getByText(name, { exact: true })
+ });
+
+// Expand an accordion item (if it is not already expanded)
+const openAccordion = async (page: Page, name: string) => {
+ const control = accordion(page, name).first();
+ await control.waitFor();
+ if ((await control.getAttribute('aria-expanded')) !== 'true') {
+ await control.click();
+ }
+};
+
+test('Admin - Users and Groups', async ({ browser }) => {
+ const suffix = generateUUID().slice(0, 8);
+ const groupName = `pw-group-${suffix}`;
+ const userName = `pw-user-${suffix}`;
+
+ const page = await doCachedLogin(browser, {
+ user: adminuser,
+ url: 'settings/admin/user'
+ });
+
+ // Create a new group
+ await openAccordion(page, 'Groups');
+ await page.getByLabel('action-button-add-group').click();
+ await page.getByLabel('text-field-name', { exact: true }).fill(groupName);
+ await page.getByRole('button', { name: 'Submit' }).click();
+
+ // Open the group detail drawer
+ await page.getByRole('cell', { name: groupName, exact: true }).click();
+ await page.getByText('Edit Group').first().waitFor();
+ await page.getByText('Group Details').waitFor();
+
+ // Edit the group roles
+ await openAccordion(page, 'Group Roles');
+ const partRow = page.getByRole('row', { name: /^Parts/ }).first();
+ await partRow.getByRole('checkbox').nth(0).check();
+ await partRow.getByRole('checkbox').nth(1).check();
+
+ // Reset the changes, then make them again and save
+ await page.getByRole('button', { name: 'Reset', exact: true }).click();
+ await partRow.getByRole('checkbox').nth(0).check();
+ await partRow.getByRole('checkbox').nth(2).check();
+ await page.getByRole('button', { name: 'Save', exact: true }).click();
+ await page.getByText('Group roles updated').first().waitFor();
+
+ // Close the drawer
+ await page.keyboard.press('Escape');
+ await page.waitForTimeout(250);
+
+ // Create a new user
+ await openAccordion(page, 'Users');
+ await page.getByLabel('action-button-add-user').click();
+ await page.getByLabel('text-field-username').fill(userName);
+ await page.getByLabel('text-field-email').fill(`${userName}@example.org`);
+ await page.getByLabel('text-field-first_name').fill('Play');
+ await page.getByLabel('text-field-last_name').fill('Wright');
+ await page.getByRole('button', { name: 'Submit' }).click();
+ await page.getByText('Added user').first().waitFor();
+
+ // Open the user detail drawer
+ const users = page.getByRole('region', { name: 'Users' });
+ await users.getByLabel('table-search-input').fill(userName);
+ await page.waitForTimeout(600);
+ await page.waitForLoadState('networkidle');
+ await page.getByRole('cell', { name: userName, exact: true }).click();
+ await page.getByText('Edit User').first().waitFor();
+ await page.getByText('User Details').waitFor();
+
+ // Assign the new group to the user via the transfer list
+ await openAccordion(page, 'User Groups');
+ const groupItem = page
+ .getByLabel('User Groups')
+ .getByText(groupName, { exact: true });
+
+ // Move all items across, and back again
+ await page.getByLabel('transfer-all-right').click();
+ await page.getByLabel('transfer-all-left').click();
+
+ // Select a single item and move it across, then back, then across again
+ // Note: items remain selected after being transferred
+ await groupItem.click();
+ await page.getByLabel('transfer-selected-right').click();
+ await page.getByLabel('transfer-selected-left').click();
+ await page.getByLabel('transfer-selected-right').click();
+ await page.getByRole('button', { name: 'Save', exact: true }).click();
+ await page.getByText('User groups updated successfully').first().waitFor();
+
+ await page.keyboard.press('Escape');
+ await page.waitForTimeout(250);
+
+ // Lock and unlock the user via the row actions
+ const userCell = page.getByRole('cell', { name: userName, exact: true });
+ await clickOnRowMenu(userCell);
+ await page.getByRole('menuitem', { name: 'Lock user' }).click();
+ await page.getByText('User updated successfully').first().waitFor();
+
+ await clickOnRowMenu(userCell);
+ await page.getByRole('menuitem', { name: 'Unlock user' }).click();
+ await page.waitForLoadState('networkidle');
+
+ // Edit via row action (opens the drawer)
+ await clickOnRowMenu(userCell);
+ await page.getByRole('menuitem', { name: 'Edit' }).click();
+ await page.getByText('User Details').waitFor();
+ await page.keyboard.press('Escape');
+ await page.waitForTimeout(250);
+
+ // Delete the user
+ await clickOnRowMenu(userCell);
+ await page.getByRole('menuitem', { name: 'Delete' }).click();
+ await page.getByRole('button', { name: 'Delete' }).click();
+ await page.getByText('User deleted').first().waitFor();
+
+ // Cleanup: delete the group via the API
+ const api = await createApi({});
+ const groups = await api
+ .get('user/group/', { params: { search: groupName } })
+ .then((res) => res.json());
+
+ for (const group of groups.results ?? groups) {
+ if (group.name == groupName) {
+ await api.delete(`user/group/${group.pk}/`);
+ }
+ }
+});
+
+test('Admin - Custom States', async ({ browser }) => {
+ const suffix = generateUUID().slice(0, 6);
+ const name = `PW_STATE_${suffix.toUpperCase()}`;
+ const key = 4000 + Math.floor(Math.random() * 1000);
+
+ const page = await doCachedLogin(browser, {
+ user: adminuser,
+ url: 'settings/admin/custom-states'
+ });
+
+ await page.getByLabel('action-button-add-state').click();
+ await page.getByLabel('choice-field-reference_status').click();
+ await page.getByRole('option', { name: 'StockStatus', exact: true }).click();
+ await page.getByLabel('choice-field-logical_key').click();
+ await page.getByRole('option', { name: 'Damaged' }).click();
+ await page.getByLabel('number-field-key').fill(`${key}`);
+ await page.getByLabel('text-field-name', { exact: true }).fill(name);
+ await page.getByLabel('text-field-label').fill(`PW ${suffix}`);
+ await page.getByLabel('related-field-model').fill('stock item');
+ await page.getByRole('option').first().click();
+ await page.getByRole('button', { name: 'Submit' }).click();
+ await page.waitForLoadState('networkidle');
+
+ await tableSearch(page, name);
+ const cell = page.getByRole('cell', { name, exact: true });
+ await cell.waitFor();
+
+ // Edit
+ await clickOnRowMenu(cell);
+ await page.getByRole('menuitem', { name: 'Edit' }).click();
+ await page.getByLabel('text-field-label').fill(`PW Edit ${suffix}`);
+ await page.getByRole('button', { name: 'Submit' }).click();
+ await page.getByRole('cell', { name: `PW Edit ${suffix}` }).waitFor();
+
+ // Duplicate (then cancel)
+ await clickOnRowMenu(cell);
+ await page.getByRole('menuitem', { name: 'Duplicate' }).click();
+ await page.getByRole('button', { name: 'Cancel' }).click();
+
+ // Delete
+ await clickOnRowMenu(cell);
+ await page.getByRole('menuitem', { name: 'Delete' }).click();
+ await page.getByRole('button', { name: 'Delete' }).click();
+ await expect(page.getByRole('cell', { name, exact: true })).toHaveCount(0);
+});
+
+test('Admin - Misc Panels', async ({ browser }) => {
+ const page = await doCachedLogin(browser, {
+ user: adminuser,
+ url: 'settings/admin/email'
+ });
+
+ // Email management panel
+ await page.getByText('Email Messages').waitFor();
+ await page.getByLabel('action-button-send-test-email').click();
+ await page.getByRole('button', { name: 'Submit' }).waitFor();
+ await page.getByRole('button', { name: 'Cancel' }).click();
+
+ // Note templates
+ await loadTab(page, 'Note Templates');
+ await page
+ .getByText('Note templates can be used to create pre-defined notes')
+ .waitFor();
+});
+
+test('Admin - Identity Federation', async ({ browser }) => {
+ const appName = `pw-oidc-${generateUUID().slice(0, 6)}`;
+
+ const page = await doCachedLogin(browser, {
+ user: adminuser,
+ url: 'settings/admin/identity'
+ });
+
+ await page.getByText('SCIM allows an external Identity Provider').waitFor();
+
+ // Enable SCIM, which displays the secret once
+ await page.getByRole('button', { name: 'Enable SCIM' }).click();
+ await page.getByText('SCIM Bearer Secret').waitFor();
+ await page.getByText('This secret is only shown once').waitFor();
+ await page.keyboard.press('Escape');
+
+ // Rotate the secret
+ await page.getByRole('button', { name: 'Rotate Secret' }).click();
+ await page.getByText('SCIM Bearer Secret').waitFor();
+ await page.keyboard.press('Escape');
+
+ // Disable SCIM again
+ await page.getByRole('button', { name: 'Disable SCIM' }).click();
+ await page.getByRole('button', { name: 'Enable SCIM' }).waitFor();
+
+ // Open (and cancel) the generic + SAML application forms
+ await openAccordion(page, 'Single Sign-On (SSO)');
+ await page.getByRole('button', { name: 'Add Generic App' }).click();
+ await page.getByRole('button', { name: 'Cancel' }).click();
+ await page.getByRole('button', { name: 'Add SAML App' }).click();
+ await page.getByText('Identity Provider Settings').first().waitFor();
+ await page.getByRole('button', { name: 'Cancel' }).click();
+
+ // Create an OIDC application
+ await page.getByRole('button', { name: 'Add OIDC App' }).click();
+ await page.getByLabel('text-field-name', { exact: true }).fill(appName);
+ await page.getByLabel('text-field-client_id').fill('pw-client');
+ await page.getByLabel('text-field-secret').fill('pw-secret');
+ await page
+ .getByLabel('text-field-server_url')
+ .fill('https://sso.example.org/');
+ await page.getByRole('button', { name: 'Submit' }).click();
+ await page.getByText('OIDC Callback URL').waitFor();
+ await page.keyboard.press('Escape');
+
+ // Edit, then delete the application
+ const cell = page.getByRole('cell', { name: appName, exact: true });
+ await clickOnRowMenu(cell);
+ await page.getByRole('menuitem', { name: 'Edit' }).click();
+ await page.getByRole('button', { name: 'Cancel' }).click();
+
+ await clickOnRowMenu(cell);
+ await page.getByRole('menuitem', { name: 'Delete' }).click();
+ await page.getByRole('button', { name: 'Delete' }).click();
+ await expect(cell).toHaveCount(0);
+});
+
+test('Admin - Error Reports', async ({ browser }) => {
+ const api = await createApi({});
+ const response = await api.get('error-report/', { params: { limit: 1 } });
+ expect(response.status()).toBe(200);
+ const data = await response.json();
+
+ const page = await doCachedLogin(browser, {
+ user: adminuser,
+ url: 'settings/admin/errors'
+ });
+
+ await page.waitForLoadState('networkidle');
+
+ if ((data.count ?? data.length) > 0) {
+ // Open the detail drawer for the first error
+ await page.locator('table tbody tr').first().click();
+ await page.getByText('Error Details').waitFor();
+ await page.getByRole('rowheader', { name: 'Traceback' }).waitFor();
+ await page.keyboard.press('Escape');
+ }
+});
diff --git a/src/frontend/tests/pui_auth.spec.ts b/src/frontend/tests/pui_auth.spec.ts
new file mode 100644
index 0000000000..09a05ae00e
--- /dev/null
+++ b/src/frontend/tests/pui_auth.spec.ts
@@ -0,0 +1,98 @@
+/**
+ * Tests for the unauthenticated "account" pages:
+ * - Password reset
+ * - Set password (from reset link)
+ * - Email verification
+ * - User registration
+ */
+
+import { expect, generateUUID, test } from './baseFixtures.js';
+import { loginUrl } from './defaults.js';
+import { navigate } from './helpers.js';
+import { setSettingState } from './settings.js';
+
+test('Auth - Password Reset', async ({ page }) => {
+ await setSettingState({ setting: 'LOGIN_ENABLE_PWD_FORGOT', value: true });
+
+ try {
+ // Navigate to the login page, and follow the "reset password" link
+ await navigate(page, loginUrl);
+ await page.getByRole('button', { name: 'Reset password' }).click();
+ await page.waitForURL('**/reset-password');
+
+ await page.getByText('Reset Password').first().waitFor();
+ await page.getByPlaceholder('email@example.org').fill('nobody@example.org');
+ await page.getByRole('button', { name: 'Send Email' }).click();
+
+ // Allauth reports success regardless of whether the account exists
+ // Note: this also checks that the CSRF cookie is set before the request
+ await page.getByText('Mail delivery successful').waitFor();
+ await page.waitForURL('**/login');
+ } finally {
+ await setSettingState({
+ setting: 'LOGIN_ENABLE_PWD_FORGOT',
+ value: false
+ });
+ }
+});
+
+test('Auth - Set Password', async ({ page }) => {
+ // No key provided - should redirect back to the login page
+ await navigate(page, 'set-password');
+ await page.getByText('Key invalid').first().waitFor();
+
+ // Invalid key provided
+ await navigate(page, 'set-password?key=not-a-valid-key');
+ await page.getByText('Set new password').first().waitFor();
+ await page.getByRole('textbox', { name: 'Password' }).fill('a-new-Passw0rd!');
+ await page.getByRole('button', { name: 'Send Password' }).click();
+ await page.waitForLoadState('networkidle');
+});
+
+test('Auth - Verify Email', async ({ page }) => {
+ // An invalid key is rejected by the server (no redirect occurs)
+ await navigate(page, 'verify-email/not-a-valid-key');
+ await page.getByText('Verify Email').first().waitFor();
+ await page.getByRole('button', { name: 'Verify' }).click();
+ await page.waitForLoadState('networkidle');
+ await expect(page).toHaveURL(/verify-email/);
+});
+
+test('Auth - Registration', async ({ page }) => {
+ const username = `pw-reg-${generateUUID().slice(0, 8)}`;
+
+ await setSettingState({ setting: 'LOGIN_ENABLE_REG', value: true });
+
+ try {
+ await navigate(page, loginUrl);
+ await page.getByText("Don't have an account?").waitFor();
+ await page.getByRole('button', { name: 'Register' }).click();
+
+ // Go back to login, then return to the registration form
+ await page.getByRole('button', { name: 'Go back to login' }).click();
+ await page.getByRole('button', { name: 'Register' }).click();
+
+ // Mismatched passwords
+ await page.getByLabel('register-username').fill(username);
+ await page.getByLabel('register-email').fill(`${username}@example.org`);
+ await page.getByLabel('register-password', { exact: true }).fill('abc');
+ await page.getByLabel('register-password-repeat').fill('xyz');
+ await page.getByRole('button', { name: 'Register', exact: true }).click();
+ await page.waitForLoadState('networkidle');
+
+ // Valid registration
+ await page
+ .getByLabel('register-password', { exact: true })
+ .fill('Pl@ywright-Passw0rd');
+ await page
+ .getByLabel('register-password-repeat')
+ .fill('Pl@ywright-Passw0rd');
+ await page.getByRole('button', { name: 'Register', exact: true }).click();
+ await page.waitForLoadState('networkidle');
+ await expect(page.getByLabel('register-username')).toBeVisible({
+ visible: false
+ });
+ } finally {
+ await setSettingState({ setting: 'LOGIN_ENABLE_REG', value: false });
+ }
+});
diff --git a/src/frontend/tests/pui_calendar.spec.ts b/src/frontend/tests/pui_calendar.spec.ts
new file mode 100644
index 0000000000..7f91acddb6
--- /dev/null
+++ b/src/frontend/tests/pui_calendar.spec.ts
@@ -0,0 +1,185 @@
+/**
+ * Tests for the order "calendar" views.
+ *
+ * The demo dataset does not contain any orders in the current month,
+ * so each test creates an order (via the API) which is visible in the
+ * default calendar view. This allows the event rendering, tooltip,
+ * click and drag handlers to be exercised.
+ */
+
+import type { Page } from '@playwright/test';
+import { createApi } from './api.js';
+import { expect, generateUUID, test } from './baseFixtures.js';
+import { loadTab, showCalendarView, showTableView } from './helpers.js';
+import { doCachedLogin } from './login.js';
+
+// Return a YYYY-MM-DD date string for a given day in the current month
+const dateInMonth = (day: number): string => {
+ const now = new Date();
+ const d = new Date(now.getFullYear(), now.getMonth(), day);
+ const mm = String(d.getMonth() + 1).padStart(2, '0');
+ const dd = String(d.getDate()).padStart(2, '0');
+ return `${d.getFullYear()}-${mm}-${dd}`;
+};
+
+const firstResult = async (api, url: string, params: any) => {
+ const response = await api.get(url, { params: { limit: 1, ...params } });
+ expect(response.status()).toBe(200);
+ const data = await response.json();
+ return (data.results ?? data)[0];
+};
+
+/**
+ * Exercise the common calendar functionality for an order which
+ * is known to be displayed in the current month
+ */
+const exerciseCalendar = async (page: Page, description: string) => {
+ await showCalendarView(page);
+
+ // Search for the order within the calendar
+ await page.getByLabel('table-search-input').fill(description);
+ await page.waitForTimeout(600);
+ await page.waitForLoadState('networkidle');
+
+ const event = page.getByText(description, { exact: true }).first();
+ await event.waitFor();
+
+ // Hover over the event to display the tooltip
+ await event.hover();
+ await page.getByText('Target Date').first().waitFor();
+
+ // Drag the event to a different day, triggering an update to the order
+ const target = page.locator('td.fc-day').filter({ hasText: /^15$/ }).first();
+
+ if (await target.isVisible()) {
+ await event.dragTo(target);
+ await page.waitForLoadState('networkidle');
+ }
+
+ // Toggle the calendar filters
+ await page.getByLabel('calendar-select-filters').click();
+ await page.getByText('Calendar Filters').first().waitFor();
+ await page.keyboard.press('Escape');
+
+ // Refresh the calendar
+ await page.getByLabel('calendar-refresh').click();
+ await page.waitForLoadState('networkidle');
+
+ // Open the export modal
+ await page.getByLabel('calendar-export-data').click();
+ await page.getByRole('button', { name: 'Cancel' }).click();
+
+ // Navigate months
+ await page.getByLabel('action-button-next-month').click();
+ await page.getByLabel('action-button-previous-month').click();
+
+ // Click the event to open the preview drawer (or navigate to the order)
+ const url = page.url();
+ await page.getByText(description, { exact: true }).first().click();
+ await page.waitForLoadState('networkidle');
+ await page.keyboard.press('Escape');
+
+ if (page.url() != url) {
+ await page.goBack();
+ }
+
+ // Restore the table view
+ await showTableView(page);
+};
+
+test('Calendar - Purchase Orders', async ({ browser }) => {
+ const api = await createApi({});
+ const description = `pw-cal-po-${generateUUID().slice(0, 8)}`;
+ const supplier = await firstResult(api, 'company/', { is_supplier: true });
+
+ const response = await api.post('order/po/', {
+ data: {
+ supplier: supplier.pk,
+ description: description,
+ start_date: dateInMonth(8),
+ target_date: dateInMonth(10)
+ }
+ });
+ expect(response.status()).toBe(201);
+ const order = await response.json();
+
+ const page = await doCachedLogin(browser, { url: 'purchasing/index/' });
+ await loadTab(page, 'Purchase Orders');
+ await exerciseCalendar(page, description);
+
+ // Cleanup
+ await api.post(`order/po/${order.pk}/cancel/`, {});
+});
+
+test('Calendar - Sales Orders', async ({ browser }) => {
+ const api = await createApi({});
+ const description = `pw-cal-so-${generateUUID().slice(0, 8)}`;
+ const customer = await firstResult(api, 'company/', { is_customer: true });
+
+ const response = await api.post('order/so/', {
+ data: {
+ customer: customer.pk,
+ description: description,
+ start_date: dateInMonth(8),
+ target_date: dateInMonth(10)
+ }
+ });
+ expect(response.status()).toBe(201);
+ const order = await response.json();
+
+ const page = await doCachedLogin(browser, { url: 'sales/index/' });
+ await loadTab(page, 'Sales Orders');
+ await exerciseCalendar(page, description);
+
+ await api.post(`order/so/${order.pk}/cancel/`, {});
+});
+
+test('Calendar - Return Orders', async ({ browser }) => {
+ const api = await createApi({});
+ const description = `pw-cal-ro-${generateUUID().slice(0, 8)}`;
+ const customer = await firstResult(api, 'company/', { is_customer: true });
+
+ const response = await api.post('order/ro/', {
+ data: {
+ customer: customer.pk,
+ description: description,
+ start_date: dateInMonth(8),
+ target_date: dateInMonth(10)
+ }
+ });
+ expect(response.status()).toBe(201);
+ const order = await response.json();
+
+ const page = await doCachedLogin(browser, { url: 'sales/index/' });
+ await loadTab(page, 'Return Orders');
+ await exerciseCalendar(page, description);
+
+ await api.post(`order/ro/${order.pk}/cancel/`, {});
+});
+
+test('Calendar - Build Orders', async ({ browser }) => {
+ const api = await createApi({});
+ const title = `pw-cal-bo-${generateUUID().slice(0, 8)}`;
+ const part = await firstResult(api, 'part/', {
+ assembly: true,
+ active: true
+ });
+
+ const response = await api.post('build/', {
+ data: {
+ part: part.pk,
+ quantity: 1,
+ title: title,
+ start_date: dateInMonth(8),
+ target_date: dateInMonth(10)
+ }
+ });
+ expect(response.status()).toBe(201);
+ const build = await response.json();
+
+ const page = await doCachedLogin(browser, { url: 'manufacturing/index/' });
+ await loadTab(page, 'Build Orders');
+ await exerciseCalendar(page, title);
+
+ await api.post(`build/${build.pk}/cancel/`, {});
+});
diff --git a/src/frontend/tests/pui_forms.spec.ts b/src/frontend/tests/pui_forms.spec.ts
index 6c57ee1d66..d7c64f19e1 100644
--- a/src/frontend/tests/pui_forms.spec.ts
+++ b/src/frontend/tests/pui_forms.spec.ts
@@ -331,41 +331,48 @@ test('Forms - Nested Object Field', async ({ browser }) => {
test('Forms - Initial Stock Field', async ({ browser }) => {
await setSettingState({ setting: 'PART_CREATE_INITIAL', value: true });
- const partName = `Initial Stock Part ${Date.now()}`;
- await deletePart(partName);
+ try {
+ const partName = `Initial Stock Part ${Date.now()}`;
+ await deletePart(partName);
- const page = await doCachedLogin(browser, {
- user: stevenuser,
- url: 'part/category/index/parts'
- });
- await page.waitForURL('**/part/category/index/**');
+ const page = await doCachedLogin(browser, {
+ user: stevenuser,
+ url: 'part/category/index/parts'
+ });
+ await page.waitForURL('**/part/category/index/**');
- await page.getByRole('button', { name: 'action-menu-add-parts' }).click();
- await page
- .getByRole('menuitem', { name: 'action-menu-add-parts-create-part' })
- .click();
+ await page.getByRole('button', { name: 'action-menu-add-parts' }).click();
+ await page
+ .getByRole('menuitem', { name: 'action-menu-add-parts-create-part' })
+ .click();
- await page.getByLabel('text-field-name', { exact: true }).fill(partName);
+ await page.getByLabel('text-field-name', { exact: true }).fill(partName);
- const quantityField = page.getByLabel('number-field-initial_stock.quantity');
- await expect(quantityField).toBeVisible();
+ const quantityField = page.getByLabel(
+ 'number-field-initial_stock.quantity'
+ );
+ await expect(quantityField).toBeVisible();
- await quantityField.fill('25');
- await page.getByLabel('tree-field-initial_stock.location').fill('production');
- await page.getByText('Electronics production facility').click();
+ await quantityField.fill('25');
+ await page
+ .getByLabel('tree-field-initial_stock.location')
+ .fill('production');
+ await page.getByText('Electronics production facility').click();
- await page.getByRole('button', { name: 'Submit' }).click();
- await page.getByText('Item Created').waitFor();
+ await page.getByRole('button', { name: 'Submit' }).click();
+ await page.getByText('Item Created').waitFor();
- await page.getByText(partName).first().click();
- await page
- .getByLabel('panel-tabs-part')
- .getByRole('tab', { name: 'Stock', exact: true })
- .click();
- await page.getByText('25').first().waitFor();
+ await page.getByText(partName).first().click();
+ await page
+ .getByLabel('panel-tabs-part')
+ .getByRole('tab', { name: 'Stock', exact: true })
+ .click();
+ await page.getByText('25').first().waitFor();
- await deletePart(partName);
- await setSettingState({ setting: 'PART_CREATE_INITIAL', value: false });
+ await deletePart(partName);
+ } finally {
+ await setSettingState({ setting: 'PART_CREATE_INITIAL', value: false });
+ }
});
test('Forms - Default Part Location', async ({ browser }) => {
diff --git a/src/frontend/tests/pui_plugins.spec.ts b/src/frontend/tests/pui_plugins.spec.ts
index 7616dde59e..77c2f7f03e 100644
--- a/src/frontend/tests/pui_plugins.spec.ts
+++ b/src/frontend/tests/pui_plugins.spec.ts
@@ -84,9 +84,20 @@ test('Plugins - User Settings', async ({ browser }) => {
await page.getByText('User Setting 3').waitFor();
// Check for expected setting options
- await page.getByLabel('edit-setting-USER_SETTING_3').click();
+ // Note: the settings list may re-render (e.g. if the plugin registry reloads
+ // due to concurrent tests), so retry until the edit modal is displayed
+ const choiceField = page.getByLabel('choice-field-value');
- const val = await page.getByLabel('choice-field-value').inputValue();
+ await expect(async () => {
+ if (!(await choiceField.isVisible())) {
+ await page
+ .getByLabel('edit-setting-USER_SETTING_3')
+ .click({ timeout: 5000 });
+ }
+ await expect(choiceField).toBeVisible({ timeout: 5000 });
+ }).toPass({ timeout: 45000 });
+
+ const val = await choiceField.inputValue();
await page.getByLabel('choice-field-value').click();
@@ -210,17 +221,20 @@ test('Plugins - Custom Admin', async ({ browser }) => {
user: adminuser
});
- // Ensure that the SampleUI plugin is enabled
- await setPluginState({
- plugin: 'sampleui',
- state: true
- });
+ // Ensure that the SampleUI plugin is enabled, and open the plugin drawer.
+ // Note: other tests (running concurrently) may toggle this plugin,
+ // which reloads the plugin registry - so retry if required
+ await expect(async () => {
+ await setPluginState({
+ plugin: 'sampleui',
+ state: true
+ });
- // Navigate to the "admin" page
- await navigate(page, 'settings/admin/plugin/');
+ await navigate(page, 'settings/admin/plugin/');
+ await page.getByText('SampleUI').first().click({ timeout: 10000 });
+ }).toPass({ timeout: 60000 });
- // Open the plugin drawer, and ensure that the custom admin elements are visible
- await page.getByText('SampleUI').click();
+ // Ensure that the custom admin elements are visible
await page.getByRole('button', { name: 'Plugin Information' }).click();
await page
.getByLabel('Plugin Detail')
diff --git a/src/frontend/tests/pui_spotlight.spec.ts b/src/frontend/tests/pui_spotlight.spec.ts
new file mode 100644
index 0000000000..5d9a468b08
--- /dev/null
+++ b/src/frontend/tests/pui_spotlight.spec.ts
@@ -0,0 +1,63 @@
+/**
+ * Exercise each of the default "spotlight" navigation actions
+ */
+
+import type { Page } from '@playwright/test';
+import { test } from './baseFixtures.js';
+import { adminuser } from './defaults.js';
+import { doCachedLogin } from './login.js';
+
+// Open the spotlight, search for an action, and run it
+const runAction = async (page: Page, label: string) => {
+ await page.getByLabel('open-spotlight').click();
+ await page.getByRole('textbox', { name: 'Search...' }).fill(label);
+ await page
+ .getByRole('button', { name: new RegExp(`^${label}`) })
+ .first()
+ .click();
+ await page.waitForLoadState('networkidle');
+};
+
+test('Spotlight - Navigation Actions', async ({ browser }) => {
+ const page = await doCachedLogin(browser, { user: adminuser });
+
+ // Actions which navigate to a new page
+ const actions: [string, RegExp][] = [
+ ['User Settings', /settings\/user/],
+ ['Import Data', /settings\/admin\/import/],
+ ['Purchase Orders', /purchasing\/index\/purchaseorders/],
+ ['Sales Orders', /sales\/index\/salesorders/],
+ ['Transfer Orders', /transfer/],
+ ['Return Orders', /sales\/index\/returnorders/],
+ ['Build Orders', /manufacturing\/index\/buildorders/],
+ ['System Settings', /settings\/system/],
+ ['Admin Center', /settings\/admin/],
+ ['Error Logs', /settings\/admin\/errors/],
+ ['Users', /settings\/admin\/user/],
+ ['Plugins', /settings\/admin\/plugin/],
+ ['Machines', /settings\/admin\/machine/],
+ ['Report Templates', /settings\/admin\/reports/],
+ ['Label Templates', /settings\/admin\/labels/]
+ ];
+
+ for (const [label, url] of actions) {
+ await test.step(label, async () => {
+ await runAction(page, label);
+ await page.waitForURL(url);
+ });
+ }
+
+ // Open the barcode scanning modal
+ await runAction(page, 'Scan');
+ await page.getByPlaceholder('Enter barcode data').waitFor();
+ await page.keyboard.press('Escape');
+
+ // Display the list of hotkeys
+ await runAction(page, 'Hotkeys');
+ await page.getByText('Ctrl').first().waitFor();
+ await page.keyboard.press('Escape');
+
+ // Open the navigation menu
+ await runAction(page, 'Open Navigation');
+ await page.keyboard.press('Escape');
+});
diff --git a/src/frontend/tests/pui_webauthn.spec.ts b/src/frontend/tests/pui_webauthn.spec.ts
new file mode 100644
index 0000000000..3e85ebb735
--- /dev/null
+++ b/src/frontend/tests/pui_webauthn.spec.ts
@@ -0,0 +1,134 @@
+/**
+ * Tests for WebAuthn (security key / passkey) multi-factor authentication.
+ *
+ * A Chromium "virtual authenticator" (via the DevTools protocol) is used
+ * to emulate a hardware security key.
+ */
+
+import type { Page } from '@playwright/test';
+import { createApi } from './api.js';
+import { expect, generateUUID, test } from './baseFixtures.js';
+import { logoutUrl } from './defaults.js';
+import { navigate } from './helpers.js';
+import { doLogin } from './login.js';
+
+// Attach a virtual authenticator to the provided page
+const addVirtualAuthenticator = async (page: Page) => {
+ const client = await page.context().newCDPSession(page);
+ await client.send('WebAuthn.enable');
+ await client.send('WebAuthn.addVirtualAuthenticator', {
+ options: {
+ protocol: 'ctap2',
+ transport: 'internal',
+ hasResidentKey: true,
+ hasUserVerification: true,
+ isUserVerified: true,
+ automaticPresenceSimulation: true
+ }
+ });
+ return client;
+};
+
+// Create a new user account (with a known password) for this test
+const createUser = async (username: string, password: string) => {
+ const api = await createApi({});
+ const response = await api.post('user/', {
+ data: {
+ username: username,
+ email: `${username}@example.org`,
+ first_name: 'Web',
+ last_name: 'Authn'
+ }
+ });
+ expect(response.status()).toBe(201);
+ const user = await response.json();
+
+ const pwd = await api.put(`user/${user.pk}/set-password/`, {
+ data: { password: password, override_warning: true }
+ });
+ expect([200, 201]).toContain(pwd.status());
+
+ return user.pk;
+};
+
+// Handle a re-authentication prompt (if displayed)
+const reauthenticate = async (page: Page, password: string) => {
+ const field = page.getByLabel('text-field-password');
+ const visible = await field
+ .waitFor({ timeout: 3000 })
+ .then(() => true)
+ .catch(() => false);
+
+ if (visible) {
+ await field.fill(password);
+ await page.getByRole('button', { name: 'Submit' }).click();
+ }
+};
+
+test('Login - MFA - WebAuthn', async ({ page, browserName }) => {
+ // The virtual authenticator requires the Chrome DevTools Protocol
+ test.skip(
+ browserName !== 'chromium',
+ 'WebAuthn virtual authenticator is only available in Chromium'
+ );
+
+ const username = `pw-webauthn-${generateUUID().slice(0, 6)}`;
+ const password = `Pw-${generateUUID()}`;
+ const userId = await createUser(username, password);
+
+ try {
+ await addVirtualAuthenticator(page);
+ await doLogin(page, { username, password });
+
+ await navigate(page, 'settings/user/security', {
+ waitUntil: 'networkidle'
+ });
+
+ // Remove the (unverified) email address for this user
+ // Note: allauth prevents adding MFA factors while an unverified email exists
+ await page.getByLabel(`${username}@example.org`).click();
+ await page.getByRole('button', { name: 'Remove' }).click();
+ await page
+ .getByText('Currently no email addresses are registered')
+ .waitFor();
+
+ // Register a new WebAuthn credential
+ await page
+ .getByRole('button', { name: 'Multi-Factor Authentication' })
+ .click();
+ await page.getByRole('button', { name: 'add-factor-webauthn' }).click();
+ await reauthenticate(page, password);
+ await page
+ .getByText('WebAuthn credential registered successfully')
+ .waitFor();
+
+ // Log out, and log back in using the security key
+ await navigate(page, logoutUrl);
+ await page.waitForURL('**/web/login');
+ await page.getByRole('textbox', { name: 'login-username' }).fill(username);
+ await page.getByRole('textbox', { name: 'login-password' }).fill(password);
+ await page.getByRole('button', { name: 'Log in' }).click();
+
+ // The MFA page should automatically use the virtual authenticator,
+ // and complete the login process
+ await page.waitForURL('**/web/mfa');
+ await page.waitForURL(/\/web(\/home)?$/, { timeout: 30000 });
+ await page.getByRole('button', { name: 'navigation-menu' }).waitFor();
+
+ // Remove the WebAuthn credential
+ await navigate(page, 'settings/user/security', {
+ waitUntil: 'networkidle'
+ });
+ await page
+ .getByRole('button', { name: 'Multi-Factor Authentication' })
+ .click();
+ await page.getByRole('button', { name: 'remove-webauthn' }).click();
+ await page.getByText('Remove WebAuthn Credential').waitFor();
+ await page.getByRole('button', { name: 'Remove', exact: true }).click();
+ await reauthenticate(page, password);
+ await page.getByText('WebAuthn credential removed successfully').waitFor();
+ } finally {
+ const api = await createApi({});
+ await api.delete(`user/${userId}/`);
+ }
+});