feat: add case.json support for HTML report generation and enhance logging for step execution

This commit is contained in:
lilong.129
2025-06-25 14:40:04 +08:00
parent f1f099cc48
commit bfa6a0e4cd
3 changed files with 264 additions and 27 deletions
+1 -1
View File
@@ -1 +1 @@
v5.0.0-beta-2506251207 v5.0.0-beta-2506251440
+261 -24
View File
@@ -41,6 +41,7 @@ type HTMLReportGenerator struct {
ReportDir string ReportDir string
SummaryContent string // Raw summary.json content for download SummaryContent string // Raw summary.json content for download
LogContent string // Raw hrp.log content for download LogContent string // Raw hrp.log content for download
CaseContent string // Raw case.json content for display
} }
// LogEntry represents a single log entry // LogEntry represents a single log entry
@@ -72,6 +73,11 @@ func NewHTMLReportGenerator(summaryFile, logFile string) (*HTMLReportGenerator,
} }
} }
// Load case.json data if exists
if err := generator.loadCaseData(); err != nil {
log.Warn().Err(err).Msg("failed to load case data, continuing without case display")
}
return generator, nil return generator, nil
} }
@@ -208,7 +214,24 @@ func (g *HTMLReportGenerator) loadLogData() error {
return scanner.Err() return scanner.Err()
} }
// getStepLogs filters log entries for a specific test step based on step boundaries // loadCaseData loads test case data from case.json file
func (g *HTMLReportGenerator) loadCaseData() error {
caseFile := filepath.Join(g.ReportDir, "case.json")
if !builtin.FileExists(caseFile) {
return nil // case.json is optional
}
data, err := os.ReadFile(caseFile)
if err != nil {
return err
}
// Store the case content for display
g.CaseContent = string(data)
return nil
}
// getStepLogs filters log entries for a specific test step using prefix matching and time range filtering
func (g *HTMLReportGenerator) getStepLogs(stepName string, startTime int64, elapsed int64) []LogEntry { func (g *HTMLReportGenerator) getStepLogs(stepName string, startTime int64, elapsed int64) []LogEntry {
if len(g.LogData) == 0 { if len(g.LogData) == 0 {
return nil return nil
@@ -217,8 +240,19 @@ func (g *HTMLReportGenerator) getStepLogs(stepName string, startTime int64, elap
var stepLogs []LogEntry var stepLogs []LogEntry
var inCurrentStep bool = false var inCurrentStep bool = false
// Simple approach: use step start/end markers for precise boundaries // Calculate step end time (startTime + elapsed, both in milliseconds)
endTime := startTime + elapsed
// Convert step times to time.Time for comparison
// The startTime from step result is in milliseconds timestamp
stepStartTime := time.UnixMilli(startTime)
stepEndTime := time.UnixMilli(endTime)
// Use step start/end markers with prefix matching for precise boundaries
for _, logEntry := range g.LogData { for _, logEntry := range g.LogData {
// Parse log entry timestamp for time range validation
logTime, timeParseErr := g.parseLogTime(logEntry.Time)
// Check for step boundaries to control inclusion // Check for step boundaries to control inclusion
if logEntry.Message == RUN_STEP_START { if logEntry.Message == RUN_STEP_START {
if stepFieldValue, exists := logEntry.Fields["step"].(string); exists { if stepFieldValue, exists := logEntry.Fields["step"].(string); exists {
@@ -228,7 +262,7 @@ func (g *HTMLReportGenerator) getStepLogs(stepName string, startTime int64, elap
stepLogs = append(stepLogs, logEntry) stepLogs = append(stepLogs, logEntry)
continue continue
} else if inCurrentStep { } else if inCurrentStep {
// This is a different step starting, we're done // This is a different step starting, we're done with current step
break break
} }
} }
@@ -240,31 +274,30 @@ func (g *HTMLReportGenerator) getStepLogs(stepName string, startTime int64, elap
if strings.HasPrefix(stepName, stepFieldValue) { if strings.HasPrefix(stepName, stepFieldValue) {
stepLogs = append(stepLogs, logEntry) stepLogs = append(stepLogs, logEntry)
inCurrentStep = false inCurrentStep = false
continue break // End of current step, stop processing
} }
} }
} }
// Only include logs when we're in the current step // Include logs when we're in the current step AND within the time range
if inCurrentStep { if inCurrentStep {
stepLogs = append(stepLogs, logEntry) // Apply time range filtering if time parsing succeeded
if timeParseErr == nil {
// Only include logs within the step time range
if (logTime.Equal(stepStartTime) || logTime.After(stepStartTime)) &&
(logTime.Equal(stepEndTime) || logTime.Before(stepEndTime)) {
stepLogs = append(stepLogs, logEntry)
}
} else {
// If time parsing failed, include all logs in the step boundary
stepLogs = append(stepLogs, logEntry)
}
} }
} }
// Sort logs by time, then by original index for stable ordering // Sort logs by original index to maintain chronological order
sort.Slice(stepLogs, func(i, j int) bool { sort.Slice(stepLogs, func(i, j int) bool {
timeI, errI := g.parseLogTime(stepLogs[i].Time) return stepLogs[i].LogIndex < stepLogs[j].LogIndex
timeJ, errJ := g.parseLogTime(stepLogs[j].Time)
if errI != nil || errJ != nil {
return stepLogs[i].LogIndex < stepLogs[j].LogIndex
}
if timeI.Equal(timeJ) {
// For same timestamps, use original log index to maintain order
return stepLogs[i].LogIndex < stepLogs[j].LogIndex
}
return timeI.Before(timeJ)
}) })
return stepLogs return stepLogs
@@ -514,6 +547,9 @@ func (g *HTMLReportGenerator) GenerateReport(outputFile string) error {
"getLogContentBase64": func() string { "getLogContentBase64": func() string {
return base64.StdEncoding.EncodeToString([]byte(g.LogContent)) return base64.StdEncoding.EncodeToString([]byte(g.LogContent))
}, },
"getCaseContentBase64": func() string {
return base64.StdEncoding.EncodeToString([]byte(g.CaseContent))
},
"safeHTML": func(s string) template.HTML { "safeHTML": func(s string) template.HTML {
return template.HTML(s) return template.HTML(s)
}, },
@@ -1908,6 +1944,105 @@ const htmlTemplate = `<!DOCTYPE html>
object-fit: contain; object-fit: contain;
} }
.json-modal {
display: none;
position: fixed;
z-index: 1000;
left: 0;
top: 0;
width: 100%;
height: 100%;
background-color: rgba(0,0,0,0.8);
overflow: auto;
}
.json-modal-content {
background-color: #fefefe;
margin: 2% auto;
padding: 0;
border: none;
border-radius: 12px;
width: 90%;
max-width: 1000px;
max-height: 90%;
box-shadow: 0 4px 20px rgba(0,0,0,0.3);
display: flex;
flex-direction: column;
}
.json-modal-header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 20px 30px;
border-radius: 12px 12px 0 0;
display: flex;
justify-content: space-between;
align-items: center;
}
.json-modal-title {
font-size: 1.5em;
font-weight: 600;
margin: 0;
}
.json-modal-body {
padding: 0;
flex: 1;
overflow: hidden;
display: flex;
flex-direction: column;
}
.json-content {
background: #f8f9fa;
margin: 0;
padding: 20px;
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', 'Consolas', monospace;
font-size: 13px;
line-height: 1.5;
color: #333;
overflow: auto;
flex: 1;
white-space: pre;
border-radius: 0 0 12px 12px;
}
.json-toolbar {
background: #e9ecef;
padding: 10px 20px;
border-top: 1px solid #dee2e6;
display: flex;
justify-content: space-between;
align-items: center;
}
.json-toolbar button {
background: #007bff;
color: white;
border: none;
padding: 6px 12px;
border-radius: 4px;
cursor: pointer;
font-size: 0.9em;
transition: background-color 0.3s;
}
.json-toolbar button:hover {
background: #0056b3;
}
.json-toolbar .copy-status {
font-size: 0.9em;
color: #28a745;
opacity: 0;
transition: opacity 0.3s;
}
.json-toolbar .copy-status.show {
opacity: 1;
}
.close { .close {
position: absolute; position: absolute;
top: 15px; top: 15px;
@@ -1919,6 +2054,22 @@ const htmlTemplate = `<!DOCTYPE html>
cursor: pointer; cursor: pointer;
} }
.json-close {
color: white;
font-size: 30px;
font-weight: bold;
cursor: pointer;
transition: color 0.3s;
background: none;
border: none;
padding: 0;
line-height: 1;
}
.json-close:hover {
color: #ffcccc;
}
.close:hover, .close:hover,
.close:focus { .close:focus {
color: #bbb; color: #bbb;
@@ -2187,7 +2338,7 @@ const htmlTemplate = `<!DOCTYPE html>
</div> </div>
<div class="header-right"> <div class="header-right">
<div class="download-section"> <div class="download-section">
<div class="download-title">📥 Download</div> <div class="download-title">📥 Download & View</div>
<div class="download-buttons"> <div class="download-buttons">
<button class="download-btn" onclick="downloadSummary()"> <button class="download-btn" onclick="downloadSummary()">
<span>📄</span> <span>📄</span>
@@ -2197,6 +2348,12 @@ const htmlTemplate = `<!DOCTYPE html>
<span>📋</span> <span>📋</span>
<span>hrp.log</span> <span>hrp.log</span>
</button> </button>
{{if getCaseContentBase64}}
<button class="download-btn" onclick="showCaseJson()">
<span>📋</span>
<span>case.json</span>
</button>
{{end}}
</div> </div>
</div> </div>
</div> </div>
@@ -2714,10 +2871,31 @@ const htmlTemplate = `<!DOCTYPE html>
<img class="modal-content" id="modalImage"> <img class="modal-content" id="modalImage">
</div> </div>
<!-- JSON Case Modal -->
<div id="jsonModal" class="json-modal">
<div class="json-modal-content">
<div class="json-modal-header">
<h2 class="json-modal-title">📋 Test Case JSON</h2>
<button class="json-close" onclick="closeJsonModal()">&times;</button>
</div>
<div class="json-modal-body">
<div class="json-toolbar">
<div>
<button onclick="copyJsonContent()">📋 Copy to Clipboard</button>
<button onclick="downloadCaseJson()">📥 Download case.json</button>
</div>
<span class="copy-status" id="copyStatus">✅ Copied!</span>
</div>
<pre class="json-content" id="jsonContent"></pre>
</div>
</div>
</div>
<script> <script>
// Embedded file contents for download (Base64 encoded) // Embedded file contents for download (Base64 encoded)
const summaryContentBase64 = "{{getSummaryContentBase64}}"; const summaryContentBase64 = "{{getSummaryContentBase64}}";
const logContentBase64 = "{{getLogContentBase64}}"; const logContentBase64 = "{{getLogContentBase64}}";
const caseContentBase64 = "{{getCaseContentBase64}}";
// Decode Base64 content with proper UTF-8 handling // Decode Base64 content with proper UTF-8 handling
function decodeBase64UTF8(base64) { function decodeBase64UTF8(base64) {
@@ -2738,6 +2916,7 @@ const htmlTemplate = `<!DOCTYPE html>
const summaryContent = decodeBase64UTF8(summaryContentBase64); const summaryContent = decodeBase64UTF8(summaryContentBase64);
const logContent = decodeBase64UTF8(logContentBase64); const logContent = decodeBase64UTF8(logContentBase64);
const caseContent = decodeBase64UTF8(caseContentBase64);
// Download functions // Download functions
function downloadSummary() { function downloadSummary() {
@@ -2768,6 +2947,59 @@ const htmlTemplate = `<!DOCTYPE html>
window.URL.revokeObjectURL(url); window.URL.revokeObjectURL(url);
} }
// JSON Case Modal functions
function showCaseJson() {
if (!caseContent) {
alert('Case JSON content not available');
return;
}
try {
// Parse and format JSON for beautiful display
const jsonObj = JSON.parse(caseContent);
const formattedJson = JSON.stringify(jsonObj, null, 2);
document.getElementById('jsonContent').textContent = formattedJson;
document.getElementById('jsonModal').style.display = 'block';
} catch (e) {
console.error('Failed to parse JSON:', e);
// Fallback to raw content if parsing fails
document.getElementById('jsonContent').textContent = caseContent;
document.getElementById('jsonModal').style.display = 'block';
}
}
function closeJsonModal() {
document.getElementById('jsonModal').style.display = 'none';
}
function copyJsonContent() {
const jsonContent = document.getElementById('jsonContent').textContent;
if (!jsonContent) {
alert('No content to copy');
return;
}
navigator.clipboard.writeText(jsonContent).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() {
if (!caseContent) {
alert('Case JSON content not available');
return;
}
downloadFile(caseContent, 'case.json', 'application/json');
}
function toggleStep(stepIndex) { function toggleStep(stepIndex) {
const content = document.getElementById('step-' + stepIndex); const content = document.getElementById('step-' + stepIndex);
const icon = document.getElementById('toggle-' + stepIndex); const icon = document.getElementById('toggle-' + stepIndex);
@@ -2836,11 +3068,16 @@ const htmlTemplate = `<!DOCTYPE html>
document.getElementById('imageModal').style.display = 'none'; document.getElementById('imageModal').style.display = 'none';
} }
// Close modal when clicking outside the image // Close modal when clicking outside the image or JSON modal
window.onclick = function(event) { window.onclick = function(event) {
const modal = document.getElementById('imageModal'); const imageModal = document.getElementById('imageModal');
if (event.target == modal) { const jsonModal = document.getElementById('jsonModal');
modal.style.display = 'none';
if (event.target == imageModal) {
imageModal.style.display = 'none';
}
if (event.target == jsonModal) {
jsonModal.style.display = 'none';
} }
} }
+2 -2
View File
@@ -855,8 +855,6 @@ func (r *SessionRunner) RunStep(step IStep) (stepResult *StepResult, err error)
stepName := step.Name() stepName := step.Name()
stepType := string(step.Type()) stepType := string(step.Type())
log.Info().Str("step", stepName).Str("type", stepType).Msg(RUN_STEP_START)
// execute step with parameters iterator // execute step with parameters iterator
tasks, err := r.generateExecutionTasks(step) tasks, err := r.generateExecutionTasks(step)
if err != nil { if err != nil {
@@ -897,6 +895,8 @@ func (r *SessionRunner) RunStep(step IStep) (stepResult *StepResult, err error)
// execute with loops as outer iteration // execute with loops as outer iteration
for _, task := range tasks { for _, task := range tasks {
log.Info().Str("step", task.stepName).Str("type", stepType).Msg(RUN_STEP_START)
// Check for interrupt signal before each parameter iteration // Check for interrupt signal before each parameter iteration
select { select {
case <-r.caseRunner.hrpRunner.interruptSignal: case <-r.caseRunner.hrpRunner.interruptSignal: