@xylentis/testgen 1.1.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,30 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.2.0 (2026-10-06)
4
+
5
+ - **Sửa bước trong giao diện:** sửa locator (viết như trong test Playwright, ví dụ `getByRole('button', { name: 'Đăng nhập' })`), chữ đã gõ, giá trị đã chọn, giá trị assert, phím, đường dẫn file upload; đổi thứ tự hoặc xóa bước; **Undo** để quay lại. testgen chặn những thay đổi làm một trang bị dùng trước khi mở hoặc sau khi đóng.
6
+ - **Replay:** chạy lại các bước bằng Playwright ngay trong giao diện, mỗi bước hiện ✓ hoặc ✗. Bước lỗi có thông báo, ảnh chụp trang lúc lỗi và nút **Edit** để sửa. Replay chạy chính bản ghi trên Playwright, không chạy code Selenium đã sinh.
7
+ - **Checks:** testgen nhắc những chỗ nên xem lại trước khi dùng test: không có assertion nào, các bước cuối không được kiểm tra, locator dựa vào vị trí (`nth`, `first`) hay cấu trúc trang (XPath, `div > span`), file upload chỉ có tên, locator dựa vào chữ hiển thị khi app chưa có test id, và các `// TODO(testgen)` của từng target. Giao diện hiện trong mục **Checks**, CLI in ra sau khi sinh code.
8
+ - **Mật khẩu không còn nằm trong test.** Giá trị gõ vào ô mật khẩu, PIN, OTP, token, API key… được đọc từ biến môi trường, ví dụ `TEST_PASSWORD`: Java `TestgenSupport.secret("TEST_PASSWORD")`, TypeScript/JavaScript `secret('TEST_PASSWORD')`, Go `os.LookupEnv("TEST_PASSWORD")`. Thiếu biến thì test dừng ngay với thông báo rõ ràng. Giá trị đó cũng không vào file CSV, bản ghi do `testgen record` lưu và file của **Download recording**. Khi giá trị nằm trong một chữ khác (locator, chữ mong đợi), code dùng `secret(...)` ở đúng chỗ đó và bản ghi lưu `${TEST_PASSWORD}`. Log và thông báo lỗi của Selenium và của Replay hiện `***`. Trong giao diện có thể bật/tắt và đặt tên biến cho từng bước; bước đã là secret vẫn là secret khi sửa locator.
9
+ - Đây là thay đổi so với 1.1.x: test sinh từ bản ghi có ô mật khẩu cần đặt biến môi trường trước khi chạy, ví dụ `TEST_PASSWORD=... mvn test`.
10
+ - **Data-driven cho mọi target:** `playwright-ts`, `playwright-go` và `selenium-js` cũng đọc dữ liệu từ CSV và chạy test một lần cho mỗi dòng. File CSV nằm cạnh file test, riêng Go nằm trong `testdata/`. Chữ trong locator *chứa* giá trị đã gõ nay cũng đi theo dữ liệu, ví dụ `filterHasText(whatNeedsToBeDone + " Delete")` thay cho `filterHasText("Buy milk Delete")`.
11
+ - **Bảo mật giao diện:** server của `testgen ui` chỉ nhận thay đổi từ chính trang testgen (kiểm tra `Origin`, `Sec-Fetch-Site` và yêu cầu JSON). Trước đây một trang web khác đang mở trong trình duyệt có thể gửi lệnh tới `testgen ui`, ví dụ ghi file vào thư mục project.
12
+ - **`--log-steps` cho `selenium-js`:** test gọi `page.setLogger(console.log)`, ghi từng bước và kết quả từng assert như bản Java. Hai target Playwright không cần tùy chọn này vì report và trace của Playwright đã có từng bước.
13
+ - Tài liệu hướng dẫn sử dụng chi tiết: [docs/huong-dan-su-dung.md](https://git.xylentis.com/tools/automation-test-code/-/blob/main/docs/huong-dan-su-dung.md).
14
+
15
+ ## 1.1.2 (2026-10-06)
16
+
17
+ - Thêm target `selenium-java-testng`: Selenium WebDriver + TestNG (`@BeforeMethod`, `@AfterMethod(alwaysRun = true)`, `@Test`), dùng chung `TestgenSupport.java` với `selenium-java`. Project mẫu ở `e2e/projects/selenium-java-testng`.
18
+ - Thêm `--log-steps` (ô **Log steps** trong giao diện): test Java ghi log từng bước kèm giá trị và kết quả từng assert (`passed` / `FAILED`). TestNG ghi qua `Reporter.log`, JUnit in ra console. Trong log và thông báo lỗi, các trang mở sau (popup) được gọi là `page1`, `page2`…, đúng tên biến trong test.
19
+ - Thêm `--data-driven` (ô **Data-driven (CSV)**): giá trị đã gõ, chọn và assert thành cột của `<Tên>Test.csv`, đặt trong `src/test/resources/<package>/`. Test chạy một lần cho mỗi dòng, qua `@DataProvider` (TestNG) hoặc `@ParameterizedTest` (JUnit). File CSV đã có không bao giờ bị ghi đè.
20
+ - `TestgenSupport.readCsv(...)` đọc CSV UTF-8 có hoặc không có BOM, phân cách bằng dấu phẩy hoặc chấm phẩy.
21
+ - `testgen targets` căn cột theo id dài nhất.
22
+
23
+ ## 1.1.1 (2026-10-06)
24
+
25
+ - Code sinh ra luôn dùng thuộc tính test id mà bản ghi đã dùng. Trước đây, đổi **Test id attribute** trong giao diện sau khi ghi, hoặc truyền `--test-id-attribute` khác cho `testgen generate`, làm `getByTestId(...)` tìm theo sai thuộc tính. Nay giao diện hiện ghi chú khi ô này khác với bản ghi, còn `testgen generate` báo khi bỏ qua tùy chọn.
26
+ - README: thêm mục về cách recorder chọn locator và cách xử lý web nhiều ngôn ngữ (i18n).
27
+
3
28
  ## 1.1.0 (2026-10-06)
4
29
 
5
30
  - Khi ghi thao tác không còn mở cửa sổ Playwright Inspector, chỉ còn trình duyệt bạn đang thao tác. Code xem trong giao diện testgen hoặc trong các file sinh ra.
package/README.md CHANGED
@@ -2,6 +2,9 @@
2
2
 
3
3
  Ghi thao tác trên web **một lần** bằng recorder của Playwright, rồi sinh test UI cho **nhiều framework và nhiều ngôn ngữ**.
4
4
  Cách dùng giống `npx playwright codegen`, nhưng không chỉ sinh code Playwright mà còn sinh cả Selenium, và thêm ngôn ngữ Go.
5
+ Trong giao diện, bạn sửa được từng bước, chạy thử lại (Replay) và xem các cảnh báo trước khi lưu test: **ghi → sửa → chạy thử → xuất**.
6
+
7
+ Hướng dẫn từng bước, có ví dụ cho từng target: **[Hướng dẫn sử dụng](https://git.xylentis.com/tools/automation-test-code/-/blob/main/docs/huong-dan-su-dung.md)** (`docs/huong-dan-su-dung.md`).
5
8
 
6
9
  ![Giao diện testgen](https://cdn.jsdelivr.net/npm/@xylentis/testgen@1/docs/testgen-ui.png)
7
10
 
@@ -10,6 +13,7 @@ Cách dùng giống `npx playwright codegen`, nhưng không chỉ sinh code Play
10
13
  | `playwright-ts` | Playwright Test (`@playwright/test`) | TypeScript | `login.spec.ts` |
11
14
  | `playwright-go` | [playwright-go](https://github.com/mxschmitt/playwright-go) + testify | Go | `login_test.go` |
12
15
  | `selenium-java` | Selenium 4 + JUnit 5 | Java 17+ | `LoginTest.java` + `TestgenSupport.java` |
16
+ | `selenium-java-testng` | Selenium 4 + TestNG | Java 17+ | `LoginTest.java` + `TestgenSupport.java` |
13
17
  | `selenium-js` | Selenium 4 + Mocha | JavaScript | `login.spec.js` + `testgen-support.js` |
14
18
 
15
19
  ## Cài đặt
@@ -40,16 +44,30 @@ testgen ui # mở http://127.0.0.1:9323
40
44
  2. Một cửa sổ trình duyệt mở ra. Bạn thao tác như người dùng thật: click, gõ, chọn…
41
45
  - Thanh công cụ của recorder nằm ở đầu trang, dùng để thêm assert: *Assert visibility*, *Assert text*, *Assert value*.
42
46
  - Cửa sổ Playwright Inspector được ẩn đi, chỉ còn trình duyệt bạn đang thao tác. Muốn hiện lại thì dùng `testgen record --inspector`.
43
- 3. Trong khi bạn thao tác, giao diện testgen cập nhật **danh sách bước** và **code của cả 4 target**.
47
+ 3. Trong khi bạn thao tác, giao diện testgen cập nhật **danh sách bước** và **code của mọi target**.
44
48
  4. Đóng trình duyệt hoặc bấm **Stop** khi xong.
45
- 5. Với từng target, bạn có thể **Copy**, **Download** hoặc **Save files** để lưu thẳng vào thư mục project. Đường dẫn tính từ thư mục bạn chạy `testgen ui`.
49
+ 5. Sửa các bước nếu cần, bấm **Replay** để chạy thử, rồi xem mục **Checks** (xem bên dưới).
50
+ 6. Với từng target, bạn có thể **Copy**, **Download** hoặc **Save files** để lưu thẳng vào thư mục project. Đường dẫn tính từ thư mục bạn chạy `testgen ui`.
46
51
 
47
52
  Một số thao tác khác:
48
53
 
49
- - **Đổi Test name / Package / Test id attribute:** code sinh lại ngay.
50
- - **Download recording:** lưu file `.jsonl` để lần sau sinh lại.
54
+ - **Đổi Test name / Package:** code sinh lại ngay.
55
+ - **Test id attribute:** đặt trước khi bấm **Record**. Code luôn dùng thuộc tính mà bản ghi đã dùng, nên đổi sau khi ghi chỉ có tác dụng cho lần ghi sau. Giao diện hiện ghi chú khi hai giá trị khác nhau. Xem thêm mục **Locator và web nhiều ngôn ngữ** bên dưới.
56
+ - **Log steps** (target Selenium) và **Data-driven (CSV)** (mọi target): xem mục **Log từng bước và test data-driven** bên dưới.
57
+ - **Download recording:** lưu file `.jsonl` để lần sau sinh lại. File không chứa mật khẩu đã gõ, xem mục **Mật khẩu và dữ liệu nhạy cảm**.
51
58
  - **Open recording…:** mở lại một file đã lưu.
52
59
 
60
+ ### Sửa bước, Replay và Checks
61
+
62
+ - **Sửa bước:** rê chuột (hoặc Tab) vào một bước để hiện **Edit**, **↑**, **↓**, **✕**. **Edit** cho sửa:
63
+ - locator, viết như trong test Playwright: `getByRole('button', { name: 'Đăng nhập' })`, `getByLabel('Email')`, `getByTestId('save')`, `locator('#email')`;
64
+ - chữ đã gõ, giá trị đã chọn, chữ hoặc giá trị mong đợi của assert, phím (`Enter`, `Shift+Tab`), URL, đường dẫn file upload;
65
+ - ô **Secret** của bước gõ chữ: giá trị đọc từ biến môi trường nào.
66
+
67
+ **↑ ↓** đổi thứ tự, **✕** xóa, **Undo** quay lại thay đổi trước. testgen chặn những thay đổi làm một trang bị dùng trước khi mở hoặc sau khi đóng, ví dụ xóa bước mở popup khi các bước sau vẫn thao tác trên popup đó.
68
+ - **Replay:** chạy lại các bước bằng Playwright, trong trình duyệt của bản ghi. Mỗi bước hiện ✓ hoặc ✗. Bước lỗi hiện thông báo và ảnh chụp trang lúc lỗi; bấm **Edit step N** để sửa rồi Replay lại. Replay chạy chính bản ghi, nên nó cho biết locator và dữ liệu còn đúng trên Playwright, **chưa** cho biết code Selenium có chạy được hay không (xem **Giới hạn hiện tại**).
69
+ - **Checks:** những chỗ nên xem lại trước khi dùng test, ví dụ bản ghi không có assertion nào, các bước cuối không được kiểm tra, locator dựa vào vị trí (`nth(1)`) hay cấu trúc trang (XPath), file upload chỉ có tên, và những gì target đang xem không làm được (`// TODO(testgen)`). Bấm vào một dòng để nhảy tới bước đó. Lệnh `testgen record` và `testgen generate` in các cảnh báo này ra sau khi sinh code.
70
+
53
71
  ## Dùng dòng lệnh
54
72
 
55
73
  ```bash
@@ -64,6 +82,14 @@ testgen generate generated/recording.jsonl -t selenium-java -o src/test/java/com
64
82
  testgen generate generated/recording.jsonl -t playwright-go -o e2e/login_test.go
65
83
  testgen generate generated/recording.jsonl -d out # tất cả target, mỗi target một thư mục
66
84
 
85
+ # TestNG, log từng bước, dữ liệu đọc từ src/test/resources/com/acme/e2e/SearchTest.csv
86
+ testgen generate generated/recording.jsonl -t selenium-java-testng --log-steps --data-driven \
87
+ -o src/test/java/com/acme/e2e/SearchTest.java
88
+
89
+ # Playwright, dữ liệu đọc từ tests/search.csv; Go đọc từ e2e/testdata/search.csv
90
+ testgen generate generated/recording.jsonl -t playwright-ts --data-driven -o tests/search.spec.ts
91
+ testgen generate generated/recording.jsonl -t playwright-go --data-driven -o e2e/search_test.go
92
+
67
93
  testgen targets # liệt kê target
68
94
  ```
69
95
 
@@ -74,11 +100,64 @@ testgen targets # liệt kê target
74
100
  | `-d, --out-dir` | Thư mục ra. Nếu có nhiều target thì mỗi target một thư mục con. |
75
101
  | `-n, --name` | Tên test. Mặc định lấy từ tên file `--output`, nếu không có thì là `recorded`. |
76
102
  | `--package` | Package cho Java/Go. Java tự lấy từ đường dẫn sau `src/test/java/`, Go tự lấy từ tên thư mục. |
77
- | `--test-id-attribute` | Thuộc tính dùng cho `getByTestId` (mặc định `data-testid`). |
78
- | `--save` | (`record`) Nơi lưu bản ghi `.jsonl`. |
103
+ | `--test-id-attribute` | Thuộc tính recorder dùng cho `getByTestId` (mặc định `data-testid`). Khi sinh code, testgen dùng thuộc tính đã lưu trong bản ghi; tùy chọn này chỉ có tác dụng với bản ghi không có bước `getByTestId` nào. |
104
+ | `--log-steps` | (Selenium) Ghi log từng bước và kết quả từng assert khi test chạy. |
105
+ | `--data-driven` | Giá trị đã gõ, chọn và assert thành cột của một file CSV, test chạy một lần cho mỗi dòng. File CSV đã có không bị ghi đè. |
106
+ | `--save` | (`record`) Nơi lưu bản ghi `.jsonl`, không kèm mật khẩu đã gõ. |
79
107
  | `--browser`, `--channel`, `--device`, `--viewport-size`, `--lang`, `--timezone`, `--color-scheme`, `--user-agent`, `--ignore-https-errors`, `--load-storage`, `--save-storage` | (`record`) Giống hệt `playwright codegen`. |
80
108
  | `--inspector` | (`record`) Hiện thêm cửa sổ Playwright Inspector (mặc định ẩn). |
81
109
 
110
+ ## Locator và web nhiều ngôn ngữ (i18n)
111
+
112
+ testgen giữ nguyên locator mà recorder của Playwright chọn. Recorder ưu tiên theo thứ tự:
113
+
114
+ 1. test id: `getByTestId('welcome')`, theo thuộc tính `data-testid` hoặc thuộc tính đặt bằng `--test-id-attribute`;
115
+ 2. role + tên (accessible name, thường là chữ hiển thị): `getByRole('button', { name: 'Sign in' })`;
116
+ 3. placeholder, label, alt, text, title;
117
+ 4. `#id`; sau đó mới đến tên thẻ và vị trí.
118
+
119
+ `#id` xếp sau vì id thường do framework sinh ra và đổi giữa các lần build (`:r1:` của React, `mat-input-0` của Angular Material). Id trông như chuỗi ngẫu nhiên thì recorder bỏ qua hẳn. Ví dụ trong app demo, `<input id="email">` có label "Email" nên thành `getByRole('textbox', { name: 'Email' })` chứ không phải `#email`.
120
+
121
+ Vì vậy, nếu app không có test id, phần lớn locator dựa vào chữ trên trang. Test ghi trên giao diện tiếng Anh sẽ gãy khi chạy trên giao diện tiếng Việt: `getByRole('button', { name: 'Sign in' })` không khớp với nút "Đăng nhập". Có ba cách xử lý:
122
+
123
+ - **Cố định ngôn ngữ khi chạy test.** Ghi với `--lang`, code sinh ra sẽ đặt cùng locale (Selenium: thêm `--lang` cho Chrome và Edge):
124
+
125
+ ```bash
126
+ testgen record https://your-app.example --lang en-US
127
+ ```
128
+
129
+ Cách này chỉ có tác dụng khi app chọn ngôn ngữ theo trình duyệt. Nếu app chọn theo URL (`/en/...`), cookie hay cài đặt tài khoản thì cần cố định ngôn ngữ ở đó, ví dụ ghi từ URL `/en/...` hoặc dùng tài khoản test có ngôn ngữ cố định.
130
+
131
+ - **Thêm test id vào app.** Đây là cách bền nhất khi một test phải chạy trên nhiều ngôn ngữ, vì test id luôn được ưu tiên. App dùng thuộc tính khác thì khai báo khi ghi, ví dụ `--test-id-attribute data-qa`.
132
+
133
+ - **Dùng `id` sẵn có làm test id:** `--test-id-attribute id`, hoặc nhập `id` vào ô **Test id attribute** trong giao diện. Phần tử có id thành `getByTestId('email')`, phần tử không có id vẫn dùng role + tên. Chỉ nên dùng khi id do người viết đặt, vì với test id recorder không lọc các id sinh tự động.
134
+
135
+ Thuộc tính test id được lưu trong bản ghi, và code sinh ra luôn dùng đúng thuộc tính đó. Vì vậy hãy chọn thuộc tính **trước khi ghi**. `--test-id-attribute` khi chạy `generate` chỉ có tác dụng với bản ghi không có bước `getByTestId` nào.
136
+
137
+ ## Mật khẩu và dữ liệu nhạy cảm
138
+
139
+ Recorder của Playwright ghi lại mọi thứ bạn gõ, kể cả mật khẩu. testgen không để giá trị đó vào test:
140
+
141
+ - **Phát hiện:** bước gõ chữ vào ô mà locator gọi là mật khẩu, PIN, OTP, mã xác thực, token, API key… (tiếng Anh, tiếng Việt và vài ngôn ngữ khác, ví dụ `getByRole('textbox', { name: 'Mật khẩu' })`, `#txtPwd`, `input[type="password"]`) là bước **secret**.
142
+ - **Code sinh ra đọc biến môi trường** đặt tên theo ô đó: `TEST_PASSWORD`, `TEST_MAT_KHAU`, `TEST_OLD_PASSWORD`… Gõ cùng một giá trị hai lần (mật khẩu và xác nhận mật khẩu) thì dùng chung một biến. Assert kiểm tra đúng giá trị đó cũng đọc biến này.
143
+
144
+ ```java
145
+ page.getByRole("textbox", "Password").fill(TestgenSupport.secret("TEST_PASSWORD")); // Java
146
+ ```
147
+ ```ts
148
+ await page.getByRole('textbox', { name: 'Password' }).fill(secret('TEST_PASSWORD')); // TypeScript, JavaScript
149
+ ```
150
+ ```go
151
+ testPassword, ok := os.LookupEnv("TEST_PASSWORD") // Go
152
+ require.True(t, ok, "set the environment variable TEST_PASSWORD, ...")
153
+ ```
154
+
155
+ Thiếu biến thì test dừng ngay ở đầu với thông báo nêu tên biến. Java đọc cả system property, nên `mvn test -DTEST_PASSWORD=...` cũng được.
156
+ - **Không lưu giá trị:** bản ghi do `testgen record` lưu và file của **Download recording** thay giá trị bằng `""` và ghi tên biến vào bước (`"secret": "TEST_PASSWORD"`). Sinh lại từ file này cho đúng code cũ. Replay một bản ghi như vậy thì giao diện hỏi giá trị và chỉ giữ trong bộ nhớ của `testgen ui`. `testgen generate` nhắc khi file bản ghi cũ vẫn còn giá trị.
157
+ - **Secret nằm trong chữ khác** (ví dụ assert trên phần tử hiện đúng mật khẩu, hay chữ mong đợi `Saved for hunter2!`): code dùng `secret(...)` ở đúng chỗ đó, ví dụ `page.getByText(secret('TEST_PASSWORD'))`, còn bản ghi lưu `${TEST_PASSWORD}` thay cho giá trị.
158
+ - **Log:** `--log-steps`, thông báo lỗi của Selenium và của Replay hiện giá trị secret là `***`.
159
+ - **Sửa khi phát hiện sai:** trong giao diện, **Edit** một bước gõ chữ rồi bật/tắt ô **Secret** hoặc đổi tên biến. Bước đã là secret vẫn là secret khi bạn sửa locator của nó. Với CLI, thêm `"secret": false` (không phải secret) hoặc `"secret": "TEN_BIEN"` vào dòng của bước đó trong file `.jsonl`.
160
+
82
161
  ## Chạy test đã sinh
83
162
 
84
163
  Trong `e2e/projects/` có sẵn project mẫu cho từng target. Đó là đúng các project dùng để kiểm thử testgen, nên copy về dùng ngay được.
@@ -100,6 +179,8 @@ go test ./... # HEADED=1 go test ./... để xem trình duyệt
100
179
 
101
180
  **selenium-java**: cần `selenium-java` và `junit-jupiter` (xem `e2e/projects/selenium-java/pom.xml`). `LoginTest.java` và `TestgenSupport.java` phải nằm cùng package. Chạy bằng `mvn test`.
102
181
 
182
+ **selenium-java-testng**: giống `selenium-java` nhưng chạy bằng TestNG (`@BeforeMethod`, `@AfterMethod`, `@Test`), cần `selenium-java` và `testng` (xem `e2e/projects/selenium-java-testng/pom.xml`). `TestgenSupport.java` giống hệt bản JUnit. Assert sai ném `AssertionError`, nên TestNG báo test đó là Fail. Chạy bằng `mvn test`.
183
+
103
184
  **selenium-js**: cần `selenium-webdriver` và `mocha` (xem `e2e/projects/selenium-js`). `testgen-support.js` phải nằm cạnh file test. Chạy bằng `npx mocha`.
104
185
 
105
186
  Biến môi trường mà test Selenium hiểu:
@@ -108,6 +189,8 @@ Biến môi trường mà test Selenium hiểu:
108
189
  - `SELENIUM_REMOTE_URL=http://grid:4444`: chạy trên Selenium Grid.
109
190
  - `TESTGEN_TIMEOUT=15000`: thời gian tự chờ, đơn vị ms, mặc định 10000.
110
191
 
192
+ Test của mọi target còn cần các biến secret của nó, ví dụ `TEST_PASSWORD=... npx playwright test` (xem mục **Mật khẩu và dữ liệu nhạy cảm**).
193
+
111
194
  ### Code Selenium trông như thế nào
112
195
 
113
196
  Selenium không có `getByRole` hay `getByText`, cũng không tự chờ element. Vì vậy test Selenium đi kèm một **file hỗ trợ** (`TestgenSupport.java` / `testgen-support.js`) để code sinh ra vẫn gọn và chạy ổn định:
@@ -126,6 +209,64 @@ Bản JavaScript dùng **đúng API của Playwright** (`page.getByRole('button'
126
209
 
127
210
  Bên dưới vẫn là Selenium WebDriver: các hành động dùng `WebElement.click()`, `sendKeys()`, `Select`, `Actions`. Biến `driver` vẫn có sẵn trong test nếu bạn cần viết thêm code Selenium thuần.
128
211
 
212
+ ### Log từng bước và test data-driven
213
+
214
+ Hai tùy chọn này tương ứng với ô **Log steps** và **Data-driven (CSV)** trong giao diện.
215
+
216
+ **`--log-steps`** (`selenium-java`, `selenium-java-testng`, `selenium-js`): test gọi `page.setLogger(...)`. Mỗi hành động được ghi kèm giá trị thật (secret hiện `***`), mỗi assert ghi `passed` hoặc `FAILED`. Bản TestNG ghi qua `Reporter.log` (vào report của TestNG và ra console), bản JUnit và bản JavaScript in ra console. Hai target Playwright không cần tùy chọn này: report và trace của Playwright đã có từng bước.
217
+
218
+ ```
219
+ 02:51:07.955 page.getByRole("textbox", "Email").fill("dev@example.com")
220
+ 02:51:08.121 page.getByTestId("welcome").shouldContainText("Welcome, dev@example.com (vn)") passed
221
+ 02:51:28.390 page.getByTestId("welcome").shouldContainText("Welcome, wrong (vn)") FAILED, received: Welcome, dev@example.com (vn)
222
+ ```
223
+
224
+ **`--data-driven`** (mọi target): giá trị đã gõ (`fill`), đã chọn (`selectOption`) và đã assert (`shouldHaveText`, `shouldContainText`, `shouldHaveValue`) trở thành cột của một file CSV. Test chạy một lần cho mỗi dòng, và mỗi dòng có kết quả Pass/Fail riêng. Giá trị secret không thành cột: chúng vẫn đọc từ biến môi trường.
225
+
226
+ ```java
227
+ @DataProvider(name = "loginData")
228
+ public static Object[][] loginData() {
229
+ return TestgenSupport.readCsv(LoginTest.class, "LoginTest.csv", "email", "password", "expectedWelcome");
230
+ }
231
+
232
+ @Test(dataProvider = "loginData")
233
+ public void login(String email, String password, String expectedWelcome) {
234
+ page.getByRole("textbox", "Email").fill(email);
235
+ page.getByRole("textbox", "Password").fill(password);
236
+ page.getByRole("button", "Sign in").click();
237
+ page.getByTestId("welcome").shouldContainText(expectedWelcome);
238
+ }
239
+ ```
240
+
241
+ Bản TypeScript, JavaScript và Go đọc từng dòng thành `row`:
242
+
243
+ ```ts
244
+ const rows = readCsv(path.join(__dirname, 'login.csv'), ['email', 'expectedWelcome']);
245
+
246
+ for (const [index, row] of rows.entries()) {
247
+ test(`login (row ${index + 1})`, async ({ page }) => {
248
+ await page.getByRole('textbox', { name: 'Email' }).fill(row.email);
249
+ await page.getByRole('textbox', { name: 'Password' }).fill(secret('TEST_PASSWORD'));
250
+ await page.getByRole('button', { name: 'Sign in' }).click();
251
+ await expect(page.getByTestId('welcome')).toContainText(row.expectedWelcome);
252
+ });
253
+ }
254
+ ```
255
+
256
+ | Target | File CSV | Mỗi dòng chạy thành |
257
+ | --- | --- | --- |
258
+ | `selenium-java`, `selenium-java-testng` | `<Tên>Test.csv`, resource cùng package với class test. Khi file test nằm trong `src/test/java/<package>/`, testgen ghi CSV vào `src/test/resources/<package>/`. | một lần chạy của `@ParameterizedTest` (JUnit) hoặc `@Test(dataProvider = ...)` (TestNG) |
259
+ | `playwright-ts` | `<tên>.csv` cạnh file `.spec.ts`, kèm file hỗ trợ `testgen-data.ts` | một test `<tên> (row N)` |
260
+ | `selenium-js` | `<tên>.csv` cạnh file `.spec.js` | một `it` `<tên> (row N)` |
261
+ | `playwright-go` | `testdata/<tên>.csv`, kèm file hỗ trợ `testgen_data_test.go` | một subtest `row_N` (`go test -run 'TestLogin/row_2'`) |
262
+
263
+ - **Dòng trong CSV:** dòng đầu là tên cột, dòng thứ hai là giá trị lúc ghi. Thêm dòng để thêm bộ dữ liệu.
264
+ - **Không ghi đè:** testgen không bao giờ ghi đè file CSV đã có (báo `kept`). Muốn sinh lại từ đầu thì xóa file đó.
265
+ - **Tên cột** lấy theo ô nhập hoặc phần tử được assert: `Email` → `email`, placeholder `Tìm kiếm sản phẩm...` → `timKiemSanPham`, assert trên `getByTestId("welcome")` → `expectedWelcome`. Gõ cùng một giá trị nhiều lần thì dùng chung một cột.
266
+ - **Locator đi theo dữ liệu** khi chữ trong locator bằng, hoặc chứa nguyên từ, một giá trị đã gõ trước đó. Ví dụ tìm "Những Kẻ Mê Sách" rồi bấm link "Những Kẻ Mê Sách": link dùng chung cột với từ khóa. Thêm việc "Buy milk" rồi bấm nút xóa của mục "Buy milk Delete": locator thành `filterHasText(whatNeedsToBeDone + " Delete")`. Giá trị ngắn hơn 3 ký tự chỉ được thay khi khớp nguyên văn, vì "1" có thể là một phần của bất kỳ số nào. Các chữ khác giữ nguyên như lúc ghi.
267
+ - **Assert là cột riêng:** chữ mong đợi như `Welcome, qa@example.com (jp)` là một cột riêng, kể cả khi nó chứa giá trị đã gõ. Mỗi dòng phải điền đúng chữ mong đợi của dòng đó.
268
+ - **Mở bằng Excel:** file sinh ra có BOM để Excel hiện đúng tiếng Việt. Mọi target đọc được cả dấu phẩy lẫn dấu chấm phẩy (Excel ở một số ngôn ngữ lưu bằng `;`), bỏ qua dòng trống và các cột thừa, ví dụ một cột `note` để mô tả từng dòng.
269
+
129
270
  ## Cách hoạt động
130
271
 
131
272
  ```
@@ -136,16 +277,18 @@ Bên dưới vẫn là Selenium WebDriver: các hành động dùng `WebElement.
136
277
  │ selector → chuỗi locator: dùng lại mã của Playwright (src/vendor/playwright)
137
278
  ▼
138
279
  Generators (src/generators/*)
139
- ├─ playwright-ts giống `playwright codegen --target playwright-test`
140
- ├─ playwright-go API playwright-go + require.NoError
141
- ├─ selenium-java JUnit 5 + TestgenSupport.java
142
- └─ selenium-js Mocha + testgen-support.js
143
- └─ runtime/selenium/engine.js: chạy trong trang (executeScript)
144
- để tìm element theo role / text / label / test id… như Playwright
280
+ ├─ playwright-ts giống `playwright codegen --target playwright-test`
281
+ ├─ playwright-go API playwright-go + require.NoError
282
+ ├─ selenium-java JUnit 5 + TestgenSupport.java
283
+ ├─ selenium-java-testng TestNG + TestgenSupport.java
284
+ └─ selenium-js Mocha + testgen-support.js
285
+ └─ runtime/selenium/engine.js: chạy trong trang (executeScript)
286
+ để tìm element theo role / text / label / test id… như Playwright
145
287
  ```
146
288
 
147
289
  - **Recorder:** dùng nguyên recorder của Playwright, nên cách chọn locator (ưu tiên test id, role + tên, label…) giống hệt Playwright codegen.
148
290
  - **Engine locator cho Selenium:** được kiểm tra bằng cách so kết quả với chính Playwright trên 70 locator mẫu (`test/engine.test.ts`).
291
+ - **Giữa bản ghi và generators:** `src/secrets.ts` tìm các bước secret, `src/generators/test-data.ts` quyết định mỗi giá trị là chữ, cột CSV hay biến môi trường, `src/checks.ts` tạo cảnh báo. Giao diện sửa bản ghi bằng `src/editing.ts` (locator gõ tay được đổi lại thành selector bằng mã của Playwright) và chạy lại bằng `src/replay.ts` (`playwright-core`).
149
292
 
150
293
  ## Giới hạn hiện tại
151
294
 
@@ -158,7 +301,11 @@ Bên dưới vẫn là Selenium WebDriver: các hành động dùng `WebElement.
158
301
  - `colorScheme` / `timezone` / `geolocation`;
159
302
  - lưu storage state.
160
303
  - **`getByRole` trên Selenium chỉ gần đúng**, vì thuật toán tính accessible name được đơn giản hóa. Khi gặp sai lệch, hãy thêm `data-testid` cho app, hoặc thêm trường hợp đó vào `test/engine.test.ts`.
161
- - **Bản ghi không lưu đường dẫn đầy đủ của file upload**, chỉ có tên file. Cần sửa đường dẫn trong `setInputFiles(...)`.
304
+ - **Bản ghi không lưu đường dẫn đầy đủ của file upload**, chỉ có tên file. Sửa bước đó trong giao diện (ô **Files**, mỗi dòng một đường dẫn) hoặc sửa `setInputFiles(...)` trong test. Replay bỏ qua bước upload khi không tìm thấy file.
305
+ - **Replay chạy bản ghi trên Playwright**, không chạy code đã sinh. Replay qua không bảo đảm test Selenium qua, vì `getByRole` trên Selenium chỉ gần đúng (xem trên). Muốn chắc thì chạy chính test đã sinh, như `npm run e2e` làm với app demo.
306
+ - **Phát hiện secret dựa vào locator**, không dựa vào `type="password"` của ô. Ô không có nhãn hay id gợi ý (ví dụ `#field3`) sẽ không được nhận ra: xem mục **Checks** và bật ô **Secret** trong giao diện. Ngược lại, ô có chữ "password" trong tên mà không phải mật khẩu thì tắt đi.
307
+ - **Report và trace của Playwright vẫn hiện giá trị gõ vào ô**, kể cả giá trị đọc từ biến môi trường. Đừng chia sẻ chúng ra ngoài nếu test dùng mật khẩu thật.
308
+ - **`--log-steps` chỉ dành cho Selenium.** Hai target Playwright bỏ qua tùy chọn này, CLI và giao diện có ghi chú khi bỏ qua.
162
309
 
163
310
  ## Phát triển
164
311
 
@@ -167,7 +314,7 @@ Package trên npm chỉ chứa bản build. Để sửa code thì làm việc tr
167
314
  ```bash
168
315
  npm ci # tự build (script prepare); gọi tool bằng `node dist/cli.js`
169
316
  npm test # vitest: so engine với Playwright, snapshot code sinh ra, test giao diện (cần Chrome)
170
- npm run e2e # sinh test cho cả 4 target rồi chạy trên app demo e2e/app (cần Go, JDK 17+ và Maven, Chrome)
317
+ npm run e2e # sinh test cho mọi target rồi chạy trên app demo e2e/app (cần Go, JDK 17+ và Maven, Chrome)
171
318
  E2E_TARGETS="selenium-js playwright-go" npm run e2e
172
319
  ```
173
320
 
@@ -185,8 +332,36 @@ runtime/ file hỗ trợ Selenium (Java, JS) + engine locator chạy tron
185
332
  ui/ giao diện web (HTML/CSS/JS thuần, không cần build)
186
333
  test/ unit test, fixture, snapshot
187
334
  e2e/ app demo, bản ghi thật, project mẫu cho từng target, run.sh
335
+ scripts/ script phát hành lên npm (release.mjs)
336
+ ```
337
+
338
+ ### Phát hành lên npm
339
+
340
+ GitLab CI ([.gitlab-ci.yml](.gitlab-ci.yml)) chạy test cho mọi lần push. Phát hành một version mới:
341
+
342
+ 1. Ghi thay đổi vào mục `## Chưa phát hành` ở đầu `CHANGELOG.md` rồi commit.
343
+ 2. Chạy `npm run release -- patch` (hoặc `minor`, `major`, `1.2.3`). Script kiểm tra, chạy test, tăng version trong `package.json` và `package-lock.json`, đổi `## Chưa phát hành` thành `## <version> (<ngày>)`, commit `chore: release <version>`, rồi push `main` cùng tag `v<version>`.
344
+ 3. Pipeline của tag chạy test, rồi đưa version đó vào khu chờ duyệt của npm (`npm stage publish`).
345
+ 4. Duyệt bằng 2FA để version lên npm: `npm stage list @xylentis/testgen` rồi `npm stage approve <stage-id>`, hoặc bấm **Approve** ở tab **Staged Packages** trên trang package ở npmjs.com.
346
+
347
+ ```bash
348
+ npm run release -- patch # hoặc minor, major, 1.2.3
349
+ npm run release # phát hành version đang có trong package.json (CHANGELOG.md phải có mục `## <version>`)
350
+ npm run release -- --dry-run # chạy thử: kiểm tra, test và `npm pack --dry-run`, không thay đổi gì
188
351
  ```
189
352
 
353
+ Trước khi tag, script kiểm tra: đang ở nhánh `main`, không có thay đổi chưa commit, không chậm hơn `origin/main`, version chưa có trên npm và mới hơn version `latest`, chưa có tag `v<version>`. `npm run e2e` cần Go, JDK và Maven nên không chạy trong script hay CI; hãy tự chạy khi đổi generator hay runtime Selenium.
354
+
355
+ **Vì sao vẫn phải duyệt tay:** npm chưa hỗ trợ trusted publishing (OIDC) cho GitLab tự host. Token bỏ qua 2FA cũng sẽ mất quyền publish thẳng từ tháng 1/2027. CI vì vậy chỉ dùng token "stage only": lộ token thì cũng không publish được.
356
+
357
+ **Duyệt theo thứ tự version.** Version được duyệt sẽ thành `latest`, kể cả khi đã có version mới hơn. Bản stage không dùng nữa thì xóa bằng `npm stage reject <stage-id>`.
358
+
359
+ **Cài đặt một lần:**
360
+
361
+ 1. Trên npmjs.com: **Access Tokens → Generate New Token**, quyền **Read and write (stage only)**, chỉ cho package `@xylentis/testgen`. Token có quyền ghi hết hạn sau tối đa 90 ngày, nên cần tạo lại định kỳ.
362
+ 2. Lưu token vào biến CI/CD `NPM_TOKEN` của project, bật **Masked** và **Protected**: trong GitLab (**Settings → CI/CD → Variables**), hoặc copy token rồi chạy `pbpaste | glab variable set NPM_TOKEN --masked --protected`.
363
+ 3. Bảo vệ tag `v*` (**Settings → Repository → Protected tags**) để pipeline của tag nhận được biến protected: `glab api -X POST projects/:id/protected_tags -f name='v*' -f create_access_level=40`.
364
+
190
365
  ## License
191
366
 
192
367
  [GNU AGPL-3.0-only](LICENSE), Copyright (C) 2026 Xylentis.
package/dist/checks.js ADDED
@@ -0,0 +1,100 @@
1
+ import { parseLocator } from './locator.js';
2
+ import { collectSecrets } from './secrets.js';
3
+ // Actions whose effect an assertion should verify.
4
+ const changes = new Set(['click', 'fill', 'press', 'select', 'check', 'uncheck', 'setInputFiles']);
5
+ export function checkRecording(recording) {
6
+ const { actions } = recording;
7
+ const checks = [];
8
+ const secrets = collectSecrets(recording);
9
+ const lastAssertion = actions.findLastIndex(({ action }) => action.name.startsWith('assert'));
10
+ if (lastAssertion === -1 && actions.some(({ action }) => changes.has(action.name)))
11
+ checks.push({ level: 'warning', message: 'No assertion: the test shows that the steps run, not that they work. Add checks with the recorder toolbar (Assert visibility, text, value).' });
12
+ let locators = 0;
13
+ let textLocators = 0;
14
+ let testIds = 0;
15
+ actions.forEach(({ action }, index) => {
16
+ const step = index + 1;
17
+ if ('selector' in action) {
18
+ const steps = parseLocator(action.selector);
19
+ const position = positionOf(steps);
20
+ if (position)
21
+ checks.push({ level: 'warning', step: index, message: `Step ${step} finds its element by position (${position}): it breaks when elements are added or reordered. A test id, or a role and name, is more stable.` });
22
+ const structure = structureOf(steps);
23
+ if (structure)
24
+ checks.push({ level: 'warning', step: index, message: `Step ${step} depends on the page structure (${structure}): it breaks when the layout changes.` });
25
+ locators++;
26
+ if (dependsOnText(steps))
27
+ textLocators++;
28
+ if (allSteps(steps).some(s => s.kind === 'test-id'))
29
+ testIds++;
30
+ }
31
+ if (action.name === 'setInputFiles')
32
+ checks.push({ level: 'warning', step: index, message: `Step ${step} uploads ${action.files.join(', ')}: recordings keep only file names. Set the full paths by editing the step.` });
33
+ const secret = secrets.of(index);
34
+ if (secret && action.name === 'fill')
35
+ checks.push({ level: 'info', step: index, message: `Step ${step} types into a password field: the test reads the value from the environment variable ${secret.name}.` });
36
+ });
37
+ if (lastAssertion !== -1) {
38
+ const unchecked = actions.findIndex(({ action }, index) => index > lastAssertion && changes.has(action.name));
39
+ if (unchecked !== -1)
40
+ checks.push({ level: 'info', step: unchecked, message: `No assertion follows step ${unchecked + 1}: add one to verify what the last steps do.` });
41
+ }
42
+ if (textLocators && !testIds)
43
+ checks.push({ level: 'info', message: `${textLocators} of ${locators} locators use visible text (role names, labels, text): they break when the wording or the language of the page changes. Test ids (data-testid) are more stable.` });
44
+ return checks;
45
+ }
46
+ // What a target cannot do: the TODO(testgen) comments of its generated test.
47
+ export function checkFiles(files) {
48
+ const counts = new Map();
49
+ for (const file of files.filter(f => !f.support && !f.data)) {
50
+ for (const [, message] of file.content.matchAll(/TODO\(testgen\): (.*)/g))
51
+ counts.set(message, (counts.get(message) ?? 0) + 1);
52
+ }
53
+ return [...counts].map(([message, count]) => ({ level: 'warning', message: `TODO(testgen) in the code: ${message}${count > 1 ? ` (${count} times)` : ''}` }));
54
+ }
55
+ function allSteps(steps) {
56
+ return steps.flatMap(step => [step, ...allSteps(step.inner ?? [])]);
57
+ }
58
+ function positionOf(steps) {
59
+ const step = allSteps(steps).find(s => s.kind === 'nth' || s.kind === 'first' || s.kind === 'last');
60
+ return step && (step.kind === 'nth' ? `nth(${step.body})` : `${step.kind}()`);
61
+ }
62
+ // An XPath or a CSS selector with combinators or :nth-child(), as in "#list > li:nth-child(2)".
63
+ function structureOf(steps) {
64
+ for (const step of allSteps(steps)) {
65
+ if (step.kind !== 'default')
66
+ continue;
67
+ const css = String(step.body);
68
+ if (/^(xpath=|\/\/|\.\.)/.test(css))
69
+ return css;
70
+ // Combinators outside of attribute values and quotes.
71
+ const outside = css.replace(/"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|\[[^\]]*\]|\([^)]*\)/g, '');
72
+ if (/[>+~\s]|:nth-/.test(outside.trim()) || /:nth-/.test(css))
73
+ return css;
74
+ }
75
+ return undefined;
76
+ }
77
+ // Whether the element is found by text a page shows: a role and name, a label, a text...
78
+ function dependsOnText(steps) {
79
+ for (const step of [...steps].reverse()) {
80
+ switch (step.kind) {
81
+ case 'role':
82
+ return step.name !== undefined;
83
+ case 'text':
84
+ case 'label':
85
+ case 'placeholder':
86
+ case 'alt':
87
+ case 'title':
88
+ return true;
89
+ case 'test-id':
90
+ case 'default':
91
+ return false;
92
+ case 'and':
93
+ case 'or':
94
+ case 'chain':
95
+ return dependsOnText(step.inner);
96
+ }
97
+ }
98
+ return false;
99
+ }
100
+ //# sourceMappingURL=checks.js.map