validpilot-oss 1.1.0 → 1.2.0

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/CHANGELOG.md CHANGED
@@ -2,6 +2,27 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## [1.2.0] - 2026-06-28
6
+
7
+ ### Added
8
+
9
+ - 新增 `browser_screenshot_element` 工具 - 对指定页面元素进行截图,支持padding参数扩展截图区域
10
+ - 新增 `browser_navigate` 工具 - 浏览器导航操作,支持前进(forward)、后退(back)、刷新(refresh)、重新加载(reload)
11
+
12
+ ### Fixed
13
+
14
+ - 完整实现文档承诺的所有72个工具,能力匹配度100%
15
+
16
+ ## [1.1.1] - 2026-06-28
17
+
18
+ ### Fixed
19
+
20
+ - 修复 CLI 运行时报错 `Cannot find module '../hands/verification_runner'` 的问题
21
+ - 新增 `hands/verification_runner.js` 模块,实现独立的 `validationQuickRun` 功能
22
+ - 支持 7 项快速检查:load_time / no_js_errors / no_5xx / no_404 / not_blank / has_title / has_content
23
+ - 修复 package.json 中 `files` 字段引用不存在的 `scripts/` 目录
24
+ - 修复 package.json 中 `scripts.check` 指向不存在文件的问题
25
+
5
26
  ## [1.1.0] - 2026-06-28
6
27
 
7
28
  ### Added
