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:
2026-08-05 13:23:07 +05:30
parent 26cf2a146a
commit 722b1e78ed
36 changed files with 20627 additions and 137 deletions
+209
View File
@@ -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}`)
}
}
+23
View File
@@ -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")
+113
View File
@@ -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
}