@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.
@@ -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
- 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 };
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`;