[UI] playwright updates (#13231)

* [UI] playwright updates

- Increased coverage
- various tweaks / fixes

* Additional updates

* Robustify further
This commit is contained in:
Oliver authored and GitHub committed 2026-11-09 22:57:13 +11:00
1 parent 2497dd5425
commit 48ec9d1dd2
18 files changed
+1583 -74

No files matched your search

@@ -174,6 +174,7 @@ export function TransferList({
variant='outline'
size='md'
disabled={leftItems.length == 0}
aria-label='transfer-all-right'
onClick={() => transferLeftToRight(true)}
>
<IconCircleChevronRight />
@@ -182,6 +183,7 @@ export function TransferList({
variant='outline'
size='md'
disabled={!leftSelected}
aria-label='transfer-selected-right'
onClick={() => transferLeftToRight(false)}
>
<IconChevronRight />
@@ -190,6 +192,7 @@ export function TransferList({
variant='outline'
size='md'
disabled={!rightSelected}
aria-label='transfer-selected-left'
onClick={() => transferRightToLeft(false)}
>
<IconChevronLeft />
@@ -198,6 +201,7 @@ export function TransferList({
variant='outline'
size='md'
disabled={rightItems.length == 0}
aria-label='transfer-all-left'
onClick={() => transferRightToLeft(true)}
>
<IconCircleChevronLeft />
+32 -21
View File
@@ -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), {
+11 -1
View File
@@ -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<boolean>(false);
useEffect(() => {
if (webauthnStarted.current) {
return;
}
if (mfa_types.includes('webauthn') || mfa_types.includes('webauthn_2fa')) {
webauthnStarted.current = true;
handleWebauthnLogin(navigate, location);
}
}, [mfa_types]);
+2
View File
@@ -164,9 +164,11 @@ export const test = baseTest.extend<TestFixtures, {}>({
!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') &&
+7 -3
View File
@@ -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);
}
};
@@ -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/`, {});
});
@@ -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<boolean> => {
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);
});
@@ -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
);
});
@@ -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);
@@ -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<number> => {
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}/`);
}
});
+287
View File
@@ -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');
}
});
+98
View File
@@ -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 });
}
});
+185
View File
@@ -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/`, {});
});
+34 -27
View File
@@ -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 }) => {
+25 -11
View File
@@ -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')
+63
View File
@@ -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');
});
+134
View File
@@ -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}/`);
}
});