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