codeceptjs 4.0.0-rc.1 → 4.0.0-rc.11

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.
Files changed (49) hide show
  1. package/README.md +39 -27
  2. package/bin/mcp-server.js +637 -0
  3. package/docs/webapi/appendField.mustache +5 -0
  4. package/docs/webapi/attachFile.mustache +12 -0
  5. package/docs/webapi/checkOption.mustache +1 -1
  6. package/docs/webapi/clearField.mustache +5 -0
  7. package/docs/webapi/dontSeeCurrentPathEquals.mustache +10 -0
  8. package/docs/webapi/dontSeeElement.mustache +4 -0
  9. package/docs/webapi/dontSeeInField.mustache +5 -0
  10. package/docs/webapi/fillField.mustache +5 -0
  11. package/docs/webapi/moveCursorTo.mustache +5 -1
  12. package/docs/webapi/seeCurrentPathEquals.mustache +10 -0
  13. package/docs/webapi/seeElement.mustache +4 -0
  14. package/docs/webapi/seeInField.mustache +5 -0
  15. package/docs/webapi/selectOption.mustache +5 -0
  16. package/docs/webapi/uncheckOption.mustache +1 -1
  17. package/lib/codecept.js +20 -17
  18. package/lib/command/init.js +0 -3
  19. package/lib/command/run-workers.js +1 -0
  20. package/lib/container.js +19 -4
  21. package/lib/element/WebElement.js +81 -2
  22. package/lib/els.js +12 -6
  23. package/lib/helper/Appium.js +8 -8
  24. package/lib/helper/Playwright.js +224 -138
  25. package/lib/helper/Puppeteer.js +211 -69
  26. package/lib/helper/WebDriver.js +183 -64
  27. package/lib/helper/errors/MultipleElementsFound.js +27 -110
  28. package/lib/helper/errors/NonFocusedType.js +8 -0
  29. package/lib/helper/extras/elementSelection.js +58 -0
  30. package/lib/helper/extras/focusCheck.js +43 -0
  31. package/lib/helper/scripts/dropFile.js +11 -0
  32. package/lib/html.js +14 -1
  33. package/lib/listener/globalRetry.js +32 -6
  34. package/lib/mocha/cli.js +10 -0
  35. package/lib/plugin/aiTrace.js +464 -0
  36. package/lib/plugin/retryFailedStep.js +28 -19
  37. package/lib/plugin/stepByStepReport.js +5 -1
  38. package/lib/step/config.js +15 -2
  39. package/lib/step/record.js +1 -1
  40. package/lib/utils.js +48 -0
  41. package/lib/workers.js +49 -7
  42. package/package.json +5 -3
  43. package/typings/index.d.ts +19 -0
  44. package/lib/listener/enhancedGlobalRetry.js +0 -110
  45. package/lib/plugin/enhancedRetryFailedStep.js +0 -99
  46. package/lib/plugin/htmlReporter.js +0 -3648
  47. package/lib/retryCoordinator.js +0 -207
  48. package/typings/promiseBasedTypes.d.ts +0 -9469
  49. package/typings/types.d.ts +0 -11402
