feat: integrate Playwright for E2E testing and enhance testing framework

- Added Playwright for end-to-end testing in the `apps/web` module, including a new `login.spec.ts` for testing login functionality.
- Updated package.json to include Playwright dependencies and new test commands for E2E testing.
- Enhanced .gitignore to exclude Playwright test results and reports.
- Modified existing documentation to reflect the integration of Playwright and updated testing guidelines.
- Refactored test commands to streamline the testing process, including a dedicated command for E2E tests.

These changes improve the testing framework by providing robust E2E testing capabilities, enhancing the reliability and quality of the application.
This commit is contained in:
shancheas
2026-08-31 11:52:27 +07:00
parent 166e0d40ac
commit 67e1e0a74f
23 changed files with 318 additions and 108 deletions
+28 -28
View File
@@ -10,10 +10,10 @@ You need permission to view each menu. Status buttons (Process, Complete, Submit
Set up the records that every sales document needs:
| You need | Where to create it |
| --- | --- |
| Division, branch, customer, product | **Settings → Data** |
| Salesperson | **Sales → Data → Employees** |
| You need | Where to create it |
| ----------------------------------- | ---------------------------- |
| Division, branch, customer, product | **Settings → Data** |
| Salesperson | **Sales → Data → Employees** |
If a menu is missing, ask an administrator to give your privilege **View** (and **Update** for status buttons) on that area.
@@ -70,7 +70,7 @@ A request is optional. You can go straight to a sales order.
1. Click **Create**. Fill in the same kind of details as a request. On a new order you can pick a sales request under **Source**; that copies the request into the form. Save. A new order starts as **Draft**.
2. On a **Draft** order, click **Process**.
- You will see: *Processing creates a sales invoice and, unless skipped, a packing slip.*
- You will see: _Processing creates a sales invoice and, unless skipped, a packing slip._
- **Generate packing slip** is checked by default. Uncheck it if you do not want a packing slip.
3. On **Draft** or **Processed**, you can **Cancel**. There is no **Complete** button on orders. If the order later shows **Completed**, that was not something you clicked on this screen.
4. On the order detail page you can see linked packing slips and invoices, and click **Create Sales Invoice**.
@@ -134,32 +134,32 @@ Approving a payment does not change the invoice status from this screen. Refresh
Both use Draft → Pending → Approved or Rejected.
| Current status | Buttons | Change status |
| --- | --- | --- |
| Draft | Submit | Rejected |
| Pending | Approve, Reject. Payments also have Rollback (back to Draft) | Draft, Approved, Rejected |
| Approved | — | — |
| Rejected | — | Draft |
| Current status | Buttons | Change status |
| -------------- | ------------------------------------------------------------ | ------------------------- |
| Draft | Submit | Rejected |
| Pending | Approve, Reject. Payments also have Rollback (back to Draft) | Draft, Approved, Rejected |
| Approved | — | — |
| Rejected | — | Draft |
### Sales order
| Current status | Buttons | Change status |
| --- | --- | --- |
| Draft | Process, Cancel | Processed, Cancelled |
| Processed | Cancel | Cancelled |
| Completed | — | — |
| Cancelled | — | — |
| Current status | Buttons | Change status |
| -------------- | --------------- | -------------------- |
| Draft | Process, Cancel | Processed, Cancelled |
| Processed | Cancel | Cancelled |
| Completed | — | — |
| Cancelled | — | — |
**Process** always opens the packing-slip checkbox dialog (not Change status).
### Packing slip
| Current status | Buttons | Change status |
| --- | --- | --- |
| Draft | Cancel | Processed, Cancelled |
| Processed | Complete, Cancel | Completed, Cancelled |
| Completed | — | — |
| Cancelled | — | — |
| Current status | Buttons | Change status |
| -------------- | ---------------- | -------------------- |
| Draft | Cancel | Processed, Cancelled |
| Processed | Complete, Cancel | Completed, Cancelled |
| Completed | — | — |
| Cancelled | — | — |
**Complete** always asks for delivered quantities.
@@ -167,11 +167,11 @@ Both use Draft → Pending → Approved or Rejected.
You may see Draft, Processed, Partial, Completed, or Cancelled.
| Current status | What you can do |
| --- | --- |
| Draft, Processed, or Partial | Cancel |
| Completed | No status buttons |
| Cancelled | No status buttons |
| Current status | What you can do |
| ---------------------------- | ----------------- |
| Draft, Processed, or Partial | Cancel |
| Completed | No status buttons |
| Cancelled | No status buttons |
---
+6 -6
View File
@@ -8,12 +8,12 @@ Use the sidebar to open **Sales** for day-to-day selling, **Logistics** for pack
## Where to find things
| Area | Menu | What you do there |
| --- | --- | --- |
| Sales | Sales → Activities | Requests, orders, invoices, payments, visit plans |
| Logistics | Logistics → Activities | Packing slips, delivery plans |
| Company data | Settings → Data | Divisions, branches, customers, products |
| Access | Settings → User | Users and privileges |
| Area | Menu | What you do there |
| ------------ | ---------------------- | ------------------------------------------------- |
| Sales | Sales → Activities | Requests, orders, invoices, payments, visit plans |
| Logistics | Logistics → Activities | Packing slips, delivery plans |
| Company data | Settings → Data | Divisions, branches, customers, products |
| Access | Settings → User | Users and privileges |
If a menu item is missing, your privilege does not include **View** for that area.
+1
View File
@@ -48,6 +48,7 @@ This repository uses **[Turborepo](https://turbo.build/repo)** to orchestrate ta
| `pnpm build:docs-dev` | Build only the docs-dev application |
| `pnpm build:desktop` | Build the web app, then compile the Electron app |
| `pnpm test` | Run unit tests ([Vitest](https://vitest.dev/)) across all packages |
| `pnpm test:e2e:web` | Run Playwright E2E for `apps/web` (starts Vite on port 4173) |
| `pnpm lint` | Run [ESLint](https://eslint.org/) across the workspace |
| `pnpm format` | Format code using [Prettier](https://prettier.io/) |
+4
View File
@@ -0,0 +1,4 @@
export const E2E_LOGIN = {
username: 'administrator',
password: 'password123',
} as const;
+11
View File
@@ -0,0 +1,11 @@
export function createFakeJwt(expiresInSeconds = 3600): string {
const header = Buffer.from(JSON.stringify({ alg: 'none', typ: 'JWT' })).toString('base64url');
const payload = Buffer.from(
JSON.stringify({
sub: 'e2e-user',
exp: Math.floor(Date.now() / 1000) + expiresInSeconds,
}),
).toString('base64url');
return `${header}.${payload}.e2e`;
}
+91
View File
@@ -0,0 +1,91 @@
import type { Page, Route } from '@playwright/test';
import { E2E_LOGIN } from './credentials';
import { createFakeJwt } from './fake-jwt';
const APP_HOSTS = new Set(['127.0.0.1', 'localhost']);
const APP_PORT = Number(process.env.E2E_PORT ?? 4173);
const EMPTY_LIST = {
data: [],
meta: {
currentPage: 1,
itemsPerPage: 10,
totalItems: 0,
totalPages: 0,
itemCount: 0,
},
};
const E2E_USER = {
id: 'e2e-user-id',
username: E2E_LOGIN.username,
isSuperadmin: true,
privilege: null,
permissions: {},
};
export type LoginApiOutcome = 'success' | 'unauthorized';
export async function mockBackend(page: Page, login: LoginApiOutcome = 'success'): Promise<void> {
await page.route('**/*', async (route) => {
const requestUrl = new URL(route.request().url());
if (isAppRequest(requestUrl)) {
await route.continue();
return;
}
await fulfillBackend(route, login);
});
}
function isAppRequest(requestUrl: URL): boolean {
const port = Number(requestUrl.port || (requestUrl.protocol === 'https:' ? 443 : 80));
return APP_HOSTS.has(requestUrl.hostname) && port === APP_PORT;
}
async function fulfillBackend(route: Route, login: LoginApiOutcome): Promise<void> {
const request = route.request();
const pathname = new URL(request.url()).pathname.replace(/\/$/, '');
if (pathname.endsWith('/auth/login') && request.method() === 'POST') {
if (login === 'unauthorized') {
await route.fulfill({
status: 401,
contentType: 'application/json',
body: JSON.stringify({ statusCode: 401, message: 'Unauthorized' }),
});
return;
}
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
accessToken: createFakeJwt(),
refreshToken: createFakeJwt(86_400),
}),
});
return;
}
if (pathname.endsWith('/auth/me') && request.method() === 'GET') {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(E2E_USER),
});
return;
}
const resourceType = request.resourceType();
if (resourceType === 'xhr' || resourceType === 'fetch') {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(EMPTY_LIST),
});
return;
}
await route.abort();
}
+40
View File
@@ -0,0 +1,40 @@
import { expect, test } from '@playwright/test';
import { E2E_LOGIN } from './fixtures/credentials';
import { mockBackend } from './fixtures/mock-api';
test.describe('login', () => {
test('renders the login form', async ({ page }) => {
await mockBackend(page);
await page.goto('/auth/login');
await expect(page.getByRole('heading', { name: 'Login to your account!' })).toBeVisible();
await expect(page.getByLabel('Username')).toBeVisible();
await expect(page.getByLabel('Password')).toBeVisible();
await expect(page.getByRole('button', { name: 'Login' })).toBeVisible();
});
test('signs in with valid credentials and lands in the app', async ({ page }) => {
await mockBackend(page, 'success');
await page.goto('/auth/login');
await page.getByLabel('Username').fill(E2E_LOGIN.username);
await page.getByLabel('Password').fill(E2E_LOGIN.password);
await page.getByRole('button', { name: 'Login' }).click();
await expect(page).toHaveURL(/\/app(\/|$)/);
await expect(page.getByRole('heading', { name: 'Full Page' })).toBeVisible();
});
test('shows an error and stays on login when credentials are rejected', async ({ page }) => {
await mockBackend(page, 'unauthorized');
await page.goto('/auth/login');
await page.getByLabel('Username').fill(E2E_LOGIN.username);
await page.getByLabel('Password').fill(E2E_LOGIN.password);
await page.getByRole('button', { name: 'Login' }).click();
await expect(page.getByText('Invalid username or password')).toBeVisible();
await expect(page).toHaveURL(/\/auth\/login/);
await expect(page.getByRole('heading', { name: 'Login to your account!' })).toBeVisible();
});
});
+5 -1
View File
@@ -8,9 +8,12 @@
"dev": "pnpm run copy:brand && vite --clearScreen false",
"build": "pnpm run copy:brand && tsc && vite build",
"preview": "vite preview",
"lint": "eslint \"src/**/*.ts\"",
"lint": "eslint \"src/**/*.ts\" \"e2e/**/*.ts\" \"playwright.config.ts\"",
"test": "vitest run",
"test:watch": "vitest --watch",
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui",
"test:e2e:install": "playwright install chromium",
"typecheck": "tsc --noEmit"
},
"dependencies": {
@@ -37,6 +40,7 @@
"zustand": "^5.0.14"
},
"devDependencies": {
"@playwright/test": "^1.55.0",
"@repo/eslint-config": "workspace:*",
"@repo/typescript-config": "workspace:*",
"@types/react": "^19.2.7",
+25
View File
@@ -0,0 +1,25 @@
import { defineConfig, devices } from '@playwright/test';
const E2E_PORT = Number(process.env.E2E_PORT ?? 4173);
const E2E_ORIGIN = `http://127.0.0.1:${E2E_PORT}`;
export default defineConfig({
testDir: './e2e',
fullyParallel: true,
forbidOnly: Boolean(process.env.CI),
retries: process.env.CI ? 2 : 0,
reporter: 'list',
timeout: 60_000,
use: {
baseURL: E2E_ORIGIN,
trace: 'on-first-retry',
screenshot: 'only-on-failure',
},
webServer: {
command: `pnpm run copy:brand && vite --clearScreen false --host 127.0.0.1 --port ${E2E_PORT} --strictPort`,
url: E2E_ORIGIN,
reuseExistingServer: !process.env.CI,
timeout: 120_000,
},
projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],
});
+1
View File
@@ -21,5 +21,6 @@ export default defineConfig({
test: {
environment: 'node',
globals: false,
exclude: ['**/node_modules/**', '**/dist/**', '**/e2e/**'],
},
});