Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
220 changes: 159 additions & 61 deletions bin/mcp-server.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,22 @@ import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprot
import Codecept from '../lib/codecept.js'
import container from '../lib/container.js'
import { getParamsToString } from '../lib/parser.js'
import { methodsOfObject } from '../lib/utils.js'
import { methodsOfObject, safeStringify, truncateString } from '../lib/utils.js'
import {
captureSnapshot,
pickActingHelper,
traceDirFor,
snapshotDirFor,
artifactsToFileUrls,
writeTraceMarkdown,
} from '../lib/utils/trace.js'
import event from '../lib/event.js'
import { fileURLToPath } from 'url'
import { dirname, resolve as resolvePath } from 'path'
import path from 'path'
import crypto from 'crypto'
import { spawn } from 'child_process'
import { createRequire } from 'module'
import { existsSync, readdirSync, writeFileSync } from 'fs'
import { existsSync, readdirSync } from 'fs'
import { mkdirp } from 'mkdirp'

const require = createRequire(import.meta.url)
Expand Down Expand Up @@ -224,15 +231,8 @@ async function resolveTestToFile({ cli, root, configPath, test }) {
return fsFound ? normalizePath(fsFound) : null
}

function clearString(str) {
return str.replace(/[^a-zA-Z0-9]/g, '_')
}

function getTraceDir(testTitle, testFile) {
const hash = crypto.createHash('sha256').update(testFile + testTitle).digest('hex').slice(0, 8)
const cleanTitle = clearString(testTitle).slice(0, 200)
const outputDir = global.output_dir || resolvePath(process.cwd(), 'output')
return resolvePath(outputDir, `trace_${cleanTitle}_${hash}`)
function outputBaseDir() {
return global.output_dir || resolvePath(process.cwd(), 'output')
}

async function initCodecept(configPath) {
Expand Down Expand Up @@ -337,6 +337,17 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
description: 'Stop the browser session.',
inputSchema: { type: 'object', properties: {} },
},
{
name: 'snapshot',
description: 'Capture current browser state (HTML, ARIA, screenshot, console, URL) without performing any action.',
inputSchema: {
type: 'object',
properties: {
config: { type: 'string' },
fullPage: { type: 'boolean' },
},
},
},
],
}))

Expand Down Expand Up @@ -416,74 +427,132 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
return { content: [{ type: 'text', text: JSON.stringify({ status: 'Browser stopped successfully' }, null, 2) }] }
}

case 'snapshot': {
const { config: configPath, fullPage = false } = args || {}
await initCodecept(configPath)

const helper = pickActingHelper(container.helpers())
if (!helper) throw new Error('No supported acting helper available (Playwright, Puppeteer, WebDriver).')

const dir = snapshotDirFor(outputBaseDir())
mkdirp.sync(dir)

const captured = await captureSnapshot(helper, { dir, prefix: 'snapshot', fullPage })
const traceFile = writeTraceMarkdown({
dir,
title: 'snapshot',
file: 'mcp',
durationMs: 0,
commands: [],
captured,
})

return {
content: [{
type: 'text',
text: JSON.stringify({
status: 'success',
dir,
traceFile: `file://${traceFile}`,
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It can will broken windows. Use pathToFileURL()

artifacts: artifactsToFileUrls(captured, dir),
}, null, 2),
}],
}
}

case 'run_code': {
const { code, timeout = 60000, config: configPath, saveArtifacts = true } = args
await initCodecept(configPath)

const I = container.support('I')
if (!I) throw new Error('I object not available. Make sure helpers are configured.')

const result = { status: 'unknown', output: '', error: null, artifacts: {} }
const result = { status: 'unknown', output: '', error: null, commands: [], artifacts: {} }

const commands = []
const onStepAfter = step => {
try { commands.push(step.toString()) } catch {}
}
event.dispatcher.on(event.step.after, onStepAfter)

const traceDir = traceDirFor(`mcp_${Date.now()}`, 'run_code', outputBaseDir())
mkdirp.sync(traceDir)
const startedAt = Date.now()

const MAX_LOG_ENTRIES = 100
const MAX_LOG_MSG_BYTES = 2000
const MAX_RETURN_BYTES = 20000
const consoleLogs = []
const consoleMethods = ['log', 'info', 'warn', 'error', 'debug']
const origConsoleMethods = {}
const captureLog = level => (...args) => {
if (consoleLogs.length >= MAX_LOG_ENTRIES) return
const message = args.map(a => {
if (typeof a === 'string') return a
return truncateString(safeStringify(a, [], 2), MAX_LOG_MSG_BYTES).value
}).join(' ')
consoleLogs.push({ level, message, t: Date.now() - startedAt })
}
for (const m of consoleMethods) {
origConsoleMethods[m] = console[m]
console[m] = captureLog(m)
}

let returnValue
try {
const asyncFn = new Function('I', `return (async () => { ${code} })()`)
await Promise.race([
returnValue = await Promise.race([
asyncFn(I),
new Promise((_, reject) => setTimeout(() => reject(new Error(`Timeout after ${timeout}ms`)), timeout)),
])

result.status = 'success'
result.output = 'Code executed successfully'

if (saveArtifacts) {
const helpers = container.helpers()
const helper = Object.values(helpers)[0]
if (helper) {
try {
const traceDir = getTraceDir('mcp', 'run_code')
mkdirp.sync(traceDir)

if (helper.grabAriaSnapshot) {
const aria = await helper.grabAriaSnapshot()
const ariaFile = path.join(traceDir, 'aria.txt')
writeFileSync(ariaFile, aria)
result.artifacts.aria = `file://${ariaFile}`
}

if (helper.grabCurrentUrl) {
result.artifacts.url = await helper.grabCurrentUrl()
}

if (helper.grabBrowserLogs) {
const logs = (await helper.grabBrowserLogs()) || []
const logsFile = path.join(traceDir, 'console.json')
writeFileSync(logsFile, JSON.stringify(logs, null, 2))
result.artifacts.consoleLogs = `file://${logsFile}`
}

if (helper.grabSource) {
const html = await helper.grabSource()
const htmlFile = path.join(traceDir, 'page.html')
writeFileSync(htmlFile, html)
result.artifacts.html = `file://${htmlFile}`
}

if (helper.saveScreenshot) {
const screenshotFile = path.join(traceDir, 'screenshot.png')
await helper.saveScreenshot(screenshotFile)
result.artifacts.screenshot = `file://${screenshotFile}`
}
} catch (e) {
result.output += ` (Warning: ${e.message})`
}
}
}
} catch (error) {
result.status = 'failed'
result.error = error.message
result.output = error.stack || error.message
} finally {
for (const m of consoleMethods) console[m] = origConsoleMethods[m]
try { event.dispatcher.removeListener(event.step.after, onStepAfter) } catch {}
}

result.commands = commands
result.logs = consoleLogs
if (consoleLogs.length === MAX_LOG_ENTRIES) result.logsTruncated = true

if (returnValue !== undefined) {
const json = typeof returnValue === 'string' ? returnValue : safeStringify(returnValue, [], 2)
const stringified = truncateString(json, MAX_RETURN_BYTES)
result.returnValue = stringified.value
if (stringified.truncated) result.returnValueTruncated = true
}

let captured = {}
if (saveArtifacts) {
const helper = pickActingHelper(container.helpers())
if (helper) {
try {
captured = await captureSnapshot(helper, { dir: traceDir, prefix: 'mcp' })
result.artifacts = artifactsToFileUrls(captured, traceDir)
} catch (e) {
result.output += ` (Warning: ${e.message})`
}
}
}

const traceFile = writeTraceMarkdown({
dir: traceDir,
title: 'run_code',
file: 'mcp',
durationMs: Date.now() - startedAt,
commands,
captured,
error: result.error,
})
result.dir = traceDir
result.traceFile = `file://${traceFile}`

return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] }
}

Expand Down Expand Up @@ -549,27 +618,56 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {

const results = []
const currentSteps = {}
const traceDirs = {}
let currentTestTitle = null
const testFile = testFiles[0]

const onBefore = (t) => {
const traceDir = getTraceDir(t.title, t.file)
const traceDir = traceDirFor(t.file, t.title, outputBaseDir())
currentTestTitle = t.title
currentSteps[t.title] = []
traceDirs[t.title] = traceDir
results.push({
test: t.title,
file: t.file,
traceFile: `file://${resolvePath(traceDir, 'trace.md')}`,
status: 'running',
steps: [],
})
}

const onAfter = (t) => {
const onAfter = async (t) => {
const r = results.find(x => x.test === t.title)
if (r) {
r.status = t.err ? 'failed' : 'completed'
if (t.err) r.error = t.err.message

if (t.artifacts?.aiTrace) {
r.traceFile = `file://${t.artifacts.aiTrace}`
}
if (t.artifacts?.har) r.har = `file://${t.artifacts.har}`
if (t.artifacts?.trace) r.trace = `file://${t.artifacts.trace}`

if (!t.artifacts?.aiTrace) {
try {
const helper = pickActingHelper(container.helpers())
const dir = traceDirs[t.title]
if (helper && dir) {
mkdirp.sync(dir)
const captured = await captureSnapshot(helper, { dir, prefix: 'final' })
r.artifacts = artifactsToFileUrls(captured, dir)
const tracePath = writeTraceMarkdown({
dir,
title: t.title,
file: t.file,
durationMs: 0,
commands: (currentSteps[t.title] || []).map(s => s.step),
captured,
error: r.error,
})
r.traceFile = `file://${tracePath}`
}
} catch {}
}
}
currentTestTitle = null
}
Expand Down
8 changes: 5 additions & 3 deletions docs/aitrace.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,16 +53,18 @@ For each test, a `trace_<sha256>` directory is created with the following files:

**0000_step_name_screenshot.png** - Screenshot for each step (file names include step names)

**0000_step_name_page.html** - Full HTML of the page at each step
**0000_step_name_page.html** - Full HTML of the page at each step. Processed through a `minify -> clean -> beautify` pipeline so the file is multi-line indented, free of `<script>` / `<style>` / `<noscript>` content, free of inline `style=""` attributes, and free of trash class names (Tailwind utilities, framework-generated `v-*` / `ember-*`, hashed classes, scoped `xl:hidden`-style classes). Semantic attributes (`id`, `aria-*`, `data-*`, `role`, etc.) are preserved.

**0000_step_name_aria.txt** - ARIA accessibility snapshot (AI-readable structure without HTML noise)
**0000_step_name_aria.txt** - ARIA accessibility snapshot (AI-readable structure without HTML noise; Playwright only)

**0000_step_name_console.json** - Browser console logs
**0000_step_name_console.json** - Browser console logs, normalized to plain `{ type, text }` objects (Playwright `ConsoleMessage` instances are coerced via their `.type()` / `.text()` methods so the JSON file is genuinely useful — not full of empty objects).

When HAR or trace recording is enabled in your helper config, links to those files are also included.

**Note:** Artifact files are named using step names for easier identification (e.g., `0000_I_see_Product_screenshot.png` instead of just `0000_screenshot.png`).

**Storage state:** Cookies and `localStorage` are intentionally **not** captured per-step by `aiTrace` (they rarely change between actions, so per-step `_storage.json` files would be noise). Use the `pageInfo` plugin or the MCP `snapshot()` tool when you need a storage snapshot.

## Trace File Format

The `trace.md` file contains a structured execution log with links to all artifacts:
Expand Down
Loading