Skip to content

Commit b7b5a33

Browse files
DavertMikclaude
andcommitted
feat(mcp): snapshot tool, run_code return/console capture, storage state, HTML cleanup
Adds a `snapshot` MCP tool that captures the current browser state without performing any action. `run_code` now also returns the value the code produced and captures `console.*` output (level + message + relative time) using the existing `safeStringify` from lib/utils.js with a `truncateString` helper, so models can use debug statements and inspect return values. Captures cookies + localStorage via Playwright's `grabStorageState()` (with a `grabCookie + executeScript` fallback for Puppeteer/WebDriver), normalized to a single shape and saved as `_storage.json` alongside the other artifacts. Centralizes the artifact-grab + trace-record glue: - New `lib/utils/captureSnapshot.js` is the single funnel for HTML / ARIA / screenshot / console / storage capture. - New `lib/utils/trace.js` holds shared helpers (`pickActingHelper`, `traceDirFor`, `snapshotDirFor`, `artifactLinks`, `artifactsToFileUrls`) used by `aiTrace`, `pageInfo`, and the MCP server. - aiTrace and pageInfo now go through the same funnel — pageInfo's three serial recorder steps collapse to one delegated capture. HTML processing pipeline (`formatHtml` in `lib/html.js`): minify -> cleanHtml -> beautify. `cleanHtml` (new) drops `<script>` (no src) / `<style>` / `<noscript>`, strips trash class names (Tailwind utilities, hashed/scoped/framework-generated), and removes inline `style=""`. Built on `js-beautify` (already a dep) instead of a hand-rolled pretty-printer. Browser-log normalization fixes a latent bug where `JSON.stringify` was called on Playwright `ConsoleMessage` objects, producing files full of empty objects. Logs are now coerced to plain `{type, text}` (or kept as strings for WebDriver) before serialization. `safeStringify` in lib/utils.js is extended to also coerce Function/Error/BigInt/Symbol values — universal improvements that close a JSON.stringify(BigInt) crash and don't regress the existing 12 mocha serialization tests. Tests: 72 unit tests passing (12 circular_reference + 23 html (incl. new `#cleanHtml`, `#formatHtml`, `#isTrashClass` describes) + 13 aiTrace + 32 mcp + 1 pending). Docs updated in `docs/mcp.md` and `docs/aitrace.md`. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 6d8c234 commit b7b5a33

10 files changed

Lines changed: 753 additions & 231 deletions

File tree

bin/mcp-server.js

Lines changed: 180 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,19 @@ import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprot
44
import Codecept from '../lib/codecept.js'
55
import container from '../lib/container.js'
66
import { getParamsToString } from '../lib/parser.js'
7-
import { methodsOfObject } from '../lib/utils.js'
7+
import { methodsOfObject, safeStringify, truncateString } from '../lib/utils.js'
8+
import { captureSnapshot } from '../lib/utils/captureSnapshot.js'
9+
import {
10+
pickActingHelper,
11+
traceDirFor,
12+
snapshotDirFor,
13+
artifactLinks,
14+
artifactsToFileUrls,
15+
} from '../lib/utils/trace.js'
816
import event from '../lib/event.js'
917
import { fileURLToPath } from 'url'
1018
import { dirname, resolve as resolvePath } from 'path'
1119
import path from 'path'
12-
import crypto from 'crypto'
1320
import { spawn } from 'child_process'
1421
import { createRequire } from 'module'
1522
import { existsSync, readdirSync, writeFileSync } from 'fs'
@@ -224,15 +231,32 @@ async function resolveTestToFile({ cli, root, configPath, test }) {
224231
return fsFound ? normalizePath(fsFound) : null
225232
}
226233

227-
function clearString(str) {
228-
return str.replace(/[^a-zA-Z0-9]/g, '_')
234+
function outputBaseDir() {
235+
return global.output_dir || resolvePath(process.cwd(), 'output')
229236
}
230237

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}`)
238+
function writeTraceMarkdown({ dir, title, file, durationMs, commands, captured, error }) {
239+
let md = `file: ${file || 'mcp'}\n`
240+
md += `name: ${title}\n`
241+
md += `time: ${(durationMs / 1000).toFixed(2)}s\n`
242+
md += `---\n\n`
243+
244+
if (error) md += `Error: ${error}\n\n---\n\n`
245+
246+
if (commands && commands.length) {
247+
md += `### Commands\n`
248+
for (const c of commands) md += `- ${c}\n`
249+
md += `\n`
250+
}
251+
252+
md += `### Final State\n`
253+
if (captured.url) md += ` > URL: ${captured.url}\n`
254+
const links = artifactLinks(captured)
255+
if (links) md += links + '\n'
256+
257+
const traceFile = path.join(dir, 'trace.md')
258+
writeFileSync(traceFile, md)
259+
return traceFile
236260
}
237261

