@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/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
- const snapshot = () => JSON.stringify({ ...state, ...render(state) });
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
- const record = (body) => {
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(state.recording);
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
- stopRecorder({ detach: true });
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
- json(response, { written: writeTargets(outputs).map(file => path.relative(options.cwd, file) || file) });
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
- // Steps and generated code for every target, for the current recording.
197
- function render(state) {
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 { steps: [], outputs: {} };
379
+ return empty;
200
380
  let recording;
201
381
  try {
202
382
  recording = parseRecording(state.recording);
203
383
  }
204
384
  catch (error) {
205
- return { steps: [], outputs: {}, parseError: error.message };
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 = recording.actions.map(({ action, pageGuid }) => ({ page: aliases.alias(pageGuid), text: describeAction(action) }));
219
- return { steps, outputs, recordedTestIdAttribute: recordedTestIdAttribute(recording) };
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
- function describeAction(action) {
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 ${JSON.stringify(action.text)}`;
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 ${JSON.stringify(action.text)}`;
236
- case 'assertValue': return `expect ${target} to have value ${JSON.stringify(action.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