@@ -0,0 +1,637 @@
1
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js'
2
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
3
+ import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js'
4
+ import Codecept from '../lib/codecept.js'
5
+ import container from '../lib/container.js'
6
+ import { getParamsToString } from '../lib/parser.js'
7
+ import { methodsOfObject } from '../lib/utils.js'
8
+ import event from '../lib/event.js'
9
+ import { fileURLToPath } from 'url'
10
+ import { dirname, resolve as resolvePath } from 'path'
11
+ import path from 'path'
12
+ import crypto from 'crypto'
13
+ import { spawn } from 'child_process'
14
+ import { createRequire } from 'module'
15
+ import { existsSync, readdirSync, writeFileSync } from 'fs'
16
+ import { mkdirp } from 'mkdirp'
17
+
18
+ const require = createRequire(import.meta.url)
19
+
20
+ const __filename = fileURLToPath(import.meta.url)
21
+ const __dirname = dirname(__filename)
22
+
23
+ let codecept = null
24
+ let containerInitialized = false
25
+ let browserStarted = false
26
+
27
+ let runLock = Promise.resolve()
28
+ async function withLock(fn) {
29
+ const prev = runLock
30
+ let release
31
+ runLock = new Promise(r => (release = r))
32
+ await prev
33
+ try { return await fn() }
34
+ finally { release() }
35
+ }
36
+
37
+ async function withSilencedIO(fn) {
38
+ const origOut = process.stdout.write.bind(process.stdout)
39
+ const origErr = process.stderr.write.bind(process.stderr)
40
+
41
+ process.stdout.write = () => true
42
+ process.stderr.write = () => true
43
+
44
+ try {
45
+ return await fn()
46
+ } finally {
47
+ process.stdout.write = origOut
48
+ process.stderr.write = origErr
49
+ }
50
+ }
51
+
52
+ function runCmd(cmd, args, { cwd = process.cwd(), timeout = 60000 } = {}) {
53
+ return new Promise((resolve, reject) => {
54
+ const child = spawn(cmd, args, {
55
+ cwd,
56
+ env: { ...process.env, NODE_ENV: process.env.NODE_ENV || 'test' },
57
+ stdio: ['ignore', 'pipe', 'pipe'],
58
+ })
59
+
60
+ let out = ''
61
+ let err = ''
62
+
63
+ const t = setTimeout(() => {
64
+ child.kill('SIGKILL')
65
+ reject(new Error(`Timeout after ${timeout}ms`))
66
+ }, timeout)
67
+
68
+ child.stdout.on('data', d => (out += d.toString('utf8')))
69
+ child.stderr.on('data', d => (err += d.toString('utf8')))
70
+
71
+ child.on('error', e => {
72
+ clearTimeout(t)
73
+ reject(e)
74
+ })
75
+
76
+ child.on('close', code => {
77
+ clearTimeout(t)
78
+ resolve({ code, out, err })
79
+ })
80
+ })
81
+ }
82
+
83
+ function resolveConfigPath(configPath) {
84
+ const cwd = process.cwd()
85
+ const envRoot = process.env.CODECEPTJS_PROJECT_DIR
86
+
87
+ if (configPath && !path.isAbsolute(configPath)) {
88
+ const base = envRoot || cwd
89
+ configPath = path.resolve(base, configPath)
90
+ }
91
+
92
+ if (!configPath) {
93
+ const base = envRoot || cwd
94
+ configPath = process.env.CODECEPTJS_CONFIG || path.resolve(base, 'codecept.conf.js')
95
+ if (!existsSync(configPath)) configPath = path.resolve(base, 'codecept.conf.cjs')
96
+ }
97
+
98
+ if (!existsSync(configPath)) {
99
+ throw new Error(
100
+ `CodeceptJS config not found: ${configPath}\n` +
101
+ `CODECEPTJS_CONFIG=${process.env.CODECEPTJS_CONFIG || 'not set'}\n` +
102
+ `CODECEPTJS_PROJECT_DIR=${process.env.CODECEPTJS_PROJECT_DIR || 'not set'}\n` +
103
+ `cwd=${cwd}`
104
+ )
105
+ }
106
+
107
+ return { configPath, configDir: path.dirname(configPath) }
108
+ }
109
+
110
+ function findCodeceptCliUpwards(startDir, { maxUp = 8 } = {}) {
111
+ let dir = startDir
112
+
113
+ for (let i = 0; i <= maxUp; i++) {
114
+ const candidates = [
115
+ path.resolve(dir, 'bin', 'codecept.js'),
116
+ path.resolve(dir, 'node_modules', 'codeceptjs', 'bin', 'codecept.js'),
117
+ path.resolve(dir, 'node_modules', '.bin', 'codeceptjs.cmd'),
118
+ path.resolve(dir, 'node_modules', '.bin', 'codeceptjs'),
119
+ ]
120
+
121
+ for (const p of candidates) {
122
+ if (existsSync(p)) return { cli: p, root: dir }
123
+ }
124
+
125
+ try {
126
+ const pkgJson = require.resolve('codeceptjs/package.json', { paths: [dir] })
127
+ const pkgDir = path.dirname(pkgJson)
128
+ const jsCli = path.resolve(pkgDir, 'bin', 'codecept.js')
129
+ if (existsSync(jsCli)) return { cli: jsCli, root: dir }
130
+ } catch {}
131
+
132
+ const parent = path.dirname(dir)
133
+ if (parent === dir) break
134
+ dir = parent
135
+ }
136
+
137
+ throw new Error(`Cannot find CodeceptJS CLI walking up from: ${startDir}`)
138
+ }
139
+
140
+ function looksLikePath(v) {
141
+ return typeof v === 'string' && (
142
+ v.includes('/') || v.includes('\\') ||
143
+ v.endsWith('.js') || v.endsWith('.ts')
144
+ )
145
+ }
146
+
147
+ function normalizePath(p) {
148
+ return String(p).replace(/\\/g, '/')
149
+ }
150
+
151
+ function findFileByBasename(rootDir, baseNames, { maxDepth = 8 } = {}) {
152
+ const targets = new Set(baseNames.map(x => x.toLowerCase()))
153
+
154
+ function walk(dir, depth) {
155
+ if (depth > maxDepth) return null
156
+
157
+ let entries
158
+ try { entries = readdirSync(dir, { withFileTypes: true }) } catch { return null }
159
+
160
+ for (const e of entries) {
161
+ const full = path.join(dir, e.name)
162
+
163
+ if (e.isDirectory()) {
164
+ if (e.name === 'node_modules' || e.name === '.git' || e.name === 'output') continue
165
+ const res = walk(full, depth + 1)
166
+ if (res) return res
167
+ continue
168
+ }
169
+
170
+ if (targets.has(e.name.toLowerCase())) return full
171
+ }
172
+
173
+ return null
174
+ }
175
+
176
+ return walk(rootDir, 0)
177
+ }
178
+
179
+ async function listTestsJson({ cli, root, configPath }) {
180
+ const args = ['list', '--config', configPath, '--json']
181
+ const isNodeScript = cli.endsWith('.js')
182
+
183
+ const res = isNodeScript
184
+ ? await runCmd(process.execPath, [cli, ...args], { cwd: root, timeout: 60000 })
185
+ : await runCmd(cli, args, { cwd: root, timeout: 60000 })
186
+
187
+ const out = (res.out || '').trim()
188
+ try { return JSON.parse(out) } catch { return null }
189
+ }
190
+
191
+ function extractFilesFromListJson(json) {
192
+ if (!json) return []
193
+ if (Array.isArray(json)) return json.map(String)
194
+ if (Array.isArray(json.tests)) return json.tests.map(String)
195
+ if (Array.isArray(json.files)) return json.files.map(String)
196
+ if (Array.isArray(json.testFiles)) return json.testFiles.map(String)
197
+ return []
198
+ }
199
+
200
+ async function resolveTestToFile({ cli, root, configPath, test }) {
201
+ if (looksLikePath(test)) return test
202
+
203
+ const raw = String(test).trim()
204
+ const candidates = [
205
+ raw,
206
+ `${raw}.js`,
207
+ `${raw}.ts`,
208
+ `${raw}_test.js`,
209
+ `${raw}.test.js`,
210
+ ].map(x => x.toLowerCase())
211
+
212
+ const json = await listTestsJson({ cli, root, configPath })
213
+ const files = extractFilesFromListJson(json).map(normalizePath)
214
+
215
+ if (files.length) {
216
+ const byName = files.find(f => candidates.some(c => path.basename(f).toLowerCase() === c))
217
+ if (byName) return byName
218
+
219
+ const byContains = files.find(f => f.toLowerCase().includes(raw.toLowerCase()))
220
+ if (byContains) return byContains
221
+ }
222
+
223
+ const fsFound = findFileByBasename(root, candidates)
224
+ return fsFound ? normalizePath(fsFound) : null
225
+ }
226
+
227
+ function clearString(str) {
228
+ return str.replace(/[^a-zA-Z0-9]/g, '_')
229
+ }
230
+
231
+ function getTraceDir(testTitle, testFile) {
232
+ const hash = crypto.createHash('sha256').update(testFile + testTitle).digest('hex').slice(0, 8)
233
+ const cleanTitle = clearString(testTitle).slice(0, 200)
234
+ const outputDir = global.output_dir || resolvePath(process.cwd(), 'output')
235
+ return resolvePath(outputDir, `trace_${cleanTitle}_${hash}`)
236
+ }
237
+
238
+ async function initCodecept(configPath) {
239
+ if (containerInitialized) return
240
+
241
+ const testRoot = process.env.CODECEPTJS_PROJECT_DIR || process.cwd()
242
+
243
+ if (!configPath) {
244
+ configPath = process.env.CODECEPTJS_CONFIG || resolvePath(testRoot, 'codecept.conf.js')
245
+ if (!existsSync(configPath)) configPath = resolvePath(testRoot, 'codecept.conf.cjs')
246
+ }
247
+
248
+ if (!existsSync(configPath)) {
249
+ throw new Error(
250
+ `CodeceptJS config not found: ${configPath}\n` +
251
+ `CODECEPTJS_CONFIG=${process.env.CODECEPTJS_CONFIG || 'not set'}\n` +
252
+ `CODECEPTJS_PROJECT_DIR=${process.env.CODECEPTJS_PROJECT_DIR || 'not set'}\n` +
253
+ `cwd=${process.cwd()}`
254
+ )
255
+ }
256
+
257
+ console.log = () => {}
258
+ console.error = () => {}
259
+ console.warn = () => {}
260
+
261
+ const { getConfig } = await import('../lib/command/utils.js')
262
+ const config = await getConfig(configPath)
263
+
264
+ codecept = new Codecept(config, {})
265
+ await codecept.init(testRoot)
266
+ await container.create(config, {})
267
+ await container.started()
268
+
269
+ containerInitialized = true
270
+ browserStarted = true
271
+ }
272
+
273
+ const server = new Server(
274
+ { name: 'codeceptjs-mcp-server', version: '1.0.0' },
275
+ { capabilities: { tools: {} } }
276
+ )
277
+
278
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({
279
+ tools: [
280
+ {
281
+ name: 'list_tests',
282
+ description: 'List all tests in the CodeceptJS project',
283
+ inputSchema: { type: 'object', properties: { config: { type: 'string' } } },
284
+ },
285
+ {
286
+ name: 'list_actions',
287
+ description: 'List all available CodeceptJS actions (I.* methods)',
288
+ inputSchema: { type: 'object', properties: { config: { type: 'string' } } },
289
+ },
290
+ {
291
+ name: 'run_code',
292
+ description: 'Run arbitrary CodeceptJS code.',
293
+ inputSchema: {
294
+ type: 'object',
295
+ properties: {
296
+ code: { type: 'string' },
297
+ timeout: { type: 'number' },
298
+ config: { type: 'string' },
299
+ saveArtifacts: { type: 'boolean' },
300
+ },
301
+ required: ['code'],
302
+ },
303
+ },
304
+ {
305
+ name: 'run_test',
306
+ description: 'Run a specific test.',
307
+ inputSchema: {
308
+ type: 'object',
309
+ properties: {
310
+ test: { type: 'string' },
311
+ timeout: { type: 'number' },
312
+ config: { type: 'string' },
313
+ },
314
+ required: ['test'],
315
+ },
316
+ },
317
+ {
318
+ name: 'run_step_by_step',
319
+ description: 'Run a test step by step with pauses between steps.',
320
+ inputSchema: {
321
+ type: 'object',
322
+ properties: {
323
+ test: { type: 'string' },
324
+ timeout: { type: 'number' },
325
+ config: { type: 'string' },
326
+ },
327
+ required: ['test'],
328
+ },
329
+ },
330
+ {
331
+ name: 'start_browser',
332
+ description: 'Start the browser session.',
333
+ inputSchema: { type: 'object', properties: { config: { type: 'string' } } },
334
+ },
335
+ {
336
+ name: 'stop_browser',
337
+ description: 'Stop the browser session.',
338
+ inputSchema: { type: 'object', properties: {} },
339
+ },
340
+ ],
341
+ }))
342
+
343
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
344
+ const { name, arguments: args } = request.params
345
+
346
+ try {
347
+ switch (name) {
348
+ case 'list_tests': {
349
+ const configPath = args?.config
350
+ await initCodecept(configPath)
351
+
352
+ codecept.loadTests()
353
+ const tests = codecept.testFiles.map(testFile => {
354
+ const relativePath = testFile.replace(process.cwd(), '').replace(/\\/g, '/')
355
+ return {
356
+ file: testFile,
357
+ relativePath: relativePath.startsWith('/') ? relativePath.slice(1) : relativePath,
358
+ }
359
+ })
360
+
361
+ return { content: [{ type: 'text', text: JSON.stringify({ count: tests.length, tests }, null, 2) }] }
362
+ }
363
+
364
+ case 'list_actions': {
365
+ const configPath = args?.config
366
+ await initCodecept(configPath)
367
+
368
+ const helpers = container.helpers()
369
+ const supportI = container.support('I')
370
+ const actions = []
371
+ const actionDetails = []
372
+
373
+ for (const helperName in helpers) {
374
+ const helper = helpers[helperName]
375
+ methodsOfObject(helper).forEach(action => {
376
+ if (actions.includes(action)) return
377
+ actions.push(action)
378
+ const params = getParamsToString(helper[action])
379
+ actionDetails.push({ helper: helperName, action, signature: `I.${action}(${params})` })
380
+ })
381
+ }
382
+
383
+ for (const n in supportI) {
384
+ if (actions.includes(n)) continue
385
+ const actor = supportI[n]
386
+ const params = getParamsToString(actor)
387
+ actionDetails.push({ helper: 'SupportObject', action: n, signature: `I.${n}(${params})` })
388
+ }
389
+
390
+ return { content: [{ type: 'text', text: JSON.stringify({ count: actionDetails.length, actions: actionDetails }, null, 2) }] }
391
+ }
392
+
393
+ case 'start_browser': {
394
+ const configPath = args?.config
395
+ if (browserStarted) {
396
+ return { content: [{ type: 'text', text: JSON.stringify({ status: 'Browser already started' }, null, 2) }] }
397
+ }
398
+ await initCodecept(configPath)
399
+ return { content: [{ type: 'text', text: JSON.stringify({ status: 'Browser started successfully' }, null, 2) }] }
400
+ }
401
+
402
+ case 'stop_browser': {
403
+ if (!containerInitialized) {
404
+ return { content: [{ type: 'text', text: JSON.stringify({ status: 'Browser not initialized' }, null, 2) }] }
405
+ }
406
+
407
+ const helpers = container.helpers()
408
+ for (const helperName in helpers) {
409
+ const helper = helpers[helperName]
410
+ try { if (helper._finish) await helper._finish() } catch {}
411
+ }
412
+
413
+ browserStarted = false
414
+ containerInitialized = false
415
+
416
+ return { content: [{ type: 'text', text: JSON.stringify({ status: 'Browser stopped successfully' }, null, 2) }] }
417
+ }
418
+
419
+ case 'run_code': {
420
+ const { code, timeout = 60000, config: configPath, saveArtifacts = true } = args
421
+ await initCodecept(configPath)
422
+
423
+ const I = container.support('I')
424
+ if (!I) throw new Error('I object not available. Make sure helpers are configured.')
425
+
426
+ const result = { status: 'unknown', output: '', error: null, artifacts: {} }
427
+
428
+ try {
429
+ const asyncFn = new Function('I', `return (async () => { ${code} })()`)
430
+ await Promise.race([
431
+ asyncFn(I),
432
+ new Promise((_, reject) => setTimeout(() => reject(new Error(`Timeout after ${timeout}ms`)), timeout)),
433
+ ])
434
+
435
+ result.status = 'success'
436
+ result.output = 'Code executed successfully'
437
+
438
+ if (saveArtifacts) {
439
+ const helpers = container.helpers()
440
+ const helper = Object.values(helpers)[0]
441
+ if (helper) {
442
+ try {
443
+ const traceDir = getTraceDir('mcp', 'run_code')
444
+ mkdirp.sync(traceDir)
445
+
446
+ if (helper.grabAriaSnapshot) {
447
+ const aria = await helper.grabAriaSnapshot()
448
+ const ariaFile = path.join(traceDir, 'aria.txt')
449
+ writeFileSync(ariaFile, aria)
450
+ result.artifacts.aria = `file://${ariaFile}`
451
+ }
452
+
453
+ if (helper.grabCurrentUrl) {
454
+ result.artifacts.url = await helper.grabCurrentUrl()
455
+ }
456
+
457
+ if (helper.grabBrowserLogs) {
458
+ const logs = (await helper.grabBrowserLogs()) || []
459
+ const logsFile = path.join(traceDir, 'console.json')
460
+ writeFileSync(logsFile, JSON.stringify(logs, null, 2))
461
+ result.artifacts.consoleLogs = `file://${logsFile}`
462
+ }
463
+
464
+ if (helper.grabSource) {
465
+ const html = await helper.grabSource()
466
+ const htmlFile = path.join(traceDir, 'page.html')
467
+ writeFileSync(htmlFile, html)
468
+ result.artifacts.html = `file://${htmlFile}`
469
+ }
470
+
471
+ if (helper.saveScreenshot) {
472
+ const screenshotFile = path.join(traceDir, 'screenshot.png')
473
+ await helper.saveScreenshot(screenshotFile)
474
+ result.artifacts.screenshot = `file://${screenshotFile}`
475
+ }
476
+ } catch (e) {
477
+ result.output += ` (Warning: ${e.message})`
478
+ }
479
+ }
480
+ }
481
+ } catch (error) {
482
+ result.status = 'failed'
483
+ result.error = error.message
484
+ result.output = error.stack || error.message
485
+ }
486
+
487
+ return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] }
488
+ }
489
+
490
+ case 'run_test': {
491
+ return await withLock(async () => {
492
+ const { test, timeout = 60000, config: configPathArg } = args || {}
493
+ const { configPath, configDir } = resolveConfigPath(configPathArg)
494
+
495
+ const { cli, root } = findCodeceptCliUpwards(configDir)
496
+ const isNodeScript = cli.endsWith('.js')
497
+
498
+ const resolvedFile = await resolveTestToFile({ cli, root, configPath, test })
499
+ const runArgs = ['run', '--config', configPath, '--reporter', 'json']
500
+
501
+ if (resolvedFile) runArgs.push(resolvedFile)
502
+ else if (looksLikePath(test)) runArgs.push(test)
503
+ else runArgs.push('--grep', String(test))
504
+
505
+ const res = isNodeScript
506
+ ? await runCmd(process.execPath, [cli, ...runArgs], { cwd: root, timeout })
507
+ : await runCmd(cli, runArgs, { cwd: root, timeout })
508
+
509
+ const { code, out, err } = res
510
+
511
+ let parsed = null
512
+ const jsonStart = out.indexOf('{')
513
+ const jsonEnd = out.lastIndexOf('}')
514
+ if (jsonStart !== -1 && jsonEnd !== -1 && jsonEnd > jsonStart) {
515
+ try { parsed = JSON.parse(out.slice(jsonStart, jsonEnd + 1)) } catch {}
516
+ }
517
+
518
+ return {
519
+ content: [{
520
+ type: 'text',
521
+ text: JSON.stringify({
522
+ meta: { exitCode: code, cli, root, configPath, args: runArgs, resolvedFile: resolvedFile || null },
523
+ reporterJson: parsed,
524
+ stderr: err ? err.slice(0, 20000) : '',
525
+ rawStdout: parsed ? '' : out.slice(0, 20000),
526
+ }, null, 2),
527
+ }],
528
+ }
529
+ })
530
+ }
531
+
532
+ case 'run_step_by_step': {
533
+ const { test, timeout = 60000, config: configPath } = args
534
+ await initCodecept(configPath)
535
+
536
+ return await withSilencedIO(async () => {
537
+ codecept.loadTests()
538
+
539
+ let testFiles = codecept.testFiles
540
+ if (test) {
541
+ const testName = normalizePath(test).toLowerCase()
542
+ testFiles = codecept.testFiles.filter(f => {
543
+ const filePath = normalizePath(f).toLowerCase()
544
+ return filePath.includes(testName) || filePath.endsWith(testName)
545
+ })
546
+ }
547
+
548
+ if (!testFiles.length) throw new Error(`No tests found matching: ${test}`)
549
+
550
+ const results = []
551
+ const currentSteps = {}
552
+ let currentTestTitle = null
553
+ const testFile = testFiles[0]
554
+
555
+ const onBefore = (t) => {
556
+ const traceDir = getTraceDir(t.title, t.file)
557
+ currentTestTitle = t.title
558
+ currentSteps[t.title] = []
559
+ results.push({
560
+ test: t.title,
561
+ file: t.file,
562
+ traceFile: `file://${resolvePath(traceDir, 'trace.md')}`,
563
+ status: 'running',
564
+ steps: [],
565
+ })
566
+ }
567
+
568
+ const onAfter = (t) => {
569
+ const r = results.find(x => x.test === t.title)
570
+ if (r) {
571
+ r.status = t.err ? 'failed' : 'completed'
572
+ if (t.err) r.error = t.err.message
573
+ }
574
+ currentTestTitle = null
575
+ }
576
+
577
+ const onStepAfter = (step) => {
578
+ if (!currentTestTitle || !currentSteps[currentTestTitle]) return
579
+ currentSteps[currentTestTitle].push({
580
+ step: step.toString(),
581
+ status: step.status,
582
+ time: step.endTime - step.startTime,
583
+ })
584
+ const r = results.find(x => x.test === currentTestTitle)
585
+ if (r) r.steps = [...currentSteps[currentTestTitle]]
586
+ }
587
+
588
+ event.dispatcher.on(event.test.before, onBefore)
589
+ event.dispatcher.on(event.test.after, onAfter)
590
+ event.dispatcher.on(event.step.after, onStepAfter)
591
+
592
+ try {
593
+ await Promise.race([
594
+ (async () => {
595
+ await codecept.bootstrap()
596
+ await codecept.run(testFile)
597
+ })(),
598
+ new Promise((_, reject) => setTimeout(() => reject(new Error(`Timeout after ${timeout}ms`)), timeout)),
599
+ ])
600
+ } catch (error) {
601
+ const lastRunning = results.filter(r => r.status === 'running').pop()
602
+ if (lastRunning) {
603
+ lastRunning.status = 'failed'
604
+ lastRunning.error = error.message
605
+ }
606
+ } finally {
607
+ try { event.dispatcher.removeListener(event.test.before, onBefore) } catch {}
608
+ try { event.dispatcher.removeListener(event.test.after, onAfter) } catch {}
609
+ try { event.dispatcher.removeListener(event.step.after, onStepAfter) } catch {}
610
+ }
611
+
612
+ return { content: [{ type: 'text', text: JSON.stringify({ results, stepByStep: true }, null, 2) }] }
613
+ })
614
+ }
615
+
616
+ default:
617
+ throw new Error(`Unknown tool: ${name}`)
618
+ }
619
+ } catch (error) {
620
+ return {
621
+ content: [{ type: 'text', text: JSON.stringify({ error: error.message, stack: error.stack }, null, 2) }],
622
+ isError: true,
623
+ }
624
+ }
625
+ })
626
+
627
+ async function main() {
628
+ const transport = new StdioServerTransport()
629
+ await server.connect(transport)
630
+ }
631
+
632
+ main().catch((error) => {
633
+ import('fs').then(fs => {
634
+ const logFile = path.resolve(process.cwd(), 'mcp-server-error.log')
635
+ fs.appendFileSync(logFile, `${new Date().toISOString()} - ${error.stack}\n`)
636
+ })
637
+ })
@@ -1,11 +1,16 @@
1
1
  Appends text to a input field or textarea.