238262
async function initCodecept(configPath) {
@@ -337,6 +361,17 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
337361
description: 'Stop the browser session.',
338362
inputSchema: { type: 'object', properties: {} },
339363
},
364+
{
365+
name: 'snapshot',
366+
description: 'Capture current browser state (HTML, ARIA, screenshot, console, URL) without performing any action.',
367+
inputSchema: {
368+
type: 'object',
369+
properties: {
370+
config: { type: 'string' },
371+
fullPage: { type: 'boolean' },
372+
},
373+
},
374+
},
340375
],
341376
}))
342377

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

454+
case 'snapshot': {
455+
const { config: configPath, fullPage = false } = args || {}
456+
await initCodecept(configPath)
457+
458+
const helper = pickActingHelper(container.helpers())
459+
if (!helper) throw new Error('No supported acting helper available (Playwright, Puppeteer, WebDriver).')
460+
461+
const dir = snapshotDirFor(outputBaseDir())
462+
mkdirp.sync(dir)
463+
464+
const captured = await captureSnapshot(helper, { dir, prefix: 'snapshot', fullPage })
465+
const traceFile = writeTraceMarkdown({
466+
dir,
467+
title: 'snapshot',
468+
file: 'mcp',
469+
durationMs: 0,
470+
commands: [],
471+
captured,
472+
})
473+
474+
return {
475+
content: [{
476+
type: 'text',
477+
text: JSON.stringify({
478+
status: 'success',
479+
dir,
480+
traceFile: `file://${traceFile}`,
481+
artifacts: artifactsToFileUrls(captured, dir),
482+
}, null, 2),
483+
}],
484+
}
485+
}
486+
419487
case 'run_code': {
420488
const { code, timeout = 60000, config: configPath, saveArtifacts = true } = args
421489
await initCodecept(configPath)
422490

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

426-
const result = { status: 'unknown', output: '', error: null, artifacts: {} }
494+
const result = { status: 'unknown', output: '', error: null, commands: [], artifacts: {} }
495+
496+
const commands = []
497+
const onStepAfter = step => {
498+
try { commands.push(step.toString()) } catch {}
499+
}
500+
event.dispatcher.on(event.step.after, onStepAfter)
501+
502+
const traceDir = traceDirFor(`mcp_${Date.now()}`, 'run_code', outputBaseDir())
503+
mkdirp.sync(traceDir)
504+
const startedAt = Date.now()
505+
506+
const MAX_LOG_ENTRIES = 100
507+
const MAX_LOG_MSG_BYTES = 2000
508+
const MAX_RETURN_BYTES = 20000
509+
const consoleLogs = []
510+
const consoleMethods = ['log', 'info', 'warn', 'error', 'debug']
511+
const origConsoleMethods = {}
512+
const captureLog = level => (...args) => {
513+
if (consoleLogs.length >= MAX_LOG_ENTRIES) return
514+
const message = args.map(a => {
515+
if (typeof a === 'string') return a
516+
return truncateString(safeStringify(a, [], 2), MAX_LOG_MSG_BYTES).value
517+
}).join(' ')
518+
consoleLogs.push({ level, message, t: Date.now() - startedAt })
519+
}
520+
for (const m of consoleMethods) {
521+
origConsoleMethods[m] = console[m]
522+
console[m] = captureLog(m)
523+
}
427524

525+
let returnValue
428526
try {
429527
const asyncFn = new Function('I', `return (async () => { ${code} })()`)
430-
await Promise.race([
528+
returnValue = await Promise.race([
431529
asyncFn(I),
432530
new Promise((_, reject) => setTimeout(() => reject(new Error(`Timeout after ${timeout}ms`)), timeout)),
433531
])
434532

435533
result.status = 'success'
436534
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-
}
481535
} catch (error) {
482536
result.status = 'failed'
483537
result.error = error.message
484538
result.output = error.stack || error.message
539+
} finally {
540+
for (const m of consoleMethods) console[m] = origConsoleMethods[m]
541+
try { event.dispatcher.removeListener(event.step.after, onStepAfter) } catch {}
485542
}
486543

544+
result.commands = commands
545+
result.logs = consoleLogs
546+
if (consoleLogs.length === MAX_LOG_ENTRIES) result.logsTruncated = true
547+
548+
if (returnValue !== undefined) {
549+
const json = typeof returnValue === 'string' ? returnValue : safeStringify(returnValue, [], 2)
550+
const stringified = truncateString(json, MAX_RETURN_BYTES)
551+
result.returnValue = stringified.value
552+
if (stringified.truncated) result.returnValueTruncated = true
553+
}
554+
555+
let captured = {}
556+
if (saveArtifacts) {
557+
const helper = pickActingHelper(container.helpers())
558+
if (helper) {
559+
try {
560+
captured = await captureSnapshot(helper, { dir: traceDir, prefix: 'mcp' })
561+
result.artifacts = artifactsToFileUrls(captured, traceDir)
562+
} catch (e) {
563+
result.output += ` (Warning: ${e.message})`
564+
}
565+
}
566+
}
567+
568+
const traceFile = writeTraceMarkdown({
569+
dir: traceDir,
570+
title: 'run_code',
571+
file: 'mcp',
572+
durationMs: Date.now() - startedAt,
573+
commands,
574+
captured,
575+
error: result.error,
576+
})
577+
result.dir = traceDir
578+
result.traceFile = `file://${traceFile}`
579+
487580
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] }
488581
}
489582

