mirror of
https://github.com/inventree/InvenTree.git
synced 2026-11-10 02:47:49 +00:00
[UI] playwright updates (#13231)
* [UI] playwright updates - Increased coverage - various tweaks / fixes * Additional updates * Robustify further
This commit is contained in:
1 parent
2497dd5425
commit
48ec9d1dd2
18 files changed
+1583
-74
No files matched your search
@@ -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
|
||||
@@ -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 />
|
||||
|
||||
@@ -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), {
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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') &&
|
||||
|
||||
@@ -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}/`);
|
||||
}
|
||||
});
|
||||
@@ -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');
|
||||
}
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
@@ -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/`, {});
|
||||
});
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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}/`);
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user