2
2
  Field is located by name, label, CSS or XPath
3
3
 
4
+ The third parameter is an optional context (CSS or XPath locator) to narrow the search.
5
+
4
6
  ```js
5
7
  I.appendField('#myTextField', 'appended');
6
8
  // typing secret
7
9
  I.appendField('password', secret('123456'));
10
+ // within a context
11
+ I.appendField('name', 'John', '.form-container');
8
12
  ```
9
13
  @param {CodeceptJS.LocatorOrString} field located by label|name|CSS|XPath|strict locator
10
14
  @param {string} value text value to append.
15
+ @param {?CodeceptJS.LocatorOrString} [context=null] (optional, `null` by default) element located by CSS | XPath | strict locator.
11
16
  @returns {void} automatically synchronized promise through #recorder
@@ -2,11 +2,23 @@ Attaches a file to element located by label, name, CSS or XPath
2
2
  Path to file is relative current codecept directory (where codecept.conf.ts or codecept.conf.js is located).
3
3
  File will be uploaded to remote system (if tests are running remotely).
4
4
 
5
+ The third parameter is an optional context (CSS or XPath locator) to narrow the search.
6
+
5
7
  ```js
6
8
  I.attachFile('Avatar', 'data/avatar.jpg');
7
9
  I.attachFile('form input[name=avatar]', 'data/avatar.jpg');
10
+ // within a context
11
+ I.attachFile('Avatar', 'data/avatar.jpg', '.form-container');
12
+ ```
13
+
14
+ If the locator points to a non-file-input element (e.g., a dropzone area),
15
+ the file will be dropped onto that element using drag-and-drop events.
16
+
17
+ ```js
18
+ I.attachFile('#dropzone', 'data/avatar.jpg');
8
19
  ```
