# sun-ecm — Hướng dẫn chung cho test tự động (Playwright)

> Tài liệu **dùng chung cho cả dự án** sun-ecm: fixtures/accounts, TIMEOUT, helper `PW`,
> import chuẩn và các pattern khai báo test. Mỗi UC có file README riêng chỉ mô tả
> phần đặc thù (trường, button, modal của màn hình đó) và link về tài liệu này.
>
> README riêng theo UC: [uc29/UC29.md](uc29/UC29.md) (các UC khác bổ sung tương tự).

---

## 1. Import chuẩn cho mọi spec

```ts
import { Page } from "@playwright/test";
import { test, expect, ACCOUNT } from "../../src/fixture/base-test";
import { TIMEOUT } from "../../src/constant/timeout";
import { PW } from "../../src/utils/PW";
// dữ liệu / tiền điều kiện dùng chung của mục, vd:
import { dungDuLieuBoHoSo } from "./1.4.1.setup";

const BASE_URL = process.env.BASE_URL!;
```

> Spec **chia theo mục số của testcase**, không chia theo thư mục UC (xem `CLAUDE.md` —
> "Quy ước đặt tên & cấu trúc test"): `tests/<mục>/<mục>.<mã case>.spec.ts`, vd
> `tests/1.4.1/1.4.1.153.spec.ts` → `src` ở `../../src`, file dùng chung của mục ở `./<mục>.setup`.

---

## 2. Tài khoản (fixtures)

Khai báo trong [src/fixture/base-test.ts](../src/fixture/base-test.ts). Mỗi fixture là một `Page` **đã đăng nhập sẵn** (auth context + record video; video tự xoá nếu test pass, đính kèm report nếu fail).

| Fixture           | Account ID | Vai trò                                                             | Hằng `ACCOUNT`      |
| ----------------- | ---------- | ------------------------------------------------------------------- | ------------------- |
| `librarian`       | `ecm01`    | Thủ thư                                                             | `ACCOUNT.LIBRARIAN` |
| `admin`           | `ecm09`    | Admin                                                               | `ACCOUNT.ADMIN`     |
| `end_user`        | `ecm04`    | End user                                                            | `ACCOUNT.END_USER`  |
| `ecm01` … `ecm09` | tương ứng  | inject trực tiếp khi cần vai cụ thể (vd `ecm05` hay dùng làm Owner) | —                   |

### Nhóm người dùng dùng chung (`GROUP` — cùng file `base-test.ts`)

| Hằng          | Tên nhóm trên app  | Thành viên | Dùng khi                                             |
| ------------- | ------------------ | ---------- | ---------------------------------------------------- |
| `GROUP.ECM06` | `AUTO_GROUP_ECM06` | `ecm06`    | Case cần phân quyền cho **nhóm** thay vì cho cá nhân |

Điền tên nhóm vào people-picker **y hệt** khi điền account (gõ tên → Enter, tìm kiếm nhanh trả cả
user lẫn nhóm), rồi đăng nhập bằng thành viên của nhóm để kiểm tra quyền có hiệu lực.

Pattern khai báo test (thường chạy 2 vai Thủ thư + Admin):

```ts
const runTest = async (page: Page) => {
  /* ... các test.step ... */
};

test(
  "UC<nn> <code> - <mô tả> - Thủ thư" + ACCOUNT.LIBRARIAN,
  async ({ librarian }) => {
    await runTest(librarian);
  },
);
test("UC<nn> <code> - <mô tả> - Admin" + ACCOUNT.ADMIN, async ({ admin }) => {
  await runTest(admin);
});
```

Cần thêm vai phụ (vd kiểm tra quyền Owner): `async ({ librarian, ecm05 }) => runTest(librarian, ecm05)`.

> 📌 **Cần assert theo tên người dùng?** Bảng quyền/avatar của app chỉ hiển thị **tên hiển thị**, mà
> tên này **có thể bị đổi trong lúc test** → đừng hard-code tên vào code. Inject fixture của account
> đó (dù nó không thao tác gì) rồi lấy tên động bằng
> `layTenHienThiNguoiDangDangNhap(page)` / `layTenDoiTuongPhanQuyen({ account, page })` —
> hover avatar ở màn danh sách, xem `screen-instructions/KHO-TAI-LIEU.md` mục 9.10.

