@xylentis/testgen 1.1.1 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +25 -2
- package/README.md +159 -12
- package/dist/checks.js +100 -0
- package/dist/cli.js +84 -18
- package/dist/editing.js +167 -0
- package/dist/generate.js +23 -6
- package/dist/generators/index.js +2 -2
- package/dist/generators/javascript-body.js +91 -13
- package/dist/generators/playwright-go.js +85 -21
- package/dist/generators/playwright-ts.js +44 -7
- package/dist/generators/selenium-java.js +201 -109
- package/dist/generators/selenium-js.js +39 -10
- package/dist/generators/test-data.js +171 -0
- package/dist/recording.js +43 -2
- package/dist/replay.js +268 -0
- package/dist/secrets.js +169 -0
- package/dist/text.js +28 -0
- package/dist/ui/server.js +220 -21
- package/dist/vendor/playwright/locatorParser.js +235 -0
- package/dist/vendor/playwright/locatorUtils.js +67 -0
- package/docs/testgen-ui.png +0 -0
- package/package.json +5 -3
- package/runtime/playwright-go/testgen_data_test.go +64 -0
- package/runtime/playwright-ts/testgen-data.ts +70 -0
- package/runtime/selenium-java/TestgenSupport.java +213 -29
- package/runtime/selenium-js/testgen-support.js +162 -22
- package/ui/app.js +386 -25
- package/ui/index.html +19 -3
- package/ui/style.css +80 -9
package/ui/app.js
CHANGED
|
@@ -10,9 +10,14 @@ const browsers = {
|
|
|
10
10
|
};
|
|
11
11
|
|
|
12
12
|
let targets = [];
|
|
13
|
-
let snapshot = { status: 'idle', steps: [], outputs: {}, settings: {} };
|
|
13
|
+
let snapshot = { status: 'idle', steps: [], outputs: {}, settings: {}, checks: [], missingSecrets: [] };
|
|
14
14
|
let activeTarget = remember('target') || 'playwright-ts';
|
|
15
15
|
let activeFile = 0;
|
|
16
|
+
// The step whose editor is open, with the steps it was opened on: any other change closes it.
|
|
17
|
+
let editing;
|
|
18
|
+
// What the step list and the replay result were last built from.
|
|
19
|
+
let renderedSteps = '';
|
|
20
|
+
let renderedReplay = '';
|
|
16
21
|
|
|
17
22
|
function remember(key, value) {
|
|
18
23
|
try {
|
|
@@ -39,38 +44,42 @@ function showError(message) {
|
|
|
39
44
|
|
|
40
45
|
// ---- Rendering --------------------------------------------------------------------------------
|
|
41
46
|
|
|
47
|
+
const plural = (count, word) => `${count} ${word}${count === 1 ? '' : 's'}`;
|
|
48
|
+
|
|
42
49
|
function render() {
|
|
43
50
|
const recording = snapshot.status === 'recording';
|
|
51
|
+
const replaying = snapshot.replay?.status === 'running';
|
|
44
52
|
const count = snapshot.steps.length;
|
|
45
53
|
document.body.classList.toggle('recording', recording);
|
|
46
54
|
$('record').hidden = recording;
|
|
47
55
|
$('stop').hidden = !recording;
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
56
|
+
if (recording)
|
|
57
|
+
$('status').textContent = `Recording… ${plural(count, 'step')}. Close the browser window or press Stop to finish.`;
|
|
58
|
+
else if (replaying)
|
|
59
|
+
$('status').textContent = `Replaying… step ${Math.min(snapshot.replay.steps.length, count)} of ${count}`;
|
|
60
|
+
else
|
|
61
|
+
$('status').textContent = snapshot.status === 'stopped' ? `${plural(count, 'step')} recorded` : 'Ready';
|
|
51
62
|
showError(snapshot.error || snapshot.parseError);
|
|
52
|
-
$('download-recording').toggleAttribute('hidden', !snapshot.
|
|
63
|
+
$('download-recording').toggleAttribute('hidden', !snapshot.hasRecording);
|
|
53
64
|
|
|
54
65
|
$('step-count').textContent = String(count);
|
|
55
66
|
$('steps-empty').hidden = count > 0;
|
|
56
|
-
$('
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
item.append(page, text);
|
|
65
|
-
return item;
|
|
66
|
-
}));
|
|
67
|
-
$('steps').lastElementChild?.scrollIntoView({ block: 'nearest' });
|
|
67
|
+
$('undo').disabled = !snapshot.canUndo || recording || replaying;
|
|
68
|
+
$('replay').hidden = replaying;
|
|
69
|
+
$('replay').disabled = recording || !count;
|
|
70
|
+
$('stop-replay').hidden = !replaying;
|
|
71
|
+
if (recording || replaying)
|
|
72
|
+
$('replay-secrets').hidden = true;
|
|
73
|
+
renderSteps();
|
|
74
|
+
renderReplay();
|
|
68
75
|
|
|
69
76
|
for (const [key, id] of [['name', 'name'], ['packageName', 'package'], ['testIdAttribute', 'test-id']]) {
|
|
70
77
|
const input = $(id);
|
|
71
78
|
if (document.activeElement !== input && snapshot.settings[key] !== undefined)
|
|
72
79
|
input.value = snapshot.settings[key];
|
|
73
80
|
}
|
|
81
|
+
for (const [key, id] of [['logSteps', 'log-steps'], ['dataDriven', 'data-driven']])
|
|
82
|
+
$(id).checked = !!snapshot.settings[key];
|
|
74
83
|
// The code keeps the test id attribute the recording was made with; the field is for the next one.
|
|
75
84
|
const recorded = snapshot.recordedTestIdAttribute;
|
|
76
85
|
const next = snapshot.settings.testIdAttribute;
|
|
@@ -79,6 +88,341 @@ function render() {
|
|
|
79
88
|
: '';
|
|
80
89
|
renderTargets();
|
|
81
90
|
renderCode();
|
|
91
|
+
renderChecks();
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ---- Steps: list, editor, replay ------------------------------------------------------------
|
|
95
|
+
|
|
96
|
+
const replaySymbols = { running: '…', passed: '✓', skipped: '–', failed: '✗' };
|
|
97
|
+
|
|
98
|
+
function renderSteps() {
|
|
99
|
+
const stepsJson = JSON.stringify(snapshot.steps);
|
|
100
|
+
if (editing && (editing.steps !== stepsJson || snapshot.status === 'recording' || snapshot.replay?.status === 'running'))
|
|
101
|
+
editing = undefined;
|
|
102
|
+
const key = JSON.stringify([stepsJson, snapshot.status, snapshot.replay?.status, snapshot.replay?.steps, snapshot.checks, editing?.index]);
|
|
103
|
+
if (key === renderedSteps)
|
|
104
|
+
return;
|
|
105
|
+
renderedSteps = key;
|
|
106
|
+
$('steps').replaceChildren(...snapshot.steps.map(stepItem));
|
|
107
|
+
if (snapshot.status === 'recording')
|
|
108
|
+
$('steps').lastElementChild?.scrollIntoView({ block: 'nearest' });
|
|
109
|
+
else if (snapshot.replay?.status === 'running')
|
|
110
|
+
$('steps').children[snapshot.replay.steps.length - 1]?.scrollIntoView({ block: 'nearest' });
|
|
111
|
+
if (editing)
|
|
112
|
+
$('steps').querySelector('.step-editor input, .step-editor textarea')?.focus();
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function stepItem(step, index) {
|
|
116
|
+
const item = document.createElement('li');
|
|
117
|
+
item.id = `step-${index + 1}`;
|
|
118
|
+
const result = snapshot.replay?.steps?.[index];
|
|
119
|
+
if (result)
|
|
120
|
+
item.dataset.replay = result.status;
|
|
121
|
+
const page = element('span', 'page', step.page);
|
|
122
|
+
const text = element('span', 'text', step.text);
|
|
123
|
+
const badges = element('span', 'badges');
|
|
124
|
+
for (const check of snapshot.checks.filter(c => c.step === index && c.level === 'warning'))
|
|
125
|
+
badges.append(element('span', 'badge warning', '!', check.message));
|
|
126
|
+
if (result)
|
|
127
|
+
badges.append(element('span', `badge replay ${result.status}`, replaySymbols[result.status], replayTitle(result)));
|
|
128
|
+
const body = element('span', 'body');
|
|
129
|
+
body.append(text, badges);
|
|
130
|
+
item.append(page, body);
|
|
131
|
+
|
|
132
|
+
const busy = snapshot.status === 'recording' || snapshot.replay?.status === 'running';
|
|
133
|
+
if (!busy && editing?.index !== index) {
|
|
134
|
+
const actions = element('span', 'step-actions');
|
|
135
|
+
const n = index + 1;
|
|
136
|
+
actions.append(
|
|
137
|
+
stepButton('Edit', `Edit step ${n}`, () => {
|
|
138
|
+
editing = { index, steps: JSON.stringify(snapshot.steps) };
|
|
139
|
+
renderSteps();
|
|
140
|
+
}),
|
|
141
|
+
stepButton('↑', `Move step ${n} up`, () => changeStep({ action: 'move', index, to: index - 1 }), index === 0),
|
|
142
|
+
stepButton('↓', `Move step ${n} down`, () => changeStep({ action: 'move', index, to: index + 1 }), index === snapshot.steps.length - 1),
|
|
143
|
+
stepButton('✕', `Delete step ${n}`, () => changeStep({ action: 'delete', index })),
|
|
144
|
+
);
|
|
145
|
+
item.append(actions);
|
|
146
|
+
}
|
|
147
|
+
if (result?.status === 'failed' || result?.note)
|
|
148
|
+
item.append(element('p', 'step-detail', result.error ?? result.note));
|
|
149
|
+
if (editing?.index === index)
|
|
150
|
+
item.append(stepEditor(step, index));
|
|
151
|
+
return item;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function replayTitle(result) {
|
|
155
|
+
const time = result.duration !== undefined ? ` in ${result.duration} ms` : '';
|
|
156
|
+
return { running: 'Running', passed: `Passed${time}`, skipped: `Skipped: ${result.note}`, failed: `Failed${time}` }[result.status];
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function stepButton(label, title, onClick, disabled = false) {
|
|
160
|
+
const button = element('button', 'step-button', label);
|
|
161
|
+
button.type = 'button';
|
|
162
|
+
button.title = title;
|
|
163
|
+
button.setAttribute('aria-label', title);
|
|
164
|
+
button.disabled = disabled;
|
|
165
|
+
button.addEventListener('click', onClick);
|
|
166
|
+
return button;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async function changeStep(body) {
|
|
170
|
+
try {
|
|
171
|
+
await post('/api/steps', body);
|
|
172
|
+
showError('');
|
|
173
|
+
} catch (error) {
|
|
174
|
+
showError(error.message);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Form for the locator, value and secret of a step; it sends only what changed.
|
|
179
|
+
function stepEditor(step, index) {
|
|
180
|
+
const { fields } = step;
|
|
181
|
+
const form = element('form', 'step-editor');
|
|
182
|
+
form.setAttribute('aria-label', `Edit step ${index + 1}`);
|
|
183
|
+
const controls = {};
|
|
184
|
+
const row = (label, control) => {
|
|
185
|
+
const wrapper = element('label', 'field');
|
|
186
|
+
wrapper.append(element('span', '', label), control);
|
|
187
|
+
form.append(wrapper);
|
|
188
|
+
};
|
|
189
|
+
if (fields.locator !== undefined) {
|
|
190
|
+
controls.locator = input('text', fields.locator);
|
|
191
|
+
controls.locator.spellcheck = false;
|
|
192
|
+
row('Locator', controls.locator);
|
|
193
|
+
}
|
|
194
|
+
if (fields.valueLabel) {
|
|
195
|
+
const lines = fields.valueLabel === 'Options' || fields.valueLabel === 'Files';
|
|
196
|
+
controls.value = lines ? element('textarea') : input(fields.secret ? 'password' : 'text', fields.value ?? '');
|
|
197
|
+
if (lines) {
|
|
198
|
+
controls.value.value = fields.value ?? '';
|
|
199
|
+
controls.value.rows = Math.min(4, Math.max(2, (fields.value ?? '').split('\n').length));
|
|
200
|
+
}
|
|
201
|
+
if (fields.secret)
|
|
202
|
+
controls.value.placeholder = 'unchanged';
|
|
203
|
+
row(lines ? `${fields.valueLabel} (one per line)` : fields.valueLabel, controls.value);
|
|
204
|
+
}
|
|
205
|
+
if (fields.substring !== undefined) {
|
|
206
|
+
controls.substring = input('checkbox');
|
|
207
|
+
controls.substring.checked = fields.substring;
|
|
208
|
+
row('Contains the text, not the whole text', controls.substring);
|
|
209
|
+
}
|
|
210
|
+
if (fields.checked !== undefined) {
|
|
211
|
+
controls.checked = input('checkbox');
|
|
212
|
+
controls.checked.checked = fields.checked;
|
|
213
|
+
row('Checked', controls.checked);
|
|
214
|
+
}
|
|
215
|
+
if (fields.secretChosen !== undefined) {
|
|
216
|
+
controls.secret = input('checkbox');
|
|
217
|
+
controls.secret.checked = !!fields.secret;
|
|
218
|
+
controls.secretName = input('text', fields.secret ?? '');
|
|
219
|
+
controls.secretName.spellcheck = false;
|
|
220
|
+
controls.secretName.disabled = !fields.secret;
|
|
221
|
+
controls.secretName.setAttribute('aria-label', 'Environment variable');
|
|
222
|
+
controls.secret.addEventListener('change', () => {
|
|
223
|
+
controls.secretName.disabled = !controls.secret.checked;
|
|
224
|
+
if (controls.secret.checked && !controls.secretName.value)
|
|
225
|
+
controls.secretName.value = 'TEST_SECRET';
|
|
226
|
+
});
|
|
227
|
+
const wrapper = element('div', 'field secret-field');
|
|
228
|
+
const label = element('label');
|
|
229
|
+
label.append(controls.secret, ' Secret: the test reads it from');
|
|
230
|
+
wrapper.append(label, controls.secretName);
|
|
231
|
+
form.append(wrapper);
|
|
232
|
+
}
|
|
233
|
+
const error = element('p', 'editor-error');
|
|
234
|
+
error.setAttribute('role', 'alert');
|
|
235
|
+
const buttons = element('div', 'editor-buttons');
|
|
236
|
+
const save = element('button', 'primary', 'Save');
|
|
237
|
+
save.type = 'submit';
|
|
238
|
+
const cancel = element('button', '', 'Cancel');
|
|
239
|
+
cancel.type = 'button';
|
|
240
|
+
cancel.addEventListener('click', () => {
|
|
241
|
+
editing = undefined;
|
|
242
|
+
renderSteps();
|
|
243
|
+
});
|
|
244
|
+
buttons.append(save, cancel);
|
|
245
|
+
form.append(error, buttons);
|
|
246
|
+
form.addEventListener('keydown', event => {
|
|
247
|
+
if (event.key === 'Escape')
|
|
248
|
+
cancel.click();
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
form.addEventListener('submit', async event => {
|
|
252
|
+
event.preventDefault();
|
|
253
|
+
const change = {};
|
|
254
|
+
if (controls.locator && controls.locator.value !== fields.locator)
|
|
255
|
+
change.locator = controls.locator.value;
|
|
256
|
+
if (controls.value && controls.value.value !== (fields.value ?? '') && !(fields.secret && !controls.value.value))
|
|
257
|
+
change.value = controls.value.value;
|
|
258
|
+
if (controls.substring && controls.substring.checked !== fields.substring)
|
|
259
|
+
change.substring = controls.substring.checked;
|
|
260
|
+
if (controls.checked && controls.checked.checked !== fields.checked)
|
|
261
|
+
change.checked = controls.checked.checked;
|
|
262
|
+
if (controls.secret) {
|
|
263
|
+
const name = controls.secretName.value.trim();
|
|
264
|
+
if (controls.secret.checked && name !== fields.secret)
|
|
265
|
+
change.secret = name;
|
|
266
|
+
if (!controls.secret.checked && fields.secret)
|
|
267
|
+
change.secret = false;
|
|
268
|
+
}
|
|
269
|
+
if (!Object.keys(change).length) {
|
|
270
|
+
cancel.click();
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
try {
|
|
274
|
+
await post('/api/steps', { action: 'edit', index, change });
|
|
275
|
+
// The new steps close the editor when they arrive; close it now if they have not yet.
|
|
276
|
+
if (editing?.index === index) {
|
|
277
|
+
editing = undefined;
|
|
278
|
+
renderSteps();
|
|
279
|
+
}
|
|
280
|
+
} catch (e) {
|
|
281
|
+
error.textContent = e.message;
|
|
282
|
+
}
|
|
283
|
+
});
|
|
284
|
+
return form;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function renderReplay() {
|
|
288
|
+
const replay = snapshot.replay;
|
|
289
|
+
const key = JSON.stringify(replay ?? null);
|
|
290
|
+
if (key === renderedReplay)
|
|
291
|
+
return;
|
|
292
|
+
renderedReplay = key;
|
|
293
|
+
const box = $('replay-result');
|
|
294
|
+
box.hidden = !replay;
|
|
295
|
+
if (!replay)
|
|
296
|
+
return;
|
|
297
|
+
box.className = `replay-result ${replay.status}`;
|
|
298
|
+
const failed = replay.steps.findIndex(step => step?.status === 'failed');
|
|
299
|
+
const skipped = replay.steps.filter(step => step?.status === 'skipped').length;
|
|
300
|
+
const seconds = `${((replay.duration ?? 0) / 1000).toFixed(1)} s`;
|
|
301
|
+
const summary = {
|
|
302
|
+
running: 'Replaying the steps with Playwright…',
|
|
303
|
+
passed: `Replay passed: ${plural(replay.steps.length, 'step')} in ${seconds}${skipped ? `, ${skipped} skipped` : ''}.`,
|
|
304
|
+
failed: failed >= 0 ? `Replay failed at step ${failed + 1}.` : `Replay failed: ${replay.error}`,
|
|
305
|
+
stopped: 'Replay stopped.',
|
|
306
|
+
}[replay.status];
|
|
307
|
+
const children = [element('p', 'summary', summary)];
|
|
308
|
+
if (failed >= 0) {
|
|
309
|
+
children.push(element('pre', 'replay-error', replay.steps[failed].error));
|
|
310
|
+
const link = document.createElement('a');
|
|
311
|
+
link.href = `/api/replay/screenshot?v=${replay.screenshot ?? 0}`;
|
|
312
|
+
link.target = '_blank';
|
|
313
|
+
link.className = 'screenshot';
|
|
314
|
+
if (replay.screenshot) {
|
|
315
|
+
const image = document.createElement('img');
|
|
316
|
+
// The list gets shorter once the screenshot shows.
|
|
317
|
+
image.addEventListener('load', revealFailedStep);
|
|
318
|
+
image.src = link.href;
|
|
319
|
+
image.alt = `The page when step ${failed + 1} failed`;
|
|
320
|
+
link.append(image);
|
|
321
|
+
children.push(link);
|
|
322
|
+
}
|
|
323
|
+
const edit = element('button', '', `Edit step ${failed + 1}`);
|
|
324
|
+
edit.type = 'button';
|
|
325
|
+
edit.addEventListener('click', () => {
|
|
326
|
+
editing = { index: failed, steps: JSON.stringify(snapshot.steps) };
|
|
327
|
+
renderSteps();
|
|
328
|
+
$(`step-${failed + 1}`)?.scrollIntoView({ block: 'nearest' });
|
|
329
|
+
});
|
|
330
|
+
children.push(edit);
|
|
331
|
+
}
|
|
332
|
+
box.replaceChildren(...children);
|
|
333
|
+
revealFailedStep();
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function revealFailedStep() {
|
|
337
|
+
$('steps').querySelector('[data-replay="failed"]')?.scrollIntoView({ block: 'nearest' });
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// Asks for the values of secrets the recording does not keep, then replays.
|
|
341
|
+
function showReplaySecrets() {
|
|
342
|
+
const form = $('replay-secrets');
|
|
343
|
+
const fields = snapshot.missingSecrets.map(name => {
|
|
344
|
+
const label = element('label', 'field');
|
|
345
|
+
const value = input('password');
|
|
346
|
+
value.name = name;
|
|
347
|
+
value.required = true;
|
|
348
|
+
value.autocomplete = 'off';
|
|
349
|
+
label.append(element('span', '', name), value);
|
|
350
|
+
return label;
|
|
351
|
+
});
|
|
352
|
+
const start = element('button', 'primary', 'Replay');
|
|
353
|
+
start.type = 'submit';
|
|
354
|
+
const cancel = element('button', '', 'Cancel');
|
|
355
|
+
cancel.type = 'button';
|
|
356
|
+
cancel.addEventListener('click', () => form.hidden = true);
|
|
357
|
+
const buttons = element('div', 'editor-buttons');
|
|
358
|
+
buttons.append(start, cancel);
|
|
359
|
+
form.replaceChildren(element('p', '', 'The recording does not keep the values typed into password fields. Enter them to replay; testgen keeps them in memory until it stops.'), ...fields, buttons);
|
|
360
|
+
form.hidden = false;
|
|
361
|
+
form.querySelector('input')?.focus();
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
$('replay-secrets').addEventListener('submit', event => {
|
|
365
|
+
event.preventDefault();
|
|
366
|
+
const secrets = Object.fromEntries([...$('replay-secrets').querySelectorAll('input')].map(input => [input.name, input.value]));
|
|
367
|
+
$('replay-secrets').hidden = true;
|
|
368
|
+
startReplay(secrets);
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
async function startReplay(secrets = {}) {
|
|
372
|
+
editing = undefined;
|
|
373
|
+
try {
|
|
374
|
+
await post('/api/replay', { secrets });
|
|
375
|
+
showError('');
|
|
376
|
+
} catch (error) {
|
|
377
|
+
showError(error.message);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
$('replay').addEventListener('click', () => snapshot.missingSecrets.length ? showReplaySecrets() : startReplay());
|
|
382
|
+
$('stop-replay').addEventListener('click', () => post('/api/replay/stop').catch(error => showError(error.message)));
|
|
383
|
+
$('undo').addEventListener('click', () => post('/api/undo').catch(error => showError(error.message)));
|
|
384
|
+
|
|
385
|
+
// Checks of the recording, and what the code of the open target cannot do.
|
|
386
|
+
function renderChecks() {
|
|
387
|
+
const target = snapshot.outputs[activeTarget];
|
|
388
|
+
const checks = [...snapshot.checks, ...(target?.checks ?? [])];
|
|
389
|
+
$('checks').hidden = !checks.length;
|
|
390
|
+
$('check-count').textContent = String(checks.length);
|
|
391
|
+
$('check-list').replaceChildren(...checks.map(check => {
|
|
392
|
+
const item = element('li', check.level, check.message);
|
|
393
|
+
if (check.step !== undefined) {
|
|
394
|
+
item.tabIndex = 0;
|
|
395
|
+
item.title = `Show step ${check.step + 1}`;
|
|
396
|
+
const show = () => {
|
|
397
|
+
const step = $(`step-${check.step + 1}`);
|
|
398
|
+
step?.scrollIntoView({ block: 'nearest' });
|
|
399
|
+
step?.classList.add('flash');
|
|
400
|
+
setTimeout(() => step?.classList.remove('flash'), 1200);
|
|
401
|
+
};
|
|
402
|
+
item.addEventListener('click', show);
|
|
403
|
+
item.addEventListener('keydown', event => event.key === 'Enter' && show());
|
|
404
|
+
}
|
|
405
|
+
return item;
|
|
406
|
+
}));
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
function element(tag, className = '', text, title) {
|
|
410
|
+
const node = document.createElement(tag);
|
|
411
|
+
if (className)
|
|
412
|
+
node.className = className;
|
|
413
|
+
if (text !== undefined)
|
|
414
|
+
node.textContent = text;
|
|
415
|
+
if (title)
|
|
416
|
+
node.title = title;
|
|
417
|
+
return node;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
function input(type, value) {
|
|
421
|
+
const node = document.createElement('input');
|
|
422
|
+
node.type = type;
|
|
423
|
+
if (value !== undefined)
|
|
424
|
+
node.value = value;
|
|
425
|
+
return node;
|
|
82
426
|
}
|
|
83
427
|
|
|
84
428
|
function renderTargets() {
|
|
@@ -87,7 +431,9 @@ function renderTargets() {
|
|
|
87
431
|
button.type = 'button';
|
|
88
432
|
button.setAttribute('role', 'tab');
|
|
89
433
|
button.setAttribute('aria-selected', String(target.id === activeTarget));
|
|
90
|
-
|
|
434
|
+
const todos = snapshot.outputs[target.id]?.checks?.length;
|
|
435
|
+
const badge = todos ? ` <span class="tag warning" title="${plural(todos, 'thing')} this target cannot do: see Checks">${todos}</span>` : '';
|
|
436
|
+
button.innerHTML = `${escapeHtml(target.framework.split(' ')[0])} · ${escapeHtml(target.language)}${badge}<small>${escapeHtml(target.id)}</small>`;
|
|
91
437
|
button.addEventListener('click', () => {
|
|
92
438
|
activeTarget = target.id;
|
|
93
439
|
activeFile = 0;
|
|
@@ -104,6 +450,11 @@ function currentFiles() {
|
|
|
104
450
|
}
|
|
105
451
|
|
|
106
452
|
function renderCode() {
|
|
453
|
+
const target = targets.find(t => t.id === activeTarget);
|
|
454
|
+
const ignored = [['logSteps', 'Log steps'], ['dataDriven', 'Data-driven']]
|
|
455
|
+
.filter(([key]) => snapshot.settings[key] && target && !target.features.includes(key))
|
|
456
|
+
.map(([, label]) => label);
|
|
457
|
+
$('target-note').textContent = ignored.length ? `${ignored.join(' and ')}: not for this target (Playwright shows steps in its report and trace).` : '';
|
|
107
458
|
const files = currentFiles();
|
|
108
459
|
if (activeFile >= files.length)
|
|
109
460
|
activeFile = 0;
|
|
@@ -112,7 +463,8 @@ function renderCode() {
|
|
|
112
463
|
button.type = 'button';
|
|
113
464
|
button.setAttribute('role', 'tab');
|
|
114
465
|
button.setAttribute('aria-selected', String(index === activeFile));
|
|
115
|
-
|
|
466
|
+
const tag = file.support ? 'support' : file.data ? 'data' : '';
|
|
467
|
+
button.innerHTML = `${escapeHtml(file.path)}${tag ? `<span class="tag">${tag}</span>` : ''}`;
|
|
116
468
|
button.addEventListener('click', () => {
|
|
117
469
|
activeFile = index;
|
|
118
470
|
renderCode();
|
|
@@ -195,15 +547,24 @@ $('record-form').addEventListener('submit', async event => {
|
|
|
195
547
|
$('stop').addEventListener('click', () => post('/api/stop').catch(error => showError(error.message)));
|
|
196
548
|
|
|
197
549
|
let settingsTimer;
|
|
550
|
+
function postSettings() {
|
|
551
|
+
clearTimeout(settingsTimer);
|
|
552
|
+
post('/api/settings', {
|
|
553
|
+
name: $('name').value,
|
|
554
|
+
packageName: $('package').value,
|
|
555
|
+
testIdAttribute: $('test-id').value,
|
|
556
|
+
logSteps: $('log-steps').checked,
|
|
557
|
+
dataDriven: $('data-driven').checked,
|
|
558
|
+
}).catch(error => showError(error.message));
|
|
559
|
+
}
|
|
198
560
|
for (const id of ['name', 'package', 'test-id']) {
|
|
199
561
|
$(id).addEventListener('input', () => {
|
|
200
562
|
clearTimeout(settingsTimer);
|
|
201
|
-
settingsTimer = setTimeout(
|
|
202
|
-
post('/api/settings', { name: $('name').value, packageName: $('package').value, testIdAttribute: $('test-id').value })
|
|
203
|
-
.catch(error => showError(error.message));
|
|
204
|
-
}, 300);
|
|
563
|
+
settingsTimer = setTimeout(postSettings, 300);
|
|
205
564
|
});
|
|
206
565
|
}
|
|
566
|
+
for (const id of ['log-steps', 'data-driven'])
|
|
567
|
+
$(id).addEventListener('change', postSettings);
|
|
207
568
|
|
|
208
569
|
$('open').addEventListener('change', async () => {
|
|
209
570
|
const file = $('open').files[0];
|
|
@@ -243,8 +604,8 @@ $('save-form').addEventListener('submit', async event => {
|
|
|
243
604
|
event.preventDefault();
|
|
244
605
|
$('save-result').textContent = 'Saving…';
|
|
245
606
|
try {
|
|
246
|
-
const { written } = await post('/api/save', { target: activeTarget, outDir: $('out-dir').value });
|
|
247
|
-
$('save-result').textContent = `Saved ${written.join(', ')}
|
|
607
|
+
const { written, kept } = await post('/api/save', { target: activeTarget, outDir: $('out-dir').value });
|
|
608
|
+
$('save-result').textContent = `Saved ${written.join(', ')}` + (kept.length ? `. Kept the existing ${kept.join(', ')}.` : '');
|
|
248
609
|
} catch (error) {
|
|
249
610
|
$('save-result').textContent = '';
|
|
250
611
|
showError(error.message);
|
package/ui/index.html
CHANGED
|
@@ -29,6 +29,8 @@
|
|
|
29
29
|
<label>Package <input id="package" placeholder="e.g. com.acme.e2e" spellcheck="false"></label>
|
|
30
30
|
<label>Test id attribute <input id="test-id" value="data-testid" spellcheck="false" aria-describedby="test-id-note"></label>
|
|
31
31
|
<span id="test-id-note" class="note" role="status"></span>
|
|
32
|
+
<label title="Selenium targets: log every step and assertion result (console, TestNG report)"><input id="log-steps" type="checkbox"> Log steps</label>
|
|
33
|
+
<label title="Typed, selected and asserted values come from a CSV file next to the test; the test runs once per row"><input id="data-driven" type="checkbox"> Data-driven (CSV)</label>
|
|
32
34
|
<span class="spacer"></span>
|
|
33
35
|
<label class="file-button">Open recording… <input id="open" type="file" accept=".jsonl,.json,.txt"></label>
|
|
34
36
|
<a id="download-recording" class="button" href="/api/recording" download="recording.jsonl">Download recording</a>
|
|
@@ -37,17 +39,31 @@
|
|
|
37
39
|
<div id="error" class="error" hidden></div>
|
|
38
40
|
|
|
39
41
|
<main class="workspace">
|
|
40
|
-
<aside class="steps">
|
|
41
|
-
<
|
|
42
|
+
<aside class="steps" aria-label="Steps">
|
|
43
|
+
<div class="steps-bar">
|
|
44
|
+
<h2>Steps <span id="step-count" class="count">0</span></h2>
|
|
45
|
+
<span class="spacer"></span>
|
|
46
|
+
<button id="undo" type="button" title="Undo the last change to the steps" disabled>Undo</button>
|
|
47
|
+
<button id="replay" type="button" title="Run the steps again in a browser, with Playwright, to see whether they still work">Replay</button>
|
|
48
|
+
<button id="stop-replay" type="button" class="danger" hidden>Stop replay</button>
|
|
49
|
+
</div>
|
|
50
|
+
<form id="replay-secrets" class="replay-secrets" hidden></form>
|
|
51
|
+
<div id="replay-result" class="replay-result" role="status" hidden></div>
|
|
42
52
|
<ol id="steps"></ol>
|
|
43
53
|
<p id="steps-empty" class="empty">Enter a URL and press <strong>Record</strong>. A browser opens; click and type as a user would.
|
|
44
|
-
Use the recorder toolbar in the page to add assertions. Close the browser or press <strong>Stop</strong> when done
|
|
54
|
+
Use the recorder toolbar in the page to add assertions. Close the browser or press <strong>Stop</strong> when done.
|
|
55
|
+
Then edit, reorder or delete steps here, and press <strong>Replay</strong> to check that they still work.</p>
|
|
56
|
+
<details id="checks" class="checks" hidden>
|
|
57
|
+
<summary>Checks <span id="check-count" class="count">0</span></summary>
|
|
58
|
+
<ul id="check-list"></ul>
|
|
59
|
+
</details>
|
|
45
60
|
</aside>
|
|
46
61
|
|
|
47
62
|
<section class="code" aria-label="Generated code">
|
|
48
63
|
<nav id="targets" class="tabs" role="tablist"></nav>
|
|
49
64
|
<div class="file-bar">
|
|
50
65
|
<nav id="files" class="file-tabs" role="tablist"></nav>
|
|
66
|
+
<span id="target-note" class="note" role="status"></span>
|
|
51
67
|
<span class="spacer"></span>
|
|
52
68
|
<button id="copy" type="button">Copy</button>
|
|
53
69
|
<button id="download" type="button">Download</button>
|