From 48ec9d1dd25975e12499f4c64fcdeb4573fb9f76 Mon Sep 17 00:00:00 2001 From: Oliver Date: Mon, 9 Nov 2026 22:57:13 +1100 Subject: [PATCH] [UI] playwright updates (#13231) * [UI] playwright updates - Increased coverage - various tweaks / fixes * Additional updates * Robustify further --- .github/workflows/frontend.yaml | 15 +- .../src/components/items/TransferList.tsx | 4 + src/frontend/src/functions/auth.tsx | 53 ++-- src/frontend/src/pages/Auth/MFA.tsx | 12 +- src/frontend/tests/baseFixtures.ts | 2 + src/frontend/tests/helpers.ts | 10 +- .../tests/pages/pui_detail_tables.spec.ts | 207 +++++++++++++ .../tests/pages/pui_parametric.spec.ts | 120 ++++++++ .../tests/pages/pui_part_tests.spec.ts | 96 ++++++ .../tests/pages/pui_purchasing.spec.ts | 31 +- .../tests/pages/pui_stock_actions.spec.ts | 243 +++++++++++++++ src/frontend/tests/pui_admin.spec.ts | 287 ++++++++++++++++++ src/frontend/tests/pui_auth.spec.ts | 98 ++++++ src/frontend/tests/pui_calendar.spec.ts | 185 +++++++++++ src/frontend/tests/pui_forms.spec.ts | 61 ++-- src/frontend/tests/pui_plugins.spec.ts | 36 ++- src/frontend/tests/pui_spotlight.spec.ts | 63 ++++ src/frontend/tests/pui_webauthn.spec.ts | 134 ++++++++ 18 files changed, 1583 insertions(+), 74 deletions(-) create mode 100644 src/frontend/tests/pages/pui_detail_tables.spec.ts create mode 100644 src/frontend/tests/pages/pui_parametric.spec.ts create mode 100644 src/frontend/tests/pages/pui_part_tests.spec.ts create mode 100644 src/frontend/tests/pages/pui_stock_actions.spec.ts create mode 100644 src/frontend/tests/pui_admin.spec.ts create mode 100644 src/frontend/tests/pui_auth.spec.ts create mode 100644 src/frontend/tests/pui_calendar.spec.ts create mode 100644 src/frontend/tests/pui_spotlight.spec.ts create mode 100644 src/frontend/tests/pui_webauthn.spec.ts 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}/`); + } +});