feat(e2e): add Playwright end-to-end tests for authentication, GRN, production, stock transfers, and adjustments
- Introduced Playwright configuration for e2e testing. - Implemented authentication tests to validate login functionality. - Created tests for GRN (Goods Receipt Note) to ensure proper stock handling. - Developed production run tests to verify lifecycle and stock posting. - Added stock transfer tests to check movement between warehouses. - Implemented stock adjustment tests for positive and negative adjustments. - Established API seeder for test data setup and verification. - Enhanced utility functions for UI interactions and response handling.
This commit is contained in:
@@ -0,0 +1,209 @@
|
||||
import { APIRequestContext, expect, request } from "@playwright/test"
|
||||
import { env, AUTH_STORAGE_STATE } from "./env"
|
||||
|
||||
/**
|
||||
* Standalone APIRequestContext for use in `test.beforeAll`, where the test-scoped `request`
|
||||
* fixture isn't available. Reuses the same storageState the "setup" project produced, so it
|
||||
* is already authenticated. Caller must `.dispose()` it in `afterAll`.
|
||||
*/
|
||||
export async function newApiContext(): Promise<APIRequestContext> {
|
||||
return request.newContext({ baseURL: env.baseUrl, storageState: AUTH_STORAGE_STATE })
|
||||
}
|
||||
|
||||
// Thin wrapper over the same `/api/v1` surface `Frontend/erp-system/lib/api/*.ts` calls,
|
||||
// used to seed/verify data directly against the backend so specs don't have to build every
|
||||
// prerequisite (vendors, POs, templates) by driving the UI. `request` must already carry
|
||||
// the authenticated session cookie - either via the "setup" project's storageState, or by
|
||||
// passing a context created after `AuthApi.login`.
|
||||
const API_BASE = "/api/v1"
|
||||
|
||||
export interface Warehouse {
|
||||
warehouseId: number
|
||||
code: string
|
||||
name: string
|
||||
}
|
||||
|
||||
export interface Vendor {
|
||||
vendorId: number
|
||||
code: string
|
||||
name: string
|
||||
}
|
||||
|
||||
export interface Item {
|
||||
itemId: number
|
||||
sku: string
|
||||
name: string
|
||||
baseUomId: number
|
||||
}
|
||||
|
||||
export interface Uom {
|
||||
uomId: number
|
||||
name: string
|
||||
}
|
||||
|
||||
export interface Category {
|
||||
categoryId: number
|
||||
name: string
|
||||
}
|
||||
|
||||
/** Suffixes every seeded code/SKU with a run-unique token so parallel/rerun specs never collide. */
|
||||
export function uniqueSuffix(): string {
|
||||
return `${Date.now()}${Math.floor(Math.random() * 1000)}`
|
||||
}
|
||||
|
||||
export class ApiSeeder {
|
||||
constructor(private readonly request: APIRequestContext) {}
|
||||
|
||||
private async get<T>(path: string): Promise<T> {
|
||||
const res = await this.request.get(`${API_BASE}${path}`)
|
||||
expect(res.ok(), `GET ${path} -> ${res.status()}: ${await res.text()}`).toBeTruthy()
|
||||
return res.json()
|
||||
}
|
||||
|
||||
private async post<T>(path: string, data: unknown): Promise<T> {
|
||||
const res = await this.request.post(`${API_BASE}${path}`, { data })
|
||||
expect(res.ok(), `POST ${path} -> ${res.status()}: ${await res.text()}`).toBeTruthy()
|
||||
return res.json()
|
||||
}
|
||||
|
||||
// --- reference data (relies on DataSeeder's MAIN/SHOP/PCS/BOX/General Goods seed) -----
|
||||
|
||||
async firstWarehouse(): Promise<Warehouse> {
|
||||
const page = await this.get<{ items: Warehouse[] }>("/warehouses?page=1&pageSize=1")
|
||||
if (!page.items.length) throw new Error("No warehouses found - expected DataSeeder's MAIN warehouse to exist.")
|
||||
return page.items[0]
|
||||
}
|
||||
|
||||
async secondWarehouse(): Promise<Warehouse> {
|
||||
const page = await this.get<{ items: Warehouse[] }>("/warehouses?page=1&pageSize=10")
|
||||
if (page.items.length < 2) throw new Error("Need at least 2 warehouses (DataSeeder seeds MAIN + SHOP).")
|
||||
return page.items[1]
|
||||
}
|
||||
|
||||
async firstUom(): Promise<Uom> {
|
||||
const page = await this.get<{ items: Uom[] }>("/uoms?page=1&pageSize=1")
|
||||
if (!page.items.length) throw new Error("No UOMs found - expected DataSeeder's PCS uom to exist.")
|
||||
return page.items[0]
|
||||
}
|
||||
|
||||
async firstCategory(): Promise<Category> {
|
||||
const page = await this.get<{ items: Category[] }>("/categories?page=1&pageSize=1")
|
||||
if (!page.items.length) throw new Error("No categories found - expected DataSeeder's General Goods category.")
|
||||
return page.items[0]
|
||||
}
|
||||
|
||||
// --- writes used to build test fixtures -------------------------------------------
|
||||
|
||||
async createVendor(namePrefix = "E2E Vendor"): Promise<Vendor> {
|
||||
const suffix = uniqueSuffix()
|
||||
return this.post<Vendor>("/vendors", {
|
||||
code: `E2E-V-${suffix}`,
|
||||
name: `${namePrefix} ${suffix}`,
|
||||
currency: "LKR",
|
||||
})
|
||||
}
|
||||
|
||||
async createItem(opts: { namePrefix?: string; categoryId?: number; baseUomId?: number } = {}): Promise<Item> {
|
||||
const suffix = uniqueSuffix()
|
||||
const categoryId = opts.categoryId ?? (await this.firstCategory()).categoryId
|
||||
const baseUomId = opts.baseUomId ?? (await this.firstUom()).uomId
|
||||
return this.post<Item>("/items", {
|
||||
sku: `E2E-SKU-${suffix}`,
|
||||
name: `${opts.namePrefix ?? "E2E Item"} ${suffix}`,
|
||||
categoryId,
|
||||
baseUomId,
|
||||
stockNature: "Stocked",
|
||||
trackingMode: "None",
|
||||
})
|
||||
}
|
||||
|
||||
/** Direct (no-PO) GRN, confirmed immediately, so the item has on-hand stock to test against. */
|
||||
async receiveStock(opts: { warehouseId: number; vendorId: number; itemId: number; uomId: number; qty: number; unitCost: number }) {
|
||||
const grn = await this.post<{ grnId: number }>("/grns", {
|
||||
vendorId: opts.vendorId,
|
||||
warehouseId: opts.warehouseId,
|
||||
lines: [
|
||||
{
|
||||
itemId: opts.itemId,
|
||||
uomId: opts.uomId,
|
||||
qty: opts.qty,
|
||||
unitCost: opts.unitCost,
|
||||
discountPct: 0,
|
||||
vatPct: 0,
|
||||
holdStatus: "Available",
|
||||
},
|
||||
],
|
||||
})
|
||||
await this.post(`/grns/${grn.grnId}/confirm`, {})
|
||||
return grn
|
||||
}
|
||||
|
||||
/** Direct GRN with the line held for inspection, confirmed - gives the detail page a line with Release/Reject actions. */
|
||||
async receiveStockOnHold(opts: { warehouseId: number; vendorId: number; itemId: number; uomId: number; qty: number; unitCost: number }) {
|
||||
const grn = await this.post<{ grnId: number; lines: { grnLineId: number }[] }>("/grns", {
|
||||
vendorId: opts.vendorId,
|
||||
warehouseId: opts.warehouseId,
|
||||
lines: [
|
||||
{
|
||||
itemId: opts.itemId,
|
||||
uomId: opts.uomId,
|
||||
qty: opts.qty,
|
||||
unitCost: opts.unitCost,
|
||||
discountPct: 0,
|
||||
vatPct: 0,
|
||||
holdStatus: "OnHold",
|
||||
},
|
||||
],
|
||||
})
|
||||
await this.post(`/grns/${grn.grnId}/confirm`, {})
|
||||
return grn
|
||||
}
|
||||
|
||||
async createPurchaseOrder(opts: { vendorId: number; warehouseId: number; itemId: number; uomId: number; qty: number; unitPrice: number }) {
|
||||
return this.post<{ poId: number; docNo: string }>("/purchase-orders", {
|
||||
vendorId: opts.vendorId,
|
||||
lines: [
|
||||
{
|
||||
itemId: opts.itemId,
|
||||
uomId: opts.uomId,
|
||||
warehouseId: opts.warehouseId,
|
||||
qty: opts.qty,
|
||||
unitPrice: opts.unitPrice,
|
||||
tax: 0,
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal single-stage template: one stage that is both entry and terminal, one Stock
|
||||
* input (the raw material) and one item-bearing output (the finished good) - the
|
||||
* smallest graph ProductionGraphValidator accepts (Backend/ERPCore/Services/Production/
|
||||
* ProductionGraphValidator.cs: exactly one terminal, terminal has exactly one item output).
|
||||
*/
|
||||
async createSingleStageTemplate(opts: { rawItemId: number; finishedItemId: number; uomId: number }) {
|
||||
const suffix = uniqueSuffix()
|
||||
return this.post<{ templateId: number; code: string; name: string }>("/production-templates", {
|
||||
code: `E2E-TPL-${suffix}`,
|
||||
name: `E2E Template ${suffix}`,
|
||||
stages: [
|
||||
{
|
||||
key: "stage-1",
|
||||
name: "Assemble",
|
||||
estimatedMinutes: 10,
|
||||
posX: 0,
|
||||
posY: 0,
|
||||
fieldDefs: [],
|
||||
inputs: [{ source: "Stock", itemId: opts.rawItemId, uomId: opts.uomId, qtyPerBatch: 1 }],
|
||||
outputs: [{ key: "out-1", itemId: opts.finishedItemId, name: "Finished good", uomId: opts.uomId, qtyPerBatch: 1 }],
|
||||
},
|
||||
],
|
||||
edges: [],
|
||||
annotations: [],
|
||||
})
|
||||
}
|
||||
|
||||
async stockOnHand(itemId: number, warehouseId: number) {
|
||||
return this.get<{ onHand: number; available: number }>(`/stock/on-hand?itemId=${itemId}&warehouseId=${warehouseId}`)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import path from "node:path"
|
||||
import dotenv from "dotenv"
|
||||
|
||||
dotenv.config({ path: path.resolve(__dirname, "../.env.e2e") })
|
||||
|
||||
function required(name: string): string {
|
||||
const value = process.env[name]
|
||||
if (!value) throw new Error(`Missing required env var ${name} - copy .env.e2e.example to .env.e2e and fill it in.`)
|
||||
return value
|
||||
}
|
||||
|
||||
export const env = {
|
||||
baseUrl: process.env.E2E_BASE_URL ?? "http://localhost:3000",
|
||||
apiUrl: process.env.E2E_API_URL ?? "http://localhost:5224",
|
||||
get adminEmail() {
|
||||
return required("E2E_ADMIN_EMAIL")
|
||||
},
|
||||
get adminPassword() {
|
||||
return required("E2E_ADMIN_PASSWORD")
|
||||
},
|
||||
}
|
||||
|
||||
export const AUTH_STORAGE_STATE = path.resolve(__dirname, "../.auth/admin.json")
|
||||
@@ -0,0 +1,113 @@
|
||||
import { Page, Locator } from "@playwright/test"
|
||||
|
||||
/**
|
||||
* Root cause (confirmed via a repro script capturing `page.on("pageerror")`): every load of
|
||||
* these pages throws a genuine React hydration error ("Minified React error #418" - text
|
||||
* content mismatch between server and client render) - it is NOT intermittent. What IS
|
||||
* unpredictable is its effect: hydration recovery blanks the placeholder text of a random
|
||||
* subset of that page's Select triggers, but leaves everything else (the sibling <Label>/
|
||||
* <FieldLabel>, the trigger's role="combobox" attribute, the DOM structure) intact. So a
|
||||
* reload-until-clean strategy never terminates (confirmed: reloading never once produced a
|
||||
* "clean" load), and a bare `getByRole("combobox", { name: ... })` is unreliable because the
|
||||
* accessible name it depends on is exactly what gets blanked.
|
||||
*
|
||||
* The fix is to stop depending on that name at all: every Select trigger in this app sits as
|
||||
* an immediate sibling of a stable, always-intact label element, so `comboboxByLabel()` finds
|
||||
* the trigger via that label + role="combobox" alone. `retryClick`/`selectOption`/
|
||||
* `clickToReveal` remain useful as defense-in-depth for ordinary timing races (dialogs
|
||||
* mounting, popups opening) that are unrelated to this hydration bug.
|
||||
*/
|
||||
export function comboboxByLabel(scope: Page | Locator, labelText: string): Locator {
|
||||
return scope.getByText(labelText, { exact: true }).locator("..").getByRole("combobox").first()
|
||||
}
|
||||
|
||||
/**
|
||||
* `trigger.click()` gets an explicit, short per-attempt timeout deliberately: without one, a
|
||||
* momentarily-disabled/not-yet-actionable button (e.g. a trigger that's disabled for one tick
|
||||
* after navigation before client state settles) lets a SINGLE click() call sit and retry
|
||||
* internally for the whole remaining test timeout, so this loop never reaches a second attempt
|
||||
* - confirmed happening on "Cancel run" right after starting a run. A short click timeout lets
|
||||
* the loop actually cycle through multiple real attempts within the test's time budget.
|
||||
*/
|
||||
export async function retryClick(trigger: Locator, verify: () => Promise<void>, attempts = 5) {
|
||||
let lastErr: unknown
|
||||
for (let i = 0; i < attempts; i++) {
|
||||
try {
|
||||
await trigger.click({ timeout: 3000 })
|
||||
} catch (e) {
|
||||
lastErr = e
|
||||
continue
|
||||
}
|
||||
try {
|
||||
await verify()
|
||||
return
|
||||
} catch (e) {
|
||||
lastErr = e
|
||||
}
|
||||
}
|
||||
throw lastErr
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens a Select trigger and picks an option by name. Deliberately a single click, not a
|
||||
* retryClick loop: re-clicking an already-open Select trigger toggles it shut, and if the
|
||||
* option name is ever wrong the popup's portal can end up overlaying the trigger, making
|
||||
* Playwright's actionability check for a second click hang indefinitely (confirmed while
|
||||
* testing this file) instead of failing fast. `comboboxByLabel` already makes the trigger
|
||||
* lookup itself reliable, so a plain click here is both simpler and safer.
|
||||
*/
|
||||
export async function selectOption(page: Page, trigger: Locator, optionName: string | RegExp) {
|
||||
// .first() covers callers that intentionally pass a name matching multiple options (e.g. "pick
|
||||
// any scrap reason") - for the common single-match case it's a no-op.
|
||||
const option = page.getByRole("option", { name: optionName }).first()
|
||||
await trigger.click()
|
||||
await option.click()
|
||||
}
|
||||
|
||||
/** Clicks a trigger that's expected to reveal `target` (a dialog, a newly-mounted control), retrying the click. */
|
||||
export async function clickToReveal(trigger: Locator, target: Locator) {
|
||||
await retryClick(trigger, () => target.waitFor({ state: "visible", timeout: 1500 }))
|
||||
}
|
||||
|
||||
/**
|
||||
* Same as `clickToReveal`, but escalates to a full `page.reload()` between rounds when the
|
||||
* trigger itself never becomes actionable within a round - not just "the popup didn't open"
|
||||
* but "the trigger stayed disabled" or "never rendered at all". Confirmed on the production run
|
||||
* detail page's "Cancel run"/"Return leftover" buttons (RunActions.tsx, gated on
|
||||
* `run.status === "InProgress"`): occasionally that gate/enabled-state renders wrong for the
|
||||
* rest of a page's life - the same class of one-shot render corruption as the hydration bug
|
||||
* documented above, just hitting a component's disabled/mounted state instead of a Select's
|
||||
* placeholder text. A reload gets a fresh render attempt; `trigger`/`target` are re-queried
|
||||
* live each round since Playwright locators aren't tied to a specific DOM snapshot.
|
||||
*/
|
||||
export async function clickToRevealWithReload(page: Page, trigger: Locator, target: Locator, reloadAttempts = 3) {
|
||||
let lastErr: unknown
|
||||
for (let i = 0; i < reloadAttempts; i++) {
|
||||
try {
|
||||
await clickToReveal(trigger, target)
|
||||
return
|
||||
} catch (e) {
|
||||
lastErr = e
|
||||
if (i < reloadAttempts - 1) await page.reload()
|
||||
}
|
||||
}
|
||||
throw lastErr
|
||||
}
|
||||
|
||||
/**
|
||||
* Every stage-action / document-action button in this app fires an async POST and only updates
|
||||
* the DOM once the response comes back (`onActed()`/`onSuccess()` refetch pattern) - Playwright's
|
||||
* `.click()` resolves as soon as the click event dispatches, NOT once that request settles. A
|
||||
* test that clicks "Approve & receive" and immediately reads stock through a separate API call
|
||||
* can race ahead of the backend commit and observe pre-action state (confirmed: production run
|
||||
* stock checks reading 0 immediately after a click the UI later shows as successful). Wrapping
|
||||
* the click in `page.waitForResponse` for the specific endpoint makes the helper actually wait
|
||||
* for the request that matters, not just the DOM event.
|
||||
*/
|
||||
export async function submitAndWait(page: Page, trigger: Locator, urlIncludes: string, method: "POST" | "PUT" = "POST") {
|
||||
const [response] = await Promise.all([
|
||||
page.waitForResponse((res) => res.url().includes(urlIncludes) && res.request().method() === method),
|
||||
trigger.click(),
|
||||
])
|
||||
return response
|
||||
}
|
||||
Reference in New Issue
Block a user