@xylentis/testgen 1.1.1 → 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 +25 -2
- package/README.md +159 -12
- package/dist/checks.js +100 -0
- package/dist/cli.js +84 -18
- package/dist/editing.js +167 -0
- package/dist/generate.js +23 -6
- package/dist/generators/index.js +2 -2
- package/dist/generators/javascript-body.js +91 -13
- package/dist/generators/playwright-go.js +85 -21
- package/dist/generators/playwright-ts.js +44 -7
- package/dist/generators/selenium-java.js +201 -109
- package/dist/generators/selenium-js.js +39 -10
- package/dist/generators/test-data.js +171 -0
- package/dist/recording.js +43 -2
- package/dist/replay.js +268 -0
- package/dist/secrets.js +169 -0
- package/dist/text.js +28 -0
- package/dist/ui/server.js +220 -21
- package/dist/vendor/playwright/locatorParser.js +235 -0
- package/dist/vendor/playwright/locatorUtils.js +67 -0
- package/docs/testgen-ui.png +0 -0
- package/package.json +5 -3
- package/runtime/playwright-go/testgen_data_test.go +64 -0
- package/runtime/playwright-ts/testgen-data.ts +70 -0
- package/runtime/selenium-java/TestgenSupport.java +213 -29
- package/runtime/selenium-js/testgen-support.js +162 -22
- package/ui/app.js +386 -25
- package/ui/index.html +19 -3
- package/ui/style.css +80 -9
package/CHANGELOG.md
CHANGED
|
@@ -1,11 +1,34 @@
|
|
|
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.
|
|
6
31
|
- Thêm `testgen record --inspector` để hiện lại Inspector khi cần.
|
|
7
|
-
- 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.
|
|
8
|
-
- README: thêm mục về cách recorder chọn locator và cách xử lý web nhiều ngôn ngữ (i18n).
|
|
9
32
|
|
|
10
33
|
## 1.0.0 (2026-10-05)
|
|
11
34
|
|
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
|

