@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 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
  ![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,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 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
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
- - **Download recording:** lưu file `.jsonl` để lần sau sinh lạ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**.
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
- | `--save` | (`record`) Nơi lưu bản ghi `.jsonl`. |
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 giống `playwright codegen --target playwright-test`
168
- ├─ playwright-go API playwright-go + require.NoError
169
- ├─ selenium-java JUnit 5 + TestgenSupport.java
170
- └─ selenium-js Mocha + testgen-support.js
171
- └─ runtime/selenium/engine.js: chạy trong trang (executeScript)
172
- để 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
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. 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.
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 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)
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 of every target live
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(16)}${generator.framework} (${generator.language})`);
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 [{ files }] = generateTargets(recording, output);
95
- process.stdout.write(files[0].content);
96
- for (const support of files.slice(1))
97
- console.error(`note: the test also needs ${support.path}; use --out-dir or --output to write it.`);
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
- report(writeTargets(generateTargets(recording, { ...output, outDir: values['out-dir'] })));
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
- fs.rmSync(save, { force: true });
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], save, codegen);
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
- if (!fs.existsSync(save))
131
- throw new Error(`Nothing was recorded (the recorder exited with code ${code}).`);
132
- console.log(`Recording saved to ${path.relative(process.cwd(), save)}`);
133
- report(writeTargets(generateTargets(parseRecording(fs.readFileSync(save, 'utf8'), save), { ...output, outDir })));
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(files) {
151
- for (const file of files)
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';