9
20
 
10
21
  @param {CodeceptJS.LocatorOrString} locator field located by label|name|CSS|XPath|strict locator.
11
22
  @param {string} pathToFile local file path relative to codecept.conf.ts or codecept.conf.js config file.
23
+ @param {?CodeceptJS.LocatorOrString} [context=null] (optional, `null` by default) element located by CSS | XPath | strict locator.
12
24
  @returns {void} automatically synchronized promise through #recorder
@@ -1,7 +1,7 @@
1
1
  Selects a checkbox or radio button.
2
2
  Element is located by label or name or CSS or XPath.
3
3
 
4
- The second parameter is a context (CSS or XPath locator) to narrow the search.
4
+ The second parameter is an optional context (CSS or XPath locator) to narrow the search.
5
5
 
6
6
  ```js
7
7
  I.checkOption('#agree');
@@ -1,9 +1,14 @@
1
1
  Clears a `<textarea>` or text `<input>` element's value.
2
2
 
3
+ The second parameter is an optional context (CSS or XPath locator) to narrow the search.
4
+
3
5
  ```js
4
6
  I.clearField('Email');
5
7
  I.clearField('user[email]');
6
8
  I.clearField('#email');
9
+ // within a context
10
+ I.clearField('Email', '.form-container');
7
11
  ```
8
12
  @param {LocatorOrString} editable field located by label|name|CSS|XPath|strict locator.
13
+ @param {?CodeceptJS.LocatorOrString} [context=null] (optional, `null` by default) element located by CSS | XPath | strict locator.
9
14
  @returns {void} automatically synchronized promise through #recorder.