---

## 3. TIMEOUT (ms) — [src/constant/timeout.ts](../src/constant/timeout.ts)

Giá trị dưới đây **đọc trực tiếp từ code** (cập nhật 2026-08-20 — bảng cũ ghi sai gấp 2–4 lần):

| Hằng               | Giá trị | Dùng cho                                 |
| ------------------ | ------- | ---------------------------------------- |
| `PAGE_LOADING`     | 40.000  | Chờ trang load / nút primary của màn     |
| `ELEMENT`          | 5.000   | Element thường                           |
| `CONTROL_LOADING`  | 3.000   | Chờ control hiện/ẩn (vd nút trong modal) |
| `ACTION_LOADING`   | 15.000  | Chờ sau action (mở modal, toast success) |
| `VALIDATE_WAITING` | 5.000   | Chờ validate giá trị input               |
| `HARD_WAITING`     | 10.000  | Hard wait sau khi vào màn (chờ JS init)  |
| `LOGIN_NAVIGATE`   | 240.000 | Điều hướng login                         |
| `DATA_LOADING`     | 15.000  | Chờ load dữ liệu (search, bảng)          |

> Khi search trong modal, nhiều chỗ dùng hard wait `page.waitForTimeout(10000)` sau `press("Enter")` vì kết quả load chậm.

---

## 4. Helper `PW` — [src/utils/PW.ts](../src/utils/PW.ts)

Khởi tạo: `const pw = new PW(page);`

### 4a. Method theo nhóm

| Method                                                    | Tác dụng                                                                           |
| --------------------------------------------------------- | ---------------------------------------------------------------------------------- |
| `isVisible(testId, timeout?, msg?)`                       | assert element visible                                                             |
| `isEmpty(testId)`                                         | assert control rỗng (tự nhận loại theo prefix)                                     |
| `wait(ms)`                                                | `page.waitForTimeout`                                                              |
| `clickButton(testId)`                                     | click theo testId                                                                  |
| `inputText(testId, text)` / `inputTextArea(testId, text)` | điền text / textarea                                                               |
| `inputDropDownList(testId, optionText?, matchMode?)`      | chọn dropdown đơn. Bỏ trống `optionText` → chọn item đầu tiên. Có `optionText` → **gõ nó vào ô search rồi mới chọn** (xem 4c). `matchMode`: `"exact"` (mặc định) / `"contains"` |
| `inputDropDownListWithSearch(testId, optionText, ...)`    | như trên + **ép đóng dropdown** sau khi chọn (cho select multiple)                  |
| `inputTreeDropDown(testId, optionText?, extraLocator?)`   | chọn tree dropdown                                                                 |
| `inputDropDownTagsList(testId, tags[])`                   | nhập nhiều tag                                                                     |
| `inputDatetime(testId, value)`                            | điền ngày                                                                          |
| `inputPeoplePicker(testId, value)`                        | nhập people picker, `value` dạng `"a,b"`                                           |
| `batchInput(data[], checkBranch?)`                        | điền cả form theo prefix testId (xem 4b)                                           |
| `valueShouldBe / valueShouldContain(testId, v)`           | assert giá trị control                                                             |
| `clearValue(testId)`                                      | xoá giá trị select/text                                                            |
| `getValue(testId)`                                        | lấy text của select                                                                |
| `checkListValueDropdownList(testId, values[])`            | assert đúng danh sách option                                                       |
| `checkListValueDropdownListTags(testId, values[])`        | assert tags đã chọn                                                                |
| `checkSearchDropdownHasOption(testId, keyword)`           | assert option search chứa keyword (cả không dấu)                                   |
| `checkSearchTreeDropdownHasOption(testId, keyword)`       | tương tự cho tree dropdown                                                         |
| `inputRelatedECM(testId, order?)`                         | mở pop-up liên quan, chọn dòng đầu, bấm `btn-add-related` (đặc thù UC có liên kết) |

