@xylentis/testgen 1.1.0 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +25 -0
- package/README.md +189 -14
- package/dist/checks.js +100 -0
- package/dist/cli.js +93 -22
- package/dist/editing.js +167 -0
- package/dist/generate.js +25 -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/locator.js +17 -0
- package/dist/recording.js +60 -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 +4 -2
- 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 +392 -25
- package/ui/index.html +21 -4
- package/ui/style.css +81 -9
package/dist/secrets.js
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { isRegex, parseLocator } from './locator.js';
|
|
2
|
+
import { replaceWholeWords } from './text.js';
|
|
3
|
+
// Words naming a secret field, matched on lowercase ASCII words ("Mật khẩu" → "mat khau").
|
|
4
|
+
const secretWords = new RegExp(`\\b(${[
|
|
5
|
+
'pass', 'password', 'passwd', 'passwort', 'kennwort', 'pwd', 'passcode', 'passphrase', 'pin', 'otp', 'totp',
|
|
6
|
+
'cvv', 'cvv2', 'cvc', 'secret', 'token', 'api key', 'apikey', 'access key', 'private key', 'one time code',
|
|
7
|
+
'verification code', 'security code', 'mat khau', 'ma xac thuc', 'ma xac minh', 'ma bao mat', 'contrasena',
|
|
8
|
+
'senha', 'mot de passe',
|
|
9
|
+
].join('|')})\\b`);
|
|
10
|
+
const nonLatinSecretWords = /パスワード|暗証番号|密码|密碼|비밀번호|пароль/i;
|
|
11
|
+
// How a saved recording writes a secret inside another text, e.g. a locator finding the element that
|
|
12
|
+
// shows the password: getByText('${TEST_PASSWORD}').
|
|
13
|
+
export function secretPlaceholder(name) {
|
|
14
|
+
return '${' + name + '}';
|
|
15
|
+
}
|
|
16
|
+
// Whether a fill into `selector` types a secret, judging from the field it points at.
|
|
17
|
+
export function looksSecret(selector) {
|
|
18
|
+
const { texts, css } = targetOf(parseLocator(selector));
|
|
19
|
+
return [...texts, css ?? ''].some(text => secretWords.test(words(text).join(' ')) || nonLatinSecretWords.test(text));
|
|
20
|
+
}
|
|
21
|
+
export function collectSecrets(recording) {
|
|
22
|
+
const list = [];
|
|
23
|
+
const byStep = new Map();
|
|
24
|
+
const named = (name, value) => {
|
|
25
|
+
let secret = list.find(s => s.name === name);
|
|
26
|
+
if (!secret) {
|
|
27
|
+
secret = { name, value };
|
|
28
|
+
list.push(secret);
|
|
29
|
+
}
|
|
30
|
+
else if (!secret.value) {
|
|
31
|
+
secret.value = value;
|
|
32
|
+
}
|
|
33
|
+
return secret;
|
|
34
|
+
};
|
|
35
|
+
// Fills of a password-like field, or marked as secret. Typing the same value twice (password and
|
|
36
|
+
// confirmation) is one secret.
|
|
37
|
+
recording.actions.forEach(({ action, secret }, index) => {
|
|
38
|
+
if (action.name !== 'fill' || secret === false)
|
|
39
|
+
return;
|
|
40
|
+
if (typeof secret === 'string') {
|
|
41
|
+
byStep.set(index, named(secret, action.text));
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
if (!action.text || !looksSecret(action.selector))
|
|
45
|
+
return;
|
|
46
|
+
const known = list.find(s => s.value === action.text);
|
|
47
|
+
if (known) {
|
|
48
|
+
byStep.set(index, known);
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
const base = secretName(action.selector);
|
|
52
|
+
let name = base;
|
|
53
|
+
for (let i = 2; list.some(s => s.name === name); i++)
|
|
54
|
+
name = `${base}_${i}`;
|
|
55
|
+
byStep.set(index, named(name, action.text));
|
|
56
|
+
});
|
|
57
|
+
// Checks expecting a secret value (the value of a password field) expect the secret.
|
|
58
|
+
recording.actions.forEach(({ action, secret }, index) => {
|
|
59
|
+
const expected = action.name === 'assertValue' ? action.value : action.name === 'assertText' ? action.text : undefined;
|
|
60
|
+
if (expected === undefined || secret === false || byStep.has(index))
|
|
61
|
+
return;
|
|
62
|
+
if (typeof secret === 'string') {
|
|
63
|
+
byStep.set(index, named(secret, expected));
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
const known = expected ? list.find(s => s.value === expected) : undefined;
|
|
67
|
+
if (known)
|
|
68
|
+
byStep.set(index, known);
|
|
69
|
+
});
|
|
70
|
+
return { list, of: index => byStep.get(index) };
|
|
71
|
+
}
|
|
72
|
+
// The recording without secret values: secret steps keep the name of their environment variable, and
|
|
73
|
+
// other texts (locators, longer values) show the secret as a placeholder.
|
|
74
|
+
export function redactRecording(recording) {
|
|
75
|
+
const secrets = collectSecrets(recording);
|
|
76
|
+
const hide = (text, inSelector = false) => {
|
|
77
|
+
let result = text;
|
|
78
|
+
for (const { name, value } of secrets.list) {
|
|
79
|
+
if (!value)
|
|
80
|
+
continue;
|
|
81
|
+
// Selectors quote texts as JSON strings, which escape quotes and backslashes.
|
|
82
|
+
if (inSelector)
|
|
83
|
+
result = replaceWholeWords(result, JSON.stringify(value).slice(1, -1), secretPlaceholder(name));
|
|
84
|
+
result = replaceWholeWords(result, value, secretPlaceholder(name));
|
|
85
|
+
}
|
|
86
|
+
return result;
|
|
87
|
+
};
|
|
88
|
+
return {
|
|
89
|
+
header: recording.header,
|
|
90
|
+
actions: recording.actions.map((step, index) => {
|
|
91
|
+
let action = step.action;
|
|
92
|
+
if ('selector' in action)
|
|
93
|
+
action = { ...action, selector: hide(action.selector, true) };
|
|
94
|
+
const secret = secrets.of(index);
|
|
95
|
+
if (action.name === 'fill' || action.name === 'assertText')
|
|
96
|
+
action = { ...action, text: secret ? '' : hide(action.text) };
|
|
97
|
+
else if (action.name === 'assertValue')
|
|
98
|
+
action = { ...action, value: secret ? '' : hide(action.value) };
|
|
99
|
+
return secret ? { ...step, action, secret: secret.name } : { ...step, action };
|
|
100
|
+
}),
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
// The recording with each secret step naming its environment variable, so that the step stays a secret
|
|
104
|
+
// after a change, even when its new locator no longer looks like a password field.
|
|
105
|
+
export function markSecrets(recording) {
|
|
106
|
+
const secrets = collectSecrets(recording);
|
|
107
|
+
return {
|
|
108
|
+
header: recording.header,
|
|
109
|
+
actions: recording.actions.map((step, index) => {
|
|
110
|
+
const secret = secrets.of(index);
|
|
111
|
+
return secret && step.secret === undefined ? { ...step, secret: secret.name } : step;
|
|
112
|
+
}),
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
// TEST_ and the field's name, e.g. TEST_PASSWORD for getByLabel('Password'), TEST_MAT_KHAU for
|
|
116
|
+
// getByPlaceholder('Mật khẩu'), TEST_OLD_PASSWORD for getByRole('textbox', { name: 'Old password' }).
|
|
117
|
+
function secretName(selector) {
|
|
118
|
+
const { texts, css } = targetOf(parseLocator(selector));
|
|
119
|
+
const subject = words(texts.find(text => words(text).length) ?? cssName(css) ?? '');
|
|
120
|
+
if (subject.length && subject.length <= 3 && secretWords.test(subject.join(' ')))
|
|
121
|
+
return ['TEST', ...subject].join('_').toUpperCase();
|
|
122
|
+
for (const text of [...texts, css ?? '']) {
|
|
123
|
+
const match = secretWords.exec(words(text).join(' '));
|
|
124
|
+
if (match)
|
|
125
|
+
return `TEST_${match[1].replace(/ /g, '_').toUpperCase()}`;
|
|
126
|
+
if (nonLatinSecretWords.test(text))
|
|
127
|
+
return 'TEST_PASSWORD';
|
|
128
|
+
}
|
|
129
|
+
return 'TEST_SECRET';
|
|
130
|
+
}
|
|
131
|
+
// What a locator points at: the texts naming the element (role name, label, test id...) or, for a CSS
|
|
132
|
+
// selector, the selector. Filters and positions after it (has-text, nth...) do not name it.
|
|
133
|
+
function targetOf(steps) {
|
|
134
|
+
const text = (matcher) => isRegex(matcher) ? matcher.source : matcher;
|
|
135
|
+
for (const step of [...steps].reverse()) {
|
|
136
|
+
switch (step.kind) {
|
|
137
|
+
case 'role':
|
|
138
|
+
return { texts: [step.name, step.description].filter(m => m !== undefined).map(text) };
|
|
139
|
+
case 'test-id':
|
|
140
|
+
case 'label':
|
|
141
|
+
case 'placeholder':
|
|
142
|
+
case 'alt':
|
|
143
|
+
case 'title':
|
|
144
|
+
case 'text':
|
|
145
|
+
return { texts: [text(step.body)] };
|
|
146
|
+
case 'default':
|
|
147
|
+
return { texts: [], css: String(step.body) };
|
|
148
|
+
case 'and':
|
|
149
|
+
case 'or':
|
|
150
|
+
case 'chain':
|
|
151
|
+
return targetOf(step.inner);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return { texts: [] };
|
|
155
|
+
}
|
|
156
|
+
// Last #id or [name=...] of a CSS selector.
|
|
157
|
+
function cssName(css) {
|
|
158
|
+
const match = [...(css ?? '').matchAll(/#([\w-]+)|\[name=["']?([\w-]+)/g)].pop();
|
|
159
|
+
return match ? match[1] ?? match[2] : undefined;
|
|
160
|
+
}
|
|
161
|
+
// Lowercase ASCII words: "Mật khẩu" → ["mat", "khau"], "txtPwd" → ["txt", "pwd"].
|
|
162
|
+
function words(text) {
|
|
163
|
+
return text.normalize('NFD').replace(/\p{M}/gu, '').replace(/[đĐ]/g, 'd')
|
|
164
|
+
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
|
|
165
|
+
.toLowerCase()
|
|
166
|
+
.split(/[^a-z0-9]+/)
|
|
167
|
+
.filter(Boolean);
|
|
168
|
+
}
|
|
169
|
+
//# sourceMappingURL=secrets.js.map
|
package/dist/text.js
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// Finding a value inside a longer text without cutting words in two: "Buy milk" in "Buy milk Delete",
|
|
2
|
+
// but not "milk" in "milkshake" nor "12" in "Total: 120".
|
|
3
|
+
const wordCharacter = /[\p{L}\p{N}_]/u;
|
|
4
|
+
export function isWholeWords(text, start, end) {
|
|
5
|
+
return !(start > 0 && wordCharacter.test(text[start - 1]) && wordCharacter.test(text[start]))
|
|
6
|
+
&& !(end < text.length && wordCharacter.test(text[end - 1]) && wordCharacter.test(text[end]));
|
|
7
|
+
}
|
|
8
|
+
// Start of each occurrence of `part` in `text` that cuts no word in two.
|
|
9
|
+
export function wholeWordOccurrences(text, part) {
|
|
10
|
+
const result = [];
|
|
11
|
+
if (!part)
|
|
12
|
+
return result;
|
|
13
|
+
for (let start = text.indexOf(part); start !== -1; start = text.indexOf(part, start + part.length)) {
|
|
14
|
+
if (isWholeWords(text, start, start + part.length))
|
|
15
|
+
result.push(start);
|
|
16
|
+
}
|
|
17
|
+
return result;
|
|
18
|
+
}
|
|
19
|
+
export function replaceWholeWords(text, part, replacement) {
|
|
20
|
+
let result = '';
|
|
21
|
+
let last = 0;
|
|
22
|
+
for (const start of wholeWordOccurrences(text, part)) {
|
|
23
|
+
result += text.slice(last, start) + replacement;
|
|
24
|
+
last = start + part.length;
|
|
25
|
+
}
|
|
26
|
+
return result + text.slice(last);
|
|
27
|
+
}
|
|
28
|
+
//# sourceMappingURL=text.js.map
|
package/dist/ui/server.js
CHANGED
|
@@ -2,11 +2,15 @@ import fs from 'node:fs';
|
|
|
2
2
|
import http from 'node:http';
|
|
3
3
|
import os from 'node:os';
|
|
4
4
|
import path from 'node:path';
|
|
5
|
+
import { checkFiles, checkRecording } from '../checks.js';
|
|
6
|
+
import { deleteStep, editStep, moveStep, stepFields } from '../editing.js';
|
|
5
7
|
import { generateTargets, writeTargets } from '../generate.js';
|
|
6
8
|
import { generators } from '../generators/index.js';
|
|
7
9
|
import { describeSelector } from '../locator.js';
|
|
8
|
-
import { PageAliases, parseRecording } from '../recording.js';
|
|
10
|
+
import { PageAliases, parseRecording, recordedTestIdAttribute, serializeRecording } from '../recording.js';
|
|
9
11
|
import { startCodegen } from '../record.js';
|
|
12
|
+
import { missingSecrets, replayRecording } from '../replay.js';
|
|
13
|
+
import { collectSecrets, markSecrets, redactRecording } from '../secrets.js';
|
|
10
14
|
const uiRoot = new URL('../../ui/', import.meta.url);
|
|
11
15
|
const staticFiles = {
|
|
12
16
|
'/': 'index.html',
|
|
@@ -26,12 +30,25 @@ export async function startUiServer(options) {
|
|
|
26
30
|
status: 'idle',
|
|
27
31
|
log: '',
|
|
28
32
|
recording: '',
|
|
29
|
-
settings: { name: 'recorded', packageName: '', testIdAttribute: 'data-testid' },
|
|
33
|
+
settings: { name: 'recorded', packageName: '', testIdAttribute: 'data-testid', logSteps: false, dataDriven: false },
|
|
34
|
+
undo: [],
|
|
30
35
|
};
|
|
31
36
|
let child;
|
|
32
37
|
let watcher;
|
|
33
38
|
let recordingFile = '';
|
|
34
|
-
|
|
39
|
+
// Values of secrets entered for replays; they stay in this process.
|
|
40
|
+
const secretValues = {};
|
|
41
|
+
let replayAbort;
|
|
42
|
+
let screenshot;
|
|
43
|
+
const snapshot = () => JSON.stringify({
|
|
44
|
+
status: state.status,
|
|
45
|
+
error: state.error,
|
|
46
|
+
settings: state.settings,
|
|
47
|
+
hasRecording: !!state.recording.trim(),
|
|
48
|
+
canUndo: state.undo.length > 0,
|
|
49
|
+
replay: state.replay,
|
|
50
|
+
...render(state, secretValues),
|
|
51
|
+
});
|
|
35
52
|
const broadcast = () => {
|
|
36
53
|
const data = `data: ${snapshot()}\n\n`;
|
|
37
54
|
for (const client of clients)
|
|
@@ -57,10 +74,15 @@ export async function startUiServer(options) {
|
|
|
57
74
|
clearInterval(watcher);
|
|
58
75
|
}
|
|
59
76
|
};
|
|
60
|
-
|
|
77
|
+
// A new recording replaces the steps, their undo history and the last replay.
|
|
78
|
+
const replaceRecording = (recording, status) => {
|
|
61
79
|
stopRecorder({ detach: true });
|
|
80
|
+
replayAbort?.abort();
|
|
81
|
+
Object.assign(state, { status, error: undefined, log: '', recording, undo: [], replay: undefined });
|
|
82
|
+
};
|
|
83
|
+
const record = (body) => {
|
|
84
|
+
replaceRecording('', 'recording');
|
|
62
85
|
recordingFile = path.join(workDir, `recording-${Date.now()}.jsonl`);
|
|
63
|
-
Object.assign(state, { status: 'recording', error: undefined, log: '', recording: '' });
|
|
64
86
|
if (body.testIdAttribute)
|
|
65
87
|
state.settings.testIdAttribute = body.testIdAttribute;
|
|
66
88
|
const codegen = {
|
|
@@ -92,6 +114,81 @@ export async function startUiServer(options) {
|
|
|
92
114
|
watcher = setInterval(readRecording, 250);
|
|
93
115
|
broadcast();
|
|
94
116
|
};
|
|
117
|
+
const changeSteps = (change) => {
|
|
118
|
+
if (state.status === 'recording')
|
|
119
|
+
throw new Error('Stop the recording before changing its steps.');
|
|
120
|
+
if (state.replay?.status === 'running')
|
|
121
|
+
throw new Error('Wait for the replay to finish, or stop it, before changing the steps.');
|
|
122
|
+
// Steps found to be secrets stay secrets, whatever the change does to their locator.
|
|
123
|
+
const changed = serializeRecording(change(markSecrets(parseRecording(state.recording))));
|
|
124
|
+
state.undo = [...state.undo.slice(-49), state.recording];
|
|
125
|
+
state.recording = changed;
|
|
126
|
+
state.replay = undefined;
|
|
127
|
+
broadcast();
|
|
128
|
+
};
|
|
129
|
+
const undo = () => {
|
|
130
|
+
const previous = state.undo.pop();
|
|
131
|
+
if (previous === undefined)
|
|
132
|
+
throw new Error('Nothing to undo.');
|
|
133
|
+
if (state.replay?.status === 'running') {
|
|
134
|
+
state.undo.push(previous);
|
|
135
|
+
throw new Error('Wait for the replay to finish, or stop it, before changing the steps.');
|
|
136
|
+
}
|
|
137
|
+
state.recording = previous;
|
|
138
|
+
state.replay = undefined;
|
|
139
|
+
broadcast();
|
|
140
|
+
};
|
|
141
|
+
const replay = (body) => {
|
|
142
|
+
if (state.status === 'recording')
|
|
143
|
+
throw new Error('Stop the recording before replaying it.');
|
|
144
|
+
if (state.replay?.status === 'running')
|
|
145
|
+
throw new Error('A replay is already running.');
|
|
146
|
+
const recording = parseRecording(state.recording);
|
|
147
|
+
if (!recording.actions.length)
|
|
148
|
+
throw new Error('There are no steps to replay.');
|
|
149
|
+
for (const [name, value] of Object.entries(body.secrets ?? {})) {
|
|
150
|
+
if (typeof value === 'string')
|
|
151
|
+
secretValues[name] = value;
|
|
152
|
+
}
|
|
153
|
+
const missing = missingSecrets(recording, secretValues);
|
|
154
|
+
if (missing.length)
|
|
155
|
+
throw new Error(`Enter ${missing.join(', ')} to replay: the recording does not keep the values typed into password fields.`);
|
|
156
|
+
const abort = new AbortController();
|
|
157
|
+
const current = { status: 'running', steps: [] };
|
|
158
|
+
const started = Date.now();
|
|
159
|
+
replayAbort = abort;
|
|
160
|
+
screenshot = undefined;
|
|
161
|
+
state.replay = current;
|
|
162
|
+
broadcast();
|
|
163
|
+
void replayRecording(recording, { headless: options.headlessReplay, secrets: { ...secretValues }, cwd: options.cwd, signal: abort.signal }, event => {
|
|
164
|
+
// An edit or a new recording dropped this replay.
|
|
165
|
+
if (state.replay !== current)
|
|
166
|
+
return;
|
|
167
|
+
if (event.type === 'step') {
|
|
168
|
+
const result = { status: event.status };
|
|
169
|
+
if (event.status !== 'running') {
|
|
170
|
+
result.duration = event.duration;
|
|
171
|
+
if (event.status === 'failed') {
|
|
172
|
+
result.error = event.error;
|
|
173
|
+
if (event.screenshot) {
|
|
174
|
+
screenshot = event.screenshot;
|
|
175
|
+
current.screenshot = (current.screenshot ?? 0) + 1;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
else if (event.note) {
|
|
179
|
+
result.note = event.note;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
current.steps[event.index] = result;
|
|
183
|
+
}
|
|
184
|
+
else {
|
|
185
|
+
Object.assign(current, { status: event.status, error: event.error, duration: Date.now() - started });
|
|
186
|
+
if (replayAbort === abort)
|
|
187
|
+
replayAbort = undefined;
|
|
188
|
+
}
|
|
189
|
+
broadcast();
|
|
190
|
+
});
|
|
191
|
+
};
|
|
95
192
|
const server = http.createServer(async (request, response) => {
|
|
96
193
|
try {
|
|
97
194
|
// Only answer requests addressed to this local server (protects against DNS rebinding).
|
|
@@ -100,6 +197,12 @@ export async function startUiServer(options) {
|
|
|
100
197
|
response.writeHead(403).end('Forbidden');
|
|
101
198
|
return;
|
|
102
199
|
}
|
|
200
|
+
// Only this page may change anything. Browsers send the Origin of the page making a request,
|
|
201
|
+
// and a page of another site cannot send JSON without a preflight request, which gets a 404.
|
|
202
|
+
if (request.method === 'POST' && !fromThisPage(request)) {
|
|
203
|
+
response.writeHead(403).end('Forbidden');
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
103
206
|
const url = new URL(request.url ?? '/', 'http://localhost');
|
|
104
207
|
if (request.method === 'GET' && staticFiles[url.pathname]) {
|
|
105
208
|
const file = staticFiles[url.pathname];
|
|
@@ -115,12 +218,29 @@ export async function startUiServer(options) {
|
|
|
115
218
|
return;
|
|
116
219
|
}
|
|
117
220
|
if (request.method === 'GET' && url.pathname === '/api/targets') {
|
|
118
|
-
json(response, generators.map(g => ({ id: g.id, framework: g.framework, language: g.language, highlight: g.highlight })));
|
|
221
|
+
json(response, generators.map(g => ({ id: g.id, framework: g.framework, language: g.language, highlight: g.highlight, features: g.features ?? [] })));
|
|
119
222
|
return;
|
|
120
223
|
}
|
|
121
224
|
if (request.method === 'GET' && url.pathname === '/api/recording') {
|
|
225
|
+
// Without the values typed into password fields; a text testgen cannot read goes as it is.
|
|
226
|
+
let text = state.recording;
|
|
227
|
+
try {
|
|
228
|
+
text = serializeRecording(redactRecording(parseRecording(state.recording)));
|
|
229
|
+
}
|
|
230
|
+
catch {
|
|
231
|
+
// Not a recording testgen can read: download it as it is.
|
|
232
|
+
}
|
|
122
233
|
response.writeHead(200, { 'content-type': 'application/x-ndjson', 'content-disposition': 'attachment; filename="recording.jsonl"' });
|
|
123
|
-
response.end(
|
|
234
|
+
response.end(text);
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
if (request.method === 'GET' && url.pathname === '/api/replay/screenshot') {
|
|
238
|
+
if (!screenshot) {
|
|
239
|
+
response.writeHead(404).end('No screenshot');
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
response.writeHead(200, { 'content-type': 'image/png', 'cache-control': 'no-store' });
|
|
243
|
+
response.end(screenshot);
|
|
124
244
|
return;
|
|
125
245
|
}
|
|
126
246
|
if (request.method !== 'POST') {
|
|
@@ -138,16 +258,45 @@ export async function startUiServer(options) {
|
|
|
138
258
|
json(response, { ok: true });
|
|
139
259
|
return;
|
|
140
260
|
case '/api/load':
|
|
141
|
-
|
|
142
|
-
Object.assign(state, { status: 'stopped', error: undefined, log: '', recording: String(body.recording ?? '') });
|
|
261
|
+
replaceRecording(String(body.recording ?? ''), 'stopped');
|
|
143
262
|
broadcast();
|
|
144
263
|
json(response, { ok: true });
|
|
145
264
|
return;
|
|
265
|
+
case '/api/steps': {
|
|
266
|
+
const index = Number(body.index);
|
|
267
|
+
changeSteps(recording => {
|
|
268
|
+
switch (body.action) {
|
|
269
|
+
case 'delete':
|
|
270
|
+
return deleteStep(recording, index);
|
|
271
|
+
case 'move':
|
|
272
|
+
return moveStep(recording, index, Number(body.to));
|
|
273
|
+
case 'edit':
|
|
274
|
+
return editStep(recording, index, stepChange(body.change), recordedTestIdAttribute(recording) ?? state.settings.testIdAttribute);
|
|
275
|
+
}
|
|
276
|
+
throw new Error(`Unknown step action "${body.action}".`);
|
|
277
|
+
});
|
|
278
|
+
json(response, { ok: true });
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
case '/api/undo':
|
|
282
|
+
undo();
|
|
283
|
+
json(response, { ok: true });
|
|
284
|
+
return;
|
|
285
|
+
case '/api/replay':
|
|
286
|
+
replay(body);
|
|
287
|
+
json(response, { ok: true });
|
|
288
|
+
return;
|
|
289
|
+
case '/api/replay/stop':
|
|
290
|
+
replayAbort?.abort();
|
|
291
|
+
json(response, { ok: true });
|
|
292
|
+
return;
|
|
146
293
|
case '/api/settings':
|
|
147
294
|
state.settings = {
|
|
148
295
|
name: String(body.name ?? state.settings.name),
|
|
149
296
|
packageName: String(body.packageName ?? state.settings.packageName),
|
|
150
297
|
testIdAttribute: String(body.testIdAttribute || state.settings.testIdAttribute),
|
|
298
|
+
logSteps: Boolean(body.logSteps ?? state.settings.logSteps),
|
|
299
|
+
dataDriven: Boolean(body.dataDriven ?? state.settings.dataDriven),
|
|
151
300
|
};
|
|
152
301
|
broadcast();
|
|
153
302
|
json(response, { ok: true });
|
|
@@ -159,7 +308,9 @@ export async function startUiServer(options) {
|
|
|
159
308
|
outDir,
|
|
160
309
|
...generationOptions(state.settings),
|
|
161
310
|
});
|
|
162
|
-
|
|
311
|
+
const { written, kept } = writeTargets(outputs);
|
|
312
|
+
const relative = (file) => path.relative(options.cwd, file) || file;
|
|
313
|
+
json(response, { written: written.map(relative), kept: kept.map(relative) });
|
|
163
314
|
return;
|
|
164
315
|
}
|
|
165
316
|
}
|
|
@@ -178,6 +329,7 @@ export async function startUiServer(options) {
|
|
|
178
329
|
url: `http://${options.host === '0.0.0.0' ? 'localhost' : options.host}:${address.port}/`,
|
|
179
330
|
close: async () => {
|
|
180
331
|
stopRecorder({ detach: true });
|
|
332
|
+
replayAbort?.abort();
|
|
181
333
|
clearInterval(watcher);
|
|
182
334
|
for (const client of clients)
|
|
183
335
|
client.end();
|
|
@@ -191,35 +343,82 @@ function generationOptions(settings) {
|
|
|
191
343
|
name: settings.name || 'recorded',
|
|
192
344
|
packageName: settings.packageName || undefined,
|
|
193
345
|
testIdAttribute: settings.testIdAttribute || 'data-testid',
|
|
346
|
+
logSteps: settings.logSteps,
|
|
347
|
+
dataDriven: settings.dataDriven,
|
|
194
348
|
};
|
|
195
349
|
}
|
|
196
|
-
|
|
197
|
-
|
|
350
|
+
function fromThisPage(request) {
|
|
351
|
+
const { origin, host } = request.headers;
|
|
352
|
+
const site = request.headers['sec-fetch-site'];
|
|
353
|
+
if (origin !== undefined && origin !== `http://${host}`)
|
|
354
|
+
return false;
|
|
355
|
+
if (site !== undefined && site !== 'same-origin' && site !== 'none')
|
|
356
|
+
return false;
|
|
357
|
+
return /^application\/json\b/i.test(request.headers['content-type'] ?? '');
|
|
358
|
+
}
|
|
359
|
+
// The parts of a step change the page may send.
|
|
360
|
+
function stepChange(value) {
|
|
361
|
+
const change = {};
|
|
362
|
+
if (typeof value?.locator === 'string')
|
|
363
|
+
change.locator = value.locator;
|
|
364
|
+
if (typeof value?.value === 'string')
|
|
365
|
+
change.value = value.value;
|
|
366
|
+
if (typeof value?.substring === 'boolean')
|
|
367
|
+
change.substring = value.substring;
|
|
368
|
+
if (typeof value?.checked === 'boolean')
|
|
369
|
+
change.checked = value.checked;
|
|
370
|
+
if (value?.secret === null || value?.secret === false || typeof value?.secret === 'string')
|
|
371
|
+
change.secret = value.secret;
|
|
372
|
+
return change;
|
|
373
|
+
}
|
|
374
|
+
// Steps, checks and generated code for every target, for the current recording. Secret values stay
|
|
375
|
+
// on the server: steps show the environment variable instead.
|
|
376
|
+
function render(state, secretValues) {
|
|
377
|
+
const empty = { steps: [], outputs: {}, checks: [], missingSecrets: [] };
|
|
198
378
|
if (!state.recording.trim())
|
|
199
|
-
return
|
|
379
|
+
return empty;
|
|
200
380
|
let recording;
|
|
201
381
|
try {
|
|
202
382
|
recording = parseRecording(state.recording);
|
|
203
383
|
}
|
|
204
384
|
catch (error) {
|
|
205
|
-
return {
|
|
385
|
+
return { ...empty, parseError: error.message };
|
|
206
386
|
}
|
|
207
387
|
const outputs = {};
|
|
208
388
|
for (const generator of generators) {
|
|
209
389
|
try {
|
|
210
390
|
const [output] = generateTargets(recording, { targets: [generator.id], ...generationOptions(state.settings) });
|
|
211
|
-
outputs[generator.id] = { files: output.files };
|
|
391
|
+
outputs[generator.id] = { files: output.files, checks: checkFiles(output.files) };
|
|
212
392
|
}
|
|
213
393
|
catch (error) {
|
|
214
394
|
outputs[generator.id] = { error: error.message };
|
|
215
395
|
}
|
|
216
396
|
}
|
|
397
|
+
// Steps and checks show secrets by their environment variable, as a saved recording does.
|
|
398
|
+
const shown = redactRecording(recording);
|
|
399
|
+
const secrets = collectSecrets(shown);
|
|
217
400
|
const aliases = new PageAliases();
|
|
218
|
-
const steps =
|
|
219
|
-
|
|
401
|
+
const steps = shown.actions.map(({ action, pageGuid }, index) => {
|
|
402
|
+
const secret = secrets.of(index)?.name;
|
|
403
|
+
const fields = stepFields(shown, index, secrets);
|
|
404
|
+
if (secret) {
|
|
405
|
+
delete fields.value;
|
|
406
|
+
fields.secret = secret;
|
|
407
|
+
}
|
|
408
|
+
return { page: aliases.alias(pageGuid), text: describeAction(action, secret), fields };
|
|
409
|
+
});
|
|
410
|
+
return {
|
|
411
|
+
steps,
|
|
412
|
+
outputs,
|
|
413
|
+
checks: checkRecording(shown),
|
|
414
|
+
missingSecrets: missingSecrets(recording, secretValues),
|
|
415
|
+
recordedTestIdAttribute: recordedTestIdAttribute(recording),
|
|
416
|
+
};
|
|
220
417
|
}
|
|
221
|
-
|
|
418
|
+
// `secret` names the environment variable a step types or expects, instead of its value.
|
|
419
|
+
function describeAction(action, secret) {
|
|
222
420
|
const target = 'selector' in action ? describeSelector(action.selector) : '';
|
|
421
|
+
const value = (text) => secret ? `secret ${secret}` : JSON.stringify(text);
|
|
223
422
|
switch (action.name) {
|
|
224
423
|
case 'openPage': return `open page${action.url && action.url !== 'about:blank' ? ' ' + action.url : ''}`;
|
|
225
424
|
case 'closePage': return 'close page';
|
|
@@ -228,12 +427,12 @@ function describeAction(action) {
|
|
|
228
427
|
case 'hover': return `hover ${target}`;
|
|
229
428
|
case 'check': return `check ${target}`;
|
|
230
429
|
case 'uncheck': return `uncheck ${target}`;
|
|
231
|
-
case 'fill': return `fill ${target} with ${
|
|
430
|
+
case 'fill': return `fill ${target} with ${value(action.text)}`;
|
|
232
431
|
case 'press': return `press ${action.key} in ${target}`;
|
|
233
432
|
case 'select': return `select ${action.options.join(', ')} in ${target}`;
|
|
234
433
|
case 'setInputFiles': return `upload ${action.files.join(', ')} to ${target}`;
|
|
235
|
-
case 'assertText': return `expect ${target} ${action.substring ? 'to contain' : 'to have'} text ${
|
|
236
|
-
case 'assertValue': return `expect ${target} to have value ${
|
|
434
|
+
case 'assertText': return `expect ${target} ${action.substring ? 'to contain' : 'to have'} text ${value(action.text)}`;
|
|
435
|
+
case 'assertValue': return `expect ${target} to have value ${value(action.value)}`;
|
|
237
436
|
case 'assertChecked': return `expect ${target} to be ${action.checked ? 'checked' : 'unchecked'}`;
|
|
238
437
|
case 'assertVisible': return `expect ${target} to be visible`;
|
|
239
438
|
case 'assertSnapshot': return `expect ${target} to match aria snapshot`;
|