@xylentis/testgen 1.1.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 CHANGED
@@ -4,6 +4,8 @@
4
4
 
5
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
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).
7
9
 
8
10
  ## 1.0.0 (2026-10-05)
9
11
 
package/README.md CHANGED
@@ -46,7 +46,8 @@ testgen ui # mở http://127.0.0.1:9323
46
46
 
47
47
  Một số thao tác khác:
48
48
 
49
- - **Đổi Test name / Package / Test id attribute:** code sinh lại ngay.
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,11 +75,38 @@ 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`. |
80
81
  | `--inspector` | (`record`) Hiện thêm cửa sổ Playwright Inspector (mặc định ẩn). |
81
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.
109
+
82
110
  ## Chạy test đã sinh
83
111
 
84
112
  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.
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,7 +24,8 @@ 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 used by getByTestId (default: data-testid)
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:
@@ -39,7 +40,7 @@ const options = {
39
40
  'out-dir': { type: 'string', short: 'd' },
40
41
  'name': { type: 'string', short: 'n' },
41
42
  'package': { type: 'string' },
42
- 'test-id-attribute': { type: 'string', default: 'data-testid' },
43
+ 'test-id-attribute': { type: 'string' },
43
44
  'save': { type: 'string' },
44
45
  'browser': { type: 'string', short: 'b' },
45
46
  'channel': { type: 'string' },
@@ -66,12 +67,13 @@ async function main(argv) {
66
67
  return;
67
68
  }
68
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';
69
71
  const output = {
70
72
  targets,
71
73
  output: values.output,
72
74
  name: values.name,
73
75
  packageName: values.package,
74
- testIdAttribute: values['test-id-attribute'],
76
+ testIdAttribute,
75
77
  };
76
78
  switch (command) {
77
79
  case 'targets':
@@ -83,6 +85,9 @@ async function main(argv) {
83
85
  if (!file)
84
86
  throw new Error('Usage: testgen generate <recording.jsonl> [options]');
85
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}.`);
86
91
  if (!values.output && !values['out-dir']) {
87
92
  if (targets.length !== 1)
88
93
  throw new Error('Pass --out-dir (or a single --target to print the test to stdout).');
@@ -112,7 +117,7 @@ async function main(argv) {
112
117
  ignoreHttpsErrors: values['ignore-https-errors'],
113
118
  loadStorage: values['load-storage'],
114
119
  saveStorage: values['save-storage'],
115
- testIdAttribute: values['test-id-attribute'],
120
+ testIdAttribute,
116
121
  inspector: values.inspector,
117
122
  };
118
123
  console.log('Recording... interact with the page, then close the browser window (or press Ctrl+C) to generate the tests.');
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: options.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/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.1.0",
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",
@@ -49,4 +49,4 @@
49
49
  "typescript": "^7.0.2",
50
50
  "vitest": "^5.0.3"
51
51
  }
52
- }
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;