### 4b. Quy ước prefix testId → control (dùng bởi `batchInput`)

| Prefix                | Control                | Helper được route tới   |
| --------------------- | ---------------------- | ----------------------- |
| `txt-`                | text input             | `inputText`             |
| `txa-`                | textarea               | `inputTextArea`         |
| `date-`               | datepicker             | `inputDatetime`         |
| `sel-`                | dropdown đơn           | `inputDropDownList`     |
| `sel-tags-`           | dropdown tags (multi)  | `inputDropDownTagsList` |
| `tree-sel-`           | tree dropdown          | `inputTreeDropDown`     |
| `pp-multi-`           | people picker          | `inputPeoplePicker`     |
| `btn-add-related-ecm` | nút mở modal liên quan | `inputRelatedECM`       |

`batchInput(data, checkBranch)`:

- `data`: mảng `{ testId, value, extraLocator? }` (thường là `<uc>DefaultData`).
- `checkBranch=true`: sau khi chọn `sel-loaiBoHoSo`, nếu là loại "dự án" sẽ hiện và tự điền thêm `sel-duAn`.
- Bỏ qua field không muốn điền bằng `.filter(...)` rồi xử lý riêng (vd people picker hay flaky → tách ra gọi `inputPeoplePicker`).

### 4c. 🚨 Dropdown chỉ nạp ~10 option đầu → `inputDropDownList` phải gõ search

Khảo sát 2026-07-30 trên sitdev (modal "Tạo mới bộ hồ sơ"): mở `sel-loaiBoHoSo` ra chỉ thấy **10
option đầu tiên**, `AUTO-TEST-TYPE` **không** nằm trong đó; gõ `"AUTO"` vào ô search thì app lọc
(ở server) và trả về đúng option đó. `sel-companyInvestor` cũng vậy.

Vì vậy `inputDropDownList(testId, optionText)` **luôn gõ `optionText` vào ô search trước khi chọn**:

- Ô search là `input.ant-select-selection-search-input` **bên trong** element `sel-*`
  (element có testId chính là `div.ant-select`).
- Mọi `sel-*` đã khảo sát đều có class `ant-select-show-search` và ô search **không** readonly
  (khác people-picker ở mục 5). Select nào không có class đó → hàm tự bỏ qua bước gõ.
- App lọc **không phân biệt dấu**: gõ `a` ở `sel-securityLevel` trả về `Mật`, `Tuyệt mật`.
- Option được tìm trong **dropdown đang mở** (`.ant-select-dropdown:visible` cuối cùng), không quét
  cả trang → không bắt nhầm option của select khác.
- Không khớp option nào → hàm ném lỗi kèm **danh sách option đang hiển thị** để khỏi phải mở trace.

Kiểm chứng 2026-07-30 (5 tình huống, đều pass): option ngoài 10 cái đầu (`AUTO-TEST-TYPE`),
khớp exact (`Đã đủ`), `matchMode: "contains"` (`Tuyệt` → `Tuyệt mật`), không truyền `optionText`
(chọn option đầu), và option không tồn tại (ném lỗi có kèm danh sách option).

---

## 5. Selectors AntD dùng chung

Dự án dùng **Ant Design**, nên các selector sau lặp lại ở nhiều màn:

| Mục đích                  | Locator                                                                                                                                    |
| ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
| Modal đang mở             | `page.locator(".ant-modal-content:visible").last()`                                                                                        |
| Dòng bảng (level 0)       | `.ant-table-row.ant-table-row-level-0`                                                                                                     |
| Header cột bảng           | `.ant-table-thead th`                                                                                                                      |
| Checkbox chọn dòng        | `.ant-table-row .ant-checkbox-wrapper` (hover dòng trước khi click)                                                                        |
| Toast thành công          | `.ant-message-success`                                                                                                                     |
| Phân trang                | `.ant-pagination`; `li[title="Previous Page"]`, `li[title="Next Page"]`, `.ant-pagination-item-<n>`, active = `ant-pagination-item-active` |
| Nút theo nhãn             | `page.getByRole("button", { name: "Hủy" })`                                                                                                |
| Selection item của select | `.ant-select-selection-item` / placeholder rỗng = `.ant-select-selection-placeholder`                                                      |
| Ô search của select       | `.ant-select-selection-search-input` — 🚨 **luôn có `readonly` khi select đang đóng**, kể cả lúc field bình thường. Field bị khoá thật thì input có `disabled` + wrapper có class `ant-select-disabled`. Muốn assert "nhập được" thì phải **click vào rồi mới** `toBeEditable()` (hoặc gõ thử và kiểm tra gợi ý) — đừng assert khi chưa click |
| Nút "..." trên title modal | `getByTestId("btn-more")` — hover để mở dropdown menu                                                                                     |
| Mục "Kiểm tra phân quyền" | `getByTestId("btn-kiem-tra-phan-quyen")` — chỉ visible sau khi hover `btn-more`                                                            |

