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 8e24ed6375
commit c31e23c2b9
36 changed files with 20627 additions and 137 deletions
+120
View File
@@ -0,0 +1,120 @@
import { Page, expect } from "@playwright/test"
import { clickToReveal, selectOption, comboboxByLabel } from "../support/ui"
// Locators verified against Frontend/erp-system/app/dashboard/receiving/grn/new/page.tsx
// and .../grn/[id]/page.tsx. Things that DOM inspection caught and a placeholder-only guess
// would not have:
// - The Qty/Unit cost/Disc%/VAT% <Input type="number"> cells carry no accessible name
// (no htmlFor/aria-label) - located by column position within the row instead.
// - On a PO-based line (line.poLineId set) Item/UOM render as plain text, not a Select -
// fillFirstLine() only touches the item combobox when one is actually present (checked by
// role alone, not name - see below).
// - Every page here intermittently throws a real React hydration error (#418) that blanks a
// random subset of Select triggers' placeholder text for that page's lifetime, WITHOUT
// affecting their sibling <Label> or role="combobox" attribute (support/ui.ts has the full
// writeup). So triggers are located via comboboxByLabel() (label + role, no name lookup)
// instead of getByRole("combobox", { name }) - the row-scoped item/uom/bin combos have no
// adjacent label and are instead found by position, which is equally immune to the bug.
export class GrnNewPage {
constructor(private readonly page: Page) {}
async goto() {
await this.page.goto("/dashboard/receiving/grn/new")
}
async useDirectReceipt() {
await clickToReveal(
this.page.getByRole("button", { name: /direct receipt/i }),
comboboxByLabel(this.page, "Vendor")
)
}
async useAgainstPo() {
await clickToReveal(
this.page.getByRole("button", { name: /against po/i }),
comboboxByLabel(this.page, "Purchase order")
)
}
async selectVendor(name: string) {
await selectOption(this.page, comboboxByLabel(this.page, "Vendor"), name)
}
async selectWarehouse(name: string) {
await selectOption(this.page, comboboxByLabel(this.page, "Warehouse"), name)
}
/** `docNo` is what the PO option renders (`{docNo} — Vendor #{vendorId} ({status})`) - not the numeric id. */
async selectPurchaseOrder(docNo: string) {
await selectOption(this.page, comboboxByLabel(this.page, "Purchase order"), new RegExp(docNo))
}
private firstRow() {
return this.page.locator("table tbody tr").first()
}
/**
* Item and UOM are each only a combobox when the row is NOT tied to a PO line
* (`line.poLineId` gates both cells identically in the source - a PO line renders them as
* plain text instead). Checked per-cell (td:nth(0) for Item, td:nth(1) for UOM) rather than
* "row has any combobox", since the Bin/Hold-status cells always have one regardless of PO
* mode - a row-wide check would false-positive on a PO line and select the wrong control.
* Selecting the app doesn't auto-fill UOM from the chosen item, so a direct-receipt/off-PO
* line needs it set explicitly or submit blocks with "Select a UOM".
*/
async fillFirstLine(opts: { item?: string; uom?: string; qty: number; unitCost?: number }) {
const row = this.firstRow()
const cells = row.locator("td")
if (opts.item) {
const itemCombo = cells.nth(0).getByRole("combobox")
if (await itemCombo.count()) {
await selectOption(this.page, itemCombo, opts.item)
}
}
if (opts.uom) {
const uomCombo = cells.nth(1).getByRole("combobox")
if (await uomCombo.count()) {
await selectOption(this.page, uomCombo, opts.uom)
}
}
const numberInputs = row.locator('input[type="number"]')
await numberInputs.nth(0).fill(String(opts.qty)) // Qty
if (opts.unitCost !== undefined) {
await numberInputs.nth(1).fill(String(opts.unitCost)) // Unit cost
}
}
async submit() {
await this.page.getByRole("button", { name: /create grn/i }).click()
}
}
export class GrnDetailPage {
constructor(private readonly page: Page) {}
async gotoById(grnId: number) {
await this.page.goto(`/dashboard/receiving/grn/${grnId}`)
}
/** GrnStatusBadge/HoldStatusBadge render the raw status string verbatim - exact match avoids
* matching prose like "Confirmed — stock layers created" in the post-confirm success panel. */
async expectStatus(status: "Draft" | "Confirmed") {
await expect(this.page.getByText(status, { exact: true })).toBeVisible()
}
async confirm() {
await this.page.getByRole("button", { name: /confirm grn/i }).click()
}
async releaseFirstOnHoldLine() {
await this.page.getByRole("button", { name: /^release$/i }).first().click()
}
async rejectFirstOnHoldLine() {
await this.page.getByRole("button", { name: /^reject$/i }).first().click()
}
async expectCreateReturnLink() {
await expect(this.page.getByRole("link", { name: /create return/i })).toBeVisible()
}
}
+23
View File
@@ -0,0 +1,23 @@
import { Page, expect } from "@playwright/test"
export class LoginPage {
constructor(private readonly page: Page) {}
async goto() {
await this.page.goto("/login")
}
async login(email: string, password: string) {
await this.page.locator("#email").fill(email)
await this.page.locator("#password").fill(password)
await this.page.getByRole("button", { name: /sign in/i }).click()
}
async expectLoggedIn() {
await expect(this.page).toHaveURL(/\/dashboard/)
}
async expectError() {
await expect(this.page.getByRole("alert")).toBeVisible()
}
}
+135
View File
@@ -0,0 +1,135 @@
import { Page, expect } from "@playwright/test"
import { clickToReveal, clickToRevealWithReload, selectOption, comboboxByLabel, submitAndWait } from "../support/ui"
// Locators verified against Frontend/erp-system/app/dashboard/production/runs/page.tsx,
// .../runs/[id]/page.tsx, .../runs/[id]/StageDrawer.tsx, and .../runs/[id]/RunActions.tsx.
// Key DOM facts that shaped these locators:
// - "Start Run" (the list page's dialog trigger, capital R) and "Start run" (the dialog's
// submit button, lowercase r) both match a case-insensitive /start run/i once the dialog
// is open (the trigger stays mounted behind it) - the submit click is scoped to
// getByRole("dialog") to avoid a strict-mode double match.
// - STAGE_STATUS_LABEL.InProgress is "In Progress" - the same text StageStatusLegend
// always renders on the run detail page, so a run-status assertion of "In Progress"
// collides with the legend. expectStatus() takes the first DOM match, which is always
// the run-header badge (it renders before the legend section).
// - AlertDialogContent's rejectForRework confirmation reuses "Reject for rework" as both
// the trigger and the confirm button's label - first()/last() disambiguates, same as
// cancelRun's "Cancel run" trigger/confirm pair.
// - Every page here intermittently throws a real React hydration error (#418) that blanks a
// random subset of Select triggers' placeholder text for that page's lifetime, without
// affecting their sibling <FieldLabel> or role="combobox" attribute (support/ui.ts has the
// full writeup). Triggers are located via comboboxByLabel() (label + role, no name lookup)
// instead of getByRole("combobox", { name }); the scrap-reason Select has no adjacent
// label, so it's found via its "Scrapped" sibling block instead.
// - Every stage/run action button here fires an async POST that the UI only reflects once the
// response lands (StageDrawer/RunActions' `submit()` wrapper) - submitAndWait() (support/ui.ts)
// waits for that specific response instead of just the click event, so a test reading stock
// right after clicking "Approve & receive" (etc.) doesn't race the backend commit.
export class ProductionRunListPage {
constructor(private readonly page: Page) {}
async goto() {
await this.page.goto("/dashboard/production/runs")
}
async openStartRunDialog() {
await clickToReveal(
this.page.getByRole("button", { name: /start run/i }),
this.page.getByRole("dialog")
)
}
async startRun(opts: { template: string; targetQty: number; warehouse: string }) {
await this.openStartRunDialog()
const dialog = this.page.getByRole("dialog")
await selectOption(this.page, comboboxByLabel(dialog, "Template"), opts.template)
await dialog.locator("#target-qty").fill(String(opts.targetQty))
await selectOption(this.page, comboboxByLabel(dialog, "Warehouse"), opts.warehouse)
await submitAndWait(this.page, dialog.getByRole("button", { name: /^start run$/i }), "/production-runs")
}
}
export class ProductionRunDetailPage {
constructor(private readonly page: Page) {}
async gotoById(runId: number) {
await this.page.goto(`/dashboard/production/runs/${runId}`)
}
async expectStatus(status: RegExp | string) {
await expect(this.page.getByText(status).first()).toBeVisible()
}
/** Opens the StageDrawer for a named stage node on the React Flow canvas. */
async openStage(stageName: string) {
await clickToReveal(
this.page.getByText(stageName, { exact: true }),
this.page.getByRole("button", { name: /save quantities/i })
)
}
/** The StageDrawer is a modal Sheet - run-level actions (Return leftover, Cancel run) sit
* behind it and need it dismissed first. */
async closeStageDrawer() {
await this.page.keyboard.press("Escape")
}
async saveQuantities() {
// updateStageQuantities is a PUT, unlike every other stage action.
await submitAndWait(this.page, this.page.getByRole("button", { name: /save quantities/i }), "/quantities", "PUT")
}
async startStage() {
await submitAndWait(this.page, this.page.getByRole("button", { name: /^start stage$/i }), "/start")
}
async completeStage(opts: { producedQty: number; scrappedQty?: number }) {
await this.page.getByRole("spinbutton", { name: /produced/i }).first().fill(String(opts.producedQty))
if (opts.scrappedQty) {
await this.page.getByRole("spinbutton", { name: /scrapped/i }).first().fill(String(opts.scrappedQty))
const scrapBlock = this.page.getByText("Scrapped", { exact: true }).locator("../..")
await selectOption(this.page, scrapBlock.getByRole("combobox"), /.+/)
}
await submitAndWait(this.page, this.page.getByRole("button", { name: /complete stage/i }), "/complete")
}
async approveAndReceive() {
await submitAndWait(this.page, this.page.getByRole("button", { name: /approve\s*&\s*receive/i }), "/approve")
}
async approveAndTransfer() {
await submitAndWait(this.page, this.page.getByRole("button", { name: /approve\s*&\s*transfer/i }), "/approve")
}
async rejectForRework() {
const button = this.page.getByRole("button", { name: /^reject for rework$/i })
await clickToReveal(button.first(), this.page.getByRole("dialog"))
await submitAndWait(this.page, button.last(), "/reject")
}
async openReturnLeftoverDialog() {
await clickToRevealWithReload(
this.page,
this.page.getByRole("button", { name: /return leftover/i }),
this.page.getByRole("dialog")
)
}
async returnLeftover(opts: { material: string; qty: number; reason: string }) {
await this.openReturnLeftoverDialog()
const dialog = this.page.getByRole("dialog")
await selectOption(this.page, comboboxByLabel(dialog, "Consumed material"), opts.material)
await dialog.locator("#return-qty").fill(String(opts.qty))
await selectOption(this.page, comboboxByLabel(dialog, "Reason"), opts.reason)
await submitAndWait(this.page, dialog.getByRole("button", { name: /return to stock/i }), "/return-leftover")
}
async cancelRun(opts: { reason: string; note?: string }) {
const cancelRunButton = this.page.getByRole("button", { name: /^cancel run$/i })
const dialog = this.page.getByRole("dialog")
await clickToRevealWithReload(this.page, cancelRunButton.first(), dialog)
await selectOption(this.page, comboboxByLabel(dialog, "Reason"), opts.reason)
if (opts.note) await dialog.locator("#cancel-note").fill(opts.note)
await submitAndWait(this.page, cancelRunButton.last(), "/cancel")
}
}
+82
View File
@@ -0,0 +1,82 @@
import { Page, expect } from "@playwright/test"
import { selectOption, comboboxByLabel } from "../support/ui"
// Locators verified against Frontend/erp-system/app/dashboard/stock/transfers/new/page.tsx,
// .../transfers/[id]/page.tsx, and .../stock/adjustments/new/page.tsx.
// Every page here intermittently throws a real React hydration error (#418) that blanks a
// random subset of Select triggers' placeholder text for that page's lifetime, without
// affecting their sibling <Label> or role="combobox" attribute (support/ui.ts has the full
// writeup). Triggers are located via comboboxByLabel() (label + role, no name lookup) instead
// of getByRole("combobox", { name }); the row-scoped item combo has no adjacent label and is
// instead found by position (it's the first combobox in the row).
export class StockTransferNewPage {
constructor(private readonly page: Page) {}
async goto() {
await this.page.goto("/dashboard/stock/transfers/new")
}
async fill(opts: { fromWarehouse: string; toWarehouse: string; item: string; qty: number }) {
await selectOption(this.page, comboboxByLabel(this.page, "From warehouse"), opts.fromWarehouse)
await selectOption(this.page, comboboxByLabel(this.page, "To warehouse"), opts.toWarehouse)
const row = this.page.locator("table tbody tr").first()
await selectOption(this.page, row.getByRole("combobox").first(), opts.item)
// The Qty <Input type="number"> carries no accessible name - it's the only number input in the row.
await row.locator('input[type="number"]').fill(String(opts.qty))
}
async submit() {
await this.page.getByRole("button", { name: /create transfer/i }).click()
}
}
export class StockTransferDetailPage {
constructor(private readonly page: Page) {}
async gotoById(transferId: number) {
await this.page.goto(`/dashboard/stock/transfers/${transferId}`)
}
/**
* TransferStatusBadge renders the raw enum literal ("Draft" | "InTransit" | "Received") -
* exact match, since "Received" is also a substring of the post-receive success panel's
* heading ("Received — destination layers created").
*/
async expectStatus(status: "Draft" | "InTransit" | "Received") {
await expect(this.page.getByText(status, { exact: true })).toBeVisible()
}
async dispatch() {
await this.page.getByRole("button", { name: /^dispatch$/i }).click()
}
async receive() {
await this.page.getByRole("button", { name: /^receive$/i }).click()
}
}
export class StockAdjustmentNewPage {
constructor(private readonly page: Page) {}
async goto() {
await this.page.goto("/dashboard/stock/adjustments/new")
}
async fill(opts: { warehouse: string; reasonCode: string; item: string; qtyDelta: number }) {
await selectOption(this.page, comboboxByLabel(this.page, "Warehouse"), opts.warehouse)
await selectOption(this.page, comboboxByLabel(this.page, "Reason code"), opts.reasonCode)
const row = this.page.locator("table tbody tr").first()
await selectOption(this.page, row.getByRole("combobox").first(), opts.item)
await row.getByPlaceholder(/e\.g\. -15 or 50/i).fill(String(opts.qtyDelta))
}
async submit() {
await this.page.getByRole("button", { name: /post adjustment/i }).click()
}
async expectPosted() {
await expect(this.page.getByRole("button", { name: /new adjustment/i })).toBeVisible()
}
}