test-ui
Test UI tự động bằng Playwright: truy cập localhost:3000, đăng nhập, mở dialog/page, test create/update từng field, chặn payload/response API để phát hiện field thiếu (FE ko gửi/BE ko trả), field upda
SKILL.md.claude/skills/_archived-sasuco/test-ui/SKILL.md
GitHubTest UI — Kiểm Thử Giao Diện & Field Mapping FE-BE Bằng Playwright
Mục tiêu: Tự động mở browser, login, navigate đến màn hình, mở dialog, test create/update từng field, chặn & phân tích payload/response API → báo cáo field thiếu, field sai, FK thiếu display.
🎯 Tổng Quan
[1] Login localhost:3000 → [2] Navigate route → [3] Mở dialog → [4] Quét fields
→ [5] Fill + Lưu (Create) → [6] Chặn payload/response → [7] Mở lại Edit
→ [8] Đọc lại + so sánh → [9] Báo cáo field thiếu/sai
Các loại kiểm tra:
| Loại | Mô tả | Output |
|---|---|---|
| Create | Fill tất cả field → Lưu → đọc response + payload | Field nào không có trong payload? Field nào không có trong response? |
| Update | Sửa từng field → Lưu → đọc lại → so sánh | Field nào update thiếu? Field nào sai giá trị? |
| FK Select | Chọn đối tượng từ combobox → kiểm tra payload chỉ gửi ID | Có gửi kèm code/name không? Response có trả đủ display fields? |
| FK Response | Sau create/edit → kiểm tra response có đủ field để hiển thị | Thiếu code? Thiếu name? Thiếu các field liên quan? |
🔧 Cấu Hình Đầu Vào
Trước khi chạy test, xác định rõ:
| Thông tin | Bắt buộc | Mô tả | Ví dụ |
|---|---|---|---|
| Route | ✅ | Hash route của màn hình | #/ketoan/don-vi-tinh |
| Tên form/dialog | ✅ | Tên hiển thị trong báo cáo | Đơn vị tính |
| Selector nút Thêm | ✅ | data-qa hoặc text | [data-qa="btn_them_moi"] |
| Selector nút Sửa | ✅ | data-qa dòng đầu tiên | [data-qa="btn_sua"] |
| Selector nút Lưu | ✅ | data-qa hoặc text | [data-qa="btn_luu"] |
| Selector dialog | Không | Mặc định [role="dialog"] |
.dm-dialog |
| Loại test | ✅ | all / create-only / update-only / fk-only |
all |
| DTO source | Không | Đường dẫn file types API hoặc DTO text từ user để đối chiếu | src/modules/.../DM.types.api.ts |
| FK fields | Không | Danh sách FK (bao gồm TK nợ/có) cần kiểm tra đặc biệt | [{ dataQa: 'sel_kh', fkField: 'accountObjectId', displayFields: ['objectCode', 'objectName'] }] |
📋 Quy Tắc Xác Định Field Trong Payload
Quy tắc chung để xác định field nào cần gửi lên BE, field nào không.
Rule mặc định (áp dụng khi user không có yêu cầu đặc biệt):
| Pattern | Có gửi lên payload? | Giải thích | Ví dụ |
|---|---|---|---|
Field editable trên UI (có <Label>) |
✅ PHẢI CÓ | Mọi field người dùng có thể nhập/sửa trên UI → PHẢI gửi lên payload. Đây là rule quan trọng nhất. | Người liên hệ → contactPerson |
| Field KHÔNG có label, auto-fill khi select bản ghi | ❌ KHÔNG | Field được fill tự động từ dữ liệu của bản ghi được chọn (VD: chọn NCC → fill Địa chỉ, MST) → không gửi, BE tự resolve | Địa chỉ, Mã số thuế (fill từ NCC) |
| FK select từ danh mục (TableSearchCombobox) | ✅ Chỉ gửi ID | Combobox chọn đối tượng → gửi xxxId lên payload. KHÔNG gửi code/name kèm theo |
accountObjectId, employeeId |
| FK response display | ✅ Expect name/code | Sau khi gửi ID, expect response từ BE có kèm name/code/number để hiển thị |
Response có accountObjectCode, accountObjectName |
| TK nợ / TK có (debitAccount/creditAccount) | ✅ Chỉ gửi ID | Gửi debitAccountId / creditAccountId. KHÔNG gửi debitAccountNumber / creditAccountNumber |
Chỉ debitAccountId: "abc-123", không gửi debitAccountNumber: "111" |
| TK nợ/có response display | ✅ Expect number | Sau khi gửi ID, expect response có debitAccountNumber / creditAccountNumber |
Response có debitAccountNumber: "111", creditAccountNumber: "331" |
| Field auto-code | ❌ KHÔNG | Số chứng từ, mã tự sinh do BE tạo → FE không gửi | orderCode |
| Field computed | ❌ KHÔNG | Field được tính toán (tổng tiền, thành tiền...) → BE tự tính hoặc FE tính rồi gửi kết quả | totalAmount (FE tính từ details) |
Rule đặc biệt (override khi user yêu cầu):
Nếu user có yêu cầu đặc biệt (VD: field không có label cũng gửi lên payload), thì thực hiện theo yêu cầu đặc biệt đó, KHÔNG theo rule mặc định.
| Tình huống | Hành động |
|---|---|
| User yêu cầu field X (dù không label) cũng gửi | Thêm field đó vào buildPayload |
| User yêu cầu field Y gửi kèm cả code/name (không chỉ ID) | Giữ nguyên logic gửi code/name |
| User yêu cầu field Z không gửi dù có label | Bỏ field đó khỏi payload |
Cách phân loại field khi test:
1. Quét tất cả field trong dialog → phân biệt có <Label> / không có <Label>
2. Với field có Label → mặc định EXPECT có trong payload
3. Với field không có Label → mặc định EXPECT KHÔNG có trong payload
4. Nếu user có yêu cầu đặc biệt → override rule mặc định
5. Với FK combobox → kiểm tra payload CHỈ có ID, response CÓ kèm display fields
Mapping Label → Payload cho Đơn mua (ví dụ):
| # | Label | data-qa | Field formData | Gửi payload? | Payload key |
|---|---|---|---|---|---|
| 1 | Nhà cung cấp* | sel_ncc |
accountObjectId |
✅ ID | accountObjectId |
| 2 | Địa chỉ | txt_supplier_address |
supplierAddress |
❌ (auto-fill từ NCC) | — |
| 3 | Mã số thuế | txt_supplier_tax |
supplierTaxCode |
❌ (auto-fill từ NCC) | — |
| 4 | Người liên hệ | txt_contact_person |
contactPerson |
✅ CÓ | contactPerson |
| 5 | Diễn giải | txt_description |
description |
✅ CÓ | description |
| 6 | Nhân viên mua hàng | sel_nhan_vien |
employeeId |
✅ ID | employeeId |
| 7 | Điều khoản TT | sel_payment_terms |
paymentTermId |
✅ ID | paymentTerms |
| 8 | Số ngày nợ | txt_credit_days |
creditDays |
✅ CÓ | creditDays |
| 9 | Ngày đơn hàng* | dt_order_date |
orderDate |
✅ CÓ | orderDate |
| 10 | Số đơn hàng | txt_order_code |
orderCode |
❌ (auto-code) | — |
| 11 | Tình trạng* | sel_order_status |
orderStatus |
✅ CÓ | orderStatus |
| 12 | Ngày giao hàng | dt_delivery_date |
deliveryDate |
✅ CÓ | deliveryDate |
Mapping Bảng hàng tiền (details[]):
| # | Cột | Có label? | Gửi payload? | Payload key |
|---|---|---|---|---|
| 1 | Mã hàng | ✅ | ✅ ID | itemId |
| 2 | Tên hàng | ✅ | ✅ (qua description) | description |
| 3 | ĐVT | ✅ | ✅ | unit |
| 4 | Số lượng | ✅ | ✅ | quantity |
| 5 | SL nhận | ✅ (readonly) | ✅ | receivedQuantity |
| 6 | Đơn giá | ✅ | ✅ | unitPrice |
| 7 | Thành tiền | ✅ | ✅ | amount |
| 8 | % Thuế GTGT | ✅ | ✅ | taxRate |
| 9 | Khoản mục CP | ✅ | ✅ ID | costItemId |
� Đối Chiếu DTO BE Với Payload Thực Tế
Mục tiêu: Khi user cung cấp DTO BE mới → so sánh với DTO hiện tại trong code FE → báo cáo khác biệt → hỏi user có muốn cập nhật không → đối chiếu payload FE gửi lên với DTO để phát hiện field thừa/thiếu.
Quy Trình Đối Chiếu DTO
[1] User cung cấp DTO mới (hoặc agent tự tìm từ types file)
[2] So sánh DTO mới vs DTO hiện tại trong code
[3] Báo cáo field: thêm mới / xóa / đổi kiểu / đổi optional→required
[4] Hỏi user: "Có muốn cập nhật types theo DTO mới không?"
[5] Nếu user đồng ý → cập nhật types file
[6] Đối chiếu payload FE thực tế với DTO → báo cáo field thừa/thiếu
Quy Tắc Expect Payload Dựa Trên DTO
| Tình huống | Expect | Hành động nếu sai |
|---|---|---|
| Field trong DTO là required | ✅ PHẢI có trong payload | Thêm field vào buildFormData |
| Field trong DTO là optional | CÓ THỂ có hoặc không | Chỉ báo lỗi nếu field đó có trên UI editable |
| Field KHÔNG có trong DTO nhưng có trong payload | ❌ Field dư thừa | Xóa field khỏi payload, kiểm tra buildFormData |
| Field trên UI editable nhưng không có trong payload | ❌ THIẾU | Thêm field vào buildFormData |
| Field từ TableSearchCombobox (FK) | ✅ CHỈ gửi xxxId |
Xóa code/name khỏi payload nếu có |
| Field TK nợ / TK có | ✅ CHỈ gửi debitAccountId / creditAccountId |
KHÔNG gửi debitAccountNumber / creditAccountNumber |
| Field auto-code (mã tự sinh) | ❌ KHÔNG gửi | BE tự sinh |
| Field computed (tổng tiền...) | Tuỳ DTO | Nếu DTO có → gửi; nếu không → không gửi |
Cách Đối Chiếu DTO (Không Cần Chạy Playwright)
Khi user cung cấp DTO dạng text/json/typescript → agent có thể đối chiếu trực tiếp với types file hiện tại mà không cần mở browser.
// Bước 1: Đọc DTO hiện tại từ file types
// VD: src/modules/KetoanApp/features/nghiep-vu/mua-hang/don-mua/types/DM.types.api.ts
// Bước 2: Parse DTO mới từ user input
// User cung cấp dạng:
// interface DonMuaDto {
// id: string
// orderCode: string
// orderDate: string
// totalAmount: number
// // ... thêm field mới
// postedDate?: string // ← field mới
// }
// Bước 3: So sánh & báo cáo
// Output: Bảng diff DTO
Bảng Báo Cáo Diff DTO
| # | Field | Trạng thái | DTO cũ | DTO mới | Hành động |
|---|---|---|---|---|---|
| 1 | postedDate |
🟢 THÊM MỚI | — | string? |
Thêm vào types |
| 2 | oldField |
🔴 ĐÃ XÓA | number |
— | Xóa khỏi types |
| 3 | totalAmount |
🟡 ĐỔI KIỂU | number |
string |
Cập nhật types |
| 4 | orderCode |
🟡 OPTIONAL→REQUIRED | string? |
string |
Cập nhật types |
Mapping Payload Thực Tế vs DTO
Sau khi cập nhật DTO, đối chiếu payload FE gửi lên với DTO mới:
| # | Field DTO | Required? | Có trong payload? | Payload key | Trạng thái |
|---|---|---|---|---|---|
| 1 | accountObjectId |
✅ | ✅ | accountObjectId |
✅ OK |
| 2 | contactPerson |
❌ | ❌ | — | ⚠️ CÓ TRÊN UI → THIẾU |
| 3 | creditDays |
❌ | ✅ | creditDays |
✅ OK |
| 4 | orderCode |
❌ | ❌ | — | ✅ Auto-code, không gửi |
| 5 | debitAccountNumber |
❌ | ❌ | — | ✅ TK chỉ gửi ID |
Quy Tắc Ưu Tiên Xác Định Field Gửi Payload
1. Nếu user cung cấp DTO → ưu tiên DTO làm chuẩn
2. Nếu không có DTO → đọc types file (.types.api.ts) làm chuẩn
3. Nếu không có types file → dùng rule mặc định (field có label → gửi)
4. Rule đặc biệt (TK nợ/có, TableSearchCombobox) luôn override rule mặc định
🏦 Quy Tắc Đặc Biệt: TK Nợ / TK Có (Accounting Accounts)
Quy tắc: Khi dialog có field chọn tài khoản nợ/có (debit/credit account):
- Payload: CHỈ gửi
debitAccountId/creditAccountId— KHÔNG gửi account number- Response: Expect BE trả về
debitAccountNumber/creditAccountNumberđể hiển thị- Edit form: Resolve account number từ ID để hiển thị đúng cho user
Ví dụ Payload Đúng/Sai
// ✅ ĐÚNG: Chỉ gửi ID
{
"accountingDetails": [
{ "debitAccountId": "abc-123", "creditAccountId": "def-456", "amount": 1000000 }
]
}
// ❌ SAI: Gửi cả account number (dư thừa, BE tự resolve)
{
"accountingDetails": [
{
"debitAccountId": "abc-123",
"debitAccountNumber": "111", // ← KHÔNG GỬI
"creditAccountId": "def-456",
"creditAccountNumber": "331", // ← KHÔNG GỬI
"amount": 1000000
}
]
}
Expect Response Từ BE
// ✅ ĐỦ: Response có account number để hiển thị
{
"accountingDetails": [
{
"debitAccountId": "abc-123",
"debitAccountNumber": "111", // ← CẦN CÓ
"creditAccountId": "def-456",
"creditAccountNumber": "331", // ← CẦN CÓ
"amount": 1000000
}
]
}
// ❌ THIẾU: Response không có account number → FE không hiển thị được
{
"accountingDetails": [
{ "debitAccountId": "abc-123", "creditAccountId": "def-456", "amount": 1000000 }
]
}
Resolve TK Hiển Thị Khi Edit
Khi mở form edit, nếu response chỉ có
debitAccountIdmà không códebitAccountNumber: → FE phải tự resolve bằng cách fetch danh sách tài khoản (AccountApiService)
// Pattern resolve trong buildFormData (Pattern A - Ưu tiên)
const buildFormData = useCallback(async (d: XxxDetail): Promise<XxxFormState> => {
// Thu thập tất cả accountId cần resolve
const accountIds = d.accountingDetails
.flatMap(line => [line.debitAccountId, line.creditAccountId])
.filter((id): id is string => !!id)
// Fetch song song
const [accountMap] = await Promise.all([
accountIds.length > 0
? AccountApiService.list({ pageIndex: 1, pageSize: 9999 })
.then(r => new Map(r.data?.items?.map(a => [a.id, a]) ?? []))
.catch(() => new Map())
: new Map(),
])
// Map → form state
const formData: XxxFormState = {
...d,
accountingDetails: d.accountingDetails.map(line => {
const debitAcc = line.debitAccountId ? accountMap.get(line.debitAccountId) : null
const creditAcc = line.creditAccountId ? accountMap.get(line.creditAccountId) : null
return {
...line,
debitAccountNumber: line.debitAccountNumber || debitAcc?.number || '',
creditAccountNumber: line.creditAccountNumber || creditAcc?.number || '',
}
}),
}
return formData
}, [])
🔍 Resolve TableSearchCombobox Khi Mở Form Edit
Quy tắc: Khi mở form edit, các field dùng
TableSearchComboboxcần resolve lại giá trị để hiển thị đúng.
Nguyên tắc
| Tình huống | Cách xử lý |
|---|---|
| Combobox hiển thị name khi select | Resolve cũng phải hiển thị name |
| Combobox hiển thị code - name khi select | Resolve cũng phải hiển thị code - name |
| Combobox hiển thị number (TK) | Resolve cũng phải hiển thị number |
| Response BE đã có đủ display fields | KHÔNG cần resolve thêm |
| Response BE chỉ có ID | PHẢI resolve bằng API getById hoặc list |
Các Bước Resolve
[1] Nhận DTO từ API GET /{id}
[2] Với mỗi TableSearchCombobox field:
├── Nếu DTO đã có display field (name/code/number) → dùng luôn
└── Nếu DTO chỉ có ID → fetch API để lấy display value
[3] Set vào formData TRƯỚC KHI render form
Ví dụ Màn Hình Chi Tiền
// DTO từ BE: BankPaymentDetail
{
"id": "xxx",
"refNo": "CT00001",
"accountObjectId": "kh-123",
// BE KHÔNG trả objectCode, objectName
"bankAccountId": "bank-456",
// BE KHÔNG trả bankAccountNumber, bankName
}
// Hook resolve:
const buildFormData = async (d: BankPaymentDetail) => {
const [khRes, bankRes] = await Promise.all([
d.accountObjectId ? KHApiService.getById(d.accountObjectId) : null,
d.bankAccountId ? BankAccountApiService.getById(d.bankAccountId) : null,
])
return {
...d,
// Resolve KH: TableSearchCombobox hiển thị "code - name"
objectCode: khRes?.data?.code || '',
objectName: khRes?.data?.name || '',
// Resolve TK ngân hàng: TableSearchCombobox hiển thị "number - name"
bankAccountNumber: bankRes?.data?.accountNumber || '',
bankName: bankRes?.data?.bankName || '',
}
}
⚠️ Lưu ý Quan Trọng Khi Resolve
- Luôn resolve trong
buildFormDatacủa Hook (Pattern A) — KHÔNG resolve tronguseEffectcủa Dialog (Pattern B) - Fetch song song bằng
Promise.allđể giảm latency - Có fallback khi API fail (
.catch(() => null)) - Nếu BE đã trả đủ display fields → không cần resolve, dùng thẳng từ DTO
�🚀 Script Mẫu — Tạo File Test
BẮT BUỘC: Tạo script dựa trên template
e2e/test-field-update.ts, bổ sung thêm network interception.
Template script (lưu vào e2e/test-ui-<ten-trang>.ts):
/**
* Script TEST UI — Test field mapping FE-BE cho dialog/form
*
* Cách dùng:
* 1. Sửa CONFIG bên dưới
* 2. Chạy: npx tsx e2e/test-ui-<ten-trang>.ts
*
* Tự động:
* - Login localhost:3000 (0985908750 / Hoadon@2022!#)
* - Navigate đến route
* - Mở dialog Thêm mới
* - Quét fields
* - Fill dữ liệu → Lưu → Chặn payload + response
* - Mở lại Edit → Đọc dữ liệu → So sánh
* - Báo cáo field thiếu trong payload, response, và field update sai
*/
import { chromium, Browser, BrowserContext, Page } from 'playwright';
import { writeFileSync, mkdirSync, existsSync } from 'node:fs';
import { join } from 'node:path';
import { createConnection } from 'node:net';
// ═══════════════════════════════════════════════════════════
// 🎯 CONFIG — SỬA TẠI ĐÂY
// ═══════════════════════════════════════════════════════════
const CONFIG = {
/** Hash route của màn hình cần test */
route: '#/ketoan/don-vi-tinh',
/** Tên form/dialog (dùng trong báo cáo) */
formName: 'Đơn vị tính',
/** Loại test: 'all' | 'create-only' | 'update-only' | 'fk-only' */
testMode: 'all' as 'all' | 'create-only' | 'update-only' | 'fk-only',
/** Selector nút Thêm mới */
addButtonSelector: '[data-qa="btn_them_moi"]',
/** Selector nút Sửa (dòng đầu tiên) */
editButtonSelector: '[data-qa="btn_sua"]',
/** Selector nút Lưu */
saveButtonSelector: '[data-qa="btn_luu"]',
/** Selector dialog container */
dialogSelector: '[role="dialog"]',
/** Danh sách FK fields cần kiểm tra đặc biệt (select đối tượng + TK nợ/có) */
fkFields: [
// { dataQa: 'sel_khach_hang', fkField: 'accountObjectId', displayFields: ['objectCode', 'objectName'] },
// { dataQa: 'sel_tk_no', fkField: 'debitAccountId', displayFields: ['debitAccountNumber'] },
// { dataQa: 'sel_tk_co', fkField: 'creditAccountId', displayFields: ['creditAccountNumber'] },
],
/** Đường dẫn file types API để đối chiếu DTO (tùy chọn) */
dtoSource: '',
/** Thời gian chờ (ms) */
waitTime: 500,
saveWaitTime: 1500,
pageLoadWaitTime: 800,
};
// ═══════════════════════════════════════════════════════════
// 🔑 CREDENTIALS
// ═══════════════════════════════════════════════════════════
const AUTH = {
baseUrl: 'http://localhost:3000',
username: '0985908750',
password: 'Hoadon@2022!#',
};
// ═══════════════════════════════════════════════════════════
// 📡 NETWORK INTERCEPTOR
// ═══════════════════════════════════════════════════════════
interface CapturedRequest {
url: string;
method: string;
postData?: string;
timestamp: number;
}
interface CapturedResponse {
url: string;
status: number;
body?: string;
timestamp: number;
}
const capturedRequests: CapturedRequest[] = [];
const capturedResponses: CapturedResponse[] = [];
function setupNetworkInterceptor(page: Page) {
// Chặn request
page.on('request', (request) => {
if (request.method() === 'POST' || request.method() === 'PUT' || request.method() === 'PATCH') {
capturedRequests.push({
url: request.url(),
method: request.method(),
postData: request.postData() || undefined,
timestamp: Date.now(),
});
}
});
// Chặn response
page.on('response', async (response) => {
const url = response.url();
const method = response.request().method();
if (method === 'POST' || method === 'PUT' || method === 'PATCH' || method === 'GET') {
try {
const body = await response.text();
capturedResponses.push({
url,
status: response.status(),
body: body.length < 50000 ? body : body.substring(0, 50000) + '...(truncated)',
timestamp: Date.now(),
});
} catch {
capturedResponses.push({ url, status: response.status(), timestamp: Date.now() });
}
}
});
}
function clearCapturedData() {
capturedRequests.length = 0;
capturedResponses.length = 0;
}
/** Lấy request POST/PUT cuối cùng liên quan đến form */
function getLastApiCall(): CapturedRequest | undefined {
return [...capturedRequests].reverse().find(r =>
r.postData && !r.url.includes('/auth/') && !r.url.includes('/login')
);
}
/** Lấy response cuối cùng liên quan đến form */
function getLastApiResponse(): CapturedResponse | undefined {
return [...capturedResponses].reverse().find(r =>
r.body && !r.url.includes('/auth/') && !r.url.includes('/login')
);
}
// ═══════════════════════════════════════════════════════════
// 🛠 HELPERS
// ═══════════════════════════════════════════════════════════
let counter = 0;
function generateTestValue(field: any): string {
counter++;
const label = (field.label || '').toLowerCase();
const name = (field.name || '').toLowerCase();
const dataQa = (field.dataQa || '').toLowerCase();
if (label.includes('email') || name.includes('email') || dataQa.includes('email'))
return `test-auto-${counter}@example.com`;
if (label.includes('điện thoại') || label.includes('sđt') || label.includes('phone') || name.includes('phone') || dataQa.includes('phone'))
return `098${String(counter).padStart(7, '0')}`;
if (label.includes('mst') || label.includes('mã số thuế') || name.includes('tax') || dataQa.includes('tax'))
return `0123456${String(counter).padStart(3, '0')}`;
if (label.includes('số tài khoản') || name.includes('account') || dataQa.includes('account'))
return `${counter}234567890`;
if (field.type === 'number')
return String(10000 + counter);
if (label.includes('mã') || name.includes('code') || dataQa.includes('ma'))
return `TEST-${Date.now().toString(36).toUpperCase()}-${counter}`;
if (label.includes('tên') || name.includes('name') || dataQa.includes('ten'))
return `Test Tự Động ${counter} - ${field.label || field.name}`;
if (label.includes('địa chỉ') || name.includes('address') || dataQa.includes('dia_chi'))
return '123 Đường Test, P.Bến Thành, Q.1, TP.HCM';
if (label.includes('mô tả') || label.includes('ghi chú') || name.includes('description') || dataQa.includes('mo_ta'))
return `[TEST AUTO] Mô tả kiểm tra field update - #${counter}`;
if (label.includes('link') || label.includes('url') || label.includes('website'))
return `https://test-${counter}.example.com`;
if (field.type === 'email') return `test-auto-${counter}@example.com`;
if (field.type === 'tel') return `098${String(counter).padStart(7, '0')}`;
return `TEST-${counter}`;
}
function normalizeForCompare(value: string): string {
return value.replace(/\s+/g, ' ').replace(/[–—−]/g, '-').trim().toLowerCase();
}
function getFailReason(key: string, expected: string, actual: string): string {
if (!actual || actual === '(không đọc được)')
return 'Giá trị trả về rỗng → field có thể không được map với BE hoặc không có trong API response';
if (expected.length !== actual.length)
return `Độ dài khác nhau (mong đợi ${expected.length}, thực tế ${actual.length}) → có thể bị truncate`;
const expNoNum = normalizeForCompare(expected.replace(/[0-9]/g, ''));
const actNoNum = normalizeForCompare(actual.replace(/[0-9]/g, ''));
if (expNoNum === actNoNum)
return 'Chỉ khác số → định dạng số (dấu phân cách, thập phân) bị thay đổi khi lưu';
if (actual.includes('??') || actual.includes('�'))
return 'Lỗi encoding Unicode → BE không hỗ trợ UTF-8 hoặc double-encode';
return 'Giá trị khác biệt → kiểm tra mapping FE-BE, format, hoặc transform trong hook';
}
// ═══════════════════════════════════════════════════════════
// 🌐 BROWSER SETUP
// ═══════════════════════════════════════════════════════════
function isDaemonRunning(): Promise<boolean> {
return new Promise((resolve) => {
const sock = createConnection({ port: 9222, host: '127.0.0.1' }, () => {
sock.destroy(); resolve(true);
});
sock.on('error', () => resolve(false));
sock.setTimeout(1000, () => { sock.destroy(); resolve(false); });
});
}
async function connectToDaemon(): Promise<{ browser: Browser; context: BrowserContext; page: Page } | null> {
try {
const browser = await chromium.connectOverCDP('http://localhost:9222');
const contexts = browser.contexts();
if (contexts.length === 0) return null;
const page = await contexts[0].newPage();
console.log('✅ Đã connect daemon CDP.');
return { browser, context: contexts[0], page };
} catch { return null; }
}
async function ensureLoggedIn(page: Page): Promise<void> {
await page.goto(AUTH.baseUrl, { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(CONFIG.pageLoadWaitTime);
const passInput = page.locator('input[type="password"]').first();
const needLogin = await passInput.isVisible({ timeout: 3000 }).catch(() => false);
if (needLogin) {
console.log('🔐 Đang đăng nhập...');
await page.getByPlaceholder(/0123456789/).first().fill(AUTH.username);
await passInput.fill(AUTH.password);
await page.getByRole('button', { name: /Đăng nhập/i }).click();
await passInput.waitFor({ state: 'detached', timeout: 20000 }).catch(() => {});
await page.waitForTimeout(2500);
console.log('✅ Đăng nhập OK');
} else {
console.log('✅ Đã có session, không cần login');
}
}
// ═══════════════════════════════════════════════════════════
// 🔍 QUÉT FIELDS
// ═══════════════════════════════════════════════════════════
async function scanFields(page: Page): Promise<any[]> {
return page.evaluate((dialogSelector) => {
const dialog = document.querySelector(dialogSelector);
const scope = dialog || document;
const inputs = scope.querySelectorAll(
'input:not([type="hidden"]):not([type="checkbox"]):not([type="radio"]), textarea, select, [role="combobox"]'
);
return Array.from(inputs)
.map((el) => {
const rect = el.getBoundingClientRect();
if (rect.width < 20 || rect.height < 20) return null;
const htmlEl = el as HTMLElement;
const inputEl = el as HTMLInputElement;
const dataQa = htmlEl.getAttribute('data-qa') || '';
const name = inputEl.name || '';
const id = htmlEl.id || '';
const placeholder = inputEl.placeholder || '';
const type = inputEl.type || el.tagName.toLowerCase();
const tag = el.tagName.toLowerCase();
const role = htmlEl.getAttribute('role') || '';
const value = inputEl.value || '';
const disabled = htmlEl.hasAttribute('disabled');
const readOnly = htmlEl.hasAttribute('readonly');
let label = '';
if (id) {
const lbl = scope.querySelector(`label[for="${id}"]`);
if (lbl) label = lbl.textContent?.trim() || '';
}
return {
dataQa, name, id, type, tag, role, placeholder, label,
disabled, readOnly, value,
y: Math.round(rect.top), x: Math.round(rect.left),
};
})
.filter(Boolean)
.sort((a, b) => a!.y - b!.y || a!.x - b!.x);
}, CONFIG.dialogSelector) as any[];
}
// ═══════════════════════════════════════════════════════════
// 🎯 LOCATOR BUILDER
// ═══════════════════════════════════════════════════════════
function getLocator(page: Page, field: any) {
if (field.dataQa) return page.locator(`[data-qa="${field.dataQa}"]`);
if (field.name) return page.locator(`[name="${field.name}"]`);
if (field.id) return page.locator(`#${field.id}`);
return null;
}
// ═══════════════════════════════════════════════════════════
// 📊 PHÂN TÍCH PAYLOAD & RESPONSE
// ═══════════════════════════════════════════════════════════
interface FieldAnalysis {
fieldName: string;
dataQa: string;
label: string;
testValue: string;
inPayload: boolean;
payloadKey?: string;
inResponse: boolean;
responseKey?: string;
updateOk: boolean;
savedValue: string;
notes: string[];
}
async function analyzePayloadResponse(
payload: Record<string, unknown> | null,
response: Record<string, unknown> | null,
filledData: Record<string, string>,
savedData: Record<string, string>,
fields: any[],
): Promise<FieldAnalysis[]> {
const results: FieldAnalysis[] = [];
for (const field of fields) {
if (field.disabled || field.readOnly) continue;
const key = field.dataQa || field.name || field.id;
const expectedValue = filledData[key] || '';
const actualValue = savedData[key] || '(không đọc được)';
const notes: string[] = [];
// Kiểm tra trong payload
let inPayload = false;
let payloadKey: string | undefined;
if (payload) {
const flatPayload = flattenObject(payload);
// Tìm key tương ứng trong payload
const match = findFieldInPayload(key, flatPayload);
if (match) {
inPayload = true;
payloadKey = match;
} else {
notes.push('❌ KHÔNG có trong payload gửi lên BE → FE không gửi field này');
}
}
// Kiểm tra trong response
let inResponse = false;
let responseKey: string | undefined;
if (response) {
const flatResponse = flattenObject(response);
const match = findFieldInPayload(key, flatResponse);
if (match) {
inResponse = true;
responseKey = match;
} else {
notes.push('❌ KHÔNG có trong response từ BE → không thể hiển thị lại trên UI');
}
}
// Kiểm tra update
const updateOk = normalizeForCompare(actualValue) === normalizeForCompare(expectedValue);
if (!updateOk) {
notes.push(getFailReason(key, expectedValue, actualValue));
}
results.push({
fieldName: key,
dataQa: field.dataQa || '',
label: field.label || '',
testValue: expectedValue,
inPayload,
payloadKey,
inResponse,
responseKey,
updateOk,
savedValue: actualValue,
notes,
});
}
return results;
}
/** Làm phẳng object lồng nhau thành key-value */
function flattenObject(obj: Record<string, unknown>, prefix = ''): Record<string, unknown> {
const result: Record<string, unknown> = {};
for (const [key, value] of Object.entries(obj)) {
const newKey = prefix ? `${prefix}.${key}` : key;
if (value && typeof value === 'object' && !Array.isArray(value)) {
Object.assign(result, flattenObject(value as Record<string, unknown>, newKey));
} else {
result[newKey] = value;
}
}
return result;
}
/** Tìm field trong payload dựa trên data-qa */
function findFieldInPayload(dataQa: string, flatPayload: Record<string, unknown>): string | undefined {
const lower = dataQa.toLowerCase();
// Map data-qa pattern → payload key patterns
const patterns: Record<string, string[]> = {
'i_ma': ['code', 'ma'],
'i_ten': ['name', 'ten'],
'i_dia_chi': ['address', 'diaChi'],
'i_dien_thoai': ['phone', 'phoneNumber', 'dienThoai'],
'i_email': ['email'],
'i_mst': ['taxCode', 'tax', 'mst', 'maSoThue'],
'i_mo_ta': ['description', 'moTa', 'note', 'ghiChu'],
'sel_': ['id', 'id$'],
'i_so_tai_khoan': ['accountNumber', 'bankAccount', 'soTaiKhoan'],
};
// Thử match pattern data-qa
for (const [prefix, keys] of Object.entries(patterns)) {
if (lower.startsWith(prefix.toLowerCase())) {
for (const k of keys) {
// Tìm key kết thúc bằng mẫu
for (const pk of Object.keys(flatPayload)) {
const pkLower = pk.toLowerCase();
if (k.endsWith('$')) {
if (pkLower.endsWith(k.replace('$', ''))) return pk;
} else if (pkLower === k) {
return pk;
} else if (pkLower.includes(k) && pkLower.length < k.length + 10) {
return pk;
}
}
}
}
}
return undefined;
}
// ═══════════════════════════════════════════════════════════
// 🔬 KIỂM TRA FK SELECT
// ═══════════════════════════════════════════════════════════
interface FkCheckResult {
fkFieldConfig: typeof CONFIG.fkFields[0];
onlyIdInPayload: boolean;
payloadContainsCode: boolean;
payloadContainsName: boolean;
responseHasId: boolean;
responseHasCode: boolean;
responseHasName: boolean;
responseMissingDisplay: string[];
payloadKeys: string[];
responseKeys: string[];
}
async function checkFkFields(
payload: Record<string, unknown> | null,
response: Record<string, unknown> | null,
): Promise<FkCheckResult[]> {
if (!payload || !response) return [];
const flatPayload = flattenObject(payload);
const flatResponse = flattenObject(response);
const results: FkCheckResult[] = [];
for (const fkConfig of CONFIG.fkFields) {
const payloadKeys = Object.keys(flatPayload).filter(k => k.toLowerCase().includes(fkConfig.fkField.toLowerCase()));
const responseKeys = Object.keys(flatResponse).filter(k => k.toLowerCase().includes(fkConfig.fkField.toLowerCase()));
// Kiểm tra payload chỉ gửi ID
const payloadContainsCode = payloadKeys.some(k => k.toLowerCase().includes('code'));
const payloadContainsName = payloadKeys.some(k => k.toLowerCase().includes('name'));
const onlyIdInPayload = !payloadContainsCode && !payloadContainsName;
// Kiểm tra response có đủ display fields
const responseHasId = responseKeys.some(k => k.toLowerCase().includes('id'));
const responseHasCode = responseKeys.some(k => k.toLowerCase().includes('code'));
const responseHasName = responseKeys.some(k => k.toLowerCase().includes('name'));
const responseMissingDisplay: string[] = [];
for (const df of fkConfig.displayFields) {
const hasField = Object.keys(flatResponse).some(k => k.toLowerCase().includes(df.toLowerCase()));
if (!hasField) responseMissingDisplay.push(df);
}
results.push({
fkFieldConfig: fkConfig,
onlyIdInPayload,
payloadContainsCode,
payloadContainsName,
responseHasId,
responseHasCode,
responseHasName,
responseMissingDisplay,
payloadKeys,
responseKeys,
});
}
return results;
}
// ═══════════════════════════════════════════════════════════
// 📝 BÁO CÁO KẾT QUẢ
// ═══════════════════════════════════════════════════════════
function generateReport(
fieldResults: FieldAnalysis[],
fkResults: FkCheckResult[],
apiUrl: string,
outputDir: string,
) {
const now = new Date().toISOString().replace(/[:.]/g, '-');
const reportPath = join(outputDir, `test-ui-report-${CONFIG.formName.replace(/\s+/g, '-').toLowerCase()}-${now}.md`);
const okCount = fieldResults.filter(r => r.updateOk).length;
const failCount = fieldResults.filter(r => !r.updateOk).length;
const missingPayload = fieldResults.filter(r => !r.inPayload);
const missingResponse = fieldResults.filter(r => !r.inResponse);
let report = `# 🧪 Báo Cáo Test UI — ${CONFIG.formName}\n\n`;
report += `- **Thời gian:** ${new Date().toLocaleString('vi-VN')}\n`;
report += `- **Route:** \`${CONFIG.route}\`\n`;
report += `- **API gọi:** \`${apiUrl || '(không bắt được)'}\`\n\n`;
// Tổng quan
report += `## 📊 Tổng Quan\n\n`;
report += `| Chỉ số | Kết quả |\n|---|---|\n`;
report += `| Tổng fields | ${fieldResults.length} |\n`;
report += `| ✅ Update OK | ${okCount} |\n`;
report += `| ❌ Update LỖI | ${failCount} |\n`;
report += `| ⚠️ Thiếu trong Payload | ${missingPayload.length} |\n`;
report += `| ⚠️ Thiếu trong Response | ${missingResponse.length} |\n`;
report += `| 🔗 FK fields | ${fkResults.length} |\n\n`;
// Chi tiết fields
report += `## 📋 Chi Tiết Từng Field\n\n`;
report += `| Field | Label | Test Value | Saved Value | Payload | Response | Update | Ghi chú |\n`;
report += `|-------|-------|------------|-------------|---------|----------|--------|--------|\n`;
for (const r of fieldResults) {
const payloadIcon = r.inPayload ? `✅ \`${r.payloadKey}\`` : '❌ THIẾU';
const responseIcon = r.inResponse ? `✅ \`${r.responseKey}\`` : '❌ THIẾU';
const updateIcon = r.updateOk ? '✅ OK' : '❌ LỖI';
const notes = r.notes.join('<br>');
report += `| \`${r.fieldName}\` | ${r.label} | ${r.testValue.substring(0, 30)} | ${r.savedValue.substring(0, 30)} | ${payloadIcon} | ${responseIcon} | ${updateIcon} | ${notes} |\n`;
}
// FK Report
if (fkResults.length > 0) {
report += `\n## 🔗 Kiểm Tra FK Select\n\n`;
report += `| FK Field | Chỉ gửi ID? | Payload có Code? | Payload có Name? | Response có ID? | Response có Code? | Response có Name? | Thiếu display |\n`;
report += `|----------|------------|-----------------|-----------------|----------------|-----------------|-----------------|--------------|\n`;
for (const r of fkResults) {
report += `| \`${r.fkFieldConfig.fkField}\` | ${r.onlyIdInPayload ? '✅ YES' : '❌ NO'} | ${r.payloadContainsCode ? '⚠️ CÓ' : '✅ KHÔNG'} | ${r.payloadContainsName ? '⚠️ CÓ' : '✅ KHÔNG'} | ${r.responseHasId ? '✅' : '❌'} | ${r.responseHasCode ? '✅' : '❌'} | ${r.responseHasName ? '✅' : '❌'} | ${r.responseMissingDisplay.join(', ') || '✅ Đủ'} |\n`;
}
}
// Hành động đề xuất
if (missingPayload.length > 0 || missingResponse.length > 0 || failCount > 0) {
report += `\n## 🔧 Hành Động Đề Xuất\n\n`;
if (missingPayload.length > 0) {
report += `### Fields thiếu trong Payload (FE không gửi lên BE):\n\n`;
for (const r of missingPayload) {
report += `- **\`${r.fieldName}\`** (${r.label}): Kiểm tra hook \`buildFormData\` hoặc logic map DTO → payload\n`;
}
report += `\n`;
}
if (missingResponse.length > 0) {
report += `### Fields thiếu trong Response (BE không trả về):\n\n`;
for (const r of missingResponse) {
report += `- **\`${r.fieldName}\`** (${r.label}): Cần yêu cầu BE bổ sung field này vào response hoặc FE tự tính\n`;
}
report += `\n`;
}
if (failCount > 0) {
report += `### Fields Update Lỗi:\n\n`;
for (const r of fieldResults.filter(r => !r.updateOk)) {
report += `- **\`${r.fieldName}\`**: ${r.notes.join(' | ')}\n`;
}
report += `\n`;
}
// FK recommendations
for (const r of fkResults.filter(r => r.responseMissingDisplay.length > 0)) {
report += `### FK \`${r.fkFieldConfig.fkField}\` thiếu display fields:\n`;
report += `- Response thiếu: ${r.responseMissingDisplay.join(', ')}\n`;
report += `- Cần: BE trả thêm các field này HOẶC FE tự resolve bằng API khác\n\n`;
}
}
mkdirSync(outputDir, { recursive: true });
writeFileSync(reportPath, report, 'utf-8');
console.log(`\n📄 Báo cáo đã lưu: ${reportPath}`);
return report;
}
// ═══════════════════════════════════════════════════════════
// 🚀 MAIN
// ═══════════════════════════════════════════════════════════
async function main() {
console.log(`\n🧪 TEST UI — ${CONFIG.formName}`);
console.log(` Route: ${AUTH.baseUrl}/${CONFIG.route}`);
console.log(` Mode: ${CONFIG.testMode}`);
console.log('═'.repeat(60));
// ── Kết nối browser ──
let browser: Browser;
let context: BrowserContext;
let page: Page;
const daemon = await isDaemonRunning();
if (daemon) {
const conn = await connectToDaemon();
if (conn) {
browser = conn.browser;
context = conn.context;
page = conn.page;
} else {
browser = await chromium.launch({ headless: false });
context = await browser.newContext({ viewport: { width: 1440, height: 900 } });
page = await context.newPage();
}
} else {
console.log('⚠️ Daemon không chạy, mở browser mới.');
browser = await chromium.launch({ headless: false });
context = await browser.newContext({ viewport: { width: 1440, height: 900 } });
page = await context.newPage();
}
// Setup network interceptor
setupNetworkInterceptor(page);
try {
// ── B1: Login & Navigate ──
await ensureLoggedIn(page);
await page.goto(`${AUTH.baseUrl}/${CONFIG.route}`);
await page.waitForTimeout(CONFIG.pageLoadWaitTime);
// Đóng popup nếu có
await page.getByRole('button', { name: /Đã hiểu|OK|Đồng ý/i }).click({ timeout: 3000 }).catch(() => {});
// ── B2: Mở dialog Thêm mới ──
console.log('\n📋 Bước 1: Mở dialog Thêm mới...');
try {
await page.locator(CONFIG.addButtonSelector).click({ timeout: 5000 });
} catch {
await page.getByRole('button', { name: /Thêm/i }).first().click({ timeout: 5000 });
}
await page.waitForTimeout(CONFIG.waitTime);
// ── B3: Quét fields ──
console.log('📋 Bước 2: Quét fields trong dialog...');
const fields = await scanFields(page);
if (fields.length === 0) {
console.log('❌ KHÔNG TÌM THẤY FIELD NÀO!');
await page.screenshot({ path: 'test-results/test-ui-no-fields.png', fullPage: true });
return;
}
console.log(` Tìm thấy ${fields.length} fields:`);
for (const f of fields) {
const id = f.dataQa || f.name || f.id;
const hasQa = f.dataQa ? '✅' : '⚠️';
console.log(` ${hasQa} ${id.padEnd(30)} [${f.type}] ${f.label || ''}`);
}
const missingQa = fields.filter(f => !f.dataQa && !f.disabled && !f.readOnly);
if (missingQa.length > 0) {
console.log(`\n⚠️ CẢNH BÁO: ${missingQa.length} fields KHÔNG có data-qa:`);
missingQa.forEach(f => console.log(` → ${f.name || f.id} (${f.label || f.placeholder})`));
}
if (CONFIG.testMode === 'create-only' || CONFIG.testMode === 'all') {
// ── B4: Fill dữ liệu test + Lưu (Create) ──
console.log('\n📋 Bước 3: Fill dữ liệu test...');
clearCapturedData(); // Clear trước khi fill
const filledData: Record<string, string> = {};
for (const field of fields) {
if (field.disabled || field.readOnly) {
console.log(` ⏭️ Bỏ qua disabled/readOnly: ${field.dataQa || field.name}`);
continue;
}
const locator = getLocator(page, field);
if (!locator) {
console.log(` ❌ Không tìm được locator: ${field.name || field.id}`);
continue;
}
const key = field.dataQa || field.name || field.id;
const testValue = generateTestValue(field);
try {
if (field.role === 'combobox') {
await locator.click();
await page.waitForTimeout(300);
const firstOption = page.locator('[role="option"]').first();
if (await firstOption.isVisible({ timeout: 2000 }).catch(() => false)) {
const optionText = await firstOption.textContent();
await firstOption.click();
await page.waitForTimeout(200);
filledData[key] = optionText?.trim() || '';
console.log(` ✅ ${key} → chọn "${filledData[key]}"`);
} else {
console.log(` ⚠️ ${key} → combobox không có option`);
}
} else if (field.tag === 'select') {
const options = await locator.locator('option').all();
if (options.length > 1) {
await locator.selectOption({ index: 1 });
filledData[key] = await locator.inputValue();
console.log(` ✅ ${key} → "${filledData[key]}"`);
}
} else {
await locator.fill(testValue);
filledData[key] = testValue;
console.log(` ✅ ${key} → "${testValue}"`);
}
} catch (e: any) {
console.log(` ❌ ${key} → LỖI: ${e.message}`);
}
}
console.log(`\n 📝 Đã fill ${Object.keys(filledData).length} fields`);
// ── B5: Lưu & chặn payload/response ──
console.log('\n📋 Bước 4: Lưu (Create) + chặn API...');
try {
await page.locator(CONFIG.saveButtonSelector).click({ timeout: 5000 });
} catch {
await page.getByRole('button', { name: /Lưu|Cất|Lưu lại/i }).click({ timeout: 5000 });
}
await page.waitForTimeout(CONFIG.saveWaitTime);
// Phân tích payload & response
const apiCall = getLastApiCall();
const apiResponse = getLastApiResponse();
let payload: Record<string, unknown> | null = null;
let response: Record<string, unknown> | null = null;
if (apiCall) {
console.log(`\n📡 API Gọi: ${apiCall.method} ${apiCall.url}`);
try {
payload = JSON.parse(apiCall.postData || '{}');
console.log(` Payload keys: ${Object.keys(payload).join(', ')}`);
} catch {
console.log(' ⚠️ Không parse được payload');
}
}
if (apiResponse) {
console.log(`📡 Response: ${apiResponse.status} ${apiResponse.url}`);
try {
response = JSON.parse(apiResponse.body || '{}');
if (response?.data) {
const dataKeys = Object.keys(response.data as Record<string, unknown>);
console.log(` Response data keys: ${dataKeys.join(', ')}`);
}
} catch {
console.log(' ⚠️ Không parse được response');
}
}
// ── Phân tích field trong payload/response ──
const fieldResults = await analyzePayloadResponse(
payload,
response?.data ? (response.data as Record<string, unknown>) : response,
filledData,
{},
fields,
);
// ── FK check ──
const fkResults = await checkFkFields(
payload,
response?.data ? (response.data as Record<string, unknown>) : response,
);
// ── In kết quả nhanh ──
console.log('\n' + '═'.repeat(60));
console.log('📊 PHÂN TÍCH PAYLOAD & RESPONSE (CREATE)');
console.log('═'.repeat(60));
const missingPayload = fieldResults.filter(r => !r.inPayload);
const missingResponse = fieldResults.filter(r => !r.inResponse);
if (missingPayload.length > 0) {
console.log(`\n❌ PAYLOAD THIẾU (${missingPayload.length} fields không được gửi lên BE):`);
missingPayload.forEach(r => console.log(` → ${r.fieldName} (${r.label})`));
}
if (missingResponse.length > 0) {
console.log(`\n❌ RESPONSE THIẾU (${missingResponse.length} fields không có trong response):`);
missingResponse.forEach(r => console.log(` → ${r.fieldName} (${r.label})`));
}
// FK Report
if (fkResults.length > 0) {
console.log(`\n🔗 KIỂM TRA FK:`);
for (const r of fkResults) {
console.log(` → ${r.fkFieldConfig.fkField}:`);
console.log(` Payload chỉ gửi ID: ${r.onlyIdInPayload ? '✅ YES' : '❌ NO (gửi cả code/name)'}`);
console.log(` Response thiếu display: ${r.responseMissingDisplay.join(', ') || '✅ ĐỦ'}`);
}
}
// ── B6: Mở lại Edit để đọc dữ liệu ──
if (CONFIG.testMode === 'all') {
console.log('\n📋 Bước 5: Tìm bản ghi → mở Edit...');
const maKey = Object.keys(filledData).find(k => k.includes('ma') || k.includes('code'));
if (maKey && filledData[maKey]) {
try {
await page.locator('[data-qa="i_keyword"]').fill(filledData[maKey]);
await page.locator('[data-qa="btn_tim_kiem"]').click();
await page.waitForTimeout(1000);
} catch { /* fallback */ }
}
await page.waitForTimeout(500);
try {
await page.locator(CONFIG.editButtonSelector).first().click({ timeout: 5000 });
} catch {
await page.locator('.icon-primary').first().click({ timeout: 5000 });
}
await page.waitForTimeout(CONFIG.waitTime);
// ── B7: Đọc lại dữ liệu ──
console.log('📋 Bước 6: Đọc lại dữ liệu sau lưu...');
const savedData: Record<string, string> = {};
for (const field of fields) {
if (field.disabled || field.readOnly) continue;
const locator = getLocator(page, field);
if (!locator) continue;
const key = field.dataQa || field.name || field.id;
try {
if (field.role === 'combobox') {
savedData[key] = (await locator.textContent())?.trim() || '';
} else {
savedData[key] = (await locator.inputValue()) || '';
}
} catch {
savedData[key] = '(không đọc được)';
}
}
// ── B8: So sánh update ──
console.log('\n📋 Bước 7: So sánh dữ liệu update...');
const updateResults: { field: string; expected: string; actual: string; ok: boolean; note: string }[] = [];
for (const [key, expectedValue] of Object.entries(filledData)) {
const actualValue = savedData[key] || '(không đọc được)';
const ok = normalizeForCompare(actualValue) === normalizeForCompare(expectedValue);
updateResults.push({
field: key, expected: expectedValue, actual: actualValue, ok,
note: ok ? '✅ OK' : getFailReason(key, expectedValue, actualValue),
});
}
const okCount = updateResults.filter(r => r.ok).length;
const failCount = updateResults.filter(r => !r.ok).length;
console.log(`\n✅ OK: ${okCount} ❌ LỖI: ${failCount} 📋 Tổng: ${updateResults.length}`);
for (const r of updateResults) {
console.log(`${r.ok ? '✅' : '❌'} ${r.field}`);
if (!r.ok) {
console.log(` Mong đợi: "${r.expected}"`);
console.log(` Thực tế: "${r.actual}"`);
console.log(` Lý do: ${r.note}`);
}
}
// Cập nhật field results với saved data
for (const ur of updateResults) {
const fr = fieldResults.find(f => f.fieldName === ur.field);
if (fr) {
fr.updateOk = ur.ok;
fr.savedValue = ur.actual;
if (!ur.ok) fr.notes.push(ur.note);
}
}
}
// ── B9: Generate báo cáo ──
const apiUrl = apiCall?.url || '';
const report = generateReport(fieldResults, fkResults, apiUrl, 'test-results');
console.log(report);
// Screenshot
await page.screenshot({
path: `test-results/test-ui-${CONFIG.formName.replace(/\s+/g, '-').toLowerCase()}.png`,
fullPage: true,
});
}
} catch (e: any) {
console.log(`\n❌ LỖI KHÔNG MONG ĐỢI: ${e.message}`);
await page.screenshot({ path: 'test-results/test-ui-error.png', fullPage: true });
} finally {
const usingDaemon = await isDaemonRunning();
if (!usingDaemon) {
await browser.close();
}
console.log('\n🏁 Test UI hoàn tất.');
}
}
main().catch(console.error);
📋 Hướng Dẫn Agent — Các Bước Thực Hiện
Bước 0: Xác Định Phạm Vi
- Dùng skill
xac-dinh-pham-viđể xác định route, page, dialog cần test - Đọc file page/dialog để lấy danh sách
data-qaselectors - Xác định loại test:
all: Create + Update + FK (mặc định)create-only: Chỉ kiểm tra payload/response khi createupdate-only: Chỉ kiểm tra field update sau khi có bản ghifk-only: Chỉ kiểm tra FK select fields
Bước 0.5: Đối Chiếu DTO BE (Nếu Có)
Nếu user cung cấp DTO mới: Thực hiện đối chiếu DTO trước khi test UI.
- Đọc DTO hiện tại từ file types (VD:
DM.types.api.ts,PC.types.api.ts) - Parse DTO mới từ input của user (text/json/typescript interface)
- So sánh & báo cáo diff:
- 🟢 Field THÊM MỚI trong DTO mới
- 🔴 Field ĐÃ XÓA khỏi DTO mới
- 🟡 Field ĐỔI KIỂU (number → string, optional → required...)
- Hỏi user: "DTO có thay đổi so với hiện tại. Có muốn cập nhật types theo DTO mới không?"
- Nếu user đồng ý → cập nhật types file
- Dùng DTO (mới hoặc cũ) làm chuẩn để kiểm tra payload:
- Các field required trong DTO → PHẢI có trong payload
- Các field optional nhưng có UI editable → NÊN có trong payload
- Các field không có trong DTO → KHÔNG được có trong payload
Nếu user KHÔNG cung cấp DTO: Tự động đọc types file hiện tại làm chuẩn để đối chiếu.
Bước 1: Tạo Script Test
- Copy template trên → lưu vào
e2e/test-ui-<ten-trang>.ts - Sửa
CONFIG:route: Hash route màn hìnhformName: Tên hiển thị- Các selector cho nút Thêm, Sửa, Lưu
fkFields: Khai báo các FK field cần kiểm tra đặc biệt (bao gồm cả TK nợ/có)fkFields: [ { dataQa: 'sel_khach_hang', fkField: 'accountObjectId', displayFields: ['objectCode', 'objectName', 'objectAddress'] }, { dataQa: 'sel_nhan_vien', fkField: 'employeeId', displayFields: ['employeeCode', 'employeeName'] }, // TK nợ/có: expect response có accountNumber { dataQa: 'sel_tk_no', fkField: 'debitAccountId', displayFields: ['debitAccountNumber'] }, { dataQa: 'sel_tk_co', fkField: 'creditAccountId', displayFields: ['creditAccountNumber'] }, ],dtoSource: (TÙY CHỌN) Đường dẫn file types hoặc DTO text từ user/** Đường dẫn file types API để đối chiếu DTO */ dtoSource: 'src/modules/KetoanApp/features/nghiep-vu/mua-hang/don-mua/types/DM.types.api.ts',
Bước 2: Chạy Script
# Sử dụng daemon nếu đang chạy (không cần login lại)
npx tsx e2e/test-ui-<ten-trang>.ts
# Hoặc chạy standalone (tự mở browser)
Bước 3: Phân Tích Kết Quả
Script sẽ tạo báo cáo test-results/test-ui-report-<ten-form>-<timestamp>.md với các phần:
- Tổng quan: Số lượng fields, OK/Lỗi, Thiếu payload/response
- Chi tiết từng field: Bảng field-by-field với payload key, response key, update status
- FK Report: Kiểm tra payload chỉ gửi ID, response có đủ display fields
- TK Nợ/Có Report: Kiểm tra payload chỉ gửi
debitAccountId/creditAccountId, response códebitAccountNumber/creditAccountNumber - DTO Diff Report: (nếu có) So sánh DTO cũ vs mới, field thừa/thiếu trong payload so với DTO
- Hành động đề xuất: Các file cần sửa, field cần thêm
Bước 4: Hành Động Sau Test
Dựa vào báo cáo, thực hiện các hành động:
| Phát hiện | Hành động | Cần xác định |
|---|---|---|
| Field thiếu trong payload | Sửa hook buildFormData — thêm field vào object gửi lên |
— |
| Field thiếu trong response | Gửi inbox cho BE yêu cầu bổ sung, HOẶC FE tự resolve | API endpoint + method + DTO name (xem Bước 5) |
| FK gửi cả code/name (không chỉ ID) | Sửa TableSearchCombobox onChange → chỉ set ID |
— |
| FK response thiếu display | Resolve trong buildFormData hook (Pattern A - ưu tiên) |
— |
| TK nợ/có gửi cả accountNumber | Sửa buildFormData → chỉ gửi debitAccountId/creditAccountId |
— |
| TK nợ/có response thiếu accountNumber | Gửi inbox BE hoặc resolve trong buildFormData |
— |
| TableSearchCombobox hiển thị sai khi Edit | Thêm resolve trong buildFormData (fetch getById/list) |
— |
| Field update sai giá trị | Kiểm tra transform/format trong hook, hoặc BE trả về sai | — |
| DTO thay đổi → types chưa cập nhật | Cập nhật types file theo DTO mới | — |
Khi báo lỗi BE: KHÔNG chỉ nói "BE thiếu field X". Phải chỉ rõ: API nào, method nào, DTO nào, thêm vào response hay request. Format: xem Bước 5 bên dưới.
📋 Bước 5: Báo Cáo Lỗi BE — Format Chuẩn
Khi phát hiện BE thiếu field, PHẢI báo cáo đầy đủ thông tin để BE biết chính xác sửa ở đâu.
5.1 Format Báo Cáo Lỗi BE
Mỗi field thiếu PHẢI được mô tả với 6 thông tin bắt buộc:
Field: <tên field bị thiếu>
Route: <hash route màn hình>
API: <HTTP method> <endpoint path>
Vị trí: <Response|Request>
DTO: <tên interface/class BE hiện tại>
So sánh: <DTO tương tự đã có field này (nếu có)>
Hành động: <mô tả chính xác cách sửa>
5.2 Bảng Format Chuẩn
| # | Field | API Endpoint | Method | Vị trí | DTO hiện tại | So sánh | Hành động |
|---|---|---|---|---|---|---|---|
| 1 | Response / Request |
Giải thích các cột:
| Cột | Ý nghĩa | Ví dụ |
|---|---|---|
| Field | Tên field bị thiếu (camelCase) | postedDate |
| API Endpoint | Đường dẫn API đầy đủ | /api/accounting/v1/bank/transfers/{id} |
| Method | HTTP method | GET / POST / PUT |
| Vị trí | Missing ở response hay request body | Response (GET trả thiếu) / Request (POST/PUT không nhận) |
| DTO hiện tại | Tên class/interface BE đang dùng + ghi chú | BankTransferDetail KHÔNG có field này |
| So sánh | DTO tương tự CÓ field này → làm bằng chứng BE đã có pattern | BankDepositDetail CÓ postedDate |
| Hành động | Mô tả chính xác việc BE cần làm | BE thêm postedDate vào BankTransferDetail response |
5.3 Ví Dụ Cụ Thể
🔴 BE THIẾU postedDate trong response:
Route: #/ketoan/tien-gui/chuyen-tien-noi-bo
API: GET /api/accounting/v1/bank/transfers/{id}
Vị trí: Response body
DTO: BankTransferDetail KHÔNG có postedDate
So sánh: BankDepositDetail (GET /receipts/{id}) CÓ postedDate
↓
Hành động BE:
Thêm `postedDate` (DateTime?) vào `BankTransferDetail` response DTO.
Khi GET /api/accounting/v1/bank/transfers/{id}, trả về postedDate
từ bản ghi trong DB.
🔴 BE THIẾU postedDate trong request:
Route: #/ketoan/tien-gui/chuyen-tien-noi-bo
API: POST /api/accounting/v1/bank/transfers
PUT /api/accounting/v1/bank/transfers/{id}
Vị trí: Request body
DTO: CreateBankTransferRequest KHÔNG có postedDate
So sánh: CreateBankReceiptRequest CÓ postedDate?
↓
Hành động BE:
Thêm `postedDate?` (DateTime?, optional) vào CreateBankTransferRequest.
Nếu FE gửi postedDate thì lưu, nếu không gửi thì dùng refDate.
🔴 BE THIẾU refNoManagement trong response:
Route: #/ketoan/tien-gui/chuyen-tien-noi-bo
API: GET /api/accounting/v1/bank/transfers/{id}
Vị trí: Response body
DTO: BankTransferDetail KHÔNG có refNoManagement
So sánh: BankDepositDetail CÓ refNoManagement
↓
Hành động BE:
Thêm `refNoManagement` (string) vào BankTransferDetail response.
Khi GET /transfers/{id}, trả về số chứng từ (CTNB00001 format).
🟡 BE thiếu debitAccountId/creditAccountId (optional → required):
Route: #/ketoan/tien-gui/chuyen-tien-noi-bo
API: GET /api/accounting/v1/bank/transfers/{id}
Vị trí: Response body → accountingDetails[]
DTO: BADepositDetailLineDto.debitAccountId? (optional)
↓
Hành động BE:
Đổi debitAccountId, creditAccountId trong BADepositDetailLineDto
từ optional → required. Khi GET /transfers/{id}, luôn trả về
debitAccountId và creditAccountId trong mỗi accounting detail line.
(Hiện tại FE phải workaround bằng fetch 9999 TK records)
5.4 Quy Trình: Từ Phát Hiện → Báo Cáo → Gửi BE
[1] Test UI → phát hiện UI field trống khi edit/view
[2] Kiểm tra: FE có map field từ DTO không?
├── CÓ map nhưng DTO không có → BE THIẾU → báo cáo
└── KHÔNG map → FE thiếu → sửa hook
[3] Ghi nhận API endpoint + method từ DevTools Network tab
hoặc từ service file (TGApiService.ts)
[4] So sánh với DTO tương tự (receipt/payment) xem có field không
[5] Báo cáo theo format Section 5.2 → gửi inbox cho BE
🔬 Phân Tích FK Select Chuyên Sâu
Quy tắc: TableSearchCombobox khi select 1 đối tượng → CHỈ gửi FK ID lên BE, KHÔNG gửi code/name.
Kiểm tra payload:
// ✅ ĐÚNG: Payload chỉ chứa accountObjectId
{ "accountObjectId": "abc-123", ... }
// ❌ SAI: Payload chứa cả code/name (dư thừa, BE không cần)
{ "accountObjectId": "abc-123", "objectCode": "KH001", "objectName": "Nguyễn Văn A", ... }
Kiểm tra response:
// ✅ ĐỦ: Response có objectCode + objectName để hiển thị
{
"accountObjectId": "abc-123",
"objectCode": "KH001",
"objectName": "Nguyễn Văn A",
"objectAddress": "123 Đường ABC"
}
// ❌ THIẾU: Response chỉ có ID, FE không hiển thị được tên
{ "accountObjectId": "abc-123" }
// ⚠️ Cần resolve: FE phải tự fetch thêm bằng API khác
Khi response thiếu display:
Pattern A (Ưu tiên): Resolve trong buildFormData của Hook (xem edit-form-pattern.md trong user memory).
// Trong Hook: resolve FK khi build form data
const [khRes] = await Promise.all([
d.accountObjectId ? KHApiService.getById(d.accountObjectId).catch(() => null) : null,
]);
if (khRes?.data) {
formData.objectCode = khRes.data.code;
formData.objectName = khRes.data.name;
}
Pattern B: Resolve trong Dialog useEffect (kém tối ưu hơn).
🏦 Phân Tích TK Nợ / TK Có Chuyên Sâu
Quy tắc: Field chọn tài khoản nợ/có trong bảng hạch toán → CHỈ gửi
debitAccountId/creditAccountId, KHÔNG gửi account number.
Kiểm tra payload accountingDetails[]:
// ✅ ĐÚNG: Mỗi dòng hạch toán chỉ gửi ID
{
"accountingDetails": [
{
"debitAccountId": "guid-1",
"creditAccountId": "guid-2",
"amount": 1000000,
"description": "Thu tiền hàng"
}
]
}
// ❌ SAI: Gửi cả account number cùng với ID
{
"accountingDetails": [
{
"debitAccountId": "guid-1",
"debitAccountNumber": "111", // ← XÓA
"creditAccountId": "guid-2",
"creditAccountNumber": "331", // ← XÓA
"amount": 1000000
}
]
}
Kiểm tra response:
// ✅ ĐỦ: Response có account number để hiển thị
{
"accountingDetails": [
{
"debitAccountId": "guid-1",
"debitAccountNumber": "111", // ← CẦN CÓ
"creditAccountId": "guid-2",
"creditAccountNumber": "331", // ← CẦN CÓ
"amount": 1000000
}
]
}
// ❌ THIẾU: Response không có account number
// → FE phải resolve bằng AccountApiService.list()
⚠️ Lưu Ý Quan Trọng
- Dev server phải đang chạy ở
http://localhost:3000— KHÔNG tự khởi động - Daemon mode được khuyến nghị — không cần login lại mỗi lần chạy
- KHÔNG đóng browser khi dùng daemon — script tự connect
- Network interceptor chỉ bắt API calls trong cùng page context
- FK fields + TK nợ/có cần khai báo thủ công trong CONFIG — script không tự phát hiện
- DTO đối chiếu — nếu user cung cấp DTO, ưu tiên DTO làm chuẩn; nếu không, đọc types file hiện tại
- Thời gian chờ có thể cần điều chỉnh tùy theo tốc độ BE
- Tiếng Việt Unicode — script dùng UTF-8, không dùng PowerShell để đọc/ghi kết quả
- TableSearchCombobox resolve — Luôn resolve trong
buildFormData(Pattern A), KHÔNG resolve tronguseEffectdialog (Pattern B) - Hiển thị name khi resolve — Nếu combobox hiển thị "code - name" khi select, resolve cũng phải hiển thị "code - name"
🛠 Troubleshooting
| Vấn đề | Giải pháp |
|---|---|
| Không tìm thấy fields | Kiểm tra dialogSelector, tăng waitTime |
| Không login được | Kiểm tra credentials, xóa auth.json nếu session hết hạn |
| Payload rỗng | API có thể gọi qua WebSocket hoặc khác origin — dùng page.route() để intercept |
| Combobox không mở | Tăng waitTime sau click, thử dùng page.keyboard.press('ArrowDown') |
| Mã tự động (AutoCode) không xuất hiện | Bỏ qua field code khi test create — BE tự sinh |
| Toast lỗi sau lưu | Đọc nội dung toast → báo cáo lỗi validate |
| Response truncated | Tăng giới hạn 50000 trong capturedResponse.body |