@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/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, recordedTestIdAttribute } 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`;
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright (c) Microsoft Corporation.
|
|
3
|
+
*
|
|
4
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
* you may not use this file except in compliance with the License.
|
|
6
|
+
* You may obtain a copy of the License at
|
|
7
|
+
*
|
|
8
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
*
|
|
10
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
* See the License for the specific language governing permissions and
|
|
14
|
+
* limitations under the License.
|
|
15
|
+
*/
|
|
16
|
+
import { asLocators } from './locatorGenerators.js';
|
|
17
|
+
import { encodeTestIdAttributeName } from './locatorUtils.js';
|
|
18
|
+
import { parseSelector } from './selectorParser.js';
|
|
19
|
+
import { escapeForAttributeSelector, escapeForTextSelector } from './stringUtils.js';
|
|
20
|
+
function parseLocator(locator, testIdAttributeName) {
|
|
21
|
+
locator = locator
|
|
22
|
+
.replace(/AriaRole\s*\.\s*([\w]+)/g, (_, group) => group.toLowerCase())
|
|
23
|
+
.replace(/(get_by_role|getByRole)\s*\(\s*(?:["'`])([^'"`]+)['"`]/g, (_, group1, group2) => `${group1}(${group2.toLowerCase()}`);
|
|
24
|
+
const params = [];
|
|
25
|
+
let template = '';
|
|
26
|
+
for (let i = 0; i < locator.length; ++i) {
|
|
27
|
+
const quote = locator[i];
|
|
28
|
+
if (quote !== '"' && quote !== '\'' && quote !== '`' && quote !== '/') {
|
|
29
|
+
template += quote;
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
const isRegexEscaping = locator[i - 1] === 'r' || locator[i] === '/';
|
|
33
|
+
++i;
|
|
34
|
+
let text = '';
|
|
35
|
+
while (i < locator.length) {
|
|
36
|
+
if (locator[i] === '\\') {
|
|
37
|
+
if (isRegexEscaping) {
|
|
38
|
+
if (locator[i + 1] !== quote)
|
|
39
|
+
text += locator[i];
|
|
40
|
+
++i;
|
|
41
|
+
text += locator[i];
|
|
42
|
+
}
|
|
43
|
+
else {
|
|
44
|
+
++i;
|
|
45
|
+
if (locator[i] === 'n')
|
|
46
|
+
text += '\n';
|
|
47
|
+
else if (locator[i] === 'r')
|
|
48
|
+
text += '\r';
|
|
49
|
+
else if (locator[i] === 't')
|
|
50
|
+
text += '\t';
|
|
51
|
+
else
|
|
52
|
+
text += locator[i];
|
|
53
|
+
}
|
|
54
|
+
++i;
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
if (locator[i] !== quote) {
|
|
58
|
+
text += locator[i++];
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
break;
|
|
62
|
+
}
|
|
63
|
+
params.push({ quote, text });
|
|
64
|
+
template += (quote === '/' ? 'r' : '') + '$' + params.length;
|
|
65
|
+
}
|
|
66
|
+
// Equalize languages.
|
|
67
|
+
template = template.toLowerCase()
|
|
68
|
+
.replace(/get_by_alt_text/g, 'getbyalttext')
|
|
69
|
+
.replace(/get_by_test_id/g, 'getbytestid')
|
|
70
|
+
.replace(/get_by_([\w]+)/g, 'getby$1')
|
|
71
|
+
.replace(/has_not_text/g, 'hasnottext')
|
|
72
|
+
.replace(/has_text/g, 'hastext')
|
|
73
|
+
.replace(/has_not/g, 'hasnot')
|
|
74
|
+
.replace(/frame_locator/g, 'framelocator')
|
|
75
|
+
.replace(/content_frame/g, 'contentframe')
|
|
76
|
+
.replace(/[{}\s]/g, '')
|
|
77
|
+
.replace(/new\(\)/g, '')
|
|
78
|
+
.replace(/new[\w]+\.[\w]+options\(\)/g, '')
|
|
79
|
+
.replace(/\.set/g, ',set')
|
|
80
|
+
.replace(/\.or_\(/g, 'or(') // Python has "or_" instead of "or".
|
|
81
|
+
.replace(/\.and_\(/g, 'and(') // Python has "and_" instead of "and".
|
|
82
|
+
.replace(/:/g, '=')
|
|
83
|
+
.replace(/,re\.ignorecase/g, 'i')
|
|
84
|
+
.replace(/,pattern.case_insensitive/g, 'i')
|
|
85
|
+
.replace(/,regexoptions.ignorecase/g, 'i')
|
|
86
|
+
.replace(/re.compile\(([^)]+)\)/g, '$1') // Python has regex strings as r"foo"
|
|
87
|
+
.replace(/pattern.compile\(([^)]+)\)/g, 'r$1')
|
|
88
|
+
.replace(/newregex\(([^)]+)\)/g, 'r$1')
|
|
89
|
+
.replace(/string=/g, '=')
|
|
90
|
+
.replace(/regex=/g, '=')
|
|
91
|
+
.replace(/,,/g, ',')
|
|
92
|
+
.replace(/,\)/g, ')');
|
|
93
|
+
const preferredQuote = params.map(p => p.quote).filter(quote => '\'"`'.includes(quote))[0];
|
|
94
|
+
return { selector: transform(template, params, testIdAttributeName), preferredQuote };
|
|
95
|
+
}
|
|
96
|
+
function countParams(template) {
|
|
97
|
+
return [...template.matchAll(/\$\d+/g)].length;
|
|
98
|
+
}
|
|
99
|
+
function shiftParams(template, sub) {
|
|
100
|
+
return template.replace(/\$(\d+)/g, (_, ordinal) => `$${ordinal - sub}`);
|
|
101
|
+
}
|
|
102
|
+
function transform(template, params, testIdAttributeName) {
|
|
103
|
+
// Recursively handle filter(has=, hasnot=, sethas(), sethasnot()).
|
|
104
|
+
// TODO: handle and(locator), or(locator), locator(locator), locator(has=, hasnot=, sethas(), sethasnot()).
|
|
105
|
+
while (true) {
|
|
106
|
+
const hasMatch = template.match(/filter\(,?(has=|hasnot=|sethas\(|sethasnot\()/);
|
|
107
|
+
if (!hasMatch)
|
|
108
|
+
break;
|
|
109
|
+
// Extract inner locator based on balanced parens.
|
|
110
|
+
const start = hasMatch.index + hasMatch[0].length;
|
|
111
|
+
let balance = 0;
|
|
112
|
+
let end = start;
|
|
113
|
+
for (; end < template.length; end++) {
|
|
114
|
+
if (template[end] === '(')
|
|
115
|
+
balance++;
|
|
116
|
+
else if (template[end] === ')')
|
|
117
|
+
balance--;
|
|
118
|
+
if (balance < 0)
|
|
119
|
+
break;
|
|
120
|
+
}
|
|
121
|
+
// Replace Java sethas(...) and sethasnot(...) with has=... and hasnot=...
|
|
122
|
+
let prefix = template.substring(0, start);
|
|
123
|
+
let extraSymbol = 0;
|
|
124
|
+
if (['sethas(', 'sethasnot('].includes(hasMatch[1])) {
|
|
125
|
+
// Eat extra ) symbol at the end of sethas(...)
|
|
126
|
+
extraSymbol = 1;
|
|
127
|
+
prefix = prefix.replace(/sethas\($/, 'has=').replace(/sethasnot\($/, 'hasnot=');
|
|
128
|
+
}
|
|
129
|
+
const paramsCountBeforeHas = countParams(template.substring(0, start));
|
|
130
|
+
const hasTemplate = shiftParams(template.substring(start, end), paramsCountBeforeHas);
|
|
131
|
+
const paramsCountInHas = countParams(hasTemplate);
|
|
132
|
+
const hasParams = params.slice(paramsCountBeforeHas, paramsCountBeforeHas + paramsCountInHas);
|
|
133
|
+
const hasSelector = JSON.stringify(transform(hasTemplate, hasParams, testIdAttributeName));
|
|
134
|
+
// Replace filter(has=...) with filter(has2=$5). Use has2 to avoid matching the same filter again.
|
|
135
|
+
// Replace filter(hasnot=...) with filter(hasnot2=$5). Use hasnot2 to avoid matching the same filter again.
|
|
136
|
+
template = prefix.replace(/=$/, '2=') + `$${paramsCountBeforeHas + 1}` + shiftParams(template.substring(end + extraSymbol), paramsCountInHas - 1);
|
|
137
|
+
// Replace inner params with $5 value.
|
|
138
|
+
const paramsBeforeHas = params.slice(0, paramsCountBeforeHas);
|
|
139
|
+
const paramsAfterHas = params.slice(paramsCountBeforeHas + paramsCountInHas);
|
|
140
|
+
params = paramsBeforeHas.concat([{ quote: '"', text: hasSelector }]).concat(paramsAfterHas);
|
|
141
|
+
}
|
|
142
|
+
// Transform to selector engines.
|
|
143
|
+
template = template
|
|
144
|
+
.replace(/\,set([\w]+)\(([^)]+)\)/g, (_, group1, group2) => ',' + group1.toLowerCase() + '=' + group2.toLowerCase())
|
|
145
|
+
.replace(/framelocator\(\)/g, 'internal:control=any-frame')
|
|
146
|
+
.replace(/framelocator\(([^)]+)\)/g, '$1.internal:control=enter-frame')
|
|
147
|
+
.replace(/contentframe(\(\))?/g, 'internal:control=enter-frame')
|
|
148
|
+
.replace(/locator\(([^)]+),hastext=([^),]+)\)/g, 'locator($1).internal:has-text=$2')
|
|
149
|
+
.replace(/locator\(([^)]+),hasnottext=([^),]+)\)/g, 'locator($1).internal:has-not-text=$2')
|
|
150
|
+
.replace(/locator\(([^)]+),hastext=([^),]+)\)/g, 'locator($1).internal:has-text=$2')
|
|
151
|
+
.replace(/locator\(([^)]+)\)/g, '$1')
|
|
152
|
+
.replace(/getbyrole\(([^)]+)\)/g, 'internal:role=$1')
|
|
153
|
+
.replace(/getbytext\(([^)]+)\)/g, 'internal:text=$1')
|
|
154
|
+
.replace(/getbylabel\(([^)]+)\)/g, 'internal:label=$1')
|
|
155
|
+
.replace(/getbytestid\(([^)]+)\)/g, `internal:testid=[${encodeTestIdAttributeName(testIdAttributeName)}=$1]`)
|
|
156
|
+
.replace(/getby(placeholder|alt|title)(?:text)?\(([^)]+)\)/g, 'internal:attr=[$1=$2]')
|
|
157
|
+
.replace(/first(\(\))?/g, 'nth=0')
|
|
158
|
+
.replace(/last(\(\))?/g, 'nth=-1')
|
|
159
|
+
.replace(/nth\(([^)]+)\)/g, 'nth=$1')
|
|
160
|
+
.replace(/filter\(,?visible=true\)/g, 'visible=true')
|
|
161
|
+
.replace(/filter\(,?visible=false\)/g, 'visible=false')
|
|
162
|
+
.replace(/\.visible(\(\))?(?!=)/g, '.visible=true')
|
|
163
|
+
.replace(/filter\(,?hastext=([^)]+)\)/g, 'internal:has-text=$1')
|
|
164
|
+
.replace(/filter\(,?hasnottext=([^)]+)\)/g, 'internal:has-not-text=$1')
|
|
165
|
+
.replace(/filter\(,?has2=([^)]+)\)/g, 'internal:has=$1')
|
|
166
|
+
.replace(/filter\(,?hasnot2=([^)]+)\)/g, 'internal:has-not=$1')
|
|
167
|
+
.replace(/,exact=false/g, '')
|
|
168
|
+
.replace(/(,name=\$\d+)(,description=\$\d+),exact=true/g, '$1s$2s') // exact=true is shared between name and description, so we need a special case for applying it to both.
|
|
169
|
+
.replace(/,exact=true/g, 's')
|
|
170
|
+
.replace(/,includehidden=/g, ',include-hidden=')
|
|
171
|
+
.replace(/\,/g, '][');
|
|
172
|
+
const parts = template.split('.');
|
|
173
|
+
// Turn "internal:control=enter-frame >> nth=0" into "nth=0 >> internal:control=enter-frame"
|
|
174
|
+
// because these are swapped in locators vs selectors.
|
|
175
|
+
for (let index = 0; index < parts.length - 1; index++) {
|
|
176
|
+
if (parts[index] === 'internal:control=enter-frame' && parts[index + 1].startsWith('nth=')) {
|
|
177
|
+
// Swap nth and enter-frame.
|
|
178
|
+
const [nth] = parts.splice(index, 1);
|
|
179
|
+
parts.splice(index + 1, 0, nth);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
// Substitute params.
|
|
183
|
+
return parts.map(t => {
|
|
184
|
+
if (!t.startsWith('internal:') || t === 'internal:control')
|
|
185
|
+
return t.replace(/\$(\d+)/g, (_, ordinal) => { const param = params[+ordinal - 1]; return param.text; });
|
|
186
|
+
t = t.includes('[') ? t.replace(/\]/, '') + ']' : t;
|
|
187
|
+
t = t
|
|
188
|
+
.replace(/(?:r)\$(\d+)(i)?/g, (_, ordinal, suffix) => {
|
|
189
|
+
const param = params[+ordinal - 1];
|
|
190
|
+
if (t.startsWith('internal:attr') || t.startsWith('internal:testid') || t.startsWith('internal:role'))
|
|
191
|
+
return escapeForAttributeSelector(new RegExp(param.text), false) + (suffix || '');
|
|
192
|
+
return escapeForTextSelector(new RegExp(param.text, suffix), false);
|
|
193
|
+
})
|
|
194
|
+
.replace(/\$(\d+)(i|s)?/g, (_, ordinal, suffix) => {
|
|
195
|
+
const param = params[+ordinal - 1];
|
|
196
|
+
if (t.startsWith('internal:has=') || t.startsWith('internal:has-not='))
|
|
197
|
+
return param.text;
|
|
198
|
+
if (t.startsWith('internal:testid'))
|
|
199
|
+
return escapeForAttributeSelector(param.text, true);
|
|
200
|
+
if (t.startsWith('internal:attr') || t.startsWith('internal:role'))
|
|
201
|
+
return escapeForAttributeSelector(param.text, suffix === 's');
|
|
202
|
+
return escapeForTextSelector(param.text, suffix === 's');
|
|
203
|
+
});
|
|
204
|
+
return t;
|
|
205
|
+
}).join(' >> ');
|
|
206
|
+
}
|
|
207
|
+
export function locatorOrSelectorAsSelector(language, locator, testIdAttributeName = 'data-testid') {
|
|
208
|
+
try {
|
|
209
|
+
return unsafeLocatorOrSelectorAsSelector(language, locator, testIdAttributeName);
|
|
210
|
+
}
|
|
211
|
+
catch (e) {
|
|
212
|
+
return '';
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
export function unsafeLocatorOrSelectorAsSelector(language, locator, testIdAttributeName = 'data-testid') {
|
|
216
|
+
try {
|
|
217
|
+
parseSelector(locator);
|
|
218
|
+
return locator;
|
|
219
|
+
}
|
|
220
|
+
catch (e) {
|
|
221
|
+
}
|
|
222
|
+
const { selector, preferredQuote } = parseLocator(locator, testIdAttributeName);
|
|
223
|
+
const locators = asLocators(language, selector, undefined, undefined, preferredQuote);
|
|
224
|
+
const digest = digestForComparison(language, locator);
|
|
225
|
+
if (locators.some(candidate => digestForComparison(language, candidate) === digest))
|
|
226
|
+
return selector;
|
|
227
|
+
return '';
|
|
228
|
+
}
|
|
229
|
+
function digestForComparison(language, locator) {
|
|
230
|
+
locator = locator.replace(/\s/g, '');
|
|
231
|
+
if (language === 'javascript')
|
|
232
|
+
locator = locator.replace(/\\?["`]/g, '\'').replace(/,{}/g, '');
|
|
233
|
+
return locator;
|
|
234
|
+
}
|
|
235
|
+
//# sourceMappingURL=locatorParser.js.map
|