@xylentis/testgen 1.0.0 → 1.1.1
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 +13 -0
- package/README.md +33 -4
- package/dist/cli.js +13 -5
- package/dist/generate.js +3 -1
- package/dist/locator.js +17 -0
- package/dist/record.js +13 -27
- package/dist/recorder-process.js +102 -0
- package/dist/recording.js +17 -0
- package/dist/ui/server.js +2 -2
- package/package.json +4 -3
- package/ui/app.js +6 -0
- package/ui/index.html +2 -1
- package/ui/style.css +1 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 1.1.0 (2026-10-06)
|
|
4
|
+
|
|
5
|
+
- 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
|
+
- 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
|
+
|
|
10
|
+
## 1.0.0 (2026-10-05)
|
|
11
|
+
|
|
12
|
+
- Bản đầu tiên. Ghi thao tác bằng recorder của Playwright, sinh test cho `playwright-ts`, `playwright-go`, `selenium-java`, `selenium-js`.
|
|
13
|
+
- Có giao diện web (`testgen ui`) xem code của mọi target ngay khi đang ghi.
|
package/README.md
CHANGED
|
@@ -39,14 +39,15 @@ testgen ui # mở http://127.0.0.1:9323
|
|
|
39
39
|
1. Nhập URL, chọn trình duyệt rồi bấm **Record**.
|
|
40
40
|
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
41
|
- Thanh công cụ của recorder nằm ở đầu trang, dùng để thêm assert: *Assert visibility*, *Assert text*, *Assert value*.
|
|
42
|
-
- Cửa sổ Playwright Inspector
|
|
42
|
+
- 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
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**.
|
|
44
44
|
4. Đóng trình duyệt hoặc bấm **Stop** khi xong.
|
|
45
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`.
|
|
46
46
|
|
|
47
47
|
Một số thao tác khác:
|
|
48
48
|
|
|
49
|
-
- **Đổi Test name / Package
|
|
49
|
+
- **Đổi Test name / Package:** code sinh lại ngay.
|
|
50
|
+
- **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.
|
|
50
51
|
- **Download recording:** lưu file `.jsonl` để lần sau sinh lại.
|
|
51
52
|
- **Open recording…:** mở lại một file đã lưu.
|
|
52
53
|
|
|
@@ -74,9 +75,37 @@ testgen targets # liệt kê target
|
|
|
74
75
|
| `-d, --out-dir` | Thư mục ra. Nếu có nhiều target thì mỗi target một thư mục con. |
|
|
75
76
|
| `-n, --name` | Tên test. Mặc định lấy từ tên file `--output`, nếu không có thì là `recorded`. |
|
|
76
77
|
| `--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
|
+
| `--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. |
|
|
78
79
|
| `--save` | (`record`) Nơi lưu bản ghi `.jsonl`. |
|
|
79
80
|
| `--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
|
+
| `--inspector` | (`record`) Hiện thêm cửa sổ Playwright Inspector (mặc định ẩn). |
|
|
82
|
+
|
|
83
|
+
## Locator và web nhiều ngôn ngữ (i18n)
|
|
84
|
+
|
|
85
|
+
testgen giữ nguyên locator mà recorder của Playwright chọn. Recorder ưu tiên theo thứ tự:
|
|
86
|
+
|
|
87
|
+
1. test id: `getByTestId('welcome')`, theo thuộc tính `data-testid` hoặc thuộc tính đặt bằng `--test-id-attribute`;
|
|
88
|
+
2. role + tên (accessible name, thường là chữ hiển thị): `getByRole('button', { name: 'Sign in' })`;
|
|
89
|
+
3. placeholder, label, alt, text, title;
|
|
90
|
+
4. `#id`; sau đó mới đến tên thẻ và vị trí.
|
|
91
|
+
|
|
92
|
+
`#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`.
|
|
93
|
+
|
|
94
|
+
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ý:
|
|
95
|
+
|
|
96
|
+
- **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):
|
|
97
|
+
|
|
98
|
+
```bash
|
|
99
|
+
testgen record https://your-app.example --lang en-US
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
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.
|
|
103
|
+
|
|
104
|
+
- **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`.
|
|
105
|
+
|
|
106
|
+
- **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.
|
|
107
|
+
|
|
108
|
+
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.
|
|
80
109
|
|
|
81
110
|
## Chạy test đã sinh
|
|
82
111
|
|
|
@@ -129,7 +158,7 @@ Bên dưới vẫn là Selenium WebDriver: các hành động dùng `WebElement.
|
|
|
129
158
|
|
|
130
159
|
```
|
|
131
160
|
Bạn thao tác trên trình duyệt
|
|
132
|
-
│ playwright codegen --target=jsonl
|
|
161
|
+
│ recorder của Playwright (như `playwright codegen --target=jsonl`, Inspector ẩn), ghi lại mỗi bước
|
|
133
162
|
▼
|
|
134
163
|
recording.jsonl ── danh sách bước, chưa gắn với ngôn ngữ nào (click, fill, assertText…)
|
|
135
164
|
│ selector → chuỗi locator: dùng lại mã của Playwright (src/vendor/playwright)
|
package/dist/cli.js
CHANGED
|
@@ -6,7 +6,7 @@ import { parseArgs } from 'node:util';
|
|
|
6
6
|
import { generateTargets, writeTargets } from './generate.js';
|
|
7
7
|
import { generators } from './generators/index.js';
|
|
8
8
|
import { startCodegen, waitForExit } from './record.js';
|
|
9
|
-
import { parseRecording } from './recording.js';
|
|
9
|
+
import { parseRecording, recordedTestIdAttribute } from './recording.js';
|
|
10
10
|
import { startUiServer } from './ui/server.js';
|
|
11
11
|
const usage = `testgen: record a UI test once with the Playwright recorder, then generate it for several
|
|
12
12
|
frameworks and languages.
|
|
@@ -24,13 +24,15 @@ Output options:
|
|
|
24
24
|
(default for record: ./generated)
|
|
25
25
|
-n, --name <name> Test name (default: taken from --output, else "recorded")
|
|
26
26
|
--package <name> Java or Go package (default: derived from the output path)
|
|
27
|
-
--test-id-attribute <a> Attribute
|
|
27
|
+
--test-id-attribute <a> Attribute for getByTestId when recording (default: data-testid);
|
|
28
|
+
generated code keeps the one the recording was made with
|
|
28
29
|
--save <file.jsonl> record: where to keep the recording (default: <out-dir>/recording.jsonl)
|
|
29
30
|
|
|
30
31
|
Recorder options (record), same as playwright codegen:
|
|
31
32
|
--browser <chromium|firefox|webkit> --channel <chrome|msedge|...> --device <name>
|
|
32
33
|
--viewport-size <w,h> --lang <locale> --timezone <id> --color-scheme <light|dark>
|
|
33
34
|
--user-agent <ua> --ignore-https-errors --load-storage <file> --save-storage <file>
|
|
35
|
+
--inspector also show Playwright's Inspector window (hidden by default)
|
|
34
36
|
`;
|
|
35
37
|
const options = {
|
|
36
38
|
'target': { type: 'string', short: 't' },
|
|
@@ -38,7 +40,7 @@ const options = {
|
|
|
38
40
|
'out-dir': { type: 'string', short: 'd' },
|
|
39
41
|
'name': { type: 'string', short: 'n' },
|
|
40
42
|
'package': { type: 'string' },
|
|
41
|
-
'test-id-attribute': { type: 'string'
|
|
43
|
+
'test-id-attribute': { type: 'string' },
|
|
42
44
|
'save': { type: 'string' },
|
|
43
45
|
'browser': { type: 'string', short: 'b' },
|
|
44
46
|
'channel': { type: 'string' },
|
|
@@ -51,6 +53,7 @@ const options = {
|
|
|
51
53
|
'ignore-https-errors': { type: 'boolean' },
|
|
52
54
|
'load-storage': { type: 'string' },
|
|
53
55
|
'save-storage': { type: 'string' },
|
|
56
|
+
'inspector': { type: 'boolean' },
|
|
54
57
|
'port': { type: 'string', default: '9323' },
|
|
55
58
|
'host': { type: 'string', default: '127.0.0.1' },
|
|
56
59
|
'open': { type: 'boolean', default: true },
|
|
@@ -64,12 +67,13 @@ async function main(argv) {
|
|
|
64
67
|
return;
|
|
65
68
|
}
|
|
66
69
|
const targets = values.target ? values.target.split(',').map(t => t.trim()).filter(Boolean) : generators.map(g => g.id);
|
|
70
|
+
const testIdAttribute = values['test-id-attribute'] ?? 'data-testid';
|
|
67
71
|
const output = {
|
|
68
72
|
targets,
|
|
69
73
|
output: values.output,
|
|
70
74
|
name: values.name,
|
|
71
75
|
packageName: values.package,
|
|
72
|
-
testIdAttribute
|
|
76
|
+
testIdAttribute,
|
|
73
77
|
};
|
|
74
78
|
switch (command) {
|
|
75
79
|
case 'targets':
|
|
@@ -81,6 +85,9 @@ async function main(argv) {
|
|
|
81
85
|
if (!file)
|
|
82
86
|
throw new Error('Usage: testgen generate <recording.jsonl> [options]');
|
|
83
87
|
const recording = parseRecording(fs.readFileSync(file, 'utf8'), file);
|
|
88
|
+
const recorded = recordedTestIdAttribute(recording);
|
|
89
|
+
if (values['test-id-attribute'] && recorded && recorded !== values['test-id-attribute'])
|
|
90
|
+
console.error(`note: ignoring --test-id-attribute ${values['test-id-attribute']}: the recording's getByTestId() steps use ${recorded}.`);
|
|
84
91
|
if (!values.output && !values['out-dir']) {
|
|
85
92
|
if (targets.length !== 1)
|
|
86
93
|
throw new Error('Pass --out-dir (or a single --target to print the test to stdout).');
|
|
@@ -110,7 +117,8 @@ async function main(argv) {
|
|
|
110
117
|
ignoreHttpsErrors: values['ignore-https-errors'],
|
|
111
118
|
loadStorage: values['load-storage'],
|
|
112
119
|
saveStorage: values['save-storage'],
|
|
113
|
-
testIdAttribute
|
|
120
|
+
testIdAttribute,
|
|
121
|
+
inspector: values.inspector,
|
|
114
122
|
};
|
|
115
123
|
console.log('Recording... interact with the page, then close the browser window (or press Ctrl+C) to generate the tests.');
|
|
116
124
|
const recorder = startCodegen(rest[0], save, codegen);
|
package/dist/generate.js
CHANGED
|
@@ -2,11 +2,13 @@ import fs from 'node:fs';
|
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { findGenerator } from './generators/index.js';
|
|
4
4
|
import { nameFromFileName } from './naming.js';
|
|
5
|
+
import { recordedTestIdAttribute } from './recording.js';
|
|
5
6
|
export function generateTargets(recording, options) {
|
|
6
7
|
if (!options.targets.length)
|
|
7
8
|
throw new Error('Pick at least one target with --target.');
|
|
8
9
|
if (options.output && options.targets.length > 1)
|
|
9
10
|
throw new Error('--output takes a single target; use --out-dir for several targets.');
|
|
11
|
+
const testIdAttribute = recordedTestIdAttribute(recording) ?? options.testIdAttribute;
|
|
10
12
|
return options.targets.map(id => {
|
|
11
13
|
const generator = findGenerator(id);
|
|
12
14
|
const directory = options.output
|
|
@@ -14,7 +16,7 @@ export function generateTargets(recording, options) {
|
|
|
14
16
|
: path.join(options.outDir ?? '.', options.targets.length > 1 ? generator.id : '');
|
|
15
17
|
const name = options.name ?? (options.output ? nameFromFileName(options.output) : 'recorded');
|
|
16
18
|
const packageName = options.packageName ?? defaultPackage(generator, directory);
|
|
17
|
-
const files = generator.generate(recording, { name, packageName, testIdAttribute
|
|
19
|
+
const files = generator.generate(recording, { name, packageName, testIdAttribute });
|
|
18
20
|
if (options.output)
|
|
19
21
|
files[0] = { ...files[0], path: path.basename(options.output) };
|
|
20
22
|
return { generator, directory, files };
|
package/dist/locator.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { asLocator, asLocatorWithFactory } from './vendor/playwright/locatorGenerators.js';
|
|
2
|
+
import { parseAttributeSelector, parseSelector, visitAllSelectorParts } from './vendor/playwright/selectorParser.js';
|
|
2
3
|
const nestedKinds = ['has', 'hasNot', 'and', 'or', 'chain'];
|
|
3
4
|
export function isRegex(matcher) {
|
|
4
5
|
return typeof matcher === 'object' && matcher !== null;
|
|
@@ -46,4 +47,20 @@ export function parseLocator(selector) {
|
|
|
46
47
|
export function describeSelector(selector) {
|
|
47
48
|
return asLocator('javascript', selector);
|
|
48
49
|
}
|
|
50
|
+
// Attributes behind the selector's getByTestId() calls: internal:testid=[data-testid="welcome"s]
|
|
51
|
+
// gives ['data-testid']. The locator itself only keeps the test id ("welcome").
|
|
52
|
+
export function testIdAttributesOf(selector) {
|
|
53
|
+
const names = [];
|
|
54
|
+
try {
|
|
55
|
+
visitAllSelectorParts(parseSelector(selector), part => {
|
|
56
|
+
// Several attributes are one comma-separated name, as in Playwright's testIdAttribute option.
|
|
57
|
+
if (part.name === 'internal:testid')
|
|
58
|
+
names.push(...parseAttributeSelector(part.body, true).attributes[0].name.split(','));
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
// Not a selector Playwright can parse; it has no getByTestId() call.
|
|
63
|
+
}
|
|
64
|
+
return names;
|
|
65
|
+
}
|
|
49
66
|
//# sourceMappingURL=locator.js.map
|
package/dist/record.js
CHANGED
|
@@ -1,32 +1,18 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process';
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
export function codegenArguments(url, outputFile, options) {
|
|
9
|
-
const args = ['codegen', '--target=jsonl', '--output', outputFile];
|
|
10
|
-
const flags = [
|
|
11
|
-
['browser', '--browser'], ['channel', '--channel'], ['device', '--device'], ['viewportSize', '--viewport-size'],
|
|
12
|
-
['lang', '--lang'], ['timezone', '--timezone'], ['colorScheme', '--color-scheme'], ['userAgent', '--user-agent'],
|
|
13
|
-
['loadStorage', '--load-storage'], ['saveStorage', '--save-storage'], ['testIdAttribute', '--test-id-attribute'],
|
|
14
|
-
];
|
|
15
|
-
for (const [key, flag] of flags) {
|
|
16
|
-
const value = options[key];
|
|
17
|
-
if (typeof value === 'string' && value)
|
|
18
|
-
args.push(flag, value);
|
|
19
|
-
}
|
|
20
|
-
if (options.ignoreHttpsErrors)
|
|
21
|
-
args.push('--ignore-https-errors');
|
|
22
|
-
if (url)
|
|
23
|
-
args.push(url);
|
|
24
|
-
return args;
|
|
25
|
-
}
|
|
26
|
-
// Starts the Playwright recorder; it rewrites `outputFile` (JSONL) after every recorded action
|
|
27
|
-
// and exits when its browser window is closed.
|
|
2
|
+
import { fileURLToPath } from 'node:url';
|
|
3
|
+
// Compiled: dist/recorder-process.js. From sources (tests), Node runs the .ts file directly.
|
|
4
|
+
const recorderProcess = fileURLToPath(new URL(import.meta.url.endsWith('.ts') ? './recorder-process.ts' : './recorder-process.js', import.meta.url));
|
|
5
|
+
// Starts the Playwright recorder in a child process. It rewrites `outputFile` (JSONL) after every
|
|
6
|
+
// recorded action and exits when the recorded browser is closed.
|
|
7
|
+
// TESTGEN_RECORDER_HEADLESS=1 records in a headless browser (for tests).
|
|
28
8
|
export function startCodegen(url, outputFile, options, stdio = 'inherit') {
|
|
29
|
-
|
|
9
|
+
const processOptions = {
|
|
10
|
+
...options,
|
|
11
|
+
url,
|
|
12
|
+
outputFile,
|
|
13
|
+
headless: !!process.env.TESTGEN_RECORDER_HEADLESS,
|
|
14
|
+
};
|
|
15
|
+
return spawn(process.execPath, [recorderProcess, JSON.stringify(processOptions)], { stdio });
|
|
30
16
|
}
|
|
31
17
|
export function waitForExit(child) {
|
|
32
18
|
return new Promise((resolve, reject) => {
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// Runs Playwright's recorder (the engine behind `playwright codegen`) in its own process, with the
|
|
2
|
+
// Playwright Inspector window hidden: the user only sees the browser being recorded, and the code
|
|
3
|
+
// is shown by testgen. Mirrors codegen's own browser setup (packages/playwright-core/src/cli).
|
|
4
|
+
//
|
|
5
|
+
// Started by record.ts: node recorder-process.js '<RecorderProcessOptions as JSON>'
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
import os from 'node:os';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import * as playwright from 'playwright-core';
|
|
10
|
+
async function main(options) {
|
|
11
|
+
// Playwright's own hook for running the recorder's Inspector window headless.
|
|
12
|
+
if (!options.inspector)
|
|
13
|
+
process.env.PWTEST_CLI_HEADLESS = '1';
|
|
14
|
+
const browserType = { chromium: playwright.chromium, firefox: playwright.firefox, webkit: playwright.webkit }[options.browser ?? 'chromium'];
|
|
15
|
+
if (!browserType)
|
|
16
|
+
throw new Error(`Unknown browser "${options.browser}": use chromium, firefox or webkit.`);
|
|
17
|
+
const launchOptions = { headless: !!options.headless, handleSIGINT: false };
|
|
18
|
+
if (options.channel)
|
|
19
|
+
launchOptions.channel = options.channel;
|
|
20
|
+
let contextOptions = {};
|
|
21
|
+
if (options.device) {
|
|
22
|
+
const device = playwright.devices[options.device];
|
|
23
|
+
if (!device)
|
|
24
|
+
throw new Error(`Unknown device "${options.device}".`);
|
|
25
|
+
contextOptions = { ...device };
|
|
26
|
+
}
|
|
27
|
+
// Like codegen: a crisp headed browser, and no touch emulation on WebKit for Linux.
|
|
28
|
+
const headedScaleFactor = os.platform() === 'darwin' ? 2 : 1;
|
|
29
|
+
if (browserType.name() === 'webkit' && process.platform === 'linux') {
|
|
30
|
+
delete contextOptions.hasTouch;
|
|
31
|
+
delete contextOptions.isMobile;
|
|
32
|
+
}
|
|
33
|
+
if (options.viewportSize) {
|
|
34
|
+
const [width, height] = options.viewportSize.split(',').map(Number);
|
|
35
|
+
if (!width || !height)
|
|
36
|
+
throw new Error('Invalid viewport size: use "width,height", for example 1280,720.');
|
|
37
|
+
contextOptions.viewport = { width, height };
|
|
38
|
+
}
|
|
39
|
+
if (options.userAgent)
|
|
40
|
+
contextOptions.userAgent = options.userAgent;
|
|
41
|
+
if (options.lang)
|
|
42
|
+
contextOptions.locale = options.lang;
|
|
43
|
+
if (options.colorScheme)
|
|
44
|
+
contextOptions.colorScheme = options.colorScheme;
|
|
45
|
+
if (options.timezone)
|
|
46
|
+
contextOptions.timezoneId = options.timezone;
|
|
47
|
+
if (options.loadStorage)
|
|
48
|
+
contextOptions.storageState = options.loadStorage;
|
|
49
|
+
if (options.ignoreHttpsErrors)
|
|
50
|
+
contextOptions.ignoreHTTPSErrors = true;
|
|
51
|
+
const browser = await browserType.launch(launchOptions);
|
|
52
|
+
const context = await browser.newContext({ ...contextOptions, ...(options.headless ? {} : { deviceScaleFactor: headedScaleFactor }) });
|
|
53
|
+
let closing = false;
|
|
54
|
+
const close = async (code) => {
|
|
55
|
+
if (closing)
|
|
56
|
+
return;
|
|
57
|
+
closing = true;
|
|
58
|
+
if (options.saveStorage)
|
|
59
|
+
await context.storageState({ path: options.saveStorage }).catch(() => { });
|
|
60
|
+
// The recorder rewrites its output file on a 250ms throttle; let the last write land.
|
|
61
|
+
await new Promise(resolve => setTimeout(resolve, 500));
|
|
62
|
+
await browser.close().catch(() => { });
|
|
63
|
+
process.exit(code);
|
|
64
|
+
};
|
|
65
|
+
// Closing the last tab ends the recording, as with codegen.
|
|
66
|
+
context.on('page', page => page.on('close', () => {
|
|
67
|
+
if (!context.pages().length)
|
|
68
|
+
void close(0);
|
|
69
|
+
}));
|
|
70
|
+
browser.on('disconnected', () => void close(0));
|
|
71
|
+
for (const signal of ['SIGINT', 'SIGTERM'])
|
|
72
|
+
process.on(signal, () => void close(0));
|
|
73
|
+
context.setDefaultTimeout(0);
|
|
74
|
+
context.setDefaultNavigationTimeout(0);
|
|
75
|
+
await context._enableRecorder({
|
|
76
|
+
language: 'jsonl',
|
|
77
|
+
mode: 'recording',
|
|
78
|
+
outputFile: path.resolve(options.outputFile),
|
|
79
|
+
launchOptions: options.channel ? { channel: options.channel } : {},
|
|
80
|
+
contextOptions,
|
|
81
|
+
device: options.device,
|
|
82
|
+
saveStorage: options.saveStorage,
|
|
83
|
+
testIdAttributeName: options.testIdAttribute,
|
|
84
|
+
handleSIGINT: false,
|
|
85
|
+
});
|
|
86
|
+
const page = await context.newPage();
|
|
87
|
+
if (options.url)
|
|
88
|
+
await page.goto(normalizeUrl(options.url)).catch(error => console.error(`testgen: could not open ${options.url}: ${error.message}`));
|
|
89
|
+
}
|
|
90
|
+
// Same rules as codegen: local files become file:// URLs, bare hosts get http://.
|
|
91
|
+
function normalizeUrl(url) {
|
|
92
|
+
if (fs.existsSync(url))
|
|
93
|
+
return 'file://' + path.resolve(url);
|
|
94
|
+
if (!/^(https?:|file:|about:|data:)/.test(url))
|
|
95
|
+
return 'http://' + url;
|
|
96
|
+
return url;
|
|
97
|
+
}
|
|
98
|
+
main(JSON.parse(process.argv[2] ?? '{}')).catch(error => {
|
|
99
|
+
console.error(`testgen: ${error.message}`);
|
|
100
|
+
process.exit(1);
|
|
101
|
+
});
|
|
102
|
+
//# sourceMappingURL=recorder-process.js.map
|
package/dist/recording.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { testIdAttributesOf } from './locator.js';
|
|
1
2
|
const defaultHeader = { browserName: 'chromium', launchOptions: {}, contextOptions: {} };
|
|
2
3
|
export function parseRecording(text, source = 'recording') {
|
|
3
4
|
let header;
|
|
@@ -48,6 +49,22 @@ export function signalsOf(actionInContext) {
|
|
|
48
49
|
expect: find('expect'),
|
|
49
50
|
};
|
|
50
51
|
}
|
|
52
|
+
// The test id attribute the recording was made with (`--test-id-attribute`), read back from its
|
|
53
|
+
// getByTestId() selectors; undefined when there are none. Generated getByTestId() calls only keep
|
|
54
|
+
// the test id, so the generated code has to configure this same attribute.
|
|
55
|
+
export function recordedTestIdAttribute(recording) {
|
|
56
|
+
const names = new Set();
|
|
57
|
+
for (const { action, signals } of recording.actions) {
|
|
58
|
+
const selectors = signals.flatMap(signal => signal.name === 'expect' ? [signal.selector] : []);
|
|
59
|
+
if ('selector' in action)
|
|
60
|
+
selectors.push(action.selector);
|
|
61
|
+
for (const selector of selectors) {
|
|
62
|
+
for (const name of testIdAttributesOf(selector))
|
|
63
|
+
names.add(name);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return names.size ? [...names].join(',') : undefined;
|
|
67
|
+
}
|
|
51
68
|
// Mirrors Playwright's codegen: the first page and every page opened later get stable names
|
|
52
69
|
// ("page", "page1", ...), assigned in the order the recording mentions them.
|
|
53
70
|
export class PageAliases {
|
package/dist/ui/server.js
CHANGED
|
@@ -5,7 +5,7 @@ import path from 'node:path';
|
|
|
5
5
|
import { generateTargets, writeTargets } from '../generate.js';
|
|
6
6
|
import { generators } from '../generators/index.js';
|
|
7
7
|
import { describeSelector } from '../locator.js';
|
|
8
|
-
import { PageAliases, parseRecording } from '../recording.js';
|
|
8
|
+
import { PageAliases, parseRecording, recordedTestIdAttribute } from '../recording.js';
|
|
9
9
|
import { startCodegen } from '../record.js';
|
|
10
10
|
const uiRoot = new URL('../../ui/', import.meta.url);
|
|
11
11
|
const staticFiles = {
|
|
@@ -216,7 +216,7 @@ function render(state) {
|
|
|
216
216
|
}
|
|
217
217
|
const aliases = new PageAliases();
|
|
218
218
|
const steps = recording.actions.map(({ action, pageGuid }) => ({ page: aliases.alias(pageGuid), text: describeAction(action) }));
|
|
219
|
-
return { steps, outputs };
|
|
219
|
+
return { steps, outputs, recordedTestIdAttribute: recordedTestIdAttribute(recording) };
|
|
220
220
|
}
|
|
221
221
|
function describeAction(action) {
|
|
222
222
|
const target = 'selector' in action ? describeSelector(action.selector) : '';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xylentis/testgen",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.1",
|
|
4
4
|
"description": "Record a UI test once with the Playwright recorder, then generate it for Playwright (TypeScript, Go) and Selenium (Java, JavaScript).",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"playwright",
|
|
@@ -25,7 +25,8 @@
|
|
|
25
25
|
"runtime",
|
|
26
26
|
"ui",
|
|
27
27
|
"docs/testgen-ui.png",
|
|
28
|
-
"NOTICE"
|
|
28
|
+
"NOTICE",
|
|
29
|
+
"CHANGELOG.md"
|
|
29
30
|
],
|
|
30
31
|
"engines": {
|
|
31
32
|
"node": ">=22"
|
|
@@ -48,4 +49,4 @@
|
|
|
48
49
|
"typescript": "^7.0.2",
|
|
49
50
|
"vitest": "^5.0.3"
|
|
50
51
|
}
|
|
51
|
-
}
|
|
52
|
+
}
|
package/ui/app.js
CHANGED
|
@@ -71,6 +71,12 @@ function render() {
|
|
|
71
71
|
if (document.activeElement !== input && snapshot.settings[key] !== undefined)
|
|
72
72
|
input.value = snapshot.settings[key];
|
|
73
73
|
}
|
|
74
|
+
// The code keeps the test id attribute the recording was made with; the field is for the next one.
|
|
75
|
+
const recorded = snapshot.recordedTestIdAttribute;
|
|
76
|
+
const next = snapshot.settings.testIdAttribute;
|
|
77
|
+
$('test-id-note').textContent = recorded && next && recorded !== next
|
|
78
|
+
? `This recording was made with "${recorded}", so the code keeps it. "${next}" applies to the next recording.`
|
|
79
|
+
: '';
|
|
74
80
|
renderTargets();
|
|
75
81
|
renderCode();
|
|
76
82
|
}
|
package/ui/index.html
CHANGED
|
@@ -27,7 +27,8 @@
|
|
|
27
27
|
<section class="options" aria-label="Generation options">
|
|
28
28
|
<label>Test name <input id="name" value="recorded" spellcheck="false"></label>
|
|
29
29
|
<label>Package <input id="package" placeholder="e.g. com.acme.e2e" spellcheck="false"></label>
|
|
30
|
-
<label>Test id attribute <input id="test-id" value="data-testid" spellcheck="false"></label>
|
|
30
|
+
<label>Test id attribute <input id="test-id" value="data-testid" spellcheck="false" aria-describedby="test-id-note"></label>
|
|
31
|
+
<span id="test-id-note" class="note" role="status"></span>
|
|
31
32
|
<span class="spacer"></span>
|
|
32
33
|
<label class="file-button">Open recording… <input id="open" type="file" accept=".jsonl,.json,.txt"></label>
|
|
33
34
|
<a id="download-recording" class="button" href="/api/recording" download="recording.jsonl">Download recording</a>
|
package/ui/style.css
CHANGED
|
@@ -102,6 +102,7 @@ body.recording .status { color: var(--recording); font-weight: 600; }
|
|
|
102
102
|
.file-button { border: 1px solid var(--border); border-radius: 6px; padding: 4px 10px; cursor: pointer; color: var(--text); }
|
|
103
103
|
.file-button input { display: none; }
|
|
104
104
|
.options .button { padding: 4px 10px; }
|
|
105
|
+
.options .note { color: var(--text); }
|
|
105
106
|
|
|
106
107
|
.error {
|
|
107
108
|
margin: 12px 16px 0;
|