|
|
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,17 +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
|
|
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.
|
|
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
54
|
- **Đổi Test name / Package:** code sinh lại ngay.
|
|
50
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.
|
|
51
|
-
- **
|
|
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**.
|
|
52
58
|
- **Open recording…:** mở lại một file đã lưu.
|
|
53
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
|
+
|
|
54
71
|
## Dùng dòng lệnh
|
|
55
72
|
|
|
56
73
|
```bash
|
|
@@ -65,6 +82,14 @@ testgen generate generated/recording.jsonl -t selenium-java -o src/test/java/com
|
|
|
65
82
|
testgen generate generated/recording.jsonl -t playwright-go -o e2e/login_test.go
|
|
66
83
|
testgen generate generated/recording.jsonl -d out # tất cả target, mỗi target một thư mục
|
|
67
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
|
+
|
|
68
93
|
testgen targets # liệt kê target
|
|
69
94
|
```
|
|
70
95
|
|
|
@@ -76,7 +101,9 @@ testgen targets # liệt kê target
|
|
|
76
101
|
| `-n, --name` | Tên test. Mặc định lấy từ tên file `--output`, nếu không có thì là `recorded`. |
|
|
77
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. |
|
|
78
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. |
|
|
79
|
-
| `--
|
|
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õ. |
|
|
80
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`. |
|
|
81
108
|
| `--inspector` | (`record`) Hiện thêm cửa sổ Playwright Inspector (mặc định ẩn). |
|
|
82
109
|
|
|
@@ -107,6 +134,30 @@ Vì vậy, nếu app không có test id, phần lớn locator dựa vào chữ t
|
|
|
107
134
|
|
|
108
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.
|
|
109
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
|
+
|
|
110
161
|
## Chạy test đã sinh
|
|
111
162
|
|
|
112
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.
|
|
@@ -128,6 +179,8 @@ go test ./... # HEADED=1 go test ./... để xem trình duyệt
|
|
|
128
179
|
|
|
129
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`.
|
|
130
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
|
+
|
|
131
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`.
|
|
132
185
|
|
|
133
186
|
Biến môi trường mà test Selenium hiểu:
|
|
@@ -136,6 +189,8 @@ Biến môi trường mà test Selenium hiểu:
|
|
|
136
189
|
- `SELENIUM_REMOTE_URL=http://grid:4444`: chạy trên Selenium Grid.
|
|
137
190
|
- `TESTGEN_TIMEOUT=15000`: thời gian tự chờ, đơn vị ms, mặc định 10000.
|
|
138
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
|
+
|
|
139
194
|
### Code Selenium trông như thế nào
|
|
140
195
|
|
|
141
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:
|
|
@@ -154,6 +209,64 @@ Bản JavaScript dùng **đúng API của Playwright** (`page.getByRole('button'
|
|
|
154
209
|
|
|
155
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.
|
|
156
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
|
+
|
|
157
270
|
## Cách hoạt động
|
|
158
271
|
|
|
159
272
|
```
|
|
@@ -164,16 +277,18 @@ Bên dưới vẫn là Selenium WebDriver: các hành động dùng `WebElement.
|
|
|
164
277
|
│ selector → chuỗi locator: dùng lại mã của Playwright (src/vendor/playwright)
|
|
165
278
|
▼
|
|
166
279
|
Generators (src/generators/*)
|
|
167
|
-
├─ playwright-ts
|
|
168
|
-
├─ playwright-go
|
|
169
|
-
├─ selenium-java
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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
|
|
173
287
|
```
|
|
174
288
|
|
|
175
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.
|
|
176
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`).
|
|
177
292
|
|
|
178
293
|
## Giới hạn hiện tại
|
|
179
294
|
|
|
@@ -186,7 +301,11 @@ Bên dưới vẫn là Selenium WebDriver: các hành động dùng `WebElement.
|
|
|
186
301
|
- `colorScheme` / `timezone` / `geolocation`;
|
|
187
302
|
- lưu storage state.
|
|
188
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`.
|
|
189
|
-
- **Bản ghi không lưu đường dẫn đầy đủ của file upload**, chỉ có tên file.
|
|
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.
|
|
190
309
|
|
|
191
310
|
## Phát triển
|
|
192
311
|
|
|
@@ -195,7 +314,7 @@ Package trên npm chỉ chứa bản build. Để sửa code thì làm việc tr
|
|
|
195
314
|
```bash
|
|
196
315
|
npm ci # tự build (script prepare); gọi tool bằng `node dist/cli.js`
|
|
197
316
|
npm test # vitest: so engine với Playwright, snapshot code sinh ra, test giao diện (cần Chrome)
|
|
198
|
-
npm run e2e # sinh test cho
|
|
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)
|
|
199
318
|
E2E_TARGETS="selenium-js playwright-go" npm run e2e
|
|
200
319
|
```
|
|
201
320
|
|
|
@@ -213,8 +332,36 @@ runtime/ file hỗ trợ Selenium (Java, JS) + engine locator chạy tron
|
|
|
213
332
|
ui/ giao diện web (HTML/CSS/JS thuần, không cần build)
|
|
214
333
|
test/ unit test, fixture, snapshot
|
|
215
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)
|
|
216
336
|
```
|
|
217
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ì
|
|
351
|
+
```
|
|
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
|
+
|
|
218
365
|
## License
|
|
219
366
|
|
|
220
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
|
package/dist/cli.js
CHANGED
|
@@ -1,18 +1,22 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { spawn } from 'node:child_process';
|
|
3
3
|
import fs from 'node:fs';
|
|
4
|
+
import os from 'node:os';
|
|
4
5
|
import path from 'node:path';
|
|
5
6
|
import { parseArgs } from 'node:util';
|
|
7
|
+
import { checkFiles, checkRecording } from './checks.js';
|
|
6
8
|
import { generateTargets, writeTargets } from './generate.js';
|
|
7
|
-
import { generators } from './generators/index.js';
|
|
9
|
+
import { findGenerator, generators } from './generators/index.js';
|
|
8
10
|
import { startCodegen, waitForExit } from './record.js';
|
|
9
|
-
import { parseRecording, recordedTestIdAttribute } from './recording.js';
|
|
11
|
+
import { parseRecording, recordedTestIdAttribute, serializeRecording } from './recording.js';
|
|
12
|
+
import { collectSecrets, redactRecording } from './secrets.js';
|
|
10
13
|
import { startUiServer } from './ui/server.js';
|
|
11
14
|
const usage = `testgen: record a UI test once with the Playwright recorder, then generate it for several
|
|
12
15
|
frameworks and languages.
|
|
13
16
|
|
|
14
17
|
Usage:
|
|
15
|
-
testgen ui [--port <port>] [--no-open] Web UI: record and watch the code
|
|
18
|
+
testgen ui [--port <port>] [--no-open] Web UI: record, edit and replay steps, watch the code
|
|
19
|
+
of every target live
|
|
16
20
|
testgen record [url] [options] Record in a browser, generate when it is closed
|
|
17
21
|
testgen generate <recording.jsonl> [options] Generate tests from a saved recording
|
|
18
22
|
testgen targets List the available targets
|
|
@@ -26,6 +30,10 @@ Output options:
|
|
|
26
30
|
--package <name> Java or Go package (default: derived from the output path)
|
|
27
31
|
--test-id-attribute <a> Attribute for getByTestId when recording (default: data-testid);
|
|
28
32
|
generated code keeps the one the recording was made with
|
|
33
|
+
--log-steps Selenium: log every step and assertion result (console, TestNG report)
|
|
34
|
+
--data-driven Typed, selected and asserted values become columns of a CSV file next
|
|
35
|
+
to the test (Java: src/test/resources/<package>/, Go: testdata/); the
|
|
36
|
+
test runs once per row. An existing CSV file is never overwritten
|
|
29
37
|
--save <file.jsonl> record: where to keep the recording (default: <out-dir>/recording.jsonl)
|
|
30
38
|
|
|
31
39
|
Recorder options (record), same as playwright codegen:
|
|
@@ -41,6 +49,8 @@ const options = {
|
|
|
41
49
|
'name': { type: 'string', short: 'n' },
|
|
42
50
|
'package': { type: 'string' },
|
|
43
51
|
'test-id-attribute': { type: 'string' },
|
|
52
|
+
'log-steps': { type: 'boolean' },
|
|
53
|
+
'data-driven': { type: 'boolean' },
|
|
44
54
|
'save': { type: 'string' },
|
|
45
55
|
'browser': { type: 'string', short: 'b' },
|
|
46
56
|
'channel': { type: 'string' },
|
|
@@ -74,12 +84,27 @@ async function main(argv) {
|
|
|
74
84
|
name: values.name,
|
|
75
85
|
packageName: values.package,
|
|
76
86
|
testIdAttribute,
|
|
87
|
+
logSteps: values['log-steps'],
|
|
88
|
+
dataDriven: values['data-driven'],
|
|
77
89
|
};
|
|
90
|
+
if (command === 'generate' || command === 'record') {
|
|
91
|
+
const requested = [
|
|
92
|
+
['logSteps', '--log-steps', values['log-steps']],
|
|
93
|
+
['dataDriven', '--data-driven', values['data-driven']],
|
|
94
|
+
];
|
|
95
|
+
for (const [feature, flag, on] of requested) {
|
|
96
|
+
const ignoredBy = on ? targets.filter(id => !findGenerator(id).features?.includes(feature)) : [];
|
|
97
|
+
if (ignoredBy.length)
|
|
98
|
+
console.error(`note: ${flag} only applies to ${generators.filter(g => g.features?.includes(feature)).map(g => g.id).join(', ')}; ignored for ${ignoredBy.join(', ')}.`);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
78
101
|
switch (command) {
|
|
79
|
-
case 'targets':
|
|
102
|
+
case 'targets': {
|
|
103
|
+
const width = Math.max(...generators.map(g => g.id.length)) + 2;
|
|
80
104
|
for (const generator of generators)
|
|
81
|
-
console.log(`${generator.id.padEnd(
|
|
105
|
+
console.log(`${generator.id.padEnd(width)}${generator.framework} (${generator.language})`);
|
|
82
106
|
return;
|
|
107
|
+
}
|
|
83
108
|
case 'generate': {
|
|
84
109
|
const file = rest[0];
|
|
85
110
|
if (!file)
|
|
@@ -88,23 +113,33 @@ async function main(argv) {
|
|
|
88
113
|
const recorded = recordedTestIdAttribute(recording);
|
|
89
114
|
if (values['test-id-attribute'] && recorded && recorded !== values['test-id-attribute'])
|
|
90
115
|
console.error(`note: ignoring --test-id-attribute ${values['test-id-attribute']}: the recording's getByTestId() steps use ${recorded}.`);
|
|
116
|
+
const typed = collectSecrets(recording).list.filter(secret => secret.value);
|
|
117
|
+
if (typed.length) {
|
|
118
|
+
console.error(`note: ${file} holds the values typed into password fields; the tests read them from ${typed.map(s => s.name).join(', ')}. `
|
|
119
|
+
+ 'To drop them from the file, open it in testgen ui and download it again.');
|
|
120
|
+
}
|
|
91
121
|
if (!values.output && !values['out-dir']) {
|
|
92
122
|
if (targets.length !== 1)
|
|
93
123
|
throw new Error('Pass --out-dir (or a single --target to print the test to stdout).');
|
|
94
|
-
const
|
|
95
|
-
process.stdout.write(files[0].content);
|
|
96
|
-
for (const
|
|
97
|
-
console.error(`note: the test also needs ${
|
|
124
|
+
const outputs = generateTargets(recording, output);
|
|
125
|
+
process.stdout.write(outputs[0].files[0].content);
|
|
126
|
+
for (const other of outputs[0].files.slice(1))
|
|
127
|
+
console.error(`note: the test also needs ${other.path}; use --out-dir or --output to write it.`);
|
|
128
|
+
reportChecks(recording, outputs);
|
|
98
129
|
return;
|
|
99
130
|
}
|
|
100
|
-
|
|
131
|
+
const outputs = generateTargets(recording, { ...output, outDir: values['out-dir'] });
|
|
132
|
+
report(writeTargets(outputs));
|
|
133
|
+
reportChecks(recording, outputs);
|
|
101
134
|
return;
|
|
102
135
|
}
|
|
103
136
|
case 'record': {
|
|
104
137
|
const outDir = values['out-dir'] ?? (values.output ? undefined : 'generated');
|
|
105
138
|
const save = path.resolve(values.save ?? path.join(outDir ?? path.dirname(values.output), 'recording.jsonl'));
|
|
106
139
|
fs.mkdirSync(path.dirname(save), { recursive: true });
|
|
107
|
-
|
|
140
|
+
// The recorder writes typed passwords too; the saved recording leaves them out.
|
|
141
|
+
const workDir = fs.mkdtempSync(path.join(os.tmpdir(), 'testgen-record-'));
|
|
142
|
+
const recordingFile = path.join(workDir, 'recording.jsonl');
|
|
108
143
|
const codegen = {
|
|
109
144
|
browser: values.browser,
|
|
110
145
|
channel: values.channel,
|
|
@@ -121,16 +156,28 @@ async function main(argv) {
|
|
|
121
156
|
inspector: values.inspector,
|
|
122
157
|
};
|
|
123
158
|
console.log('Recording... interact with the page, then close the browser window (or press Ctrl+C) to generate the tests.');
|
|
124
|
-
const recorder = startCodegen(rest[0],
|
|
159
|
+
const recorder = startCodegen(rest[0], recordingFile, codegen);
|
|
125
160
|
// Ctrl+C ends the recording, not testgen: the tests are still generated afterwards.
|
|
126
161
|
const stopRecorder = () => recorder.kill('SIGINT');
|
|
127
162
|
process.on('SIGINT', stopRecorder);
|
|
128
163
|
const code = await waitForExit(recorder);
|
|
129
164
|
process.off('SIGINT', stopRecorder);
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
165
|
+
let recording;
|
|
166
|
+
try {
|
|
167
|
+
if (!fs.existsSync(recordingFile))
|
|
168
|
+
throw new Error(`Nothing was recorded (the recorder exited with code ${code}).`);
|
|
169
|
+
recording = parseRecording(fs.readFileSync(recordingFile, 'utf8'), save);
|
|
170
|
+
}
|
|
171
|
+
finally {
|
|
172
|
+
fs.rmSync(workDir, { recursive: true, force: true });
|
|
173
|
+
}
|
|
174
|
+
fs.writeFileSync(save, serializeRecording(redactRecording(recording)));
|
|
175
|
+
const secrets = collectSecrets(recording).list;
|
|
176
|
+
console.log(`Recording saved to ${path.relative(process.cwd(), save)}`
|
|
177
|
+
+ (secrets.length ? ` without the values typed into password fields (the tests read ${secrets.map(s => s.name).join(', ')})` : ''));
|
|
178
|
+
const outputs = generateTargets(recording, { ...output, outDir });
|
|
179
|
+
report(writeTargets(outputs));
|
|
180
|
+
reportChecks(recording, outputs);
|
|
134
181
|
return;
|
|
135
182
|
}
|
|
136
183
|
case 'ui': {
|
|
@@ -147,9 +194,28 @@ async function main(argv) {
|
|
|
147
194
|
throw new Error(`Unknown command "${command}".\n\n${usage}`);
|
|
148
195
|
}
|
|
149
196
|
}
|
|
150
|
-
function report(
|
|
151
|
-
for (const file of
|
|
197
|
+
function report({ written, kept }) {
|
|
198
|
+
for (const file of written)
|
|
152
199
|
console.log(` wrote ${path.relative(process.cwd(), file)}`);
|
|
200
|
+
for (const file of kept)
|
|
201
|
+
console.log(` kept ${path.relative(process.cwd(), file)} (test data is never overwritten; delete it to start over)`);
|
|
202
|
+
}
|
|
203
|
+
// Checks of the recording, then what each target cannot do; targets with the same TODO are listed once.
|
|
204
|
+
function reportChecks(recording, outputs) {
|
|
205
|
+
const byMessage = new Map();
|
|
206
|
+
for (const { generator, files } of outputs) {
|
|
207
|
+
for (const check of checkFiles(files)) {
|
|
208
|
+
const entry = byMessage.get(check.message) ?? { level: check.level, targets: [] };
|
|
209
|
+
entry.targets.push(generator.id);
|
|
210
|
+
byMessage.set(check.message, entry);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
const lines = [
|
|
214
|
+
...checkRecording(recording).map(check => `${check.level === 'warning' ? 'warning' : 'note'}: ${check.message}`),
|
|
215
|
+
...[...byMessage].map(([message, { level, targets }]) => `${level === 'warning' ? 'warning' : 'note'}: ${targets.join(', ')}: ${message}`),
|
|
216
|
+
];
|
|
217
|
+
for (const line of lines)
|
|
218
|
+
console.error(line);
|
|
153
219
|
}
|
|
154
220
|
function openInBrowser(url) {
|
|
155
221
|
const command = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'cmd' : 'xdg-open';
|