codeceptjs 4.0.0-rc.17 → 4.0.0-rc.18
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/bin/codecept.js +10 -1
- package/bin/mcp-server.js +541 -172
- package/docs/webapi/seeFileDownloaded.mustache +23 -0
- package/lib/aria.js +260 -0
- package/lib/command/dryRun.js +14 -0
- package/lib/command/list.js +150 -10
- package/lib/config.js +68 -4
- package/lib/container.js +34 -2
- package/lib/helper/Playwright.js +1 -5
- package/lib/helper/extras/PlaywrightReactVueLocator.js +45 -36
- package/lib/html.js +87 -16
- package/lib/pause.js +38 -4
- package/lib/plugin/aiTrace.js +72 -84
- package/lib/plugin/browser.js +76 -0
- package/lib/plugin/heal.js +44 -1
- package/lib/plugin/pageInfo.js +51 -48
- package/lib/plugin/pause.js +131 -0
- package/lib/plugin/pauseOnFail.js +10 -34
- package/lib/plugin/screencast.js +287 -0
- package/lib/plugin/screenshot.js +563 -0
- package/lib/plugin/screenshotOnFail.js +8 -170
- package/lib/utils/pluginParser.js +151 -0
- package/lib/utils/trace.js +297 -0
- package/lib/utils.js +25 -0
- package/package.json +6 -6
- package/typings/index.d.ts +0 -5
- package/lib/helper/AI.js +0 -214
- package/lib/plugin/pauseOn.js +0 -167
- package/lib/plugin/stepByStepReport.js +0 -432
- package/lib/plugin/subtitles.js +0 -89
|
@@ -1,177 +1,15 @@
|
|
|
1
|
-
import fs from 'fs'
|
|
2
|
-
import path from 'path'
|
|
3
|
-
|
|
4
|
-
import Container from '../container.js'
|
|
5
|
-
|
|
6
|
-
import recorder from '../recorder.js'
|
|
7
|
-
|
|
8
|
-
import event from '../event.js'
|
|
9
|
-
|
|
10
1
|
import output from '../output.js'
|
|
11
|
-
import
|
|
12
|
-
|
|
13
|
-
import { fileExists } from '../utils.js'
|
|
14
|
-
import Codeceptjs from '../index.js'
|
|
15
|
-
import { testToFileName } from '../mocha/test.js'
|
|
16
|
-
|
|
17
|
-
const defaultConfig = {
|
|
18
|
-
uniqueScreenshotNames: false,
|
|
19
|
-
disableScreenshots: false,
|
|
20
|
-
fullPageScreenshots: false,
|
|
21
|
-
}
|
|
2
|
+
import screenshot from './screenshot.js'
|
|
22
3
|
|
|
23
|
-
|
|
4
|
+
let warned = false
|
|
24
5
|
|
|
25
6
|
/**
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
* Initially this functionality was part of corresponding helper but has been moved into plugin since 1.4
|
|
29
|
-
*
|
|
30
|
-
* This plugin is **enabled by default**.
|
|
31
|
-
*
|
|
32
|
-
* #### Configuration
|
|
33
|
-
*
|
|
34
|
-
* Configuration can either be taken from a corresponding helper (deprecated) or a from plugin config (recommended).
|
|
35
|
-
*
|
|
36
|
-
* ```js
|
|
37
|
-
* plugins: {
|
|
38
|
-
* screenshotOnFail: {
|
|
39
|
-
* enabled: true
|
|
40
|
-
* }
|
|
41
|
-
* }
|
|
42
|
-
* ```
|
|
43
|
-
*
|
|
44
|
-
* Possible config options:
|
|
45
|
-
*
|
|
46
|
-
* * `uniqueScreenshotNames`: use unique names for screenshot. Default: false.
|
|
47
|
-
* * `fullPageScreenshots`: make full page screenshots. Default: false.
|
|
48
|
-
*
|
|
49
|
-
*
|
|
7
|
+
* @deprecated Use the `screenshot` plugin with `on: 'fail'` (the default).
|
|
50
8
|
*/
|
|
51
|
-
export default function (config) {
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
for (const helperName of supportedHelpers) {
|
|
56
|
-
if (Object.keys(helpers).indexOf(helperName) > -1) {
|
|
57
|
-
helper = helpers[helperName]
|
|
58
|
-
}
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
if (!helper) return // no helpers for screenshot
|
|
62
|
-
|
|
63
|
-
const options = Object.assign(defaultConfig, helper.options, config)
|
|
64
|
-
|
|
65
|
-
if (helpers.Mochawesome) {
|
|
66
|
-
if (helpers.Mochawesome.config) {
|
|
67
|
-
options.uniqueScreenshotNames = helpers.Mochawesome.config.uniqueScreenshotNames
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
if (Codeceptjs.container.mocha()) {
|
|
72
|
-
options.reportDir = Codeceptjs.container.mocha()?.options?.reporterOptions && Codeceptjs.container.mocha()?.options?.reporterOptions?.reportDir
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
if (options.disableScreenshots) {
|
|
76
|
-
// old version of disabling screenshots
|
|
77
|
-
return
|
|
9
|
+
export default function (config = {}) {
|
|
10
|
+
if (!warned) {
|
|
11
|
+
output.error('screenshotOnFail is deprecated; use the `screenshot` plugin (default on=fail).')
|
|
12
|
+
warned = true
|
|
78
13
|
}
|
|
79
|
-
|
|
80
|
-
event.dispatcher.on(event.test.failed, (test, _err, hookName) => {
|
|
81
|
-
if (hookName === 'BeforeSuite' || hookName === 'AfterSuite') {
|
|
82
|
-
// no browser here
|
|
83
|
-
return
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
recorder.add(
|
|
87
|
-
'screenshot of failed test',
|
|
88
|
-
async () => {
|
|
89
|
-
const dataType = 'image/png'
|
|
90
|
-
// This prevents data driven to be included in the failed screenshot file name
|
|
91
|
-
let fileName
|
|
92
|
-
|
|
93
|
-
if (options.uniqueScreenshotNames && test) {
|
|
94
|
-
fileName = `${testToFileName(test, { suffix: '', unique: true })}.failed.png`
|
|
95
|
-
} else {
|
|
96
|
-
fileName = `${testToFileName(test, { suffix: '', unique: false })}.failed.png`
|
|
97
|
-
}
|
|
98
|
-
const quietMode = !store.outputDir
|
|
99
|
-
if (!quietMode) {
|
|
100
|
-
output.plugin('screenshotOnFail', 'Test failed, try to save a screenshot')
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
// Re-check helpers at runtime in case they weren't ready during plugin init
|
|
104
|
-
const runtimeHelpers = Container.helpers()
|
|
105
|
-
let runtimeHelper = null
|
|
106
|
-
for (const helperName of supportedHelpers) {
|
|
107
|
-
if (Object.keys(runtimeHelpers).indexOf(helperName) > -1) {
|
|
108
|
-
runtimeHelper = runtimeHelpers[helperName]
|
|
109
|
-
break
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
if (runtimeHelper && typeof runtimeHelper.saveScreenshot === 'function') {
|
|
114
|
-
helper = runtimeHelper
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
try {
|
|
118
|
-
if (options.reportDir) {
|
|
119
|
-
fileName = path.join(options.reportDir, fileName)
|
|
120
|
-
const mochaReportDir = path.resolve(process.cwd(), options.reportDir)
|
|
121
|
-
if (!fileExists(mochaReportDir)) {
|
|
122
|
-
fs.mkdirSync(mochaReportDir)
|
|
123
|
-
}
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
// Check if browser/page is still available before attempting screenshot
|
|
127
|
-
if (helper.page && helper.page.isClosed && helper.page.isClosed()) {
|
|
128
|
-
throw new Error('Browser page has been closed')
|
|
129
|
-
}
|
|
130
|
-
if (helper.browser && helper.browser.isConnected && !helper.browser.isConnected()) {
|
|
131
|
-
throw new Error('Browser has been disconnected')
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
// Add timeout wrapper to prevent hanging with shorter timeout for ESM
|
|
135
|
-
const screenshotPromise = helper.saveScreenshot(fileName, options.fullPageScreenshots)
|
|
136
|
-
const timeoutPromise = new Promise((_, reject) => {
|
|
137
|
-
setTimeout(() => reject(new Error('Screenshot timeout after 5 seconds')), 5000)
|
|
138
|
-
})
|
|
139
|
-
|
|
140
|
-
await Promise.race([screenshotPromise, timeoutPromise])
|
|
141
|
-
|
|
142
|
-
if (!test.artifacts) test.artifacts = {}
|
|
143
|
-
const baseOutputDir = store.outputDir || null
|
|
144
|
-
if (baseOutputDir) {
|
|
145
|
-
test.artifacts.screenshot = path.join(baseOutputDir, fileName)
|
|
146
|
-
if (Container.mocha().options.reporterOptions['mocha-junit-reporter'] && Container.mocha().options.reporterOptions['mocha-junit-reporter'].options.attachments) {
|
|
147
|
-
test.attachments = [path.join(baseOutputDir, fileName)]
|
|
148
|
-
}
|
|
149
|
-
} else {
|
|
150
|
-
// Fallback: just store the file name to keep tests stable without triggering path errors
|
|
151
|
-
test.artifacts.screenshot = fileName
|
|
152
|
-
}
|
|
153
|
-
} catch (err) {
|
|
154
|
-
if (!quietMode) {
|
|
155
|
-
output.plugin('screenshotOnFail', `Failed to save screenshot: ${err.message}`)
|
|
156
|
-
}
|
|
157
|
-
// Enhanced error handling for browser closed scenarios
|
|
158
|
-
if (
|
|
159
|
-
err &&
|
|
160
|
-
((err.message &&
|
|
161
|
-
(err.message.includes('Target page, context or browser has been closed') ||
|
|
162
|
-
err.message.includes('Browser page has been closed') ||
|
|
163
|
-
err.message.includes('Browser has been disconnected') ||
|
|
164
|
-
err.message.includes('was terminated due to') ||
|
|
165
|
-
err.message.includes('no such window: target window already closed') ||
|
|
166
|
-
err.message.includes('Screenshot timeout after'))) ||
|
|
167
|
-
(err.type && err.type === 'RuntimeError'))
|
|
168
|
-
) {
|
|
169
|
-
output.log(`Can't make screenshot, ${err.message}`)
|
|
170
|
-
helper.isRunning = false
|
|
171
|
-
}
|
|
172
|
-
}
|
|
173
|
-
},
|
|
174
|
-
true,
|
|
175
|
-
)
|
|
176
|
-
})
|
|
14
|
+
return screenshot({ ...config, on: 'fail' })
|
|
177
15
|
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import Container from '../container.js'
|
|
2
|
+
import output from '../output.js'
|
|
3
|
+
|
|
4
|
+
const supportedHelpers = Container.STANDARD_ACTING_HELPERS
|
|
5
|
+
|
|
6
|
+
const RESERVED_KEYS = new Set(['on', 'path', 'line', 'pattern'])
|
|
7
|
+
const ALL_MODES = ['fail', 'test', 'step', 'file', 'url']
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Parse a plugin's _args (from CLI `-p plugin:key=value:key=value`) into a flat dict.
|
|
11
|
+
* Each entry is split on `;` then on the first `=`. Bare segments become `{ key: true }`.
|
|
12
|
+
*
|
|
13
|
+
* Examples:
|
|
14
|
+
* parsePluginArgs(['on=fail'])
|
|
15
|
+
* → { on: 'fail' }
|
|
16
|
+
* parsePluginArgs(['on=file', 'path=tests/foo.js;line=43'])
|
|
17
|
+
* → { on: 'file', path: 'tests/foo.js', line: '43' }
|
|
18
|
+
* parsePluginArgs(['on=file', 'path=tests/foo.js', 'line=43'])
|
|
19
|
+
* → { on: 'file', path: 'tests/foo.js', line: '43' }
|
|
20
|
+
* parsePluginArgs(['show'])
|
|
21
|
+
* → { show: true }
|
|
22
|
+
*/
|
|
23
|
+
export function parsePluginArgs(args = []) {
|
|
24
|
+
const opts = {}
|
|
25
|
+
for (const arg of args) {
|
|
26
|
+
if (!arg) continue
|
|
27
|
+
for (const segment of arg.split(';')) {
|
|
28
|
+
if (!segment) continue
|
|
29
|
+
if (segment.includes('=')) {
|
|
30
|
+
const eq = segment.indexOf('=')
|
|
31
|
+
const key = segment.slice(0, eq)
|
|
32
|
+
const value = segment.slice(eq + 1)
|
|
33
|
+
opts[key] = coerce(value)
|
|
34
|
+
} else {
|
|
35
|
+
opts[segment] = true
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return opts
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function coerce(v) {
|
|
43
|
+
if (v === 'true') return true
|
|
44
|
+
if (v === 'false') return false
|
|
45
|
+
return v
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Compose CLI args > config > defaults into a normalized trigger spec, then
|
|
50
|
+
* validate it. Returns `{ on, path, line, pattern, ...rest }` with `line`
|
|
51
|
+
* coerced to a number, or `null` if validation failed (an error is printed).
|
|
52
|
+
*
|
|
53
|
+
* @param {object} cliArgs — output of parsePluginArgs(config._args)
|
|
54
|
+
* @param {object} config — full plugin config object
|
|
55
|
+
* @param {object} defaults — fallback values, e.g. `{ on: 'fail' }`
|
|
56
|
+
* @param {object} options
|
|
57
|
+
* @param {string} options.name — plugin name, used in error messages
|
|
58
|
+
* @param {string[]} [options.validModes] — accepted values for `on`
|
|
59
|
+
* (default: fail, test, step, file, url)
|
|
60
|
+
*/
|
|
61
|
+
export function resolveTrigger(cliArgs = {}, config = {}, defaults = {}, options = {}) {
|
|
62
|
+
const { name = 'plugin', validModes = ALL_MODES } = options
|
|
63
|
+
const merged = { ...defaults, ...pickKnown(config), ...cliArgs }
|
|
64
|
+
if (merged.line != null) merged.line = parseInt(merged.line, 10)
|
|
65
|
+
|
|
66
|
+
const valid = new Set(validModes)
|
|
67
|
+
if (!valid.has(merged.on)) {
|
|
68
|
+
output.error(`${name}: unknown on="${merged.on}". Valid: ${validModes.join(', ')}`)
|
|
69
|
+
return null
|
|
70
|
+
}
|
|
71
|
+
if (merged.on === 'file' && !merged.path) {
|
|
72
|
+
output.error(`${name}:on=file requires path=. Example: -p ${name}:on=file:path=tests/foo.js`)
|
|
73
|
+
return null
|
|
74
|
+
}
|
|
75
|
+
if (merged.on === 'url' && !merged.pattern) {
|
|
76
|
+
output.error(`${name}:on=url requires pattern=. Example: -p ${name}:on=url:pattern=/users/*`)
|
|
77
|
+
return null
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return merged
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function pickKnown(config) {
|
|
84
|
+
const out = {}
|
|
85
|
+
for (const key of Object.keys(config || {})) {
|
|
86
|
+
if (RESERVED_KEYS.has(key)) out[key] = config[key]
|
|
87
|
+
}
|
|
88
|
+
return out
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Match a step's source location against a `path` (substring/suffix) and optional `line`.
|
|
93
|
+
* Reads the step's stack via `step.line()` to get `file:row:col`.
|
|
94
|
+
*/
|
|
95
|
+
export function matchStepFile(step, targetPath, targetLine) {
|
|
96
|
+
if (!targetPath) return false
|
|
97
|
+
const stepLine = step.line && step.line()
|
|
98
|
+
if (!stepLine) return false
|
|
99
|
+
|
|
100
|
+
const parsed = parseStepLine(stepLine)
|
|
101
|
+
if (!parsed) return false
|
|
102
|
+
|
|
103
|
+
const fileMatches = parsed.file.includes(targetPath) || parsed.file.endsWith(targetPath)
|
|
104
|
+
if (!fileMatches) return false
|
|
105
|
+
|
|
106
|
+
if (targetLine != null && !Number.isNaN(targetLine) && parsed.line !== targetLine) return false
|
|
107
|
+
return true
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function parseStepLine(stepLine) {
|
|
111
|
+
let line = stepLine.trim()
|
|
112
|
+
if (line.startsWith('at ')) line = line.substring(3).trim()
|
|
113
|
+
|
|
114
|
+
const lastColon = line.lastIndexOf(':')
|
|
115
|
+
if (lastColon < 0) return null
|
|
116
|
+
const secondLastColon = line.lastIndexOf(':', lastColon - 1)
|
|
117
|
+
if (secondLastColon < 0) return null
|
|
118
|
+
|
|
119
|
+
const file = line.substring(0, secondLastColon)
|
|
120
|
+
const lineNum = parseInt(line.substring(secondLastColon + 1, lastColon), 10)
|
|
121
|
+
|
|
122
|
+
if (Number.isNaN(lineNum)) return null
|
|
123
|
+
return { file, line: lineNum }
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Match a URL string against a glob-style pattern (supports `*` wildcards).
|
|
128
|
+
*/
|
|
129
|
+
export function matchUrl(currentUrl, pattern) {
|
|
130
|
+
if (!pattern || !currentUrl) return false
|
|
131
|
+
return patternToRegex(pattern).test(currentUrl)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function patternToRegex(pattern) {
|
|
135
|
+
const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&')
|
|
136
|
+
const regexStr = escaped.replace(/\*/g, '.*')
|
|
137
|
+
return new RegExp(regexStr)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Return the first available standard browser helper, or null.
|
|
142
|
+
*/
|
|
143
|
+
export function getBrowserHelper() {
|
|
144
|
+
const helpers = Container.helpers()
|
|
145
|
+
for (const name of supportedHelpers) {
|
|
146
|
+
if (Object.keys(helpers).indexOf(name) > -1) {
|
|
147
|
+
return helpers[name]
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return null
|
|
151
|
+
}
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
import crypto from 'crypto'
|
|
2
|
+
import fs from 'fs'
|
|
3
|
+
import path from 'path'
|
|
4
|
+
import { pathToFileURL } from 'url'
|
|
5
|
+
import Container from '../container.js'
|
|
6
|
+
import { clearString } from '../utils.js'
|
|
7
|
+
import { formatHtml } from '../html.js'
|
|
8
|
+
import { diffAriaSnapshots } from '../aria.js'
|
|
9
|
+
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
// Helper / directory naming
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
|
|
14
|
+
export function pickActingHelper(helpers) {
|
|
15
|
+
for (const name of Container.STANDARD_ACTING_HELPERS) {
|
|
16
|
+
if (helpers[name]) return helpers[name]
|
|
17
|
+
}
|
|
18
|
+
return null
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function traceDirFor(testFile, testTitle, baseDir) {
|
|
22
|
+
const hash = crypto.createHash('sha256').update((testFile || '') + (testTitle || '')).digest('hex').slice(0, 8)
|
|
23
|
+
const cleanTitle = clearString(testTitle || '').slice(0, 200)
|
|
24
|
+
return path.resolve(baseDir, `trace_${cleanTitle}_${hash}`)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function snapshotDirFor(baseDir) {
|
|
28
|
+
const hash = crypto.randomBytes(4).toString('hex')
|
|
29
|
+
return path.resolve(baseDir, `snapshot_${Date.now()}_${hash}`)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
// Artifact link rendering (trace.md)
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
|
|
36
|
+
const ARTIFACT_LABELS = {
|
|
37
|
+
html: 'HTML',
|
|
38
|
+
aria: 'ARIA',
|
|
39
|
+
screenshot: 'Screenshot',
|
|
40
|
+
console: 'Browser Logs',
|
|
41
|
+
storage: 'Storage',
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function artifactLinks(artifacts, { indent = ' ', consoleCount } = {}) {
|
|
45
|
+
const lines = []
|
|
46
|
+
const order = ['html', 'aria', 'screenshot', 'console', 'storage']
|
|
47
|
+
|
|
48
|
+
for (const key of order) {
|
|
49
|
+
const file = artifacts[key]
|
|
50
|
+
if (!file) continue
|
|
51
|
+
const label = ARTIFACT_LABELS[key]
|
|
52
|
+
let line = `${indent}> [${label}](./${file})`
|
|
53
|
+
if (key === 'console') {
|
|
54
|
+
const count = consoleCount ?? artifacts.consoleCount ?? 0
|
|
55
|
+
line += ` (${count} entries)`
|
|
56
|
+
} else if (key === 'storage') {
|
|
57
|
+
const cookies = artifacts.cookieCount ?? 0
|
|
58
|
+
const ls = artifacts.localStorageCount ?? 0
|
|
59
|
+
line += ` (${cookies} cookies, ${ls} localStorage)`
|
|
60
|
+
}
|
|
61
|
+
lines.push(line)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return lines.join('\n')
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function fileToUrl(dir, basename) {
|
|
68
|
+
return pathToFileURL(path.join(dir, basename)).href
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function writeTraceMarkdown({ dir, title, file, durationMs, commands, captured, error }) {
|
|
72
|
+
let md = `file: ${file || 'mcp'}\n`
|
|
73
|
+
md += `name: ${title}\n`
|
|
74
|
+
md += `time: ${(durationMs / 1000).toFixed(2)}s\n`
|
|
75
|
+
md += `---\n\n`
|
|
76
|
+
|
|
77
|
+
if (error) md += `Error: ${error}\n\n---\n\n`
|
|
78
|
+
|
|
79
|
+
if (commands && commands.length) {
|
|
80
|
+
md += `### Commands\n`
|
|
81
|
+
for (const c of commands) md += `- ${c}\n`
|
|
82
|
+
md += `\n`
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
md += `### Final State\n`
|
|
86
|
+
if (captured.url) md += ` > URL: ${captured.url}\n`
|
|
87
|
+
const links = artifactLinks(captured)
|
|
88
|
+
if (links) md += links + '\n'
|
|
89
|
+
|
|
90
|
+
const traceFile = path.join(dir, 'trace.md')
|
|
91
|
+
fs.writeFileSync(traceFile, md)
|
|
92
|
+
return traceFile
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function artifactsToFileUrls(captured, dir) {
|
|
96
|
+
const out = {}
|
|
97
|
+
if (captured.url) out.url = captured.url
|
|
98
|
+
if (captured.screenshot) out.screenshot = fileToUrl(dir, captured.screenshot)
|
|
99
|
+
if (captured.html) out.html = fileToUrl(dir, captured.html)
|
|
100
|
+
if (captured.aria) out.aria = fileToUrl(dir, captured.aria)
|
|
101
|
+
if (captured.console) out.console = fileToUrl(dir, captured.console)
|
|
102
|
+
if (captured.storage) out.storage = fileToUrl(dir, captured.storage)
|
|
103
|
+
if (typeof captured.consoleCount === 'number') out.consoleCount = captured.consoleCount
|
|
104
|
+
if (typeof captured.cookieCount === 'number') out.cookieCount = captured.cookieCount
|
|
105
|
+
if (typeof captured.localStorageCount === 'number') out.localStorageCount = captured.localStorageCount
|
|
106
|
+
return out
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ---------------------------------------------------------------------------
|
|
110
|
+
// Snapshot capture (HTML / ARIA / screenshot / console / storage)
|
|
111
|
+
// ---------------------------------------------------------------------------
|
|
112
|
+
|
|
113
|
+
function normalizeBrowserLogs(logs) {
|
|
114
|
+
return (logs || []).map(l => {
|
|
115
|
+
if (typeof l === 'string') return l
|
|
116
|
+
if (l && typeof l.type === 'function' && typeof l.text === 'function') {
|
|
117
|
+
return { type: l.type(), text: l.text() }
|
|
118
|
+
}
|
|
119
|
+
return l
|
|
120
|
+
})
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async function captureStorageState(helper) {
|
|
124
|
+
if (typeof helper.grabStorageState === 'function') {
|
|
125
|
+
try {
|
|
126
|
+
const state = await helper.grabStorageState()
|
|
127
|
+
if (state) return state
|
|
128
|
+
} catch {}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const state = { cookies: [], origins: [] }
|
|
132
|
+
|
|
133
|
+
if (typeof helper.grabCookie === 'function') {
|
|
134
|
+
try {
|
|
135
|
+
const cookies = await helper.grabCookie()
|
|
136
|
+
if (Array.isArray(cookies)) state.cookies = cookies
|
|
137
|
+
} catch {}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (typeof helper.executeScript === 'function') {
|
|
141
|
+
try {
|
|
142
|
+
const result = await helper.executeScript(() => {
|
|
143
|
+
const out = { origin: location.origin, items: [] }
|
|
144
|
+
for (let i = 0; i < localStorage.length; i++) {
|
|
145
|
+
const name = localStorage.key(i)
|
|
146
|
+
out.items.push({ name, value: localStorage.getItem(name) })
|
|
147
|
+
}
|
|
148
|
+
return out
|
|
149
|
+
})
|
|
150
|
+
if (result?.items?.length) {
|
|
151
|
+
state.origins.push({ origin: result.origin, localStorage: result.items })
|
|
152
|
+
}
|
|
153
|
+
} catch {}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return state
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export async function captureSnapshot(helper, {
|
|
160
|
+
dir,
|
|
161
|
+
prefix = 'snapshot',
|
|
162
|
+
fullPage = false,
|
|
163
|
+
captureURL = true,
|
|
164
|
+
captureScreenshot = true,
|
|
165
|
+
captureHTML = true,
|
|
166
|
+
captureARIA = true,
|
|
167
|
+
captureBrowserLogs = true,
|
|
168
|
+
captureStorage = true,
|
|
169
|
+
} = {}) {
|
|
170
|
+
if (!helper) return {}
|
|
171
|
+
const out = {}
|
|
172
|
+
|
|
173
|
+
if (captureURL) {
|
|
174
|
+
try {
|
|
175
|
+
if (helper.grabCurrentUrl) out.url = await helper.grabCurrentUrl()
|
|
176
|
+
} catch {}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (captureScreenshot && helper.saveScreenshot) {
|
|
180
|
+
try {
|
|
181
|
+
const file = `${prefix}_screenshot.png`
|
|
182
|
+
await helper.saveScreenshot(path.join(dir, file), fullPage)
|
|
183
|
+
out.screenshot = file
|
|
184
|
+
} catch {}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (captureHTML && helper.grabSource) {
|
|
188
|
+
try {
|
|
189
|
+
const html = await helper.grabSource()
|
|
190
|
+
// Universal funnel: every captured HTML snapshot flows through formatHtml
|
|
191
|
+
// (minify -> cleanHtml -> beautify). Don't add direct grabSource->writeFile
|
|
192
|
+
// paths elsewhere; route through this util so trash-class cleanup stays
|
|
193
|
+
// consistent across aiTrace, pageInfo, and MCP tools.
|
|
194
|
+
const formatted = await formatHtml(html)
|
|
195
|
+
const file = `${prefix}_page.html`
|
|
196
|
+
fs.writeFileSync(path.join(dir, file), formatted)
|
|
197
|
+
out.html = file
|
|
198
|
+
// Expose pre-cleanup HTML for consumers that need to inspect classes
|
|
199
|
+
// stripped by cleanHtml (e.g. pageInfo's error-class scan).
|
|
200
|
+
out.htmlRaw = html
|
|
201
|
+
} catch {}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (captureARIA && helper.grabAriaSnapshot) {
|
|
205
|
+
try {
|
|
206
|
+
const aria = await helper.grabAriaSnapshot()
|
|
207
|
+
const file = `${prefix}_aria.txt`
|
|
208
|
+
fs.writeFileSync(path.join(dir, file), aria)
|
|
209
|
+
out.aria = file
|
|
210
|
+
} catch {}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
if (captureBrowserLogs && helper.grabBrowserLogs) {
|
|
214
|
+
try {
|
|
215
|
+
const logs = await helper.grabBrowserLogs()
|
|
216
|
+
const normalized = normalizeBrowserLogs(logs)
|
|
217
|
+
const file = `${prefix}_console.json`
|
|
218
|
+
fs.writeFileSync(path.join(dir, file), JSON.stringify(normalized, null, 2))
|
|
219
|
+
out.console = file
|
|
220
|
+
out.consoleCount = normalized.length
|
|
221
|
+
} catch {}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
if (captureStorage) {
|
|
225
|
+
try {
|
|
226
|
+
const state = await captureStorageState(helper)
|
|
227
|
+
const cookieCount = state.cookies?.length || 0
|
|
228
|
+
const localStorageCount = (state.origins || [])
|
|
229
|
+
.reduce((sum, o) => sum + (o.localStorage?.length || 0), 0)
|
|
230
|
+
if (cookieCount || localStorageCount) {
|
|
231
|
+
const file = `${prefix}_storage.json`
|
|
232
|
+
fs.writeFileSync(path.join(dir, file), JSON.stringify(state, null, 2))
|
|
233
|
+
out.storage = file
|
|
234
|
+
out.cookieCount = cookieCount
|
|
235
|
+
out.localStorageCount = localStorageCount
|
|
236
|
+
}
|
|
237
|
+
} catch {}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
return out
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// ---------------------------------------------------------------------------
|
|
244
|
+
// TraceReader — read artifacts already on disk (written by aiTrace, MCP, etc.)
|
|
245
|
+
// ---------------------------------------------------------------------------
|
|
246
|
+
|
|
247
|
+
const KIND_SUFFIX = {
|
|
248
|
+
aria: '_aria.txt',
|
|
249
|
+
html: '_page.html',
|
|
250
|
+
screenshot: '_screenshot.png',
|
|
251
|
+
console: '_console.json',
|
|
252
|
+
storage: '_storage.json',
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
export class TraceReader {
|
|
256
|
+
constructor(dir) {
|
|
257
|
+
this.dir = dir
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Filenames of a given kind, sorted in capture order. aiTrace prefixes with
|
|
261
|
+
// a zero-padded step index (`0000_`, `0001_`...), so a lexical sort is
|
|
262
|
+
// chronological.
|
|
263
|
+
list(kind) {
|
|
264
|
+
const suffix = KIND_SUFFIX[kind]
|
|
265
|
+
if (!suffix || !this.dir || !fs.existsSync(this.dir)) return []
|
|
266
|
+
let entries
|
|
267
|
+
try { entries = fs.readdirSync(this.dir) } catch { return [] }
|
|
268
|
+
return entries.filter(f => f.endsWith(suffix)).sort()
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Path of the n-th file of `kind`, or null. Python-style indexing:
|
|
272
|
+
// 0..N-1 from the start, -1..-N from the end.
|
|
273
|
+
pathAt(n, kind) {
|
|
274
|
+
const files = this.list(kind)
|
|
275
|
+
if (!files.length) return null
|
|
276
|
+
const i = n < 0 ? files.length + n : n
|
|
277
|
+
if (i < 0 || i >= files.length) return null
|
|
278
|
+
return path.join(this.dir, files[i])
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// Read content of the n-th file of `kind`. Binary kinds (screenshot) are
|
|
282
|
+
// returned as Buffer; text kinds as utf8 string.
|
|
283
|
+
nth(n, kind) {
|
|
284
|
+
const p = this.pathAt(n, kind)
|
|
285
|
+
if (!p) return null
|
|
286
|
+
try {
|
|
287
|
+
if (kind === 'screenshot') return fs.readFileSync(p)
|
|
288
|
+
return fs.readFileSync(p, 'utf8')
|
|
289
|
+
} catch { return null }
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
first(kind) { return this.nth(0, kind) }
|
|
293
|
+
last(kind) { return this.nth(-1, kind) }
|
|
294
|
+
count(kind) { return this.list(kind).length }
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
export const ariaDiff = diffAriaSnapshots
|
package/lib/utils.js
CHANGED
|
@@ -617,6 +617,12 @@ function createCircularSafeReplacer(keysToSkip = []) {
|
|
|
617
617
|
return undefined
|
|
618
618
|
}
|
|
619
619
|
|
|
620
|
+
// Coerce types that JSON.stringify can't handle natively
|
|
621
|
+
if (typeof value === 'function') return `[Function: ${value.name || 'anonymous'}]`
|
|
622
|
+
if (typeof value === 'bigint') return `${value.toString()}n`
|
|
623
|
+
if (typeof value === 'symbol') return value.toString()
|
|
624
|
+
if (value instanceof Error) return { name: value.name, message: value.message, stack: value.stack }
|
|
625
|
+
|
|
620
626
|
if (value === null || typeof value !== 'object') {
|
|
621
627
|
return value
|
|
622
628
|
}
|
|
@@ -647,6 +653,25 @@ export const safeStringify = function (obj, keysToSkip = [], space = 0) {
|
|
|
647
653
|
}
|
|
648
654
|
}
|
|
649
655
|
|
|
656
|
+
/**
|
|
657
|
+
* Truncate a string at a byte cap, returning structured info.
|
|
658
|
+
* @param {string} str
|
|
659
|
+
* @param {number} maxBytes
|
|
660
|
+
* @returns {{ value: string, truncated: boolean, fullLength: number }}
|
|
661
|
+
*/
|
|
662
|
+
export const truncateString = function (str, maxBytes) {
|
|
663
|
+
if (typeof str !== 'string') str = String(str)
|
|
664
|
+
if (str.length <= maxBytes) {
|
|
665
|
+
return { value: str, truncated: false, fullLength: str.length }
|
|
666
|
+
}
|
|
667
|
+
const dropped = str.length - maxBytes
|
|
668
|
+
return {
|
|
669
|
+
value: `${str.slice(0, maxBytes)}\n...[truncated ${dropped} more chars]`,
|
|
670
|
+
truncated: true,
|
|
671
|
+
fullLength: str.length,
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
|
|
650
675
|
export const serializeError = function (error) {
|
|
651
676
|
if (error) {
|
|
652
677
|
const { stack, uncaught, message, actual, expected } = error
|