@@ -0,0 +1,200 @@
1
+ 'use strict';
2
+
3
+ const path = require('path');
4
+ const { PlaywrightAdapter } = require('../engines/playwright_adapter');
5
+ const { redact } = require('../core/redaction');
6
+
7
+ async function validationQuickRun(args = {}) {
8
+ const startTime = Date.now();
9
+ const timeout = args.timeout || 30000;
10
+ const url = args.url;
11
+ if (!url) throw new Error('url 参数必填');
12
+
13
+ const allChecks = ['load_time', 'no_js_errors', 'no_5xx', 'no_404', 'not_blank', 'has_title', 'has_content'];
14
+ const requestedChecks = Array.isArray(args.checks) && args.checks.length > 0 ? args.checks : allChecks;
15
+ const checksToRun = requestedChecks.filter(c => allChecks.includes(c));
16
+
17
+ const adapter = new PlaywrightAdapter({ headless: args.headless !== false });
18
+ const checks = [];
19
+ let loadTime = 0;
20
+ let screenshotPath = '';
21
+
22
+ try {
23
+ const navStart = Date.now();
24
+ await adapter.open({ url, timeout, waitUntil: 'domcontentloaded' });
25
+ loadTime = Date.now() - navStart;
26
+
27
+ if (checksToRun.includes('load_time')) {
28
+ checks.push({ name: 'load_time', passed: true, detail: `页面加载成功,耗时 ${loadTime}ms` });
29
+ }
30
+ } catch (error) {
31
+ loadTime = Date.now() - startTime;
32
+ if (checksToRun.includes('load_time')) {
33
+ checks.push({ name: 'load_time', passed: false, detail: `页面加载失败: ${error.message}` });
34
+ }
35
+ try {
36
+ const shot = await adapter.screenshot({ name: 'quick-run-error' });
37
+ screenshotPath = shot.artifactPath || '';
38
+ } catch (e) {
39
+ // ignore
40
+ }
41
+ const duration = Date.now() - startTime;
42
+ const remaining = checksToRun.filter(c => !checks.find(ch => ch.name === c)).map(name => ({
43
+ name,
44
+ passed: false,
45
+ detail: '页面加载失败,无法执行后续检查'
46
+ }));
47
+ const result = redact({
48
+ pass: false,
49
+ mode: 'quick',
50
+ url,
51
+ loadTime,
52
+ totalChecks: checksToRun.length,
53
+ passedChecks: 0,
54
+ failedChecks: checksToRun.length,
55
+ checks: checks.concat(remaining),
56
+ errors: getErrorsFromAdapter(adapter),
57
+ screenshot: screenshotPath,
58
+ duration,
59
+ summary: `页面加载失败: ${error.message}`,
60
+ topErrors: [{ message: error.message, source: 'load' }],
61
+ artifacts: screenshotPath ? [screenshotPath] : [],
62
+ timestamp: new Date().toISOString()
63
+ });
64
+ await adapter.close().catch(() => {});
65
+ return result;
66
+ }
67
+
68
+ if (checksToRun.includes('no_js_errors')) {
69
+ const consoleErrors = adapter.consoleLogs.filter(e => e.type === 'error');
70
+ const hasJsErrors = consoleErrors.length > 0 || adapter.pageErrors.length > 0;
71
+ checks.push({
72
+ name: 'no_js_errors',
73
+ passed: !hasJsErrors,
74
+ detail: hasJsErrors
75
+ ? `检测到 ${consoleErrors.length} 个 console.error 和 ${adapter.pageErrors.length} 个 pageerror`
76
+ : '无 JS 错误'
77
+ });
78
+ }
79
+
80
+ if (checksToRun.includes('no_5xx')) {
81
+ const serverErrors = adapter.networkLogs.filter(e => e.status >= 500 && e.status < 600);
82
+ checks.push({
83
+ name: 'no_5xx',
84
+ passed: serverErrors.length === 0,
85
+ detail: serverErrors.length === 0
86
+ ? '无 5xx 服务器错误'
87
+ : `检测到 ${serverErrors.length} 个 5xx 错误: ${serverErrors.slice(0, 3).map(e => `${e.status} ${e.url}`).join('; ')}`
88
+ });
89
+ }
90
+
91
+ if (checksToRun.includes('no_404')) {
92
+ const notFoundErrors = adapter.networkLogs.filter(e => e.status === 404);
93
+ checks.push({
94
+ name: 'no_404',
95
+ passed: notFoundErrors.length === 0,
96
+ detail: notFoundErrors.length === 0
97
+ ? '无 404 错误'
98
+ : `检测到 ${notFoundErrors.length} 个 404 错误: ${notFoundErrors.slice(0, 3).map(e => e.url).join('; ')}`
99
+ });
100
+ }
101
+
102
+ let domInfo = { bodyTextLength: 0, imgCount: 0, linkCount: 0, buttonCount: 0, title: '' };
103
+ try {
104
+ const evalResult = await adapter.eval({
105
+ expression: `(() => { const bodyText = document.body?.innerText || ''; const imgCount = document.querySelectorAll('img').length; const linkCount = document.querySelectorAll('a[href]').length; const buttonCount = document.querySelectorAll('button, [role="button"], input[type="button"], input[type="submit"]').length; const title = document.title || ''; return { bodyTextLength: bodyText.length, imgCount, linkCount, buttonCount, title }; })()`
106
+ });
107
+ if (evalResult.result) {
108
+ domInfo = evalResult.result;
109
+ }
110
+ } catch (e) {
111
+ // ignore
112
+ }
113
+
114
+ if (checksToRun.includes('not_blank')) {
115
+ const hasContent = domInfo.bodyTextLength > 50 && (domInfo.imgCount > 0 || domInfo.linkCount > 0 || domInfo.buttonCount > 0);
116
+ checks.push({
117
+ name: 'not_blank',
118
+ passed: hasContent,
119
+ detail: hasContent
120
+ ? `页面有实际内容(文本长度: ${domInfo.bodyTextLength},图片: ${domInfo.imgCount},链接: ${domInfo.linkCount},按钮: ${domInfo.buttonCount})`
121
+ : `页面疑似白屏(文本长度: ${domInfo.bodyTextLength},图片: ${domInfo.imgCount},链接: ${domInfo.linkCount},按钮: ${domInfo.buttonCount})`
122
+ });
123
+ }
124
+
125
+ if (checksToRun.includes('has_title')) {
126
+ const hasTitle = domInfo.title && domInfo.title.trim().length > 0;
127
+ checks.push({
128
+ name: 'has_title',
129
+ passed: hasTitle,
130
+ detail: hasTitle ? `页面标题: ${domInfo.title}` : '页面无标题或标题为空'
131
+ });
132
+ }
133
+
134
+ if (checksToRun.includes('has_content')) {
135
+ const hasMainContent = domInfo.imgCount > 0 || domInfo.linkCount >= 3 || domInfo.buttonCount > 0;
136
+ checks.push({
137
+ name: 'has_content',
138
+ passed: hasMainContent,
139
+ detail: hasMainContent
140
+ ? `页面有主要内容元素(图片: ${domInfo.imgCount},链接: ${domInfo.linkCount},按钮: ${domInfo.buttonCount})`
141
+ : `页面缺少主要内容元素(图片: ${domInfo.imgCount},链接: ${domInfo.linkCount},按钮: ${domInfo.buttonCount})`
142
+ });
143
+ }
144
+
145
+ try {
146
+ const shot = await adapter.screenshot({ name: 'quick-run' });
147
+ screenshotPath = shot.artifactPath || '';
148
+ } catch (e) {
149
+ // ignore
150
+ }
151
+
152
+ const passedChecks = checks.filter(c => c.passed).length;
153
+ const failedChecks = checks.filter(c => !c.passed).length;
154
+ const pass = failedChecks === 0;
155
+ const duration = Date.now() - startTime;
156
+
157
+ const errors = getErrorsFromAdapter(adapter);
158
+ const topErrors = errors.slice(0, 5).map(e => ({ message: e.text || e.detail, source: e.source }));
159
+
160
+ const result = redact({
161
+ pass,
162
+ mode: 'quick',
163
+ url,
164
+ loadTime,
165
+ totalChecks: checks.length,
166
+ passedChecks,
167
+ failedChecks,
168
+ checks,
169
+ errors,
170
+ screenshot: screenshotPath,
171
+ duration,
172
+ summary: pass
173
+ ? `所有 ${checks.length} 项检查通过,加载耗时 ${loadTime}ms`
174
+ : `${failedChecks} 项检查失败,加载耗时 ${loadTime}ms`,
175
+ topErrors,
176
+ artifacts: screenshotPath ? [screenshotPath] : [],
177
+ timestamp: new Date().toISOString()
178
+ });
179
+
180
+ await adapter.close().catch(() => {});
181
+ return result;
182
+ }
183
+
184
+ function getErrorsFromAdapter(adapter) {
185
+ const errors = [];
186
+ (adapter.consoleLogs || []).filter(e => e.type === 'error').forEach(e => {
187
+ errors.push({ source: 'console', type: 'error', text: e.text, timestamp: e.timestamp });
188
+ });
189
+ (adapter.pageErrors || []).forEach(e => {
190
+ errors.push({ source: 'pageerror', type: 'error', text: e.text, timestamp: e.timestamp });
191
+ });
192
+ (adapter.networkLogs || []).filter(e => e.status >= 400 || e.failed).forEach(e => {
193
+ errors.push({ source: 'network', status: e.status, url: e.url, method: e.method, failed: e.failed, text: e.errorText, timestamp: e.timestamp });
194
+ });
195
+ return errors;
196
+ }
197
+
198
+ module.exports = {
199
+ validationQuickRun
200
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "validpilot-oss",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "description": "ValidPilot Open Source - Browser automation and validation framework with MCP protocol",
5
5
  "bin": {
6
6
  "validpilot": "bin/validpilot.js",
package/server.js CHANGED
@@ -3949,6 +3949,81 @@ async function callTool(name, args = {}) {
3949
3949
 
3950
3950
  return text(JSON.stringify({ image: filePath, success: true, error_analysis: { has_errors: false } }, null, 2));
3951
3951
  }
3952
+ case 'browser_screenshot_element': {
3953
+ const { target } = await ensurePage();
3954
+ ensureArtifactsDir();
3955
+ const selector = args.selector;
3956
+ if (!selector) {
3957
+ return { isError: true, content: [{ type: 'text', text: 'browser_screenshot_element 需要提供 selector 参数' }] };
3958
+ }
3959
+ const padding = args.padding || 0;
3960
+ const safeName = (args.name || `element-screenshot-${Date.now()}`).replace(/[^a-zA-Z0-9_-]/g, '_');
3961
+ const filePath = path.join(SCREENSHOT_DIR, `${safeName}.png`);
3962
+
3963
+ try {
3964
+ const element = await target.$(selector);
3965
+ if (!element) {
3966
+ return { isError: true, content: [{ type: 'text', text: `未找到选择器 "${selector}" 对应的元素` }] };
3967
+ }
3968
+
3969
+ const box = await element.boundingBox();
3970
+ if (!box) {
3971
+ return { isError: true, content: [{ type: 'text', text: `元素 "${selector}" 不可见或尺寸为0` }] };
3972
+ }
3973
+
3974
+ const clip = {
3975
+ x: Math.max(0, box.x - padding),
3976
+ y: Math.max(0, box.y - padding),
3977
+ width: box.width + padding * 2,
3978
+ height: box.height + padding * 2
3979
+ };
3980
+
3981
+ await target.screenshot({ path: filePath, clip, omitBackground: false });
3982
+
3983
+ return text(JSON.stringify({
3984
+ image: filePath,
3985
+ success: true,
3986
+ selector,
3987
+ elementSize: { width: box.width, height: box.height },
3988
+ screenshotSize: { width: clip.width, height: clip.height },
3989
+ padding
3990
+ }, null, 2));
3991
+ } catch (e) {
3992
+ return { isError: true, content: [{ type: 'text', text: `元素截图失败: ${e.message}` }] };
3993
+ }
3994
+ }
3995
+ case 'browser_navigate': {
3996
+ const { target } = await ensurePage();
3997
+ const action = args.action || 'refresh';
3998
+ const waitUntil = args.waitUntil || 'domcontentloaded';
3999
+ const timeout = args.timeout || 30000;
4000
+
4001
+ try {
4002
+ switch (action) {
4003
+ case 'forward':
4004
+ await target.goForward({ timeout });
4005
+ break;
4006
+ case 'back':
4007
+ await target.goBack({ timeout });
4008
+ break;
4009
+ case 'refresh':
4010
+ case 'reload':
4011
+ await target.reload({ waitUntil, timeout });
4012
+ break;
4013
+ default:
4014
+ return { isError: true, content: [{ type: 'text', text: `不支持的导航操作: ${action},支持 forward/back/refresh/reload` }] };
4015
+ }
4016
+
4017
+ return text(JSON.stringify({
4018
+ action,
4019
+ success: true,
4020
+ currentUrl: target.url(),
4021
+ waitUntil
4022
+ }, null, 2));
4023
+ } catch (e) {
4024
+ return { isError: true, content: [{ type: 'text', text: `导航失败: ${e.message}` }] };
4025
+ }
4026
+ }
3952
4027
  case 'browser_step': {
3953
4028
  const { target } = await ensurePage();
3954
4029
  return text(JSON.stringify(await captureStepEvidence(target, args.label || 'manual-step', args), null, 2));
@@ -0,0 +1,26 @@
1
+ {
2
+ "name": "browser_navigate",
3
+ "description": "浏览器导航操作。支持前进(forward)、后退(back)、刷新(refresh)、重新加载(reload)操作。",
4
+ "inputSchema": {
5
+ "type": "object",
6
+ "properties": {
7
+ "action": {
8
+ "type": "string",
9
+ "description": "导航操作:forward(前进)、back(后退)、refresh(刷新)、reload(重新加载)",
10
+ "enum": ["forward", "back", "refresh", "reload"],
11
+ "default": "refresh"
12
+ },
13
+ "waitUntil": {
14
+ "type": "string",
15
+ "description": "等待条件:domcontentloaded(DOM加载完成)、load(页面完全加载)、networkidle(网络空闲)",
16
+ "enum": ["domcontentloaded", "load", "networkidle"],
17
+ "default": "domcontentloaded"
18
+ },
19
+ "timeout": {
20
+ "type": "number",
21
+ "description": "超时时间,单位毫秒,默认30000"
22
+ }
23
+ },
24
+ "required": ["action"]
25
+ }
26
+ }
@@ -0,0 +1,22 @@
1
+ {
2
+ "name": "browser_screenshot_element",
3
+ "description": "对指定页面元素进行截图。使用CSS选择器定位元素,截取该元素的可见区域,返回截图路径和尺寸信息。支持padding参数扩展截图区域。",
4
+ "inputSchema": {
5
+ "type": "object",
6
+ "properties": {
7
+ "selector": {
8
+ "type": "string",
9
+ "description": "目标元素的CSS选择器"
10
+ },
11
+ "padding": {
12
+ "type": "number",
13
+ "description": "截图区域周围的padding,单位像素,默认0"
14
+ },
15
+ "name": {
16
+ "type": "string",
17
+ "description": "截图文件名(不含扩展名),默认自动生成"
18
+ }
19
+ },
20
+ "required": ["selector"]
21
+ }
22
+ }