mirror of
https://github.com/httprunner/httprunner.git
synced 2026-09-07 08:27:35 +08:00
feat: add case.json support for HTML report generation and enhance logging for step execution
This commit is contained in:
@@ -1 +1 @@
|
|||||||
v5.0.0-beta-2506251207
|
v5.0.0-beta-2506251440
|
||||||
|
|||||||
@@ -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()">×</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';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
Reference in New Issue
Block a user