### Pattern kiểm tra button "Kiểm tra phân quyền" theo vai quyền

```ts
const trigger = userPage.getByTestId("btn-more");

// Vai CÓ quyền: trigger phải visible → hover → assert nút visible
await expect(trigger).toBeVisible({ timeout: TIMEOUT.CONTROL_LOADING });
await trigger.hover();
await expect(userPage.getByTestId("btn-kiem-tra-phan-quyen")).toBeVisible({
  timeout: TIMEOUT.CONTROL_LOADING,
});

// Vai KHÔNG có quyền:
// - trigger ẩn → thỏa mãn ngay (không có menu)
// - trigger hiện → hover rồi assert nút KHÔNG visible
const triggerVisible = await trigger.isVisible();
if (triggerVisible) {
  await trigger.hover();
  await expect(userPage.getByTestId("btn-kiem-tra-phan-quyen")).not.toBeVisible({
    timeout: TIMEOUT.CONTROL_LOADING,
  });
}
```

---

## 6. Lưu ý chung khi viết case

1. Vào màn xong, `pw.wait(TIMEOUT.HARD_WAITING)` trước khi thao tác nút đầu tiên (chờ JS init).
2. Sau `press("Enter")` để search trong modal, chờ `waitForTimeout(10000)` vì kết quả load chậm.
3. Hover dòng bảng trước khi click checkbox (checkbox chỉ hiện khi hover).
4. Mỗi case khai báo tối thiểu 2 test: Thủ thư (`librarian`) + Admin (`admin`).
5. Tên dữ liệu unique: `` `AT-...-${Date.now()}` `` để vừa tạo vừa tìm lại không trùng.
6. Người dùng thật khi cần (people picker) — value dạng `"a,b"`, cân nhắc tách khỏi `batchInput`.
7. ✅ **Config đã set `actionTimeout: 60s`** (từ 2026-08-20). Trước đó để trống = chờ **vô hạn**:
   mọi action/`innerText()` trên element **không tồn tại** treo tới khi hết test timeout (30 phút)
   rồi báo lỗi vô nghĩa `"Target page, context or browser has been closed"`. Đã gặp thật:
   - Case 1.4.6.609 (Admin) treo **33.7 phút** ở 1 `click` vào dòng tài liệu, vì bảng Cấu trúc hồ sơ
     hiển thị `"Chưa có dữ liệu"` (app không nạp được danh sách dù dữ liệu đã tạo).
   - `PW.getValue(testId)` trên dropdown **rỗng** (`.ant-select-selection-item` không có element).
   - `PW.clickButton(testId)` với vai **không thấy nút** đó (vd `ecm04` không có `btn-create-hstl`).

   `actionTimeout` chỉ là lưới an toàn (fail sau 60s kèm call log). **Vẫn nên** `expect(...).toBeVisible({ timeout })`
   trước khi click vào element phụ thuộc **dữ liệu** hoặc **quyền của vai** — thông báo lỗi mới nói
   được "bảng đang có gì / menu đang có mục nào", thứ mà call log của Playwright không nói.
   Các hàm trong `KHO-TAI-LIEU.functions.ts` đã làm sẵn việc này (`openChiTietTaiLieu`,
   `openRowActionMenu`, `openPhanQuyenNangCao`, `chuyenHoatDong`, `createTaiLieu`…).