@@ -549,27 +642,56 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
549642

550643
const results = []
551644
const currentSteps = {}
645+
const traceDirs = {}
552646
let currentTestTitle = null
553647
const testFile = testFiles[0]
554648

555649
const onBefore = (t) => {
556-
const traceDir = getTraceDir(t.title, t.file)
650+
const traceDir = traceDirFor(t.file, t.title, outputBaseDir())
557651
currentTestTitle = t.title
558652
currentSteps[t.title] = []
653+
traceDirs[t.title] = traceDir
559654
results.push({
560655
test: t.title,
561656
file: t.file,
562-
traceFile: `file://${resolvePath(traceDir, 'trace.md')}`,
563657
status: 'running',
564658
steps: [],
565659
})
566660
}
567661

568-
const onAfter = (t) => {
662+
const onAfter = async (t) => {
569663
const r = results.find(x => x.test === t.title)
570664
if (r) {
571665
r.status = t.err ? 'failed' : 'completed'
572666
if (t.err) r.error = t.err.message
667+
668+
if (t.artifacts?.aiTrace) {
669+
r.traceFile = `file://${t.artifacts.aiTrace}`
670+
}
671+
if (t.artifacts?.har) r.har = `file://${t.artifacts.har}`
672+
if (t.artifacts?.trace) r.trace = `file://${t.artifacts.trace}`
673+
674+
if (!t.artifacts?.aiTrace) {
675+
try {
676+
const helper = pickActingHelper(container.helpers())
677+
const dir = traceDirs[t.title]
678+
if (helper && dir) {
679+
mkdirp.sync(dir)
680+
const captured = await captureSnapshot(helper, { dir, prefix: 'final' })
681+
r.artifacts = artifactsToFileUrls(captured, dir)
682+
const tracePath = writeTraceMarkdown({
683+
dir,
684+
title: t.title,
685+
file: t.file,
686+
durationMs: 0,
687+
commands: (currentSteps[t.title] || []).map(s => s.step),
688+
captured,
689+
error: r.error,
690+
})
691+
r.traceFile = `file://${tracePath}`
692+
}
693+
} catch {}
694+
}
573695
}
574696
currentTestTitle = null
575697
}

docs/aitrace.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -53,16 +53,18 @@ For each test, a `trace_<sha256>` directory is created with the following files:
5353

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

56-
**0000_step_name_page.html** - Full HTML of the page at each step
56+
**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.
5757

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

60-
**0000_step_name_console.json** - Browser console logs
60+
**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).
6161

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

6464
**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`).
6565

66+
**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.
67+
6668
## Trace File Format
6769

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

0 commit comments

Comments
 (0)