refactor: enhance JSON handling and improve request retry logic in DriverSession

This commit is contained in:
lilong.129
2025-06-27 11:52:53 +08:00
parent 7737705ab9
commit ba43e9fd0e
15 changed files with 483 additions and 234 deletions
+1 -1
View File
@@ -1 +1 @@
v5.0.0 v5.0.0-2506271152
+353 -61
View File
@@ -12,11 +12,12 @@ import (
"strings" "strings"
"time" "time"
"github.com/pkg/errors"
"github.com/rs/zerolog/log"
"github.com/httprunner/httprunner/v5/internal/builtin" "github.com/httprunner/httprunner/v5/internal/builtin"
"github.com/httprunner/httprunner/v5/uixt" "github.com/httprunner/httprunner/v5/uixt"
"github.com/httprunner/httprunner/v5/uixt/option" "github.com/httprunner/httprunner/v5/uixt/option"
"github.com/pkg/errors"
"github.com/rs/zerolog/log"
) )
// GenerateHTMLReportFromFiles is a convenience function to generate HTML report // GenerateHTMLReportFromFiles is a convenience function to generate HTML report
@@ -553,13 +554,14 @@ func (g *HTMLReportGenerator) GenerateReport(outputFile string) error {
"safeHTML": func(s string) template.HTML { "safeHTML": func(s string) template.HTML {
return template.HTML(s) return template.HTML(s)
}, },
"toJSON": func(v any) string { "toJSONFormatted": func(v any) string {
var buf strings.Builder var buf strings.Builder
encoder := json.NewEncoder(&buf) encoder := json.NewEncoder(&buf)
encoder.SetEscapeHTML(false) encoder.SetEscapeHTML(false)
encoder.SetIndent("", " ")
_ = encoder.Encode(v) _ = encoder.Encode(v)
result := buf.String() result := strings.TrimSpace(buf.String())
return strings.TrimSpace(result) return result
}, },
"add": func(a, b int) int { return a + b }, "add": func(a, b int) int { return a + b },
"base": filepath.Base, "base": filepath.Base,
@@ -584,6 +586,20 @@ func (g *HTMLReportGenerator) GenerateReport(outputFile string) error {
// If not JSON or no thought field, return original content // If not JSON or no thought field, return original content
return content return content
}, },
"formatBodyContent": func(content string) string {
// Try to parse as JSON to format
var data interface{}
if err := json.Unmarshal([]byte(content), &data); err == nil {
var buf strings.Builder
encoder := json.NewEncoder(&buf)
encoder.SetEscapeHTML(false)
encoder.SetIndent("", " ")
_ = encoder.Encode(data)
return strings.TrimSpace(buf.String())
}
// If not JSON, return original content
return content
},
} }
// Parse template // Parse template
@@ -1372,9 +1388,12 @@ const htmlTemplate = `<!DOCTYPE html>
margin: 2px 0; margin: 2px 0;
font-family: monospace; font-family: monospace;
font-size: 0.7em; font-size: 0.7em;
max-height: 60px; max-height: 80px;
overflow-y: auto; overflow-y: auto;
word-break: break-all; word-break: break-all;
white-space: nowrap;
overflow-x: auto;
line-height: 1.3;
} }
.model-output-compact { .model-output-compact {
@@ -1504,12 +1523,6 @@ const htmlTemplate = `<!DOCTYPE html>
font-weight: 600; font-weight: 600;
} }
.screenshots-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 10px;
}
.screenshots-horizontal { .screenshots-horizontal {
display: flex; display: flex;
gap: 15px; gap: 15px;
@@ -1903,26 +1916,6 @@ const htmlTemplate = `<!DOCTYPE html>
overflow: hidden; overflow: hidden;
} }
.controls {
text-align: center;
margin-bottom: 20px;
}
.controls button {
background: #007bff;
color: white;
border: none;
padding: 8px 16px;
border-radius: 4px;
margin: 0 5px;
cursor: pointer;
transition: background-color 0.3s;
}
.controls button:hover {
background: #0056b3;
}
/* Modal styles */ /* Modal styles */
.modal { .modal {
display: none; display: none;
@@ -2008,6 +2001,68 @@ const htmlTemplate = `<!DOCTYPE html>
border-radius: 0 0 12px 12px; border-radius: 0 0 12px 12px;
} }
/* JSON Syntax Highlighting */
.json-key {
color: #0066cc;
font-weight: bold;
}
.json-string {
color: #22863a;
}
.json-number {
color: #e36209;
}
.json-boolean {
color: #d73a49;
font-weight: bold;
}
.json-null {
color: #6f42c1;
font-weight: bold;
}
.json-punctuation {
color: #24292e;
}
.json-brace {
color: #586069;
font-weight: bold;
}
.json-bracket {
color: #586069;
font-weight: bold;
}
/* Inline JSON highlighting for smaller displays */
.json-inline .json-key {
color: #0066cc;
font-weight: 600;
}
.json-inline .json-string {
color: #22863a;
}
.json-inline .json-number {
color: #e36209;
}
.json-inline .json-boolean {
color: #d73a49;
font-weight: 600;
}
.json-inline .json-null {
color: #6f42c1;
font-weight: 600;
}
.json-toolbar { .json-toolbar {
background: #e9ecef; background: #e9ecef;
padding: 10px 20px; padding: 10px 20px;
@@ -2290,21 +2345,6 @@ const htmlTemplate = `<!DOCTYPE html>
font-size: 0.95em; font-size: 0.95em;
} }
.action-output {
background: #f8f9fa;
border: 2px solid #6f42c1;
border-radius: 6px;
padding: 10px;
font-size: 0.85em;
max-height: 120px;
overflow-y: auto;
white-space: pre-wrap;
word-wrap: break-word;
color: #495057;
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
line-height: 1.4;
}
.action-session-data { .action-session-data {
margin-top: 15px; margin-top: 15px;
padding: 15px; padding: 15px;
@@ -2538,7 +2578,7 @@ const htmlTemplate = `<!DOCTYPE html>
<div class="tool-calls-info">🔧 Tool Calls: {{$planning.ToolCallsCount}}</div> <div class="tool-calls-info">🔧 Tool Calls: {{$planning.ToolCallsCount}}</div>
{{end}} {{end}}
{{if $planning.ActionNames}} {{if $planning.ActionNames}}
<div class="actions-info">🎯 Actions: {{safeHTML (toJSON $planning.ActionNames)}}</div> <div class="actions-info json-inline">🎯 Actions: {{toJSONFormatted $planning.ActionNames}}</div>
{{end}} {{end}}
</div> </div>
</div> </div>
@@ -2560,7 +2600,9 @@ const htmlTemplate = `<!DOCTYPE html>
{{if $subAction.Error}}<span class="error">❌</span>{{else}}<span class="success">✅</span>{{end}} {{if $subAction.Error}}<span class="error">❌</span>{{else}}<span class="success">✅</span>{{end}}
</div> </div>
{{if $subAction.Arguments}} {{if $subAction.Arguments}}
<div class="action-arguments">{{safeHTML (toJSON $subAction.Arguments)}}</div> <div class="action-arguments json-inline">
{{toJSONFormatted $subAction.Arguments}}
</div>
{{end}} {{end}}
{{if $subAction.Requests}} {{if $subAction.Requests}}
<div class="action-requests"> <div class="action-requests">
@@ -2668,7 +2710,7 @@ const htmlTemplate = `<!DOCTYPE html>
{{/* Display structured data for query results */}} {{/* Display structured data for query results */}}
{{if $action.AIResult.QueryResult.Data}} {{if $action.AIResult.QueryResult.Data}}
<div class="model-info">📥 Structured Data:</div> <div class="model-info">📥 Structured Data:</div>
<div class="structured-data">{{safeHTML (toJSON $action.AIResult.QueryResult.Data)}}</div> <div class="structured-data json-inline">{{toJSONFormatted $action.AIResult.QueryResult.Data}}</div>
{{end}} {{end}}
{{else if eq $action.AIResult.Type "action"}} {{else if eq $action.AIResult.Type "action"}}
{{if $action.AIResult.PlanningResult.ModelName}} {{if $action.AIResult.PlanningResult.ModelName}}
@@ -2850,7 +2892,9 @@ const htmlTemplate = `<!DOCTYPE html>
{{end}} {{end}}
</div> </div>
{{if $logEntry.Fields}} {{if $logEntry.Fields}}
<div class="log-fields collapsed">{{safeHTML (toJSON $logEntry.Fields)}}</div> <div class="log-fields collapsed json-inline">
{{toJSONFormatted $logEntry.Fields}}
</div>
{{end}} {{end}}
</div> </div>
{{end}} {{end}}
@@ -2958,6 +3002,121 @@ const htmlTemplate = `<!DOCTYPE html>
const logContent = decodeBase64UTF8(logContentBase64); const logContent = decodeBase64UTF8(logContentBase64);
const caseContent = decodeBase64UTF8(caseContentBase64); const caseContent = decodeBase64UTF8(caseContentBase64);
// Enhanced JSON highlighting with better parsing
function highlightJSONAdvanced(jsonString) {
if (!jsonString || typeof jsonString !== 'string') {
return jsonString;
}
let result = '';
let i = 0;
let inString = false;
let inKey = false;
let escaped = false;
while (i < jsonString.length) {
const char = jsonString[i];
const nextChar = jsonString[i + 1];
if (escaped) {
result += char;
escaped = false;
i++;
continue;
}
if (char === '\\' && inString) {
escaped = true;
result += char;
i++;
continue;
}
if (char === '"') {
if (!inString) {
// Starting a string
inString = true;
// Check if this is a key (followed by colon)
let j = i + 1;
let tempStr = '';
let tempEscaped = false;
while (j < jsonString.length) {
const c = jsonString[j];
if (tempEscaped) {
tempEscaped = false;
j++;
continue;
}
if (c === '\\') {
tempEscaped = true;
j++;
continue;
}
if (c === '"') {
// End of string, check what follows
j++;
while (j < jsonString.length && /\s/.test(jsonString[j])) j++;
if (j < jsonString.length && jsonString[j] === ':') {
inKey = true;
}
break;
}
j++;
}
if (inKey) {
result += '<span class="json-key">"';
} else {
result += '<span class="json-string">"';
}
} else {
// Ending a string
inString = false;
result += '"</span>';
inKey = false;
}
} else if (!inString) {
// Handle non-string content
if (char === ':') {
result += '<span class="json-punctuation">:</span>';
} else if (char === ',') {
result += '<span class="json-punctuation">,</span>';
} else if (char === '{' || char === '}') {
result += '<span class="json-brace">' + char + '</span>';
} else if (char === '[' || char === ']') {
result += '<span class="json-bracket">' + char + '</span>';
} else if (/\d/.test(char) || (char === '-' && /\d/.test(nextChar))) {
// Handle numbers
let numStr = '';
while (i < jsonString.length && /[\d\.\-\+e]/i.test(jsonString[i])) {
numStr += jsonString[i];
i++;
}
result += '<span class="json-number">' + numStr + '</span>';
i--; // Adjust for the loop increment
} else if (char === 't' && jsonString.substr(i, 4) === 'true') {
result += '<span class="json-boolean">true</span>';
i += 3; // Skip the rest of 'true'
} else if (char === 'f' && jsonString.substr(i, 5) === 'false') {
result += '<span class="json-boolean">false</span>';
i += 4; // Skip the rest of 'false'
} else if (char === 'n' && jsonString.substr(i, 4) === 'null') {
result += '<span class="json-null">null</span>';
i += 3; // Skip the rest of 'null'
} else {
result += char;
}
} else {
// Inside string, just add character
result += char;
}
i++;
}
return result;
}
// Download functions // Download functions
function downloadSummary() { function downloadSummary() {
if (!summaryContent) { if (!summaryContent) {
@@ -2997,9 +3156,11 @@ const htmlTemplate = `<!DOCTYPE html>
try { try {
// Parse and format JSON for beautiful display // Parse and format JSON for beautiful display
const jsonObj = JSON.parse(caseContent); const jsonObj = JSON.parse(caseContent);
const formattedJson = JSON.stringify(jsonObj, null, 2); const formattedJson = JSON.stringify(jsonObj, null, 4);
document.getElementById('jsonContent').textContent = formattedJson; // Apply syntax highlighting
const highlightedJson = highlightJSONAdvanced(formattedJson);
document.getElementById('jsonContent').innerHTML = highlightedJson;
document.getElementById('jsonModal').style.display = 'block'; document.getElementById('jsonModal').style.display = 'block';
} catch (e) { } catch (e) {
console.error('Failed to parse JSON:', e); console.error('Failed to parse JSON:', e);
@@ -3014,13 +3175,17 @@ const htmlTemplate = `<!DOCTYPE html>
} }
function copyJsonContent() { function copyJsonContent() {
const jsonContent = document.getElementById('jsonContent').textContent; // Copy the original formatted JSON content instead of highlighted HTML
if (!jsonContent) { if (!caseContent) {
alert('No content to copy'); alert('No content to copy');
return; return;
} }
navigator.clipboard.writeText(jsonContent).then(function() { try {
const jsonObj = JSON.parse(caseContent);
const formattedJson = JSON.stringify(jsonObj, null, 4);
navigator.clipboard.writeText(formattedJson).then(function() {
const copyStatus = document.getElementById('copyStatus'); const copyStatus = document.getElementById('copyStatus');
copyStatus.classList.add('show'); copyStatus.classList.add('show');
setTimeout(function() { setTimeout(function() {
@@ -3030,6 +3195,19 @@ const htmlTemplate = `<!DOCTYPE html>
console.error('Failed to copy to clipboard:', err); console.error('Failed to copy to clipboard:', err);
alert('Failed to copy to clipboard. Please select and copy manually.'); alert('Failed to copy to clipboard. Please select and copy manually.');
}); });
} catch (e) {
// Fallback to original content
navigator.clipboard.writeText(caseContent).then(function() {
const copyStatus = document.getElementById('copyStatus');
copyStatus.classList.add('show');
setTimeout(function() {
copyStatus.classList.remove('show');
}, 2000);
}).catch(function(err) {
console.error('Failed to copy to clipboard:', err);
alert('Failed to copy to clipboard. Please select and copy manually.');
});
}
} }
function downloadCaseJson() { function downloadCaseJson() {
@@ -3050,9 +3228,11 @@ const htmlTemplate = `<!DOCTYPE html>
try { try {
// Parse and format JSON for beautiful display // Parse and format JSON for beautiful display
const jsonObj = JSON.parse(summaryContent); const jsonObj = JSON.parse(summaryContent);
const formattedJson = JSON.stringify(jsonObj, null, 2); const formattedJson = JSON.stringify(jsonObj, null, 4);
document.getElementById('summaryContent').textContent = formattedJson; // Apply syntax highlighting
const highlightedJson = highlightJSONAdvanced(formattedJson);
document.getElementById('summaryContent').innerHTML = highlightedJson;
document.getElementById('summaryModal').style.display = 'block'; document.getElementById('summaryModal').style.display = 'block';
} catch (e) { } catch (e) {
console.error('Failed to parse JSON:', e); console.error('Failed to parse JSON:', e);
@@ -3067,13 +3247,17 @@ const htmlTemplate = `<!DOCTYPE html>
} }
function copySummaryContent() { function copySummaryContent() {
const content = document.getElementById('summaryContent').textContent; // Copy the original formatted JSON content instead of highlighted HTML
if (!content) { if (!summaryContent) {
alert('No content to copy'); alert('No content to copy');
return; return;
} }
navigator.clipboard.writeText(content).then(function() { try {
const jsonObj = JSON.parse(summaryContent);
const formattedJson = JSON.stringify(jsonObj, null, 4);
navigator.clipboard.writeText(formattedJson).then(function() {
const copyStatus = document.getElementById('summaryStatus'); const copyStatus = document.getElementById('summaryStatus');
copyStatus.classList.add('show'); copyStatus.classList.add('show');
setTimeout(function() { setTimeout(function() {
@@ -3083,6 +3267,19 @@ const htmlTemplate = `<!DOCTYPE html>
console.error('Failed to copy to clipboard:', err); console.error('Failed to copy to clipboard:', err);
alert('Failed to copy to clipboard. Please select and copy manually.'); alert('Failed to copy to clipboard. Please select and copy manually.');
}); });
} catch (e) {
// Fallback to original content
navigator.clipboard.writeText(summaryContent).then(function() {
const copyStatus = document.getElementById('summaryStatus');
copyStatus.classList.add('show');
setTimeout(function() {
copyStatus.classList.remove('show');
}, 2000);
}).catch(function(err) {
console.error('Failed to copy to clipboard:', err);
alert('Failed to copy to clipboard. Please select and copy manually.');
});
}
} }
// Log Content Modal functions // Log Content Modal functions
@@ -3141,6 +3338,19 @@ const htmlTemplate = `<!DOCTYPE html>
if (fieldsElement.classList.contains('collapsed')) { if (fieldsElement.classList.contains('collapsed')) {
fieldsElement.classList.remove('collapsed'); fieldsElement.classList.remove('collapsed');
toggleIcon.classList.add('rotated'); toggleIcon.classList.add('rotated');
// Apply JSON highlighting when expanding
if (fieldsElement.classList.contains('json-inline')) {
const text = fieldsElement.textContent;
if (text && text.trim()) {
try {
JSON.parse(text);
const highlighted = highlightJSONAdvanced(text);
fieldsElement.innerHTML = highlighted;
} catch (e) {
// If not valid JSON, leave as is
}
}
}
} else { } else {
fieldsElement.classList.add('collapsed'); fieldsElement.classList.add('collapsed');
toggleIcon.classList.remove('rotated'); toggleIcon.classList.remove('rotated');
@@ -3173,9 +3383,64 @@ const htmlTemplate = `<!DOCTYPE html>
} else { } else {
requestsContent.classList.add('show'); requestsContent.classList.add('show');
buttonElement.textContent = buttonElement.textContent.replace('Show', 'Hide'); buttonElement.textContent = buttonElement.textContent.replace('Show', 'Hide');
// Apply JSON highlighting to request/response bodies when expanding
setTimeout(() => {
applyRequestResponseHighlighting(requestsContent);
}, 10);
} }
} }
// Apply JSON highlighting to request/response content
function applyRequestResponseHighlighting(container) {
// Find all request-body-compact and response-body-compact elements
const requestBodies = container.querySelectorAll('.request-body-compact, .response-body-compact');
requestBodies.forEach(function(element) {
// Skip if already processed
if (element.querySelector('.json-key, .json-string, .json-number')) {
return;
}
const text = element.textContent;
if (text && text.trim()) {
// Extract the content after "Request:" or "Response:"
const match = text.match(/^(Request|Response):\s*(.+)$/s);
if (match) {
const label = match[1];
const content = match[2].trim();
try {
// Validate JSON by parsing it
const parsedJson = JSON.parse(content);
// Re-stringify to get a compact, normalized string, which removes extra spaces
const compactJson = JSON.stringify(parsedJson);
// Apply highlighting on the compact string
const highlighted = highlightJSONAdvanced(compactJson);
element.innerHTML = label + ': ' + highlighted;
} catch (e) {
// If not valid JSON, leave as is
console.log('Not valid JSON for ' + label + ':', content);
}
} else {
// Try to find JSON-like content even without exact format
const jsonMatch = text.match(/(\{.*\}|\[.*\])/s);
if (jsonMatch) {
const jsonContent = jsonMatch[1].trim();
try {
JSON.parse(jsonContent);
const beforeJson = text.substring(0, text.indexOf(jsonContent));
const afterJson = text.substring(text.indexOf(jsonContent) + jsonContent.length);
const highlighted = highlightJSONAdvanced(jsonContent);
element.innerHTML = beforeJson + highlighted + afterJson;
} catch (e) {
// Not valid JSON, leave as is
}
}
}
}
});
}
function openImageModal(src) { function openImageModal(src) {
const modal = document.getElementById('imageModal'); const modal = document.getElementById('imageModal');
const modalImg = document.getElementById('modalImage'); const modalImg = document.getElementById('modalImage');
@@ -3208,6 +3473,25 @@ const htmlTemplate = `<!DOCTYPE html>
} }
} }
// Apply syntax highlighting to inline JSON content
function applyInlineJSONHighlighting() {
document.querySelectorAll('.json-inline').forEach(function(element) {
const text = element.textContent;
if (text && text.trim()) {
try {
// Validate and parse JSON
JSON.parse(text);
// Apply highlighting if valid JSON
const highlighted = highlightJSONAdvanced(text);
element.innerHTML = highlighted;
} catch (e) {
// If not valid JSON, leave as is
// This handles cases where content might not be pure JSON
}
}
});
}
// Auto-expand all steps on load to show actions // Auto-expand all steps on load to show actions
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
// Expand all steps to show the actions list // Expand all steps to show the actions list
@@ -3216,6 +3500,14 @@ const htmlTemplate = `<!DOCTYPE html>
contents.forEach(content => content.classList.add('show')); contents.forEach(content => content.classList.add('show'));
icons.forEach(icon => icon.classList.add('rotated')); icons.forEach(icon => icon.classList.add('rotated'));
// Apply syntax highlighting to inline JSON content
applyInlineJSONHighlighting();
// Apply JSON highlighting to all visible request/response content
document.querySelectorAll('.requests-content-compact').forEach(function(container) {
applyRequestResponseHighlighting(container);
});
}); });
function toggleAllSteps() { function toggleAllSteps() {
+1 -1
View File
@@ -11,7 +11,7 @@ PRE_COMMIT_FILE=.git/hooks/pre-commit
# install pre-commit hook and make it executable # install pre-commit hook and make it executable
function install() { function install() {
go get mvdan.cc/gofumpt go get mvdan.cc/gofumpt
go get github.com/incu6us/goimports-reviser/v2@latest go get github.com/incu6us/goimports-reviser/v3@latest
cat > $PRE_COMMIT_FILE <<'EOF' cat > $PRE_COMMIT_FILE <<'EOF'
#!/bin/bash #!/bin/bash
+4
View File
@@ -765,6 +765,10 @@ func runStepMobileUI(s *SessionRunner, step IStep) (stepResult *StepResult, err
identifier = action.Identifier identifier = action.Identifier
break break
} }
if action.Options != nil && action.Options.Identifier != "" {
identifier = action.Options.Identifier
break
}
} }
} }
stepResult.Identifier = identifier stepResult.Identifier = identifier
+26 -9
View File
@@ -158,25 +158,42 @@ func (s *DriverSession) DELETE(urlStr string) (rawResp DriverRawResponse, err er
func (s *DriverSession) RequestWithRetry(method string, urlStr string, rawBody []byte) ( func (s *DriverSession) RequestWithRetry(method string, urlStr string, rawBody []byte) (
rawResp DriverRawResponse, err error) { rawResp DriverRawResponse, err error) {
for count := 1; count <= s.maxRetry; count++ { var lastError error
for attempt := 1; attempt <= s.maxRetry; attempt++ {
// Execute the request
rawResp, err = s.Request(method, urlStr, rawBody) rawResp, err = s.Request(method, urlStr, rawBody)
if err == nil { if err == nil {
return if attempt > 1 {
log.Info().Msgf("request succeeded after %d attempts", attempt)
} }
return rawResp, nil
}
lastError = err
log.Warn().Err(err).Msgf("request failed, attempt %d/%d", attempt, s.maxRetry)
// If this was the last attempt, break
if attempt == s.maxRetry {
log.Error().Err(lastError).Msgf("all %d retry attempts failed, giving up", s.maxRetry)
break
}
// Wait before next attempt
time.Sleep(3 * time.Second) time.Sleep(3 * time.Second)
// Try to reset the session for the next attempt
if s.resetFn != nil { if s.resetFn != nil {
log.Warn().Msg("reset driver session") log.Warn().Msgf("attempting to reset driver session before attempt %d", attempt+1)
if err2 := s.resetFn(); err2 != nil { if resetErr := s.resetFn(); resetErr != nil {
log.Error().Err(err2).Msgf( log.Error().Err(resetErr).Msgf("failed to reset session, will retry without reset")
"failed to reset session, try count %v", count)
} else { } else {
log.Info().Msgf( log.Info().Msg("session reset successful")
"reset session success, try count %v", count)
} }
} }
} }
return
return nil, lastError
} }
func (s *DriverSession) Request(method string, urlStr string, rawBody []byte) ( func (s *DriverSession) Request(method string, urlStr string, rawBody []byte) (
+7 -8
View File
@@ -36,12 +36,18 @@ func NewWDADriver(device *IOSDevice) (*WDADriver, error) {
Session: NewDriverSession(), Session: NewDriverSession(),
} }
if !device.Options.LazySetup {
// setup driver // setup driver
if err := driver.Setup(); err != nil { if err := driver.Setup(); err != nil {
return nil, err return nil, err
} }
// check WDA status
wdaStatus, err := driver.Status()
if err != nil {
return nil, err
} }
log.Info().Interface("status", wdaStatus).
Msg("check WDA status")
// register driver session reset handler // register driver session reset handler
driver.Session.RegisterResetHandler(driver.Setup) driver.Session.RegisterResetHandler(driver.Setup)
@@ -146,13 +152,6 @@ func (wd *WDADriver) Setup() error {
return err return err
} }
wdaStatus, err := wd.Status()
if err != nil {
return err
}
log.Info().Interface("status", wdaStatus).
Msg("check WDA status")
// create new session // create new session
if err := wd.InitSession(nil); err != nil { if err := wd.InitSession(nil); err != nil {
return errors.Wrap(code.DeviceHTTPDriverError, err.Error()) return errors.Wrap(code.DeviceHTTPDriverError, err.Error())
+1 -2
View File
@@ -38,8 +38,7 @@ func TestDevice_IOS_Install(t *testing.T) {
func TestDriver_WDA_LazySetup(t *testing.T) { func TestDriver_WDA_LazySetup(t *testing.T) {
device, err := NewIOSDevice( device, err := NewIOSDevice(
option.WithWDAPort(8700), option.WithWDAPort(8700),
option.WithWDAMjpegPort(8800), option.WithWDAMjpegPort(8800))
option.WithLazySetup(true))
require.Nil(t, err) require.Nil(t, err)
driver, err := NewWDADriver(device) driver, err := NewWDADriver(device)
require.Nil(t, err) require.Nil(t, err)
+25 -5
View File
@@ -193,6 +193,8 @@ func extractActionOptionsToArguments(actionOptions []option.ActionOption, argume
"tap_random_rect": tempOptions.TapRandomRect, "tap_random_rect": tempOptions.TapRandomRect,
"anti_risk": tempOptions.AntiRisk, "anti_risk": tempOptions.AntiRisk,
"pre_mark_operation": tempOptions.PreMarkOperation, "pre_mark_operation": tempOptions.PreMarkOperation,
"reset_history": tempOptions.ResetHistory,
"match_one": tempOptions.MatchOne,
} }
// Add boolean options only if they are true // Add boolean options only if they are true
@@ -209,6 +211,18 @@ func extractActionOptionsToArguments(actionOptions []option.ActionOption, argume
if tempOptions.Index != 0 { if tempOptions.Index != 0 {
arguments["index"] = tempOptions.Index arguments["index"] = tempOptions.Index
} }
if tempOptions.Interval > 0 {
arguments["interval"] = tempOptions.Interval
}
if tempOptions.Steps > 0 {
arguments["steps"] = tempOptions.Steps
}
if tempOptions.Timeout > 0 {
arguments["timeout"] = tempOptions.Timeout
}
if tempOptions.Frequency > 0 {
arguments["frequency"] = tempOptions.Frequency
}
// Only set duration if it's not already set (to avoid overriding tool-specific conversions) // Only set duration if it's not already set (to avoid overriding tool-specific conversions)
if tempOptions.Duration > 0 { if tempOptions.Duration > 0 {
if _, exists := arguments["duration"]; !exists { if _, exists := arguments["duration"]; !exists {
@@ -288,13 +302,19 @@ func extractActionOptionsToArguments(actionOptions []option.ActionOption, argume
if tempOptions.Selector != "" { if tempOptions.Selector != "" {
arguments["selector"] = tempOptions.Selector arguments["selector"] = tempOptions.Selector
} }
} if tempOptions.Identifier != "" {
arguments["identifier"] = tempOptions.Identifier
}
func getFloat64ValueOrDefault(value float64, defaultValue float64) float64 { // Add direction option (can be string or []float64)
if value == 0 { if tempOptions.Direction != nil {
return defaultValue arguments["direction"] = tempOptions.Direction
}
// Add custom options
if len(tempOptions.Custom) > 0 {
arguments["custom"] = tempOptions.Custom
} }
return value
} }
// parseActionOptions converts MCP request arguments to ActionOptions struct // parseActionOptions converts MCP request arguments to ActionOptions struct
+3 -2
View File
@@ -4,10 +4,11 @@ import (
"context" "context"
"fmt" "fmt"
"github.com/httprunner/httprunner/v5/uixt/option"
"github.com/mark3labs/mcp-go/mcp" "github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server" "github.com/mark3labs/mcp-go/server"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
"github.com/httprunner/httprunner/v5/uixt/option"
) )
// ToolStartToGoal implements the start_to_goal tool call. // ToolStartToGoal implements the start_to_goal tool call.
@@ -162,7 +163,7 @@ func (t *ToolAIQuery) Implement() server.ToolHandlerFunc {
return nil, err return nil, err
} }
// Build action options from unified request // Build all options from request arguments
opts := unifiedReq.Options() opts := unifiedReq.Options()
// AI query logic with options // AI query logic with options
+20 -64
View File
@@ -5,11 +5,12 @@ import (
"fmt" "fmt"
"slices" "slices"
"github.com/httprunner/httprunner/v5/internal/builtin"
"github.com/httprunner/httprunner/v5/uixt/option"
"github.com/mark3labs/mcp-go/mcp" "github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server" "github.com/mark3labs/mcp-go/server"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
"github.com/httprunner/httprunner/v5/internal/builtin"
"github.com/httprunner/httprunner/v5/uixt/option"
) )
// ToolSwipe implements the generic swipe tool call. // ToolSwipe implements the generic swipe tool call.
@@ -124,15 +125,13 @@ func (t *ToolSwipeDirection) Implement() server.ToolHandlerFunc {
swipeDirection, validDirections) swipeDirection, validDirections)
} }
opts := []option.ActionOption{ // Build all options from request arguments
option.WithDuration(getFloat64ValueOrDefault(unifiedReq.Duration, 0.5)), opts := unifiedReq.Options()
option.WithPressDuration(getFloat64ValueOrDefault(unifiedReq.PressDuration, 0.1)), if unifiedReq.Duration == 0 {
opts = append(opts, option.WithDuration(0.5))
} }
if unifiedReq.AntiRisk { if unifiedReq.PressDuration == 0 {
opts = append(opts, option.WithAntiRisk(true)) opts = append(opts, option.WithPressDuration(0.1))
}
if unifiedReq.PreMarkOperation {
opts = append(opts, option.WithPreMarkOperation(true))
} }
// Convert direction to coordinates and perform swipe // Convert direction to coordinates and perform swipe
@@ -240,17 +239,8 @@ func (t *ToolSwipeCoordinate) Implement() server.ToolHandlerFunc {
params := []float64{unifiedReq.FromX, unifiedReq.FromY, unifiedReq.ToX, unifiedReq.ToY} params := []float64{unifiedReq.FromX, unifiedReq.FromY, unifiedReq.ToX, unifiedReq.ToY}
// Build action options from the unified request // Build all options from request arguments
opts := []option.ActionOption{} opts := unifiedReq.Options()
if unifiedReq.Duration > 0 {
opts = append(opts, option.WithDuration(unifiedReq.Duration))
}
if unifiedReq.PressDuration > 0 {
opts = append(opts, option.WithPressDuration(unifiedReq.PressDuration))
}
if unifiedReq.AntiRisk {
opts = append(opts, option.WithAntiRisk(true))
}
swipeAction := prepareSwipeAction(driverExt, params, opts...) swipeAction := prepareSwipeAction(driverExt, params, opts...)
err = swipeAction(driverExt) err = swipeAction(driverExt)
@@ -327,7 +317,7 @@ func (t *ToolSwipeToTapApp) Implement() server.ToolHandlerFunc {
} }
// Build action options from request structure // Build action options from request structure
var opts []option.ActionOption opts := unifiedReq.Options()
// Add boolean options // Add boolean options
if unifiedReq.IgnoreNotFoundError { if unifiedReq.IgnoreNotFoundError {
@@ -400,24 +390,8 @@ func (t *ToolSwipeToTapText) Implement() server.ToolHandlerFunc {
return nil, err return nil, err
} }
// Build action options from request structure // Build all options from request arguments
var opts []option.ActionOption opts := unifiedReq.Options()
// Add boolean options
if unifiedReq.IgnoreNotFoundError {
opts = append(opts, option.WithIgnoreNotFoundError(true))
}
if unifiedReq.Regex {
opts = append(opts, option.WithRegex(true))
}
// Add numeric options
if unifiedReq.MaxRetryTimes > 0 {
opts = append(opts, option.WithMaxRetryTimes(unifiedReq.MaxRetryTimes))
}
if unifiedReq.Index > 0 {
opts = append(opts, option.WithIndex(unifiedReq.Index))
}
// Swipe to tap text action logic // Swipe to tap text action logic
err = driverExt.SwipeToTapTexts([]string{unifiedReq.Text}, opts...) err = driverExt.SwipeToTapTexts([]string{unifiedReq.Text}, opts...)
@@ -478,24 +452,8 @@ func (t *ToolSwipeToTapTexts) Implement() server.ToolHandlerFunc {
return nil, err return nil, err
} }
// Build action options from request structure // Build all options from request arguments
var opts []option.ActionOption opts := unifiedReq.Options()
// Add boolean options
if unifiedReq.IgnoreNotFoundError {
opts = append(opts, option.WithIgnoreNotFoundError(true))
}
if unifiedReq.Regex {
opts = append(opts, option.WithRegex(true))
}
// Add numeric options
if unifiedReq.MaxRetryTimes > 0 {
opts = append(opts, option.WithMaxRetryTimes(unifiedReq.MaxRetryTimes))
}
if unifiedReq.Index > 0 {
opts = append(opts, option.WithIndex(unifiedReq.Index))
}
// Swipe to tap texts action logic // Swipe to tap texts action logic
err = driverExt.SwipeToTapTexts(unifiedReq.Texts, opts...) err = driverExt.SwipeToTapTexts(unifiedReq.Texts, opts...)
@@ -575,12 +533,10 @@ func (t *ToolDrag) Implement() server.ToolHandlerFunc {
return nil, fmt.Errorf("from_x, from_y, to_x, and to_y coordinates are required") return nil, fmt.Errorf("from_x, from_y, to_x, and to_y coordinates are required")
} }
opts := []option.ActionOption{} // Build all options from request arguments
if unifiedReq.Duration > 0 { opts := unifiedReq.Options()
opts = append(opts, option.WithDuration(unifiedReq.Duration/1000.0)) if unifiedReq.Duration == 0 {
} opts = append(opts, option.WithDuration(0.5))
if unifiedReq.AntiRisk {
opts = append(opts, option.WithAntiRisk(true))
} }
// Drag action logic // Drag action logic
+7 -31
View File
@@ -4,10 +4,11 @@ import (
"context" "context"
"fmt" "fmt"
"github.com/httprunner/httprunner/v5/internal/builtin"
"github.com/httprunner/httprunner/v5/uixt/option"
"github.com/mark3labs/mcp-go/mcp" "github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server" "github.com/mark3labs/mcp-go/server"
"github.com/httprunner/httprunner/v5/internal/builtin"
"github.com/httprunner/httprunner/v5/uixt/option"
) )
// ToolTapXY implements the tap_xy tool call. // ToolTapXY implements the tap_xy tool call.
@@ -42,14 +43,9 @@ func (t *ToolTapXY) Implement() server.ToolHandlerFunc {
return nil, err return nil, err
} }
// Get options directly since ActionOptions is now ActionOptions // Build all options from request arguments
opts := unifiedReq.Options() opts := unifiedReq.Options()
// Add configurable options based on request
if unifiedReq.PreMarkOperation {
opts = append(opts, option.WithPreMarkOperation(true))
}
// Validate required parameters // Validate required parameters
if unifiedReq.X == 0 || unifiedReq.Y == 0 { if unifiedReq.X == 0 || unifiedReq.Y == 0 {
return nil, fmt.Errorf("x and y coordinates are required") return nil, fmt.Errorf("x and y coordinates are required")
@@ -123,19 +119,9 @@ func (t *ToolTapAbsXY) Implement() server.ToolHandlerFunc {
return nil, err return nil, err
} }
// Get options directly since ActionOptions is now ActionOptions // Build all options from request arguments
opts := unifiedReq.Options() opts := unifiedReq.Options()
// Add configurable options based on request
if unifiedReq.PreMarkOperation {
opts = append(opts, option.WithPreMarkOperation(true))
}
// Add AntiRisk support
if unifiedReq.AntiRisk {
opts = append(opts, option.WithAntiRisk(true))
}
// Validate required parameters // Validate required parameters
if unifiedReq.X == 0 || unifiedReq.Y == 0 { if unifiedReq.X == 0 || unifiedReq.Y == 0 {
return nil, fmt.Errorf("x and y coordinates are required") return nil, fmt.Errorf("x and y coordinates are required")
@@ -208,14 +194,9 @@ func (t *ToolTapByOCR) Implement() server.ToolHandlerFunc {
return nil, err return nil, err
} }
// Get options directly since ActionOptions is now ActionOptions // Build all options from request arguments
opts := unifiedReq.Options() opts := unifiedReq.Options()
// Add configurable options based on request
if unifiedReq.PreMarkOperation {
opts = append(opts, option.WithPreMarkOperation(true))
}
// Validate required parameters // Validate required parameters
if unifiedReq.Text == "" { if unifiedReq.Text == "" {
return nil, fmt.Errorf("text parameter is required") return nil, fmt.Errorf("text parameter is required")
@@ -277,14 +258,9 @@ func (t *ToolTapByCV) Implement() server.ToolHandlerFunc {
return nil, err return nil, err
} }
// Get options directly since ActionOptions is now ActionOptions // Build all options from request arguments
opts := unifiedReq.Options() opts := unifiedReq.Options()
// Add configurable options based on request
if unifiedReq.PreMarkOperation {
opts = append(opts, option.WithPreMarkOperation(true))
}
// For TapByCV, we need to check if there are UI types in the options // For TapByCV, we need to check if there are UI types in the options
// In the original DoAction, it requires ScreenShotWithUITypes to be set // In the original DoAction, it requires ScreenShotWithUITypes to be set
// We'll add a basic implementation that triggers CV recognition // We'll add a basic implementation that triggers CV recognition
+6 -5
View File
@@ -5,11 +5,12 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"github.com/httprunner/httprunner/v5/internal/builtin"
"github.com/httprunner/httprunner/v5/uixt/option"
"github.com/mark3labs/mcp-go/mcp" "github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server" "github.com/mark3labs/mcp-go/server"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
"github.com/httprunner/httprunner/v5/internal/builtin"
"github.com/httprunner/httprunner/v5/uixt/option"
) )
// ToolWebLoginNoneUI implements the web_login_none_ui tool call. // ToolWebLoginNoneUI implements the web_login_none_ui tool call.
@@ -170,7 +171,7 @@ func (t *ToolHoverBySelector) Implement() server.ToolHandlerFunc {
if err != nil { if err != nil {
return nil, err return nil, err
} }
// Get options directly since ActionOptions is now ActionOptions // Build all options from request arguments
opts := unifiedReq.Options() opts := unifiedReq.Options()
// Hover by selector action logic // Hover by selector action logic
@@ -228,7 +229,7 @@ func (t *ToolTapBySelector) Implement() server.ToolHandlerFunc {
if err != nil { if err != nil {
return nil, err return nil, err
} }
// Get options directly since ActionOptions is now ActionOptions // Build all options from request arguments
opts := unifiedReq.Options() opts := unifiedReq.Options()
// Tap by selector action logic // Tap by selector action logic
@@ -286,7 +287,7 @@ func (t *ToolSecondaryClickBySelector) Implement() server.ToolHandlerFunc {
if err != nil { if err != nil {
return nil, err return nil, err
} }
// Get options directly since ActionOptions is now ActionOptions // Build all options from request arguments
opts := unifiedReq.Options() opts := unifiedReq.Options()
// Secondary click by selector action logic // Secondary click by selector action logic
+7 -2
View File
@@ -7,10 +7,11 @@ import (
"reflect" "reflect"
"strings" "strings"
"github.com/httprunner/httprunner/v5/internal/builtin"
"github.com/httprunner/httprunner/v5/uixt/types"
"github.com/mark3labs/mcp-go/mcp" "github.com/mark3labs/mcp-go/mcp"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
"github.com/httprunner/httprunner/v5/internal/builtin"
"github.com/httprunner/httprunner/v5/uixt/types"
) )
type MobileAction struct { type MobileAction struct {
@@ -326,6 +327,10 @@ func (o *ActionOptions) Options() []ActionOption {
options = append(options, WithAntiRisk(true)) options = append(options, WithAntiRisk(true))
} }
if o.PreMarkOperation {
options = append(options, WithPreMarkOperation(true))
}
// custom options // custom options
if o.Custom != nil { if o.Custom != nil {
for k, v := range o.Custom { for k, v := range o.Custom {
-11
View File
@@ -152,17 +152,6 @@ func WithDeviceWDAMjpegPort(port int) DeviceOption {
} }
} }
func WithDeviceLazySetup(lazySetup bool) DeviceOption {
return func(device *DeviceOptions) {
if device.IOSDeviceOptions != nil {
device.IOSDeviceOptions.LazySetup = lazySetup
}
if device.Platform == "" {
device.Platform = "ios"
}
}
}
func WithDeviceResetHomeOnStartup(reset bool) DeviceOption { func WithDeviceResetHomeOnStartup(reset bool) DeviceOption {
return func(device *DeviceOptions) { return func(device *DeviceOptions) {
if device.IOSDeviceOptions != nil { if device.IOSDeviceOptions != nil {
-10
View File
@@ -6,7 +6,6 @@ type IOSDeviceOptions struct {
WDAPort int `json:"port,omitempty" yaml:"port,omitempty"` // WDA remote port WDAPort int `json:"port,omitempty" yaml:"port,omitempty"` // WDA remote port
WDAMjpegPort int `json:"mjpeg_port,omitempty" yaml:"mjpeg_port,omitempty"` // WDA remote MJPEG port WDAMjpegPort int `json:"mjpeg_port,omitempty" yaml:"mjpeg_port,omitempty"` // WDA remote MJPEG port
LogOn bool `json:"log_on,omitempty" yaml:"log_on,omitempty"` LogOn bool `json:"log_on,omitempty" yaml:"log_on,omitempty"`
LazySetup bool `json:"lazy_setup,omitempty" yaml:"lazy_setup,omitempty"` // lazy setup WDA
// switch to iOS springboard before init WDA session // switch to iOS springboard before init WDA session
ResetHomeOnStartup bool `json:"reset_home_on_startup,omitempty" yaml:"reset_home_on_startup,omitempty"` ResetHomeOnStartup bool `json:"reset_home_on_startup,omitempty" yaml:"reset_home_on_startup,omitempty"`
@@ -33,9 +32,6 @@ func (dev *IOSDeviceOptions) Options() (deviceOptions []IOSDeviceOption) {
if dev.LogOn { if dev.LogOn {
deviceOptions = append(deviceOptions, WithWDALogOn(true)) deviceOptions = append(deviceOptions, WithWDALogOn(true))
} }
if dev.LazySetup {
deviceOptions = append(deviceOptions, WithLazySetup(true))
}
if dev.ResetHomeOnStartup { if dev.ResetHomeOnStartup {
deviceOptions = append(deviceOptions, WithResetHomeOnStartup(true)) deviceOptions = append(deviceOptions, WithResetHomeOnStartup(true))
} }
@@ -104,12 +100,6 @@ func WithWDALogOn(logOn bool) IOSDeviceOption {
} }
} }
func WithLazySetup(lazySetup bool) IOSDeviceOption {
return func(device *IOSDeviceOptions) {
device.LazySetup = lazySetup
}
}
func WithResetHomeOnStartup(reset bool) IOSDeviceOption { func WithResetHomeOnStartup(reset bool) IOSDeviceOption {
return func(device *IOSDeviceOptions) { return func(device *IOSDeviceOptions) {
device.ResetHomeOnStartup = reset device.ResetHomeOnStartup = reset