From 98bc52db52d148d0d334b906a179d4adac5b7f5a Mon Sep 17 00:00:00 2001 From: rsultana1418 Date: Wed, 3 Jun 2026 11:43:00 -0600 Subject: [PATCH 01/15] added e2e heatmap and coverage dashboard --- cmd/api/main.go | 12 + cmd/coveragecli/main.go | 261 ++++- cmd/frontend/main.go | 6 +- cmd/frontend/web/assets/e2e.js | 1014 +++++++++++++++++ cmd/frontend/web/e2eTest.html | 198 ++++ cmd/frontend/web/index.html | 1 + internal/adapters/http/handlers.go | 174 +++ internal/adapters/http/router.go | 5 + .../postgres/e2e_spec_result_repository.go | 129 +++ .../postgres/e2e_test_run_repository.go | 401 +++++++ .../integration_test_run_repository.go | 6 +- internal/application/e2e_usecase.go | 748 ++++++++++++ internal/application/ports.go | 19 +- internal/domain/e2e.go | 66 ++ migrations/003_e2e_test_runs.sql | 58 + 15 files changed, 3090 insertions(+), 8 deletions(-) create mode 100644 cmd/frontend/web/assets/e2e.js create mode 100644 cmd/frontend/web/e2eTest.html create mode 100644 internal/adapters/postgres/e2e_spec_result_repository.go create mode 100644 internal/adapters/postgres/e2e_test_run_repository.go create mode 100644 internal/application/e2e_usecase.go create mode 100644 internal/domain/e2e.go create mode 100644 migrations/003_e2e_test_runs.sql diff --git a/cmd/api/main.go b/cmd/api/main.go index e5783c4..081eba6 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -58,6 +58,8 @@ func main() { packageRepo := postgres.NewPackageCoverageRepository(pool) integrationRunRepo := postgres.NewIntegrationTestRunRepository(pool) integrationSpecRepo := postgres.NewIntegrationSpecResultRepository(pool) + e2eRunRepo := postgres.NewE2ETestRunRepository(pool) + e2eSpecRepo := postgres.NewE2ESpecResultRepository(pool) txManager := postgres.NewTxManager(pool) authenticator := auth.NewEnvAPIKeyAuthenticator(cfg.APIKeySecret) @@ -66,28 +68,38 @@ func main() { ingestUC := application.NewIngestCoverageRunUseCase(projectRepo, runRepo, packageRepo, txManager, idGenerator, clockAdapter) ingestIntegrationUC := application.NewIngestIntegrationRunUseCase(projectRepo, integrationRunRepo, integrationSpecRepo, txManager, idGenerator, clockAdapter) + ingestE2EUC := application.NewIngestE2ERunUseCase(projectRepo, e2eRunRepo, e2eSpecRepo, txManager, idGenerator, clockAdapter) listProjectsUC := application.NewListProjectsUseCase(projectRepo) getProjectUC := application.NewGetProjectUseCase(projectRepo) listRunsUC := application.NewListCoverageRunsUseCase(runRepo) listIntegrationRunsUC := application.NewListIntegrationRunsUseCase(integrationRunRepo) + listE2ERunsUC := application.NewListE2ERunsUseCase(e2eRunRepo) latestComparisonUC := application.NewGetLatestComparisonUseCase(projectRepo, runRepo, packageRepo) latestIntegrationComparisonUC := application.NewGetLatestIntegrationComparisonUseCase(projectRepo, integrationRunRepo, integrationSpecRepo) + latestE2EComparisonUC := application.NewGetLatestE2EComparisonUseCase(projectRepo, e2eRunRepo, e2eSpecRepo) getIntegrationRunUC := application.NewGetIntegrationRunUseCase(integrationRunRepo, integrationSpecRepo) + getE2ERunUC := application.NewGetE2ERunUseCase(e2eRunRepo, e2eSpecRepo) getIntegrationHeatmapUC := application.NewGetIntegrationHeatmapUseCase(integrationRunRepo) + getE2EHeatmapUC := application.NewGetE2EHeatmapUseCase(e2eRunRepo) listBranchesUC := application.NewListBranchesUseCase(runRepo) listContributorsUC := application.NewListContributorsUseCase(projectRepo, runRepo) handler := httpadapter.NewHandler( ingestUC, ingestIntegrationUC, + ingestE2EUC, listProjectsUC, getProjectUC, listRunsUC, listIntegrationRunsUC, + listE2ERunsUC, latestComparisonUC, + latestE2EComparisonUC, latestIntegrationComparisonUC, getIntegrationRunUC, + getE2ERunUC, getIntegrationHeatmapUC, + getE2EHeatmapUC, listBranchesUC, listContributorsUC, ) diff --git a/cmd/coveragecli/main.go b/cmd/coveragecli/main.go index 9045ad9..8c71b99 100644 --- a/cmd/coveragecli/main.go +++ b/cmd/coveragecli/main.go @@ -7,6 +7,7 @@ import ( "flag" "fmt" "io" + "log" "net/http" "os" "os/exec" @@ -70,7 +71,21 @@ type integrationPayload struct { GinkgoReport map[string]any `json:"ginkgoReport"` } -type integrationUploadResponse struct { +type e2ePayload struct { + ProjectKey string `json:"projectKey"` + ProjectName string `json:"projectName,omitempty"` + ProjectGroup *string `json:"projectGroup,omitempty"` + DefaultBranch string `json:"defaultBranch,omitempty"` + Branch string `json:"branch"` + CommitSHA string `json:"commitSha"` + Author string `json:"author,omitempty"` + TriggerType string `json:"triggerType"` + RunTimestamp string `json:"runTimestamp"` + Environment *string `json:"environment,omitempty"` + TestReport map[string]any `json:"testReport"` +} + +type uploadResponse struct { Run struct { Status string `json:"status"` PassRatePercent float64 `json:"passRatePercent"` @@ -105,6 +120,9 @@ func main() { case "integration-upload": runIntegrationUpload(os.Args[2:]) return + case "e2e-upload": + runE2EUpload(os.Args[2:]) + return case "npm-upload": runNPMUpload(os.Args[2:]) return @@ -387,7 +405,7 @@ func runIntegrationUpload(args []string) { fmt.Printf("upload status: %d\n", status) fmt.Printf("upload response: %s\n", strings.TrimSpace(string(respBody))) - var parsed integrationUploadResponse + var parsed uploadResponse if err := json.Unmarshal(respBody, &parsed); err == nil { delta := "-" if parsed.Comparison.DeltaPercent != nil { @@ -401,6 +419,111 @@ func runIntegrationUpload(args []string) { } } +func runE2EUpload(args []string) { + fs := flag.NewFlagSet("e2e-upload", flag.ExitOnError) + reportPath := fs.String("e2e-report", "", "Path to e2e JSON report") + reportType := fs.String("report-type", "playwright", "E2E report type") + apiURL := fs.String("api-url", envOrDefault("API_URL", "http://localhost:8080/v1/e2e-test-runs"), "E2E test API URL") + apiKey := fs.String("api-key", os.Getenv("API_KEY"), "API key value") + apiKeyHeader := fs.String("api-key-header", "X-API-Key", "API key header name") + projectKey := fs.String("project-key", envOrDefault("COVERAGE_PROJECT_KEY", "github.com/arxdsilva/opencoverage"), "Project key") + projectName := fs.String("project-name", envOrDefault("COVERAGE_PROJECT_NAME", "coverage-api"), "Project display name") + projectGroup := fs.String("project-group", "", "Project group (optional)") + defaultBranch := fs.String("default-branch", envOrDefault("COVERAGE_DEFAULT_BRANCH", "main"), "Default branch") + branch := fs.String("branch", envOrDefault("COVERAGE_BRANCH", "main"), "Current branch") + commitSHA := fs.String("commit-sha", envOrDefault("COVERAGE_COMMIT_SHA", "local"), "Commit SHA") + author := fs.String("author", envOrDefault("COVERAGE_AUTHOR", "local"), "Author") + triggerType := fs.String("trigger-type", "manual", "Trigger type: push|pr|manual") + environment := fs.String("environment", "", "Environment: test|stage|prod (optional)") + runTimestamp := fs.String("run-timestamp", time.Now().UTC().Format(time.RFC3339), "Run timestamp (RFC3339)") + platformType := fs.String("platform-type", "web", "Platform type: web|android|ios") + if err := fs.Parse(args); err != nil { + exitErr("parse flags", err) + } + + if strings.TrimSpace(*reportPath) == "" { + exitErr("validate input", fmt.Errorf("-e2e-report is required")) + } + if strings.TrimSpace(*apiKey) == "" { + exitErr("validate input", fmt.Errorf("-api-key is required (or API_KEY env var)")) + } + if _, err := time.Parse(time.RFC3339, *runTimestamp); err != nil { + exitErr("validate input", fmt.Errorf("run timestamp must be RFC3339: %w", err)) + } + + rawReport, err := os.ReadFile(*reportPath) + if err != nil { + exitErr("read e2e report", err) + } + + var report map[string]any + if err := json.Unmarshal(rawReport, &report); err != nil { + exitErr("parse e2e report json", err) + } + + var group *string + if *projectGroup != "" { + group = projectGroup + } + + var env *string + if *environment != "" { + if *environment != "test" && *environment != "stage" && *environment != "prod" { + exitErr("validate input", fmt.Errorf("-environment must be one of: test, stage, prod")) + } + env = environment + } + + // Normalize report structure based on report type + var normalizeReport map[string]any + switch *reportType { + case "playwright": + normalizeReport = normalizePlaywrightReport(report) + case "appium": + normalizeReport = normalizeAppiumReport(report) + default: + exitErr("validate input", fmt.Errorf("unsupported report type: %s", *reportType)) + } + normalizeReport["platformType"] = *platformType + + payload := e2ePayload{ + ProjectKey: *projectKey, + ProjectName: *projectName, + ProjectGroup: group, + DefaultBranch: *defaultBranch, + Branch: *branch, + CommitSHA: *commitSHA, + Author: *author, + TriggerType: *triggerType, + RunTimestamp: *runTimestamp, + Environment: env, + TestReport: normalizeReport, + } + + body, err := json.MarshalIndent(payload, "", " ") + if err != nil { + exitErr("marshal payload", err) + } + + status, respBody, err := uploadPayload(*apiURL, *apiKeyHeader, *apiKey, body) + if err != nil { + exitErr("upload report", err) + } + + var parsed uploadResponse + if err := json.Unmarshal(respBody, &parsed); err == nil { + delta := "-" + if parsed.Comparison.DeltaPercent != nil { + delta = fmt.Sprintf("%.2f", *parsed.Comparison.DeltaPercent) + } + fmt.Printf("summary: status=%s passRatePercent=%.2f deltaPercent=%s\n", parsed.Run.Status, parsed.Run.PassRatePercent, delta) + } + + if status >= http.StatusBadRequest { + exitErr("upload report", fmt.Errorf("server returned status %d", status)) + } +} + func normalizeReport(raw map[string]any) map[string]any { result := make(map[string]any) result["suiteDescription"] = firstString(raw, "suiteDescription", "SuiteDescription") @@ -443,6 +566,140 @@ func normalizeReport(raw map[string]any) map[string]any { return result } +func normalizePlaywrightReport(raw map[string]any) map[string]any { + var suiteDescription string + var suitePath string + var framework_version string + + result := make(map[string]any) + testFramework := "playwright" + + config := firstMap(raw, "config") + suites := firstSlice(raw, "suites") + if config != nil { + suitePath = firstString(config, "rootDir") + framework_version = firstString(config, "version") + } + if len(suites) > 0 { + if first, ok := suites[0].(map[string]any); ok { + suiteDescription = firstString(first, "title") + } + } + result["suiteDescription"] = suiteDescription + result["suitePath"] = suitePath + result["reportType"] = &testFramework + result["testFramework"] = &testFramework + result["frameworkVersion"] = framework_version + result["platformType"] = "web" + + // collectSpecs recursively walks Playwright's nested suite tree, + // accumulating containerHierarchyTexts as it descends, and normalises each leaf spec + var collectSpecs func(suites []any, hierarchy []string) []map[string]any + collectSpecs = func(suites []any, hierarchy []string) []map[string]any { + var out []map[string]any + for _, item := range suites { + suiteMap, ok := item.(map[string]any) + if !ok { + continue + } + title := firstString(suiteMap, "title") + currentHierarchy := hierarchy + if title != "" { + currentHierarchy = append(append([]string{}, hierarchy...), title) + } + + // Recurse into nested suites first. + if nested := firstSlice(suiteMap, "suites"); len(nested) > 0 { + out = append(out, collectSpecs(nested, currentHierarchy)...) + } + + // Normalise leaf specs within this suite. + for _, specItem := range firstSlice(suiteMap, "specs") { + specMap, ok := specItem.(map[string]any) + if !ok { + continue + } + + // Use the last test result (accounts for retries). + tests := firstSlice(specMap, "tests") + state := "skipped" + runTime := 0.0 + var failureBlock map[string]any + + if len(tests) > 0 { + if testMap, ok := tests[0].(map[string]any); ok { + switch firstString(testMap, "status") { + case "expected": + state = "passed" + case "unexpected": + state = "failed" + case "flaky": + state = "flaky" + default: + state = "skipped" + } + + results := firstSlice(testMap, "results") + if len(results) > 0 { + // Use last result (final retry). + if lastResult, ok := results[len(results)-1].(map[string]any); ok { + // Playwright reports duration in ms; convert to seconds. + runTime = firstFloat(lastResult, "duration") / 1000.0 + + if errVal := firstMap(lastResult, "error"); len(errVal) > 0 { + failure := map[string]any{ + "message": stripANSI(firstString(errVal, "message")), + } + if locVal := firstMap(errVal, "location"); len(locVal) > 0 { + failure["location"] = map[string]any{ + "fileName": firstString(locVal, "file"), + "lineNumber": int(firstFloat(locVal, "line")), + } + } + failureBlock = failure + } + } + } + } + } + + hierarchyCopy := make([]any, len(currentHierarchy)) + for i, h := range currentHierarchy { + hierarchyCopy[i] = h + } + + normalized := map[string]any{ + "leafNodeText": firstString(specMap, "title"), + "containerHierarchyTexts": hierarchyCopy, + "state": state, + "runTime": runTime, + } + if failureBlock != nil { + normalized["failure"] = failureBlock + } + out = append(out, normalized) + } + } + return out + } + + result["specReports"] = collectSpecs(suites, nil) + return result +} + +func normalizeAppiumReport(raw map[string]any) map[string]any { + // not implemented yet + exitErr("normalize report", fmt.Errorf("appium report normalization not implemented yet")) + return nil +} + +// stripANSI removes ANSI escape codes from a string. +// This is useful to clean up error messages from Playwright which may include ANSI codes for coloring. +func stripANSI(s string) string { + ansiRegex := regexp.MustCompile(`\x1b\[[0-9;]*m`) + return ansiRegex.ReplaceAllString(s, "") +} + func firstString(src map[string]any, keys ...string) string { for _, key := range keys { if raw, ok := src[key]; ok { diff --git a/cmd/frontend/main.go b/cmd/frontend/main.go index fd73ece..e386e13 100644 --- a/cmd/frontend/main.go +++ b/cmd/frontend/main.go @@ -16,7 +16,7 @@ import ( "time" ) -//go:embed web/index.html web/integration.html web/assets/* +//go:embed web/index.html web/integration.html web/e2eTest.html web/assets/* var embeddedFrontend embed.FS type config struct { @@ -52,6 +52,8 @@ func main() { serveEmbeddedFile(w, http.FS(frontendFS), "index.html") case "/integration": serveEmbeddedFile(w, http.FS(frontendFS), "integration.html") + case "/e2e": + serveEmbeddedFile(w, http.FS(frontendFS), "e2eTest.html") default: http.NotFound(w, r) } @@ -61,6 +63,8 @@ func main() { mux.HandleFunc("/api/projects/", proxyHandler(cfg)) mux.HandleFunc("/api/integration-test-runs", proxyHandler(cfg)) mux.HandleFunc("/api/integration-test-runs/", proxyHandler(cfg)) + mux.HandleFunc("/api/e2e-test-runs", proxyHandler(cfg)) + mux.HandleFunc("/api/e2e-test-runs/", proxyHandler(cfg)) server := &http.Server{ Addr: cfg.Addr, diff --git a/cmd/frontend/web/assets/e2e.js b/cmd/frontend/web/assets/e2e.js new file mode 100644 index 0000000..e570868 --- /dev/null +++ b/cmd/frontend/web/assets/e2e.js @@ -0,0 +1,1014 @@ +const refreshProjects = document.getElementById('refreshProjects'); +const projectSelector = document.getElementById('projectSelector'); +const projectGroupFilter = document.getElementById('projectGroupFilter'); +const projectSearchInput = document.getElementById('projectSearchInput'); +const e2eScreenProjectTitle = document.getElementById('e2eScreenProjectTitle'); +const e2eScreenProjectMeta = document.getElementById('e2eScreenProjectMeta'); +const e2eStatus = document.getElementById('e2eStatus'); +const e2ePassRate = document.getElementById('e2ePassRate'); +const e2eFailedSpecsCount = document.getElementById('e2eFailedSpecsCount'); +const e2eDuration = document.getElementById('e2eDuration'); +const e2eBranchFilter = document.getElementById('e2eBranchFilter'); +const e2eStatusFilter = document.getElementById('e2eStatusFilter'); +const e2eEnvironmentFilter = document.getElementById('e2eEnvironmentFilter'); +const e2ePlatformFilter = document.getElementById('e2ePlatformFilter'); +const e2eReload = document.getElementById('e2eReload'); +const e2eAutoRefreshInterval = document.getElementById('e2eAutoRefreshInterval'); +const e2eAutoRefreshStatus = document.getElementById('e2eAutoRefreshStatus'); +const e2eAutoRefreshProgressBar = document.getElementById('e2eAutoRefreshProgressBar'); +const e2eRunChain = document.getElementById('e2eRunChain'); +const e2eRunsBody = document.getElementById('e2eRunsBody'); +const e2eFailedSpecsBody = document.getElementById('e2eFailedSpecsBody'); +const openE2EHeatmap = document.getElementById('openE2EHeatmap'); +const closeE2EHeatmap = document.getElementById('closeE2EHeatmap'); +const e2eHeatmapOverlay = document.getElementById('e2eHeatmapOverlay'); +const heatmapBranchFilter = document.getElementById('heatmapBranchFilter'); +const heatmapStatusFilter = document.getElementById('heatmapStatusFilter'); +const heatmapReload = document.getElementById('heatmapReload'); +const e2eHeatmap = document.getElementById('e2eHeatmap'); +const appShell = document.getElementById('appShell'); +const toggleSidebar = document.getElementById('toggleSidebar'); + +let projects = []; +let filteredProjects = []; +let selectedProjectId = null; +let selectedE2ERunId = null; +let currentE2ERunItems = []; +const e2eRunChainMaxItems = 5; +const allGroupsFilterValue = '__all__'; +const ungroupedFilterValue = '__ungrouped__'; +const sidebarCollapsedKey = 'opencoverage.sidebarCollapsed.e2e'; +const e2eAutoRefreshStorageKey = 'opencoverage.autoRefresh.e2e'; +const e2eDefaultAutoRefreshInterval = '60s'; +const e2eAutoRefreshIntervals = Object.freeze({ + off: 0, + '15s': 15000, + '30s': 30000, + '60s': 60000, + '5m': 300000, +}); +let e2eRefreshTimeoutId = 0; +let e2eRefreshInFlight = false; +let e2eRefreshCountdownIntervalId = 0; +let e2eNextRefreshAt = 0; +let e2eRefreshDurationMs = 0; + +refreshProjects.addEventListener('click', async () => { + await performE2ERefresh('manual'); +}); +e2eAutoRefreshInterval.addEventListener('change', () => { + persistE2EAutoRefreshInterval(e2eAutoRefreshInterval.value); + scheduleE2EAutoRefresh(); +}); +projectSelector.addEventListener('change', async (e) => { + await selectProject(e.target.value); +}); +projectGroupFilter.addEventListener('change', async () => { + filterAndRenderProjects(projectSearchInput.value); + await ensureSelectedProjectIsVisible(); +}); +projectSearchInput.addEventListener('input', (e) => { + filterAndRenderProjects(e.target.value); +}); +e2eBranchFilter.addEventListener('change', async () => { + await loadE2EScreen(selectedProjectId, { preferredRunId: null }); +}); +e2eStatusFilter.addEventListener('change', async () => { + await loadE2ERuns(selectedProjectId); +}); +e2eEnvironmentFilter.addEventListener('change', async () => { + await loadE2ERuns(selectedProjectId); +}); +e2ePlatformFilter.addEventListener('change', async () => { + await loadE2ERuns(selectedProjectId); +}); +e2eReload.addEventListener('click', async () => { + await runWithButtonBusy(e2eReload, 'Reload', 'Reloading...', async () => { + await loadE2EScreen(selectedProjectId, { preferredRunId: selectedE2ERunId }); + }); +}); +openE2EHeatmap.addEventListener('click', async () => { + const isOpen = e2eHeatmapOverlay.classList.contains('open'); + toggleE2EHeatmapOverlay(!isOpen); + if (!isOpen) { + await loadHeatmap(); + } +}); +closeE2EHeatmap.addEventListener('click', () => toggleE2EHeatmapOverlay(false)); +heatmapBranchFilter.addEventListener('change', async () => { + await loadHeatmap(); +}); +heatmapStatusFilter.addEventListener('change', async () => { + await loadHeatmap(); +}); +heatmapReload.addEventListener('click', async () => { + await runWithButtonBusy(heatmapReload, 'Reload', 'Reloading...', async () => { + await loadHeatmap(); + }); +}); +toggleSidebar.addEventListener('click', () => { + const shouldCollapse = !appShell.classList.contains('sidebar-collapsed'); + setSidebarCollapsed(shouldCollapse); +}); +document.addEventListener('visibilitychange', () => { + if (document.hidden) { + updateE2EAutoRefreshStatus(); + return; + } + + if (e2eNextRefreshAt && Date.now() >= e2eNextRefreshAt && !e2eRefreshInFlight) { + void performE2ERefresh('auto'); + return; + } + + updateE2EAutoRefreshStatus(); +}); + +initializeSidebarState(); +initializeE2EAutoRefreshControl(); + +(async () => { + await performE2ERefresh('initial'); + if (getQueryParam('heatmap') === 'open') { + toggleE2EHeatmapOverlay(true); + await loadHeatmap(); + } +})(); + +function getQueryParam(name) { + const params = new URLSearchParams(window.location.search); + return params.get(name); +} + +function initializeE2EAutoRefreshControl() { + const persisted = window.localStorage.getItem(e2eAutoRefreshStorageKey); + const nextValue = Object.prototype.hasOwnProperty.call(e2eAutoRefreshIntervals, persisted) + ? persisted + : e2eDefaultAutoRefreshInterval; + e2eAutoRefreshInterval.value = nextValue; + updateE2EAutoRefreshStatus(); +} + +function getE2EAutoRefreshIntervalValue() { + const selectedValue = e2eAutoRefreshInterval.value; + return Object.prototype.hasOwnProperty.call(e2eAutoRefreshIntervals, selectedValue) + ? selectedValue + : e2eDefaultAutoRefreshInterval; +} + +function getE2EAutoRefreshIntervalMs() { + return e2eAutoRefreshIntervals[getE2EAutoRefreshIntervalValue()] || 0; +} + +function persistE2EAutoRefreshInterval(value) { + const nextValue = Object.prototype.hasOwnProperty.call(e2eAutoRefreshIntervals, value) + ? value + : e2eDefaultAutoRefreshInterval; + window.localStorage.setItem(e2eAutoRefreshStorageKey, nextValue); +} + +function clearE2EAutoRefresh() { + if (!e2eRefreshTimeoutId) return; + window.clearTimeout(e2eRefreshTimeoutId); + e2eRefreshTimeoutId = 0; +} + +function setE2EAutoRefreshProgress(progressRatio) { + if (!e2eAutoRefreshProgressBar) return; + const safeRatio = Math.max(0, Math.min(1, progressRatio)); + e2eAutoRefreshProgressBar.style.transform = `scaleX(${safeRatio})`; +} + +function clearE2ECountdownTicker() { + if (!e2eRefreshCountdownIntervalId) return; + window.clearInterval(e2eRefreshCountdownIntervalId); + e2eRefreshCountdownIntervalId = 0; +} + +function formatRemainingTime(ms) { + const totalSeconds = Math.max(0, Math.ceil(ms / 1000)); + const minutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + if (minutes > 0) { + return `${minutes}m ${seconds}s`; + } + return `${seconds}s`; +} + +function updateE2EAutoRefreshStatus() { + if (!e2eAutoRefreshStatus) return; + + const intervalLabel = getE2EAutoRefreshIntervalValue(); + if (intervalLabel === 'off') { + e2eAutoRefreshStatus.textContent = 'Auto refresh is off.'; + setE2EAutoRefreshProgress(0); + return; + } + + if (e2eRefreshInFlight) { + e2eAutoRefreshStatus.textContent = `Refreshing now (${intervalLabel}).`; + setE2EAutoRefreshProgress(0); + return; + } + + if (!e2eNextRefreshAt) { + e2eAutoRefreshStatus.textContent = `Scheduled every ${intervalLabel}.`; + setE2EAutoRefreshProgress(0); + return; + } + + const remainingMs = e2eNextRefreshAt - Date.now(); + e2eAutoRefreshStatus.textContent = `Next refresh in ${formatRemainingTime(remainingMs)} (${intervalLabel}).`; + const denominator = e2eRefreshDurationMs || getE2EAutoRefreshIntervalMs() || 1; + setE2EAutoRefreshProgress(remainingMs / denominator); +} + +function scheduleE2EAutoRefresh() { + clearE2EAutoRefresh(); + clearE2ECountdownTicker(); + e2eNextRefreshAt = 0; + e2eRefreshDurationMs = 0; + + const intervalMs = getE2EAutoRefreshIntervalMs(); + if (!intervalMs) { + updateE2EAutoRefreshStatus(); + return; + } + + e2eRefreshDurationMs = intervalMs; + e2eNextRefreshAt = Date.now() + intervalMs; + setE2EAutoRefreshProgress(1); + updateE2EAutoRefreshStatus(); + e2eRefreshCountdownIntervalId = window.setInterval(() => { + updateE2EAutoRefreshStatus(); + }, 200); + + e2eRefreshTimeoutId = window.setTimeout(async () => { + if (e2eRefreshInFlight) { + scheduleE2EAutoRefresh(); + return; + } + + await performE2ERefresh('auto'); + }, intervalMs); +} + +function setE2ERefreshButtonBusy(busy) { + refreshProjects.disabled = busy; + refreshProjects.textContent = busy ? 'Refreshing...' : 'Refresh'; +} + +async function runWithButtonBusy(button, idleText, busyText, action) { + if (e2eRefreshInFlight) { + return false; + } + + e2eRefreshInFlight = true; + clearE2EAutoRefresh(); + clearE2ECountdownTicker(); + e2eNextRefreshAt = 0; + e2eRefreshDurationMs = 0; + updateE2EAutoRefreshStatus(); + button.disabled = true; + button.textContent = busyText; + try { + await action(); + return true; + } finally { + button.disabled = false; + button.textContent = idleText; + e2eRefreshInFlight = false; + scheduleE2EAutoRefresh(); + } +} + +async function performE2ERefresh(source = 'manual') { + if (e2eRefreshInFlight) { + return false; + } + + e2eRefreshInFlight = true; + clearE2EAutoRefresh(); + clearE2ECountdownTicker(); + e2eNextRefreshAt = 0; + e2eRefreshDurationMs = 0; + updateE2EAutoRefreshStatus(); + + const heatmapWasOpen = e2eHeatmapOverlay.classList.contains('open'); + + if (source === 'manual') { + setE2ERefreshButtonBusy(true); + } + + try { + await loadProjects(); + + if (heatmapWasOpen) { + await loadHeatmap(); + } + + return true; + } finally { + if (source === 'manual') { + setE2ERefreshButtonBusy(false); + } + + e2eRefreshInFlight = false; + scheduleE2EAutoRefresh(); + } +} + +function toggleE2EHeatmapOverlay(open) { + e2eHeatmapOverlay.classList.toggle('open', open); + e2eHeatmapOverlay.setAttribute('aria-hidden', String(!open)); +} + +function initializeSidebarState() { + const persisted = window.localStorage.getItem(sidebarCollapsedKey); + setSidebarCollapsed(persisted === 'true'); +} + +function setSidebarCollapsed(collapsed) { + appShell.classList.toggle('sidebar-collapsed', collapsed); + toggleSidebar.textContent = collapsed ? '▸' : '◂'; + toggleSidebar.setAttribute('aria-label', collapsed ? 'Expand sidebar' : 'Collapse sidebar'); + toggleSidebar.setAttribute('title', collapsed ? 'Expand sidebar' : 'Collapse sidebar'); + toggleSidebar.setAttribute('aria-expanded', String(!collapsed)); + window.localStorage.setItem(sidebarCollapsedKey, String(collapsed)); +} + +async function loadProjects() { + try { + const pageSize = 100; + let page = 1; + let totalPages = 1; + const items = []; + + while (page <= totalPages) { + const res = await fetch(`/api/projects?page=${page}&pageSize=${pageSize}`); + if (!res.ok) throw new Error(`failed to load projects (${res.status})`); + const data = await res.json(); + items.push(...(data.items || [])); + totalPages = Math.max(1, data.pagination?.totalPages || 1); + page += 1; + } + + projects = items; + renderProjectGroupFilter(); + filterAndRenderProjects(projectSearchInput.value); + + const nextSelectedProjectId = filteredProjects.some((project) => project.id === selectedProjectId) + ? selectedProjectId + : (filteredProjects[0]?.id || null); + + if (!nextSelectedProjectId) { + selectedProjectId = null; + selectedE2ERunId = null; + if (items.length === 0) { + e2eScreenProjectTitle.textContent = 'No projects found'; + e2eScreenProjectMeta.textContent = 'Upload E2E runs to populate this view.'; + e2eRunChain.innerHTML = '

No E2E runs found.

'; + e2eRunsBody.innerHTML = 'No E2E runs found.'; + } else { + e2eScreenProjectTitle.textContent = 'No projects for current filter'; + e2eScreenProjectMeta.textContent = 'Adjust group and search filters to select a project.'; + e2eRunChain.innerHTML = '

No projects match current filters.

'; + e2eRunsBody.innerHTML = 'No projects match current filters.'; + } + e2eFailedSpecsBody.innerHTML = 'No run selected.'; + e2eStatus.textContent = '-'; + e2eStatus.className = 'value'; + e2ePassRate.textContent = '-'; + e2eFailedSpecsCount.textContent = '-'; + e2eDuration.textContent = '-'; + renderProjectSelector(); + } else if (nextSelectedProjectId === selectedProjectId) { + await selectProject(nextSelectedProjectId, { preferredRunId: selectedE2ERunId }); + renderProjectSelector(); + } else { + await selectProject(nextSelectedProjectId); + renderProjectSelector(); + } + } catch (err) { + e2eScreenProjectTitle.textContent = 'Failed to load projects'; + e2eScreenProjectMeta.textContent = err.message; + } +} + +function getProjectGroupValue(project) { + const rawGroup = typeof project?.group === 'string' ? project.group.trim() : ''; + return rawGroup || ungroupedFilterValue; +} + +function renderProjectGroupFilter() { + const selectedValue = projectGroupFilter.value || allGroupsFilterValue; + const groupValues = Array.from(new Set(projects.map((project) => getProjectGroupValue(project)))); + groupValues.sort((a, b) => { + if (a === ungroupedFilterValue) return 1; + if (b === ungroupedFilterValue) return -1; + return a.localeCompare(b); + }); + + projectGroupFilter.innerHTML = ''; + + const allOption = document.createElement('option'); + allOption.value = allGroupsFilterValue; + allOption.textContent = 'All groups'; + projectGroupFilter.appendChild(allOption); + + for (const groupValue of groupValues) { + const option = document.createElement('option'); + option.value = groupValue; + option.textContent = groupValue === ungroupedFilterValue ? 'Ungrouped' : groupValue; + projectGroupFilter.appendChild(option); + } + + projectGroupFilter.value = [allGroupsFilterValue, ...groupValues].includes(selectedValue) + ? selectedValue + : allGroupsFilterValue; +} + +function renderProjectSelector() { + projectSelector.innerHTML = ''; + + const emptyOption = document.createElement('option'); + emptyOption.value = ''; + emptyOption.textContent = 'Select a project...'; + projectSelector.appendChild(emptyOption); + + if (filteredProjects.length === 0) { + const noResultsOption = document.createElement('option'); + noResultsOption.value = ''; + noResultsOption.textContent = 'No projects match current filters'; + noResultsOption.disabled = true; + projectSelector.appendChild(noResultsOption); + } + + for (const project of filteredProjects) { + const option = document.createElement('option'); + option.value = project.id; + option.textContent = `${project.name || project.projectKey} (${project.projectKey})`; + projectSelector.appendChild(option); + } + + projectSelector.value = selectedProjectId || ''; +} + +function filterAndRenderProjects(searchTerm) { + const term = searchTerm.toLowerCase(); + const selectedGroup = projectGroupFilter.value || allGroupsFilterValue; + filteredProjects = projects.filter((p) => { + const groupMatches = selectedGroup === allGroupsFilterValue + || getProjectGroupValue(p) === selectedGroup; + if (!groupMatches) return false; + if (!term) return true; + + const name = (p.name || '').toLowerCase(); + const key = (p.projectKey || '').toLowerCase(); + return name.includes(term) || key.includes(term); + }); + + renderProjectSelector(); +} + +async function ensureSelectedProjectIsVisible() { + const selectedVisible = filteredProjects.some((project) => project.id === selectedProjectId); + if (selectedVisible) { + renderProjectSelector(); + return; + } + + const nextProjectId = filteredProjects[0]?.id || null; + if (!nextProjectId) { + selectedProjectId = null; + selectedE2ERunId = null; + e2eScreenProjectTitle.textContent = 'No projects for current filter'; + e2eScreenProjectMeta.textContent = 'Adjust group and search filters to select a project.'; + e2eRunChain.innerHTML = '

No projects match current filters.

'; + e2eRunsBody.innerHTML = 'No projects match current filters.'; + e2eFailedSpecsBody.innerHTML = 'No run selected.'; + e2eStatus.textContent = '-'; + e2eStatus.className = 'value'; + e2ePassRate.textContent = '-'; + e2eFailedSpecsCount.textContent = '-'; + e2eDuration.textContent = '-'; + renderProjectSelector(); + return; + } + + await selectProject(nextProjectId); + renderProjectSelector(); +} + +function renderE2EBranchFilter(project, branches = []) { + const selectedValue = e2eBranchFilter.value; + e2eBranchFilter.innerHTML = ''; + + const defaultBranch = project?.defaultBranch || 'main'; + const orderedBranches = Array.from(new Set([defaultBranch, ...branches.filter(Boolean)])); + for (const branch of orderedBranches) { + const option = document.createElement('option'); + option.value = branch; + option.textContent = branch; + e2eBranchFilter.appendChild(option); + } + + e2eBranchFilter.value = orderedBranches.includes(selectedValue) + ? selectedValue + : (orderedBranches[0] || defaultBranch); +} + +async function loadE2EBranches(projectId, defaultBranch) { + try { + const res = await fetch(`/api/projects/${projectId}/branches`); + if (!res.ok) throw new Error(`failed to load branches (${res.status})`); + const data = await res.json(); + const branches = Array.isArray(data.branches) ? data.branches.filter(Boolean) : []; + return Array.from(new Set([defaultBranch, ...branches])); + } catch (err) { + return [defaultBranch]; + } +} + +async function selectProject(projectId, options = {}) { + const { preferredRunId = null } = options; + + if (!projectId) { + selectedProjectId = null; + selectedE2ERunId = null; + e2eScreenProjectTitle.textContent = 'Select a project'; + e2eScreenProjectMeta.textContent = 'Choose a project from the left menu.'; + await loadE2EScreen(null); + renderProjectSelector(); + return; + } + + selectedProjectId = projectId; + + const project = projects.find((p) => p.id === projectId); + e2eScreenProjectTitle.textContent = project?.name || project?.projectKey || 'Project'; + e2eScreenProjectMeta.textContent = `${project?.projectKey || ''} - default branch: ${project?.defaultBranch || 'main'}`; + + const defaultBranch = project?.defaultBranch || 'main'; + const branches = await loadE2EBranches(projectId, defaultBranch); + renderE2EBranchFilter(project, branches); + await loadE2EScreen(projectId, { preferredRunId }); +} + +async function loadE2EScreen(projectId, options = {}) { + const { preferredRunId = selectedE2ERunId } = options; + + if (!projectId) { + e2eRunChain.innerHTML = '

Select a project to view its run chain.

'; + e2eRunsBody.innerHTML = 'Select a project first.'; + e2eFailedSpecsBody.innerHTML = 'No run selected.'; + return; + } + + await Promise.all([loadE2ELatestComparison(projectId), loadE2ERuns(projectId, preferredRunId)]); +} + +async function loadE2ELatestComparison(projectId) { + try { + const requestedBranch = e2eBranchFilter.value || ''; + const url = new URL(`/api/projects/${projectId}/e2e-test-runs/latest-comparison`, window.location.origin); + if (requestedBranch) { + url.searchParams.set('branch', requestedBranch); + } + + const res = await fetch(url.toString()); + if (!res.ok) throw new Error(`failed to load E2E comparison (${res.status})`); + const data = await res.json(); + + if (projectId !== selectedProjectId) return; + if ((e2eBranchFilter.value || '') !== requestedBranch) return; + + e2eDuration.textContent = data.run?.durationMs == null ? '-' : `${Math.round(data.run.durationMs / 1000)}s`; + } catch (err) { + if (projectId !== selectedProjectId) return; + e2eStatus.textContent = 'ERROR'; + e2eStatus.className = 'value failed'; + e2ePassRate.textContent = '-'; + e2eFailedSpecsCount.textContent = '-'; + e2eDuration.textContent = '-'; + } +} + +async function loadE2ERuns(projectId, preferredRunId = null) { + e2eRunChain.innerHTML = ''; + e2eRunsBody.innerHTML = ''; + currentE2ERunItems = []; + + const retainedRunId = preferredRunId || selectedE2ERunId; + + try { + const url = new URL(`/api/projects/${projectId}/e2e-test-runs`, window.location.origin); + url.searchParams.set('page', '1'); + url.searchParams.set('pageSize', '20'); + const project = projects.find((p) => p.id === projectId); + const selectedBranch = e2eBranchFilter.value || project?.defaultBranch || 'main'; + const selectedStatus = e2eStatusFilter.value || ''; + const selectedEnvironment = e2eEnvironmentFilter.value || ''; + const selectedPlatform = e2ePlatformFilter.value || ''; + url.searchParams.set('branch', selectedBranch); + if (selectedStatus) { + url.searchParams.set('status', selectedStatus); + } + if (selectedEnvironment) { + url.searchParams.set('environment', selectedEnvironment); + } + if (selectedPlatform) { + url.searchParams.set('platform', selectedPlatform); + } + + const res = await fetch(url.toString()); + if (!res.ok) throw new Error(`failed to load E2E runs (${res.status})`); + const data = await res.json(); + const items = data.items || []; + + if (projectId !== selectedProjectId) return; + const currentProject = projects.find((p) => p.id === projectId); + const currentBranch = e2eBranchFilter.value || currentProject?.defaultBranch || 'main'; + const currentStatus = e2eStatusFilter.value || ''; + const currentEnvironment = e2eEnvironmentFilter.value || ''; + const currentPlatform = e2ePlatformFilter.value || ''; + if (currentBranch !== selectedBranch || currentStatus !== selectedStatus || currentEnvironment !== selectedEnvironment || currentPlatform !== selectedPlatform) return; + + currentE2ERunItems = items; + const passedRuns = items.filter((run) => run.status === 'passed').length; + const failedRuns = items.filter((run) => run.status === 'failed').length; + if (passedRuns === 0 && failedRuns === 0) { + e2ePassRate.textContent = '-'; + } else if (failedRuns === 0) { + e2ePassRate.textContent = '∞%'; + } else { + e2ePassRate.textContent = `${((passedRuns / failedRuns) * 100).toFixed(2)}%`; + } + + if (items.length === 0) { + selectedE2ERunId = null; + e2eStatus.textContent = '-'; + e2eStatus.className = 'value'; + e2eFailedSpecsCount.textContent = '-'; + e2eRunChain.innerHTML = '

No E2E runs found for current filters.

'; + e2eRunsBody.innerHTML = 'No E2E runs found.'; + e2eFailedSpecsBody.innerHTML = 'No run selected.'; + return; + } + + const latestRun = items[0]; + e2eStatus.textContent = (latestRun.status || '-').toUpperCase(); + e2eStatus.className = `value ${latestRun.status === 'passed' ? 'passed' : 'failed'}`; + e2eFailedSpecsCount.textContent = String(latestRun.failedSpecs ?? '-'); + + const nextSelectedRunId = retainedRunId && items.some((run) => run.id === retainedRunId) + ? retainedRunId + : items[0].id; + + selectedE2ERunId = nextSelectedRunId; + renderE2ERunChain(items.slice(0, e2eRunChainMaxItems)); + + for (const run of items) { + const tr = document.createElement('tr'); + tr.dataset.runId = run.id; + tr.innerHTML = ` + ${run.id} + ${run.branch} + ${run.commitSha} + ${run.status} + ${pct(run.passRatePercent)} + ${run.failedSpecs} + ${run.platformType || '-'} + ${run.testFramework || '-'} + ${run.environment || '-'} + ${new Date(run.runTimestamp).toLocaleString()} + `; + tr.addEventListener('click', async () => { + selectedE2ERunId = run.id; + highlightSelectedRunRow(); + renderE2ERunChain(items.slice(0, e2eRunChainMaxItems)); + await loadE2ERunDetails(projectId, run.id); + }); + e2eRunsBody.appendChild(tr); + } + + highlightSelectedRunRow(); + await loadE2ERunDetails(projectId, selectedE2ERunId); + } catch (err) { + selectedE2ERunId = null; + e2eRunChain.innerHTML = `

${err.message}

`; + e2eRunsBody.innerHTML = `${err.message}`; + e2eFailedSpecsBody.innerHTML = 'Failed to load selected run details.'; + e2ePassRate.textContent = '-'; + } +} + +function renderE2ERunChain(items) { + if (!Array.isArray(items) || items.length === 0) { + e2eRunChain.innerHTML = '

No E2E runs found for current filters.

'; + return; + } + + const track = document.createElement('div'); + track.className = 'integration-run-chain-track'; + + const displayItems = [...items].reverse(); + displayItems.forEach((run, index) => { + const item = document.createElement('div'); + item.className = 'integration-chain-item'; + + const button = document.createElement('button'); + button.type = 'button'; + button.className = `integration-chain-node ${run.status === 'passed' ? 'passed' : 'failed'}`; + if (selectedE2ERunId === run.id) { + button.classList.add('selected'); + } + button.title = `${run.status.toUpperCase()} | ${formatDateTime(run.runTimestamp)} | ${pct(run.passRatePercent)}`; + button.setAttribute('aria-label', `Run ${run.id}, ${run.status}, pass rate ${pct(run.passRatePercent)}`); + button.addEventListener('click', async () => { + selectedE2ERunId = run.id; + highlightSelectedRunRow(); + renderE2ERunChain(items); + await loadE2ERunDetails(selectedProjectId, run.id); + }); + + const label = document.createElement('p'); + label.className = 'integration-chain-label'; + label.textContent = `${shortCommit(run.commitSha)} · ${formatChainDate(run.runTimestamp)}`; + + item.appendChild(button); + item.appendChild(label); + track.appendChild(item); + + if (index < displayItems.length - 1) { + const connector = document.createElement('span'); + connector.className = 'integration-chain-connector'; + connector.textContent = '→'; + connector.title = 'Oldest to newest'; + connector.setAttribute('aria-hidden', 'true'); + track.appendChild(connector); + } + }); + + e2eRunChain.innerHTML = ''; + e2eRunChain.appendChild(track); +} + +function formatChainDate(value) { + if (!value) return '-'; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return '-'; + + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + return `${year}-${month}-${day}`; +} + +function highlightSelectedRunRow() { + const rows = e2eRunsBody.querySelectorAll('tr[data-run-id]'); + for (const row of rows) { + row.classList.toggle('selected-row', row.dataset.runId === selectedE2ERunId); + } +} + +async function loadE2ERunDetails(projectId, runId) { + e2eFailedSpecsBody.innerHTML = ''; + try { + const res = await fetch(`/api/projects/${projectId}/e2e-test-runs/${runId}`); + if (!res.ok) throw new Error(`failed to load E2E run details (${res.status})`); + const data = await res.json(); + const failedSpecs = data.failedSpecs || []; + + if (failedSpecs.length === 0) { + e2eFailedSpecsBody.innerHTML = 'No failed specs for this run.'; + return; + } + + for (const failed of failedSpecs) { + const tr = document.createElement('tr'); + tr.innerHTML = ` + ${escapeHtml(failed.specPath || '-')} + ${escapeHtml(failed.failureMessage || '-')} + ${escapeHtml(failed.file || '-')} + ${failed.line || '-'} + `; + e2eFailedSpecsBody.appendChild(tr); + } + } catch (err) { + e2eFailedSpecsBody.innerHTML = `${err.message}`; + } +} + +function pct(v) { + if (v == null || Number.isNaN(v)) return '-'; + return `${Number(v).toFixed(2)}%`; +} + +function signedPct(v) { + const n = Number(v); + if (Number.isNaN(n)) return '-'; + return `${n > 0 ? '+' : ''}${n.toFixed(2)}%`; +} + +function shortCommit(commitSha) { + if (!commitSha) return '-'; + return String(commitSha).slice(0, 7); +} + +function formatDateTime(value) { + if (!value) return '-'; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return '-'; + return date.toLocaleString(); +} + +function escapeHtml(value) { + return String(value) + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", '''); +} + +function getProjectDefaultBranch(projectId) { + const project = projects.find((p) => p.id === projectId); + return project?.defaultBranch || 'main'; +} + +async function loadHeatmap() { + e2eHeatmap.innerHTML = '

Loading heatmap…

'; + try { + const url = new URL('/api/e2e-test-runs/heatmap', window.location.origin); + url.searchParams.set('runsPerProject', '10'); + if (heatmapBranchFilter.value) url.searchParams.set('branch', heatmapBranchFilter.value); + if (heatmapStatusFilter.value) url.searchParams.set('status', heatmapStatusFilter.value); + + const res = await fetch(url.toString()); + if (!res.ok) throw new Error(`heatmap request failed (${res.status})`); + const data = await res.json(); + renderHeatmap(data.groups || []); + } catch (err) { + e2eHeatmap.innerHTML = `

${escapeHtml(err.message)}

`; + } +} + +function renderHeatmap(groups) { + e2eHeatmap.innerHTML = ''; + + if (groups.length === 0) { + e2eHeatmap.innerHTML = '

No E2E runs found.

'; + return; + } + + const environmentOrder = ['test', 'stage', 'prod']; + + for (const group of groups) { + const groupEl = document.createElement('div'); + groupEl.className = 'integration-heatmap-group'; + + const groupLabel = document.createElement('p'); + groupLabel.className = 'integration-heatmap-group-name'; + groupLabel.textContent = group.groupName || 'Ungrouped'; + groupEl.appendChild(groupLabel); + + for (const project of group.projects || []) { + const defaultBranch = getProjectDefaultBranch(project.projectId); + const runs = (project.runs || []).filter((run) => run.branch === defaultBranch); + if (runs.length === 0) { + continue; + } + + const runsByEnvironment = {}; + runs.forEach((run) => { + const env = run.environment || 'unspecified'; + if (!runsByEnvironment[env]) { + runsByEnvironment[env] = []; + } + runsByEnvironment[env].push(run); + }); + + const sortedEnvironments = [ + ...environmentOrder.filter((env) => runsByEnvironment[env]), + ...Object.keys(runsByEnvironment).filter((env) => !environmentOrder.includes(env)), + ]; + + const projectCardEl = document.createElement('section'); + projectCardEl.className = 'integration-heatmap-project-card'; + const newestProjectRun = runs[0] || null; + if (newestProjectRun?.status === 'passed') { + projectCardEl.classList.add('newest-passed'); + } else if (newestProjectRun?.status === 'failed') { + projectCardEl.classList.add('newest-failed'); + } + + const projectHeaderEl = document.createElement('div'); + projectHeaderEl.className = 'integration-heatmap-project-header'; + + const projectTitleEl = document.createElement('span'); + projectTitleEl.className = 'integration-heatmap-project-name'; + const projectDisplayName = project.projectName || project.projectKey; + projectTitleEl.textContent = projectDisplayName; + projectTitleEl.title = project.projectKey; + projectHeaderEl.appendChild(projectTitleEl); + + const projectMetaEl = document.createElement('span'); + projectMetaEl.className = 'integration-heatmap-project-meta'; + projectMetaEl.textContent = `${sortedEnvironments.length} env${sortedEnvironments.length === 1 ? '' : 's'}`; + projectHeaderEl.appendChild(projectMetaEl); + + projectCardEl.appendChild(projectHeaderEl); + + const environmentListEl = document.createElement('div'); + environmentListEl.className = 'integration-heatmap-environment-list'; + + for (const environment of sortedEnvironments) { + const envRuns = runsByEnvironment[environment]; + const rowEl = document.createElement('div'); + rowEl.className = 'integration-heatmap-environment-row'; + const newestRun = envRuns.length > 0 ? envRuns[0] : null; + if (newestRun?.status === 'passed') { + rowEl.classList.add('newest-passed'); + } else if (newestRun?.status === 'failed') { + rowEl.classList.add('newest-failed'); + } + + const envInfoEl = document.createElement('div'); + envInfoEl.className = 'integration-heatmap-environment-info'; + + const envBadgeEl = document.createElement('span'); + envBadgeEl.className = 'integration-heatmap-environment-badge'; + const envLabel = environment === 'unspecified' ? '(no env)' : environment; + envBadgeEl.textContent = envLabel; + envBadgeEl.title = `${project.projectKey} - Environment: ${environment}`; + envInfoEl.appendChild(envBadgeEl); + + const envCountEl = document.createElement('span'); + envCountEl.className = 'integration-heatmap-environment-count'; + envCountEl.textContent = `${envRuns.length} run${envRuns.length === 1 ? '' : 's'}`; + envInfoEl.appendChild(envCountEl); + + rowEl.appendChild(envInfoEl); + + const tilesEl = document.createElement('div'); + tilesEl.className = 'integration-heatmap-tiles'; + + const displayRuns = [...envRuns].reverse(); + displayRuns.forEach((run, index) => { + const tile = document.createElement('button'); + tile.type = 'button'; + tile.className = `integration-heatmap-tile ${run.status === 'passed' ? 'passed' : 'failed'}`; + tile.textContent = run.status === 'passed' ? '✅' : '❌'; + if (selectedProjectId === project.projectId && selectedE2ERunId === run.id) { + tile.classList.add('selected'); + } + tile.title = [ + projectDisplayName, + group.groupName ? `Group: ${group.groupName}` : null, + `Environment: ${environment}`, + `Branch: ${run.branch}`, + `Commit: ${shortCommit(run.commitSha)}`, + `${formatDateTime(run.runTimestamp)}`, + `Status: ${run.status.toUpperCase()}`, + `Pass rate: ${pct(run.passRatePercent)}`, + ].filter(Boolean).join('\n'); + tile.setAttribute('aria-label', `${projectDisplayName} [${envLabel}] — ${run.status} — ${pct(run.passRatePercent)}`); + + tile.addEventListener('click', async () => { + if (selectedProjectId !== project.projectId) { + projectSelector.value = project.projectId; + await selectProject(project.projectId, { preferredRunId: run.id }); + renderProjectSelector(); + } else { + selectedE2ERunId = run.id; + highlightSelectedRunRow(); + renderE2ERunChain(currentE2ERunItems); + await loadE2ERunDetails(project.projectId, run.id); + } + renderHeatmap(groups); + }); + + tilesEl.appendChild(tile); + + if (index < displayRuns.length - 1) { + const arrow = document.createElement('span'); + arrow.className = 'integration-heatmap-arrow'; + arrow.textContent = '→'; + arrow.title = 'Oldest to newest'; + arrow.setAttribute('aria-hidden', 'true'); + tilesEl.appendChild(arrow); + } + }); + + rowEl.appendChild(tilesEl); + environmentListEl.appendChild(rowEl); + } + + projectCardEl.appendChild(environmentListEl); + groupEl.appendChild(projectCardEl); + } + + e2eHeatmap.appendChild(groupEl); + } +} diff --git a/cmd/frontend/web/e2eTest.html b/cmd/frontend/web/e2eTest.html new file mode 100644 index 0000000..e1dcaa3 --- /dev/null +++ b/cmd/frontend/web/e2eTest.html @@ -0,0 +1,198 @@ + + + + + + Open Coverage E2E Test Console + + + + + + +
+
+ + +
+
+
+
+

E2E Tests

+

Select a project

+

No project selected

+
+
+ +
+
+

Status

+

-

+
+
+

Run Success Ratio % (Pass/Fail, last 20)

+

-

+
+
+

Failed Specs

+

-

+
+
+

Duration

+

-

+
+
+ +
+ + + + + +
+ +
+
+

Run Chain

+

Oldest on the left, newest on the right. Green = pass, red = fail. Showing up to 5 runs.

+
+
+
+ +
+
+
+

E2E Runs

+
+
+ + + + + + + + + + + + + + + + +
RunBranchCommitStatusPass RateFailedPlatformFrameworkEnvironmentTimestamp
+
+
+ +
+
+

Failed Specs (Selected Run)

+
+
+ + + + + + + + + + +
Spec PathMessageFileLine
+
+
+
+ + +
+
+
+ + + + diff --git a/cmd/frontend/web/index.html b/cmd/frontend/web/index.html index b9b3a41..08a2b43 100644 --- a/cmd/frontend/web/index.html +++ b/cmd/frontend/web/index.html @@ -36,6 +36,7 @@

Projects

Integration Tests + E2E Tests
diff --git a/internal/adapters/http/handlers.go b/internal/adapters/http/handlers.go index f481349..192683e 100644 --- a/internal/adapters/http/handlers.go +++ b/internal/adapters/http/handlers.go @@ -17,14 +17,19 @@ import ( type Handler struct { ingest *application.IngestCoverageRunUseCase ingestIntegration *application.IngestIntegrationRunUseCase + ingestE2E *application.IngestE2ERunUseCase listProjects *application.ListProjectsUseCase getProject *application.GetProjectUseCase listRuns *application.ListCoverageRunsUseCase listIntegrationRuns *application.ListIntegrationRunsUseCase + listE2ERuns *application.ListE2ERunsUseCase latestComparison *application.GetLatestComparisonUseCase latestIntegrationComparison *application.GetLatestIntegrationComparisonUseCase + latestE2EComparison *application.GetLatestE2EComparisonUseCase getIntegrationRun *application.GetIntegrationRunUseCase + getE2ERun *application.GetE2ERunUseCase getIntegrationHeatmap *application.GetIntegrationHeatmapUseCase + getE2EHeatmap *application.GetE2EHeatmapUseCase listBranches *application.ListBranchesUseCase listContributors *application.ListContributorsUseCase } @@ -32,28 +37,38 @@ type Handler struct { func NewHandler( ingest *application.IngestCoverageRunUseCase, ingestIntegration *application.IngestIntegrationRunUseCase, + ingestE2E *application.IngestE2ERunUseCase, listProjects *application.ListProjectsUseCase, getProject *application.GetProjectUseCase, listRuns *application.ListCoverageRunsUseCase, listIntegrationRuns *application.ListIntegrationRunsUseCase, + listE2ERuns *application.ListE2ERunsUseCase, latestComparison *application.GetLatestComparisonUseCase, + latestE2EComparison *application.GetLatestE2EComparisonUseCase, latestIntegrationComparison *application.GetLatestIntegrationComparisonUseCase, getIntegrationRun *application.GetIntegrationRunUseCase, + getE2ERun *application.GetE2ERunUseCase, getIntegrationHeatmap *application.GetIntegrationHeatmapUseCase, + getE2EHeatmap *application.GetE2EHeatmapUseCase, listBranches *application.ListBranchesUseCase, listContributors *application.ListContributorsUseCase, ) *Handler { return &Handler{ ingest: ingest, ingestIntegration: ingestIntegration, + ingestE2E: ingestE2E, listProjects: listProjects, getProject: getProject, listRuns: listRuns, listIntegrationRuns: listIntegrationRuns, + listE2ERuns: listE2ERuns, latestComparison: latestComparison, latestIntegrationComparison: latestIntegrationComparison, + latestE2EComparison: latestE2EComparison, getIntegrationRun: getIntegrationRun, + getE2ERun: getE2ERun, getIntegrationHeatmap: getIntegrationHeatmap, + getE2EHeatmap: getE2EHeatmap, listBranches: listBranches, listContributors: listContributors, } @@ -149,6 +164,56 @@ func (h *Handler) IngestIntegrationRun(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, out) } +func (h *Handler) IngestE2ERun(w http.ResponseWriter, r *http.Request) { + start := time.Now() + requestID := chiMiddleware.GetReqID(r.Context()) + slog.Info("operation", + "name", "ingest_e2e_run", + "stage", "start", + "request_id", requestID, + ) + + var in application.IngestE2ERunInput + slog.Info("operation", + "name", "ingest_e2e_run", + "stage", "decoding_input", + "e2e_ingest_input", in, + ) + if err := json.NewDecoder(r.Body).Decode(&in); err != nil { + slog.Warn("operation", + "name", "ingest_e2e_run", + "stage", "decode_failed", + "request_id", requestID, + "error", err, + ) + writeError(w, http.StatusBadRequest, application.NewInvalidArgument("invalid JSON request body", nil)) + return + } + + out, err := h.ingestE2E.Execute(r.Context(), in) + if err != nil { + slog.Error("operation", + "name", "ingest_e2e_run", + "stage", "execute_failed", + "request_id", requestID, + "project_key", in.ProjectKey, + "error", err, + ) + writeAppError(w, err) + return + } + + slog.Info("operation", + "name", "ingest_e2e_run", + "stage", "success", + "request_id", requestID, + "project_id", out.Project.ID, + "run_id", out.Run.ID, + "duration_ms", time.Since(start).Milliseconds(), + ) + writeJSON(w, http.StatusOK, out) +} + func (h *Handler) GetProject(w http.ResponseWriter, r *http.Request) { start := time.Now() requestID := chiMiddleware.GetReqID(r.Context()) @@ -284,6 +349,58 @@ func (h *Handler) ListIntegrationRuns(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, out) } +func (h *Handler) ListE2ERuns(w http.ResponseWriter, r *http.Request) { + start := time.Now() + requestID := chiMiddleware.GetReqID(r.Context()) + projectID := chi.URLParam(r, "projectId") + q := r.URL.Query() + slog.Info("operation", "name", "list_e2e_runs", "stage", "start", "request_id", requestID, "project_id", projectID) + + page, _ := strconv.Atoi(q.Get("page")) + pageSize, _ := strconv.Atoi(q.Get("pageSize")) + + var from *time.Time + if fromRaw := q.Get("from"); fromRaw != "" { + parsed, err := time.Parse(time.RFC3339, fromRaw) + if err != nil { + slog.Warn("operation", "name", "list_e2e_runs", "stage", "validation_failed", "request_id", requestID, "field", "from", "error", err) + writeError(w, http.StatusBadRequest, application.NewInvalidArgument("from must be RFC3339", map[string]any{"field": "from"})) + return + } + from = &parsed + } + + var to *time.Time + if toRaw := q.Get("to"); toRaw != "" { + parsed, err := time.Parse(time.RFC3339, toRaw) + if err != nil { + slog.Warn("operation", "name", "list_e2e_runs", "stage", "validation_failed", "request_id", requestID, "field", "to", "error", err) + writeError(w, http.StatusBadRequest, application.NewInvalidArgument("to must be RFC3339", map[string]any{"field": "to"})) + return + } + to = &parsed + } + + out, err := h.listE2ERuns.Execute(r.Context(), application.ListE2ERunsInput{ + ProjectID: projectID, + Branch: q.Get("branch"), + Status: q.Get("status"), + Environment: q.Get("environment"), + From: from, + To: to, + Page: page, + PageSize: pageSize, + }) + if err != nil { + slog.Error("operation", "name", "list_e2e_runs", "stage", "execute_failed", "request_id", requestID, "project_id", projectID, "error", err) + writeAppError(w, err) + return + } + + slog.Info("operation", "name", "list_e2e_runs", "stage", "success", "request_id", requestID, "project_id", projectID, "items", len(out.Items), "duration_ms", time.Since(start).Milliseconds()) + writeJSON(w, http.StatusOK, out) +} + func (h *Handler) GetLatestComparison(w http.ResponseWriter, r *http.Request) { start := time.Now() requestID := chiMiddleware.GetReqID(r.Context()) @@ -320,6 +437,22 @@ func (h *Handler) GetLatestIntegrationComparison(w http.ResponseWriter, r *http. writeJSON(w, http.StatusOK, out) } +func (h *Handler) GetLatestE2EComparison(w http.ResponseWriter, r *http.Request) { + start := time.Now() + requestID := chiMiddleware.GetReqID(r.Context()) + projectID := chi.URLParam(r, "projectId") + slog.Info("operation", "name", "get_latest_e2e_comparison", "stage", "start", "request_id", requestID, "project_id", projectID) + out, err := h.latestE2EComparison.Execute(r.Context(), projectID) + if err != nil { + slog.Error("operation", "name", "get_latest_e2e_comparison", "stage", "execute_failed", "request_id", requestID, "project_id", projectID, "error", err) + writeAppError(w, err) + return + } + + slog.Info("operation", "name", "get_latest_e2e_comparison", "stage", "success", "request_id", requestID, "project_id", projectID, "run_id", out.Run.ID, "duration_ms", time.Since(start).Milliseconds()) + writeJSON(w, http.StatusOK, out) +} + func (h *Handler) GetIntegrationRun(w http.ResponseWriter, r *http.Request) { start := time.Now() requestID := chiMiddleware.GetReqID(r.Context()) @@ -338,6 +471,24 @@ func (h *Handler) GetIntegrationRun(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, out) } +func (h *Handler) GetE2ERun(w http.ResponseWriter, r *http.Request) { + start := time.Now() + requestID := chiMiddleware.GetReqID(r.Context()) + projectID := chi.URLParam(r, "projectId") + runID := chi.URLParam(r, "runId") + slog.Info("operation", "name", "get_e2e_run", "stage", "start", "request_id", requestID, "project_id", projectID, "run_id", runID) + + out, err := h.getE2ERun.Execute(r.Context(), projectID, runID) + if err != nil { + slog.Error("operation", "name", "get_e2e_run", "stage", "execute_failed", "request_id", requestID, "project_id", projectID, "run_id", runID, "error", err) + writeAppError(w, err) + return + } + + slog.Info("operation", "name", "get_e2e_run", "stage", "success", "request_id", requestID, "project_id", projectID, "run_id", runID, "duration_ms", time.Since(start).Milliseconds()) + writeJSON(w, http.StatusOK, out) +} + func (h *Handler) ListBranches(w http.ResponseWriter, r *http.Request) { start := time.Now() requestID := chiMiddleware.GetReqID(r.Context()) @@ -397,6 +548,29 @@ func (h *Handler) GetIntegrationHeatmap(w http.ResponseWriter, r *http.Request) writeJSON(w, http.StatusOK, out) } +func (h *Handler) GetE2EHeatmap(w http.ResponseWriter, r *http.Request) { + start := time.Now() + requestID := chiMiddleware.GetReqID(r.Context()) + q := r.URL.Query() + runsPerProject, _ := strconv.Atoi(q.Get("runsPerProject")) + + slog.Info("operation", "name", "get_e2e_heatmap", "stage", "start", "request_id", requestID) + + out, err := h.getE2EHeatmap.Execute(r.Context(), application.E2EHeatmapInput{ + Branch: q.Get("branch"), + Status: q.Get("status"), + RunsPerProject: runsPerProject, + }) + if err != nil { + slog.Error("operation", "name", "get_e2e_heatmap", "stage", "execute_failed", "request_id", requestID, "error", err) + writeAppError(w, err) + return + } + + slog.Info("operation", "name", "get_e2e_heatmap", "stage", "success", "request_id", requestID, "groups", len(out.Groups), "duration_ms", time.Since(start).Milliseconds()) + writeJSON(w, http.StatusOK, out) +} + func writeAppError(w http.ResponseWriter, err error) { var appErr *application.AppError if errors.As(err, &appErr) { diff --git a/internal/adapters/http/router.go b/internal/adapters/http/router.go index bb4a513..99e05b9 100644 --- a/internal/adapters/http/router.go +++ b/internal/adapters/http/router.go @@ -30,12 +30,17 @@ func NewRouter(handler *Handler, auth application.APIKeyAuthenticator, apiKeyHea v1.Post("/coverage-runs", handler.IngestCoverageRun) v1.Post("/integration-test-runs", handler.IngestIntegrationRun) v1.Get("/integration-test-runs/heatmap", handler.GetIntegrationHeatmap) + v1.Post("/e2e-test-runs", handler.IngestE2ERun) + v1.Get("/e2e-test-runs/heatmap", handler.GetE2EHeatmap) v1.Get("/projects/{projectId}", handler.GetProject) v1.Get("/projects/{projectId}/coverage-runs", handler.ListCoverageRuns) v1.Get("/projects/{projectId}/coverage-runs/latest-comparison", handler.GetLatestComparison) v1.Get("/projects/{projectId}/integration-test-runs", handler.ListIntegrationRuns) v1.Get("/projects/{projectId}/integration-test-runs/latest-comparison", handler.GetLatestIntegrationComparison) v1.Get("/projects/{projectId}/integration-test-runs/{runId}", handler.GetIntegrationRun) + v1.Get("/projects/{projectId}/e2e-test-runs", handler.ListE2ERuns) + v1.Get("/projects/{projectId}/e2e-test-runs/latest-comparison", handler.GetLatestE2EComparison) + v1.Get("/projects/{projectId}/e2e-test-runs/{runId}", handler.GetE2ERun) v1.Get("/projects/{projectId}/branches", handler.ListBranches) v1.Get("/projects/{projectId}/contributors", handler.ListContributors) }) diff --git a/internal/adapters/postgres/e2e_spec_result_repository.go b/internal/adapters/postgres/e2e_spec_result_repository.go new file mode 100644 index 0000000..f019147 --- /dev/null +++ b/internal/adapters/postgres/e2e_spec_result_repository.go @@ -0,0 +1,129 @@ +package postgres + +import ( + "context" + "fmt" + + "github.com/arxdsilva/opencoverage/internal/domain" + "github.com/jackc/pgx/v5/pgxpool" +) + +type E2ESpecResultRepository struct { + pool *pgxpool.Pool +} + +func NewE2ESpecResultRepository(pool *pgxpool.Pool) *E2ESpecResultRepository { + return &E2ESpecResultRepository{pool: pool} +} + +func (r *E2ESpecResultRepository) CreateBatch(ctx context.Context, specs []domain.E2ESpecResult) error { + if len(specs) == 0 { + return nil + } + + q := getQuerier(ctx, r.pool) + for _, spec := range specs { + _, err := q.Exec(ctx, ` + INSERT INTO e2e_test_spec_results ( + id, e2e_run_id, spec_path, leaf_node_text, state, duration_ms, + failure_message, failure_location_file, failure_location_line + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + `, + spec.ID, + spec.E2ETestRunID, + spec.SpecPath, + spec.LeafNodeText, + spec.State, + spec.DurationMS, + spec.FailureMessage, + spec.FailureLocationFile, + spec.FailureLocationLine, + ) + if err != nil { + return fmt.Errorf("insert e2e spec result: %w", err) + } + } + + return nil +} + +func (r *E2ESpecResultRepository) ListByRunID(ctx context.Context, runID string) ([]domain.E2ESpecResult, error) { + q := getQuerier(ctx, r.pool) + rows, err := q.Query(ctx, ` + SELECT id, e2e_run_id, spec_path, leaf_node_text, state, duration_ms, + failure_message, failure_location_file, failure_location_line + FROM e2e_test_spec_results + WHERE e2e_run_id = $1 + ORDER BY spec_path ASC + `, runID) + if err != nil { + return nil, fmt.Errorf("query e2e spec results: %w", err) + } + defer rows.Close() + + specs := make([]domain.E2ESpecResult, 0) + for rows.Next() { + var spec domain.E2ESpecResult + if err := rows.Scan( + &spec.ID, + &spec.E2ETestRunID, + &spec.SpecPath, + &spec.LeafNodeText, + &spec.State, + &spec.DurationMS, + &spec.FailureMessage, + &spec.FailureLocationFile, + &spec.FailureLocationLine, + ); err != nil { + return nil, fmt.Errorf("scan e2e spec result: %w", err) + } + specs = append(specs, spec) + } + + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate e2e spec rows: %w", err) + } + + return specs, nil +} + +func (r *E2ESpecResultRepository) ListFailedByRunID(ctx context.Context, runID string) ([]domain.E2ESpecResult, error) { + q := getQuerier(ctx, r.pool) + rows, err := q.Query(ctx, ` + SELECT id, e2e_run_id, spec_path, leaf_node_text, state, duration_ms, + failure_message, failure_location_file, failure_location_line + FROM e2e_test_spec_results + WHERE e2e_run_id = $1 AND state IN ('failed', 'flaky') + ORDER BY spec_path ASC + `, runID) + if err != nil { + return nil, fmt.Errorf("query failed e2e spec results: %w", err) + } + defer rows.Close() + + specs := make([]domain.E2ESpecResult, 0) + for rows.Next() { + var spec domain.E2ESpecResult + if err := rows.Scan( + &spec.ID, + &spec.E2ETestRunID, + &spec.SpecPath, + &spec.LeafNodeText, + &spec.State, + &spec.DurationMS, + &spec.FailureMessage, + &spec.FailureLocationFile, + &spec.FailureLocationLine, + ); err != nil { + return nil, fmt.Errorf("scan failed e2e spec result: %w", err) + } + specs = append(specs, spec) + } + + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate failed integration spec rows: %w", err) + } + + return specs, nil +} diff --git a/internal/adapters/postgres/e2e_test_run_repository.go b/internal/adapters/postgres/e2e_test_run_repository.go new file mode 100644 index 0000000..acff266 --- /dev/null +++ b/internal/adapters/postgres/e2e_test_run_repository.go @@ -0,0 +1,401 @@ +package postgres + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/arxdsilva/opencoverage/internal/application" + "github.com/arxdsilva/opencoverage/internal/domain" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +type E2ETestRunRepository struct { + pool *pgxpool.Pool +} + +func NewE2ETestRunRepository(pool *pgxpool.Pool) *E2ETestRunRepository { + return &E2ETestRunRepository{pool: pool} +} + +func (r *E2ETestRunRepository) Create(ctx context.Context, run domain.E2ETestRun) (domain.E2ETestRun, error) { + q := getQuerier(ctx, r.pool) + _, err := q.Exec(ctx, ` + INSERT INTO e2e_test_runs ( + id, project_id, branch, commit_sha, author, trigger_type, run_timestamp, + framework_version, test_framework, platform, suite_description, suite_path, total_specs, passed_specs, + failed_specs, skipped_specs, flaked_specs, pending_specs, interrupted, + timed_out, duration_ms, status, environment, created_at + ) + VALUES ( + $1, $2, $3, $4, $5, $6, $7, + $8, $9, $10, $11, $12, + $13, $14, $15, $16, $17, + $18, $19, $20, $21, $22, $23, $24 + ) + `, + run.ID, + run.ProjectID, + run.Branch, + run.CommitSHA, + run.Author, + run.TriggerType, + run.RunTimestamp, + run.FrameworkVersion, + run.TestFramework, + run.PlatformType, + run.SuiteDescription, + run.SuitePath, + run.TotalSpecs, + run.PassedSpecs, + run.FailedSpecs, + run.SkippedSpecs, + run.FlakedSpecs, + run.PendingSpecs, + run.Interrupted, + run.TimedOut, + run.DurationMS, + run.Status, + run.Environment, + run.CreatedAt, + ) + if err != nil { + return domain.E2ETestRun{}, fmt.Errorf("insert e2e test run: %w", err) + } + return run, nil +} + +func (r *E2ETestRunRepository) GetLatestByProjectAndBranch(ctx context.Context, projectID string, branch string) (domain.E2ETestRun, error) { + q := getQuerier(ctx, r.pool) + var run domain.E2ETestRun + err := q.QueryRow(ctx, ` + SELECT id, project_id, branch, commit_sha, COALESCE(author, ''), trigger_type, run_timestamp, + COALESCE(framework_version, ''), COALESCE(test_framework, ''), COALESCE(platform::text, ''), suite_description, suite_path, total_specs, passed_specs, + failed_specs, skipped_specs, flaked_specs, pending_specs, interrupted, timed_out, + duration_ms, status, environment, created_at + FROM e2e_test_runs + WHERE project_id = $1 AND branch = $2 + ORDER BY run_timestamp DESC, created_at DESC + LIMIT 1 + `, projectID, branch).Scan( + &run.ID, + &run.ProjectID, + &run.Branch, + &run.CommitSHA, + &run.Author, + &run.TriggerType, + &run.RunTimestamp, + &run.FrameworkVersion, + &run.TestFramework, + &run.PlatformType, + &run.SuiteDescription, + &run.SuitePath, + &run.TotalSpecs, + &run.PassedSpecs, + &run.FailedSpecs, + &run.SkippedSpecs, + &run.FlakedSpecs, + &run.PendingSpecs, + &run.Interrupted, + &run.TimedOut, + &run.DurationMS, + &run.Status, + &run.Environment, + &run.CreatedAt, + ) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return domain.E2ETestRun{}, domain.ErrNotFound + } + return domain.E2ETestRun{}, fmt.Errorf("query latest e2e run by project and branch: %w", err) + } + return run, nil +} + +func (r *E2ETestRunRepository) GetLatestByProject(ctx context.Context, projectID string) (domain.E2ETestRun, error) { + q := getQuerier(ctx, r.pool) + var run domain.E2ETestRun + err := q.QueryRow(ctx, ` + SELECT id, project_id, branch, commit_sha, COALESCE(author, ''), trigger_type, run_timestamp, + COALESCE(framework_version, ''), COALESCE(test_framework, ''), COALESCE(platform::text, ''), suite_description, suite_path, total_specs, passed_specs, + failed_specs, skipped_specs, flaked_specs, pending_specs, interrupted, timed_out, + duration_ms, status, environment, created_at + FROM e2e_test_runs + WHERE project_id = $1 + ORDER BY run_timestamp DESC, created_at DESC + LIMIT 1 + `, projectID).Scan( + &run.ID, + &run.ProjectID, + &run.Branch, + &run.CommitSHA, + &run.Author, + &run.TriggerType, + &run.RunTimestamp, + &run.FrameworkVersion, + &run.TestFramework, + &run.PlatformType, + &run.SuiteDescription, + &run.SuitePath, + &run.TotalSpecs, + &run.PassedSpecs, + &run.FailedSpecs, + &run.SkippedSpecs, + &run.FlakedSpecs, + &run.PendingSpecs, + &run.Interrupted, + &run.TimedOut, + &run.DurationMS, + &run.Status, + &run.Environment, + &run.CreatedAt, + ) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return domain.E2ETestRun{}, domain.ErrNotFound + } + return domain.E2ETestRun{}, fmt.Errorf("query latest e2e run by project: %w", err) + } + return run, nil +} + +func (r *E2ETestRunRepository) GetByID(ctx context.Context, projectID string, runID string) (domain.E2ETestRun, error) { + q := getQuerier(ctx, r.pool) + var run domain.E2ETestRun + err := q.QueryRow(ctx, ` + SELECT id, project_id, branch, commit_sha, COALESCE(author, ''), trigger_type, run_timestamp, + COALESCE(framework_version, ''), COALESCE(test_framework, ''), COALESCE(platform::text, ''), suite_description, suite_path, total_specs, passed_specs, + failed_specs, skipped_specs, flaked_specs, pending_specs, interrupted, timed_out, + duration_ms, status, environment, created_at + FROM e2e_test_runs + WHERE project_id = $1 AND id = $2 + LIMIT 1 + `, projectID, runID).Scan( + &run.ID, + &run.ProjectID, + &run.Branch, + &run.CommitSHA, + &run.Author, + &run.TriggerType, + &run.RunTimestamp, + &run.FrameworkVersion, + &run.TestFramework, + &run.PlatformType, + &run.SuiteDescription, + &run.SuitePath, + &run.TotalSpecs, + &run.PassedSpecs, + &run.FailedSpecs, + &run.SkippedSpecs, + &run.FlakedSpecs, + &run.PendingSpecs, + &run.Interrupted, + &run.TimedOut, + &run.DurationMS, + &run.Status, + &run.Environment, + &run.CreatedAt, + ) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return domain.E2ETestRun{}, domain.ErrNotFound + } + return domain.E2ETestRun{}, fmt.Errorf("query e2e run by id: %w", err) + } + return run, nil +} + +func (r *E2ETestRunRepository) ListByProject(ctx context.Context, projectID string, branch string, status string, environment string, from *time.Time, to *time.Time, page int, pageSize int) ([]domain.E2ETestRun, int, error) { + q := getQuerier(ctx, r.pool) + offset := (page - 1) * pageSize + + where := "WHERE project_id = $1" + args := []any{projectID} + idx := 2 + + if branch != "" { + where += fmt.Sprintf(" AND branch = $%d", idx) + args = append(args, branch) + idx++ + } + if status != "" { + where += fmt.Sprintf(" AND status = $%d", idx) + args = append(args, status) + idx++ + } + if environment != "" { + if environment == "none" { + where += " AND environment IS NULL" + } else { + where += fmt.Sprintf(" AND environment = $%d", idx) + args = append(args, environment) + idx++ + } + } + if from != nil { + where += fmt.Sprintf(" AND run_timestamp >= $%d", idx) + args = append(args, *from) + idx++ + } + if to != nil { + where += fmt.Sprintf(" AND run_timestamp <= $%d", idx) + args = append(args, *to) + idx++ + } + + countSQL := fmt.Sprintf("SELECT COUNT(*) FROM e2e_test_runs %s", where) + var total int + if err := q.QueryRow(ctx, countSQL, args...).Scan(&total); err != nil { + return nil, 0, fmt.Errorf("count e2e runs: %w", err) + } + + listSQL := fmt.Sprintf(` + SELECT id, project_id, branch, commit_sha, COALESCE(author, ''), trigger_type, run_timestamp, + COALESCE(framework_version, ''), COALESCE(test_framework, ''), COALESCE(platform::text, ''), suite_description, suite_path, total_specs, passed_specs, + failed_specs, skipped_specs, flaked_specs, pending_specs, interrupted, timed_out, + duration_ms, status, environment, created_at + FROM e2e_test_runs + %s + ORDER BY run_timestamp DESC, created_at DESC + LIMIT $%d OFFSET $%d + `, where, idx, idx+1) + args = append(args, pageSize, offset) + + rows, err := q.Query(ctx, listSQL, args...) + if err != nil { + return nil, 0, fmt.Errorf("list integration runs: %w", err) + } + defer rows.Close() + + runs := make([]domain.E2ETestRun, 0) + for rows.Next() { + var run domain.E2ETestRun + if err := rows.Scan( + &run.ID, + &run.ProjectID, + &run.Branch, + &run.CommitSHA, + &run.Author, + &run.TriggerType, + &run.RunTimestamp, + &run.FrameworkVersion, + &run.TestFramework, + &run.PlatformType, + &run.SuiteDescription, + &run.SuitePath, + &run.TotalSpecs, + &run.PassedSpecs, + &run.FailedSpecs, + &run.SkippedSpecs, + &run.FlakedSpecs, + &run.PendingSpecs, + &run.Interrupted, + &run.TimedOut, + &run.DurationMS, + &run.Status, + &run.Environment, + &run.CreatedAt, + ); err != nil { + return nil, 0, fmt.Errorf("scan integration run: %w", err) + } + runs = append(runs, run) + } + + if err := rows.Err(); err != nil { + return nil, 0, fmt.Errorf("iterate integration run rows: %w", err) + } + + return runs, total, nil +} + +func (r *E2ETestRunRepository) HeatmapData(ctx context.Context, branch string, status string, runsPerProject int) ([]application.TestHeatmapRow, error) { + q := getQuerier(ctx, r.pool) + + where := "WHERE 1=1" + args := []any{} + idx := 1 + + if branch != "" { + where += fmt.Sprintf(" AND itr.branch = $%d", idx) + args = append(args, branch) + idx++ + } + if status != "" { + where += fmt.Sprintf(" AND itr.status = $%d", idx) + args = append(args, status) + idx++ + } + + args = append(args, runsPerProject) + + sql := fmt.Sprintf(` + WITH ranked AS ( + SELECT + itr.id AS run_id, + itr.project_id, + itr.branch, + itr.commit_sha, + itr.run_timestamp, + itr.passed_specs, + itr.total_specs, + itr.status, + itr.environment, + COALESCE(p.name, '') AS project_name, + p.project_key, + COALESCE(p.group_name, '') AS project_group, + ROW_NUMBER() OVER ( + PARTITION BY itr.project_id + ORDER BY itr.run_timestamp DESC, itr.created_at DESC + ) AS rn + FROM e2e_test_runs itr + JOIN projects p ON p.id = itr.project_id + %s + ) + SELECT run_id, project_id, project_name, project_key, project_group, + branch, commit_sha, run_timestamp, passed_specs, total_specs, status, environment + FROM ranked + WHERE rn <= $%d + ORDER BY + CASE WHEN project_group = '' THEN 1 ELSE 0 END ASC, + project_group ASC, + project_name ASC, + run_timestamp DESC + `, where, idx) + + rows, err := q.Query(ctx, sql, args...) + if err != nil { + return nil, fmt.Errorf("query heatmap data: %w", err) + } + defer rows.Close() + + result := make([]application.TestHeatmapRow, 0) + for rows.Next() { + var row application.TestHeatmapRow + if err := rows.Scan( + &row.RunID, + &row.ProjectID, + &row.ProjectName, + &row.ProjectKey, + &row.ProjectGroup, + &row.Branch, + &row.CommitSHA, + &row.RunTimestamp, + &row.PassedSpecs, + &row.TotalSpecs, + &row.Status, + &row.Environment, + ); err != nil { + return nil, fmt.Errorf("scan heatmap row: %w", err) + } + result = append(result, row) + } + + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate heatmap rows: %w", err) + } + + return result, nil +} diff --git a/internal/adapters/postgres/integration_test_run_repository.go b/internal/adapters/postgres/integration_test_run_repository.go index f8fc068..a659a3b 100644 --- a/internal/adapters/postgres/integration_test_run_repository.go +++ b/internal/adapters/postgres/integration_test_run_repository.go @@ -301,7 +301,7 @@ func (r *IntegrationTestRunRepository) ListByProject(ctx context.Context, projec return runs, total, nil } -func (r *IntegrationTestRunRepository) HeatmapData(ctx context.Context, branch string, status string, runsPerProject int) ([]application.IntegrationHeatmapRow, error) { +func (r *IntegrationTestRunRepository) HeatmapData(ctx context.Context, branch string, status string, runsPerProject int) ([]application.TestHeatmapRow, error) { q := getQuerier(ctx, r.pool) where := "WHERE 1=1" @@ -361,9 +361,9 @@ func (r *IntegrationTestRunRepository) HeatmapData(ctx context.Context, branch s } defer rows.Close() - result := make([]application.IntegrationHeatmapRow, 0) + result := make([]application.TestHeatmapRow, 0) for rows.Next() { - var row application.IntegrationHeatmapRow + var row application.TestHeatmapRow if err := rows.Scan( &row.RunID, &row.ProjectID, diff --git a/internal/application/e2e_usecase.go b/internal/application/e2e_usecase.go new file mode 100644 index 0000000..e56d63b --- /dev/null +++ b/internal/application/e2e_usecase.go @@ -0,0 +1,748 @@ +package application + +import ( + "context" + "errors" + "fmt" + "log" + "sort" + "strings" + "time" + + "github.com/arxdsilva/opencoverage/internal/domain" +) + +type IngestE2ERunInput struct { + ProjectKey string `json:"projectKey"` + ProjectName string `json:"projectName"` + ProjectGroup *string `json:"projectGroup,omitempty"` + DefaultBranch string `json:"defaultBranch"` + Branch string `json:"branch"` + CommitSHA string `json:"commitSha"` + Author string `json:"author"` + TriggerType string `json:"triggerType"` + RunTimestamp string `json:"runTimestamp"` + Environment *string `json:"environment,omitempty"` + TestReport IngestReportBody `json:"testReport"` +} + +type IngestReportBody struct { + ReportType string `json:"reportType"` + FrameworkVersion string `json:"frameworkVersion,omitempty"` + TestFramework string `json:"testFramework,omitempty"` + PlatformType string `json:"platformType,omitempty"` + SuiteDescription string `json:"suiteDescription"` + SuitePath string `json:"suitePath"` + SuiteSucceeded bool `json:"suiteSucceeded,omitempty"` + SpecialSuiteFailureReasons []string `json:"specialSuiteFailureReasons,omitempty"` + SpecReports []IngestSpecReport `json:"specReports"` +} + +type IngestSpecReport struct { + LeafNodeText string `json:"leafNodeText"` + ContainerHierarchyTexts []string `json:"containerHierarchyTexts"` + State string `json:"state"` + RunTime float64 `json:"runTime"` + Failure *IngestTestFailure `json:"failure,omitempty"` +} + +type IngestTestFailure struct { + Message string `json:"message"` + Location *IngestTestLocation `json:"location,omitempty"` +} + +type IngestTestLocation struct { + FileName string `json:"fileName"` + LineNumber int `json:"lineNumber"` +} + +type E2ERunResponse struct { + ID string `json:"id"` + Branch string `json:"branch"` + CommitSHA string `json:"commitSha"` + Author string `json:"author,omitempty"` + TriggerType string `json:"triggerType"` + RunTimestamp string `json:"runTimestamp"` + Environment *string `json:"environment,omitempty"` + TotalSpecs int `json:"totalSpecs"` + PassedSpecs int `json:"passedSpecs"` + FailedSpecs int `json:"failedSpecs"` + SkippedSpecs int `json:"skippedSpecs"` + PendingSpecs int `json:"pendingSpecs"` + FlakedSpecs int `json:"flakedSpecs"` + PassRatePercent float64 `json:"passRatePercent"` + DurationMS int64 `json:"durationMs"` + Status string `json:"status"` +} + +type E2EComparisonResponse struct { + BaselineSource string `json:"baselineSource"` + PreviousPassRatePercent *float64 `json:"previousPassRatePercent"` + CurrentPassRatePercent float64 `json:"currentPassRatePercent"` + DeltaPercent *float64 `json:"deltaPercent"` + Direction string `json:"direction"` + NewFailures int `json:"newFailures"` + ResolvedFailures int `json:"resolvedFailures"` +} + +type IngestE2ERunOutput struct { + Project ProjectResponse `json:"project"` + Run E2ERunResponse `json:"run"` + Comparison E2EComparisonResponse `json:"comparison"` + FailedSpecs []FailedSpecResponse `json:"failedSpecs"` +} + +type IngestE2ERunUseCase struct { + projects ProjectRepository + runs E2ETestRunRepository + specs E2ESpecResultRepository + tx TransactionManager + ids IDGenerator + clock Clock +} + +func NewIngestE2ERunUseCase( + projects ProjectRepository, + runs E2ETestRunRepository, + specs E2ESpecResultRepository, + tx TransactionManager, + ids IDGenerator, + clock Clock, +) *IngestE2ERunUseCase { + return &IngestE2ERunUseCase{ + projects: projects, + runs: runs, + specs: specs, + tx: tx, + ids: ids, + clock: clock, + } +} + +func (uc *IngestE2ERunUseCase) Execute(ctx context.Context, in IngestE2ERunInput) (IngestE2ERunOutput, error) { + if err := validateE2EIngestInput(in); err != nil { + return IngestE2ERunOutput{}, err + } + + runTime, err := time.Parse(time.RFC3339, in.RunTimestamp) + if err != nil { + return IngestE2ERunOutput{}, NewInvalidArgument("runTimestamp must be RFC3339", map[string]any{"field": "runTimestamp"}) + } + + project, created, err := uc.resolveOrCreateE2EProject(ctx, in) + if err != nil { + return IngestE2ERunOutput{}, err + } + + var baseline *domain.E2ETestRun + var baselineFailed []domain.E2ESpecResult + baseRun, err := uc.runs.GetLatestByProjectAndBranch(ctx, project.ID, project.DefaultBranch) + if err == nil { + baseline = &baseRun + baselineFailed, err = uc.specs.ListFailedByRunID(ctx, baseRun.ID) + if err != nil { + return IngestE2ERunOutput{}, NewInternal("failed to load baseline failed specs", err) + } + } else if !errors.Is(err, domain.ErrNotFound) { + return IngestE2ERunOutput{}, NewInternal("failed to load baseline e2e run", err) + } + + run, specEntities := uc.buildE2EEntities(project.ID, in, runTime) + + if err := uc.tx.WithinTx(ctx, func(txCtx context.Context) error { + if _, err := uc.runs.Create(txCtx, run); err != nil { + return err + } + if err := uc.specs.CreateBatch(txCtx, specEntities); err != nil { + return err + } + return nil + }); err != nil { + return IngestE2ERunOutput{}, NewInternal("failed to persist e2e run", err) + } + + failedSpecs := failedE2ESpecsFromResults(specEntities) + passRate := calculatePassRate(run.PassedSpecs, run.TotalSpecs) + var previousPassRate *float64 + newFailures := 0 + resolvedFailures := 0 + if baseline != nil { + prev := calculatePassRate(baseline.PassedSpecs, baseline.TotalSpecs) + previousPassRate = &prev + newFailures, resolvedFailures = compareFailedSpecs(failedE2ESpecsFromResults(baselineFailed), failedSpecs) + } + + return IngestE2ERunOutput{ + Project: ProjectResponse{ + ID: project.ID, + ProjectKey: project.ProjectKey, + Name: project.Name, + DefaultBranch: project.DefaultBranch, + GlobalThresholdPercent: project.GlobalThresholdPercent, + Created: created, + }, + Run: e2eRunResponse(run), + Comparison: buildE2EComparison( + passRate, + previousPassRate, + newFailures, + resolvedFailures, + ), + FailedSpecs: failedSpecs, + }, nil +} + +func (uc *IngestE2ERunUseCase) resolveOrCreateE2EProject(ctx context.Context, in IngestE2ERunInput) (domain.Project, bool, error) { + project, err := uc.projects.GetByKey(ctx, in.ProjectKey) + log.Printf("resolveOrCreateE2EProject: GetByKey result: project=%+v, err=%v\n", project, err) + if err == nil { + return project, false, nil + } + if !errors.Is(err, domain.ErrNotFound) { + return domain.Project{}, false, NewInternal("failed to load project", err) + } + + defaultBranch := in.DefaultBranch + if strings.TrimSpace(defaultBranch) == "" { + defaultBranch = domain.DefaultBranch + } + + now := uc.clock.Now().UTC() + created := domain.Project{ + ID: uc.ids.NewID(), + ProjectKey: in.ProjectKey, + Name: in.ProjectName, + Group: in.ProjectGroup, + DefaultBranch: defaultBranch, + GlobalThresholdPercent: domain.DefaultThresholdPercent, + CreatedAt: now, + UpdatedAt: now, + } + + project, err = uc.projects.Create(ctx, created) + if err != nil { + return domain.Project{}, false, NewInternal("failed to create project", err) + } + return project, true, nil +} + +func (uc *IngestE2ERunUseCase) buildE2EEntities(projectID string, in IngestE2ERunInput, runTime time.Time) (domain.E2ETestRun, []domain.E2ESpecResult) { + total := len(in.TestReport.SpecReports) + passed := 0 + failed := 0 + skipped := 0 + pending := 0 + flaky := 0 + interrupted := false + timedOut := false + var totalDurationMS int64 + + specResults := make([]domain.E2ESpecResult, 0, total) + for _, spec := range in.TestReport.SpecReports { + normalizedState := normalizeTestState(spec.State) + switch normalizedState { + case domain.E2ESpecStatePassed: + passed++ + case domain.E2ESpecStateFailed: + failed++ + case domain.E2ESpecStateSkipped: + skipped++ + case domain.E2ESpecStatePending: + pending++ + case domain.E2ESpecStateFlaky: + flaky++ + } + + if strings.EqualFold(strings.TrimSpace(spec.State), "interrupted") { + interrupted = true + } + if strings.EqualFold(strings.TrimSpace(spec.State), "timedout") { + timedOut = true + } + + durationMS := int64(spec.RunTime * 1000) + if durationMS < 0 { + durationMS = 0 + } + totalDurationMS += durationMS + + specPath := spec.LeafNodeText + if len(spec.ContainerHierarchyTexts) > 0 { + specPath = strings.Join(append(spec.ContainerHierarchyTexts, spec.LeafNodeText), " > ") + } + + var failureMessage *string + var failureFile *string + var failureLine *int + if spec.Failure != nil && strings.TrimSpace(spec.Failure.Message) != "" { + message := strings.TrimSpace(spec.Failure.Message) + failureMessage = &message + } + if spec.Failure != nil && spec.Failure.Location != nil { + if file := strings.TrimSpace(spec.Failure.Location.FileName); file != "" { + failureFile = &file + } + if spec.Failure.Location.LineNumber > 0 { + line := spec.Failure.Location.LineNumber + failureLine = &line + } + } + + specResults = append(specResults, domain.E2ESpecResult{ + ID: uc.ids.NewID(), + SpecPath: specPath, + LeafNodeText: spec.LeafNodeText, + State: normalizedState, + DurationMS: durationMS, + FailureMessage: failureMessage, + FailureLocationFile: failureFile, + FailureLocationLine: failureLine, + }) + } + + runID := uc.ids.NewID() + for i := range specResults { + specResults[i].E2ETestRunID = runID + } + + run := domain.E2ETestRun{ + ID: runID, + ProjectID: projectID, + Branch: in.Branch, + CommitSHA: in.CommitSHA, + Author: in.Author, + TriggerType: in.TriggerType, + RunTimestamp: runTime, + FrameworkVersion: in.TestReport.FrameworkVersion, + TestFramework: in.TestReport.TestFramework, + PlatformType: in.TestReport.PlatformType, + SuiteDescription: in.TestReport.SuiteDescription, + SuitePath: in.TestReport.SuitePath, + TotalSpecs: total, + PassedSpecs: passed, + FailedSpecs: failed, + SkippedSpecs: skipped, + FlakedSpecs: flaky, + Interrupted: interrupted, + TimedOut: timedOut, + DurationMS: totalDurationMS, + Status: domain.EvaluateE2ERunStatus(failed, interrupted, timedOut), + Environment: in.Environment, + CreatedAt: uc.clock.Now().UTC(), + } + + return run, specResults +} + +func validateE2EIngestInput(in IngestE2ERunInput) error { + if strings.TrimSpace(in.ProjectKey) == "" { + return NewInvalidArgument("projectKey is required", map[string]any{"field": "projectKey"}) + } + if strings.TrimSpace(in.Branch) == "" { + return NewInvalidArgument("branch is required", map[string]any{"field": "branch"}) + } + if strings.TrimSpace(in.CommitSHA) == "" { + return NewInvalidArgument("commitSha is required", map[string]any{"field": "commitSha"}) + } + if err := domain.ValidateTriggerType(in.TriggerType); err != nil { + return NewInvalidArgument(err.Error(), map[string]any{"field": "triggerType"}) + } + // if strings.TrimSpace(in.TestReport.SuiteDescription) == "" { + // return NewInvalidArgument("testReport.suiteDescription is required", map[string]any{"field": "testReport.suiteDescription"}) + // } + // if strings.TrimSpace(in.TestReport.SuitePath) == "" { + // return NewInvalidArgument("testReport.suitePath is required", map[string]any{"field": "testReport.suitePath"}) + // } + if strings.TrimSpace(in.TestReport.FrameworkVersion) == "" { + return NewInvalidArgument("testReport.frameworkVersion is required", map[string]any{"field": "testReport.frameworkVersion"}) + } + if strings.TrimSpace(in.TestReport.TestFramework) == "" { + return NewInvalidArgument("testReport.testFramework is required", map[string]any{"field": "testReport.testFramework"}) + } + if strings.TrimSpace(in.TestReport.PlatformType) == "" { + return NewInvalidArgument("testReport.platformType is required", map[string]any{"field": "testReport.platformType"}) + } + if len(in.TestReport.SpecReports) == 0 { + return NewInvalidArgument("testReport.specReports must not be empty", map[string]any{"field": "testReport.specReports"}) + } + + for i, spec := range in.TestReport.SpecReports { + if !isAcceptedTestState(spec.State) { + return NewInvalidArgument("state is invalid", map[string]any{"field": fmt.Sprintf("testReport.specReports[%d].state", i)}) + } + if spec.RunTime < 0 { + return NewInvalidArgument("runTime must be >= 0", map[string]any{"field": fmt.Sprintf("testReport.specReports[%d].runTime", i)}) + } + if normalizeTestState(spec.State) == domain.E2ESpecStateFailed && (spec.Failure == nil || strings.TrimSpace(spec.Failure.Message) == "") { + return NewInvalidArgument("failure.message is required when state is failed", map[string]any{"field": fmt.Sprintf("testReport.specReports[%d].failure.message", i)}) + } + } + + return nil +} + +func normalizeTestState(state string) domain.E2ESpecState { + normalized := strings.ToLower(strings.TrimSpace(state)) + switch normalized { + case "passed": + return domain.E2ESpecStatePassed + case "failed", "panicked", "interrupted", "timedout": + return domain.E2ESpecStateFailed + case "skipped": + return domain.E2ESpecStateSkipped + case "pending": + return domain.E2ESpecStatePending + case "flaked": + return domain.E2ESpecStateFlaky + default: + return domain.E2ESpecStateFailed + } +} + +func isAcceptedTestState(state string) bool { + switch strings.ToLower(strings.TrimSpace(state)) { + case "passed", "failed", "skipped", "pending", "interrupted", "timedout", "flaked": + return true + default: + return false + } +} + +func buildE2EComparison(current float64, previous *float64, newFailures int, resolvedFailures int) E2EComparisonResponse { + delta, direction := domain.CompareCoverage(current, previous) + return E2EComparisonResponse{ + BaselineSource: "latest_default_branch", + PreviousPassRatePercent: previous, + CurrentPassRatePercent: current, + DeltaPercent: delta, + Direction: string(direction), + NewFailures: newFailures, + ResolvedFailures: resolvedFailures, + } +} + +func e2eRunResponse(run domain.E2ETestRun) E2ERunResponse { + return E2ERunResponse{ + ID: run.ID, + Branch: run.Branch, + CommitSHA: run.CommitSHA, + Author: run.Author, + TriggerType: run.TriggerType, + RunTimestamp: run.RunTimestamp.UTC().Format(time.RFC3339), + Environment: run.Environment, + TotalSpecs: run.TotalSpecs, + PassedSpecs: run.PassedSpecs, + FailedSpecs: run.FailedSpecs, + SkippedSpecs: run.SkippedSpecs, + FlakedSpecs: run.FlakedSpecs, + PassRatePercent: calculatePassRate(run.PassedSpecs, run.TotalSpecs), + DurationMS: run.DurationMS, + Status: string(run.Status), + } +} + +func failedE2ESpecsFromResults(specs []domain.E2ESpecResult) []FailedSpecResponse { + out := make([]FailedSpecResponse, 0) + for _, spec := range specs { + if spec.State != domain.E2ESpecStateFailed && spec.State != domain.E2ESpecStateFlaky { + continue + } + failed := FailedSpecResponse{SpecPath: spec.SpecPath} + if spec.FailureMessage != nil { + failed.FailureMessage = *spec.FailureMessage + } + if spec.FailureLocationFile != nil { + failed.File = *spec.FailureLocationFile + } + if spec.FailureLocationLine != nil { + failed.Line = *spec.FailureLocationLine + } + out = append(out, failed) + } + sort.Slice(out, func(i, j int) bool { return out[i].SpecPath < out[j].SpecPath }) + return out +} + +type ListE2ERunsInput struct { + ProjectID string + Branch string + Status string + Environment string + From *time.Time + To *time.Time + Page int + PageSize int +} + +type E2ERunListItem struct { + ID string `json:"id"` + Branch string `json:"branch"` + CommitSHA string `json:"commitSha"` + RunTimestamp string `json:"runTimestamp"` + Environment *string `json:"environment,omitempty"` + TotalSpecs int `json:"totalSpecs"` + FailedSpecs int `json:"failedSpecs"` + PassRatePercent float64 `json:"passRatePercent"` + Status string `json:"status"` +} + +type ListE2ERunsOutput struct { + Items []E2ERunListItem `json:"items"` + Pagination PaginationResponse `json:"pagination"` +} + +type ListE2ERunsUseCase struct { + runs E2ETestRunRepository +} + +func NewListE2ERunsUseCase(runs E2ETestRunRepository) *ListE2ERunsUseCase { + return &ListE2ERunsUseCase{runs: runs} +} + +func (uc *ListE2ERunsUseCase) Execute(ctx context.Context, in ListE2ERunsInput) (ListE2ERunsOutput, error) { + page := in.Page + if page <= 0 { + page = 1 + } + pageSize := in.PageSize + if pageSize <= 0 { + pageSize = 20 + } + if pageSize > 100 { + pageSize = 100 + } + status := strings.ToLower(strings.TrimSpace(in.Status)) + if status != "" && status != string(domain.E2ERunStatusPassed) && status != string(domain.E2ERunStatusFailed) { + return ListE2ERunsOutput{}, NewInvalidArgument("status must be passed or failed", map[string]any{"field": "status"}) + } + environment := strings.ToLower(strings.TrimSpace(in.Environment)) + if environment != "" && environment != "test" && environment != "stage" && environment != "prod" && environment != "none" { + return ListE2ERunsOutput{}, NewInvalidArgument("environment must be one of: test, stage, prod, none", map[string]any{"field": "environment"}) + } + + runs, total, err := uc.runs.ListByProject(ctx, in.ProjectID, in.Branch, status, environment, in.From, in.To, page, pageSize) + if err != nil { + return ListE2ERunsOutput{}, NewInternal("failed to list E2E runs", err) + } + + items := make([]E2ERunListItem, 0, len(runs)) + for _, run := range runs { + items = append(items, E2ERunListItem{ + ID: run.ID, + Branch: run.Branch, + CommitSHA: run.CommitSHA, + RunTimestamp: run.RunTimestamp.UTC().Format(time.RFC3339), + Environment: run.Environment, + TotalSpecs: run.TotalSpecs, + FailedSpecs: run.FailedSpecs, + PassRatePercent: calculatePassRate(run.PassedSpecs, run.TotalSpecs), + Status: string(run.Status), + }) + } + + totalPages := 0 + if total > 0 { + totalPages = (total + pageSize - 1) / pageSize + } + + return ListE2ERunsOutput{ + Items: items, + Pagination: PaginationResponse{ + Page: page, + PageSize: pageSize, + TotalItems: total, + TotalPages: totalPages, + }, + }, nil +} + +type GetLatestE2EComparisonUseCase struct { + projects ProjectRepository + runs E2ETestRunRepository + specs E2ESpecResultRepository +} + +func NewGetLatestE2EComparisonUseCase(projects ProjectRepository, runs E2ETestRunRepository, specs E2ESpecResultRepository) *GetLatestE2EComparisonUseCase { + return &GetLatestE2EComparisonUseCase{projects: projects, runs: runs, specs: specs} +} + +func (uc *GetLatestE2EComparisonUseCase) Execute(ctx context.Context, projectID string) (IngestE2ERunOutput, error) { + project, err := uc.projects.GetByID(ctx, projectID) + if err != nil { + if errors.Is(err, domain.ErrNotFound) { + return IngestE2ERunOutput{}, NewNotFound("project not found", map[string]any{"projectId": projectID}) + } + return IngestE2ERunOutput{}, NewInternal("failed to load project", err) + } + + run, err := uc.runs.GetLatestByProject(ctx, projectID) + if err != nil { + if errors.Is(err, domain.ErrNotFound) { + return IngestE2ERunOutput{}, NewNotFound("no E2E runs found", map[string]any{"projectId": projectID}) + } + return IngestE2ERunOutput{}, NewInternal("failed to load latest E2E run", err) + } + + failedSpecs, err := uc.specs.ListFailedByRunID(ctx, run.ID) + if err != nil { + return IngestE2ERunOutput{}, NewInternal("failed to load failed specs", err) + } + + baselineRun, err := uc.runs.GetLatestByProjectAndBranch(ctx, projectID, project.DefaultBranch) + if err != nil && !errors.Is(err, domain.ErrNotFound) { + return IngestE2ERunOutput{}, NewInternal("failed to load baseline E2E run", err) + } + + var previousPassRate *float64 + newFailures := 0 + resolvedFailures := 0 + if err == nil && baselineRun.ID != run.ID { + prevRate := calculatePassRate(baselineRun.PassedSpecs, baselineRun.TotalSpecs) + previousPassRate = &prevRate + baselineFailed, bErr := uc.specs.ListFailedByRunID(ctx, baselineRun.ID) + if bErr != nil { + return IngestE2ERunOutput{}, NewInternal("failed to load baseline failed specs", bErr) + } + newFailures, resolvedFailures = compareFailedSpecs(failedE2ESpecsFromResults(baselineFailed), failedE2ESpecsFromResults(failedSpecs)) + } + + return IngestE2ERunOutput{ + Project: ProjectResponse{ + ID: project.ID, + ProjectKey: project.ProjectKey, + Name: project.Name, + DefaultBranch: project.DefaultBranch, + GlobalThresholdPercent: project.GlobalThresholdPercent, + Created: false, + }, + Run: e2eRunResponse(run), + Comparison: buildE2EComparison( + calculatePassRate(run.PassedSpecs, run.TotalSpecs), + previousPassRate, + newFailures, + resolvedFailures, + ), + FailedSpecs: failedE2ESpecsFromResults(failedSpecs), + }, nil +} + +type GetE2ERunUseCase struct { + runs E2ETestRunRepository + specs E2ESpecResultRepository +} + +func NewGetE2ERunUseCase(runs E2ETestRunRepository, specs E2ESpecResultRepository) *GetE2ERunUseCase { + return &GetE2ERunUseCase{runs: runs, specs: specs} +} + +func (uc *GetE2ERunUseCase) Execute(ctx context.Context, projectID string, runID string) (IngestE2ERunOutput, error) { + run, err := uc.runs.GetByID(ctx, projectID, runID) + if err != nil { + if errors.Is(err, domain.ErrNotFound) { + return IngestE2ERunOutput{}, NewNotFound("E2E run not found", map[string]any{"projectId": projectID, "runId": runID}) + } + return IngestE2ERunOutput{}, NewInternal("failed to load E2E run", err) + } + specs, err := uc.specs.ListByRunID(ctx, run.ID) + if err != nil { + return IngestE2ERunOutput{}, NewInternal("failed to load E2E spec results", err) + } + + return IngestE2ERunOutput{ + Run: e2eRunResponse(run), + Comparison: buildE2EComparison(calculatePassRate(run.PassedSpecs, run.TotalSpecs), nil, 0, 0), + FailedSpecs: failedE2ESpecsFromResults(specs), + }, nil +} + +// GetE2EHeatmapUseCase returns recent runs for all projects grouped by project group. + +type E2EHeatmapInput struct { + Branch string + Status string + RunsPerProject int +} + +type GetE2EHeatmapOutput struct { + Groups []HeatmapGroupItem `json:"groups"` +} + +type GetE2EHeatmapUseCase struct { + runs E2ETestRunRepository +} + +func NewGetE2EHeatmapUseCase(runs E2ETestRunRepository) *GetE2EHeatmapUseCase { + return &GetE2EHeatmapUseCase{runs: runs} +} + +func (uc *GetE2EHeatmapUseCase) Execute(ctx context.Context, in E2EHeatmapInput) (GetE2EHeatmapOutput, error) { + runsPerProject := in.RunsPerProject + if runsPerProject <= 0 { + runsPerProject = 10 + } + if runsPerProject > 30 { + runsPerProject = 30 + } + + status := strings.ToLower(strings.TrimSpace(in.Status)) + if status != "" && status != string(domain.E2ERunStatusPassed) && status != string(domain.E2ERunStatusFailed) { + return GetE2EHeatmapOutput{}, NewInvalidArgument("status must be passed or failed", map[string]any{"field": "status"}) + } + + rows, err := uc.runs.HeatmapData(ctx, in.Branch, status, runsPerProject) + if err != nil { + return GetE2EHeatmapOutput{}, NewInternal("failed to load heatmap data", err) + } + + // Rows arrive ordered: non-empty groups first (alpha), then empty group last, + // within each group projects alpha, within each project newest runs first. + // We preserve insertion order to match SQL ordering. + groupOrder := make([]string, 0) + groupSeen := make(map[string]bool) + projectOrder := make(map[string][]string) + projectSeen := make(map[string]bool) + projectMeta := make(map[string]HeatmapProjectItem) + + for _, row := range rows { + if !groupSeen[row.ProjectGroup] { + groupSeen[row.ProjectGroup] = true + groupOrder = append(groupOrder, row.ProjectGroup) + } + if !projectSeen[row.ProjectID] { + projectSeen[row.ProjectID] = true + projectOrder[row.ProjectGroup] = append(projectOrder[row.ProjectGroup], row.ProjectID) + projectMeta[row.ProjectID] = HeatmapProjectItem{ + ProjectID: row.ProjectID, + ProjectName: row.ProjectName, + ProjectKey: row.ProjectKey, + Runs: []HeatmapRunItem{}, + } + } + p := projectMeta[row.ProjectID] + p.Runs = append(p.Runs, HeatmapRunItem{ + ID: row.RunID, + Branch: row.Branch, + CommitSHA: row.CommitSHA, + RunTimestamp: row.RunTimestamp.UTC().Format(time.RFC3339), + PassRatePercent: calculatePassRate(row.PassedSpecs, row.TotalSpecs), + Status: row.Status, + Environment: row.Environment, + }) + projectMeta[row.ProjectID] = p + } + + groups := make([]HeatmapGroupItem, 0, len(groupOrder)) + for _, groupName := range groupOrder { + projectIDs := projectOrder[groupName] + projects := make([]HeatmapProjectItem, 0, len(projectIDs)) + for _, pid := range projectIDs { + projects = append(projects, projectMeta[pid]) + } + groups = append(groups, HeatmapGroupItem{ + GroupName: groupName, + Projects: projects, + }) + } + + return GetE2EHeatmapOutput{Groups: groups}, nil +} diff --git a/internal/application/ports.go b/internal/application/ports.go index f1dcd93..1fca483 100644 --- a/internal/application/ports.go +++ b/internal/application/ports.go @@ -29,7 +29,7 @@ type PackageCoverageRepository interface { ListByRunID(ctx context.Context, runID string) ([]domain.PackageCoverage, error) } -type IntegrationHeatmapRow struct { +type TestHeatmapRow struct { RunID string ProjectID string ProjectName string @@ -50,7 +50,7 @@ type IntegrationTestRunRepository interface { GetLatestByProject(ctx context.Context, projectID string) (domain.IntegrationTestRun, error) GetByID(ctx context.Context, projectID string, runID string) (domain.IntegrationTestRun, error) ListByProject(ctx context.Context, projectID string, branch string, status string, environment string, from *time.Time, to *time.Time, page int, pageSize int) ([]domain.IntegrationTestRun, int, error) - HeatmapData(ctx context.Context, branch string, status string, runsPerProject int) ([]IntegrationHeatmapRow, error) + HeatmapData(ctx context.Context, branch string, status string, runsPerProject int) ([]TestHeatmapRow, error) } type IntegrationSpecResultRepository interface { @@ -59,6 +59,21 @@ type IntegrationSpecResultRepository interface { ListFailedByRunID(ctx context.Context, runID string) ([]domain.IntegrationSpecResult, error) } +type E2ETestRunRepository interface { + Create(ctx context.Context, run domain.E2ETestRun) (domain.E2ETestRun, error) + GetLatestByProjectAndBranch(ctx context.Context, projectID string, branch string) (domain.E2ETestRun, error) + GetLatestByProject(ctx context.Context, projectID string) (domain.E2ETestRun, error) + GetByID(ctx context.Context, projectID string, runID string) (domain.E2ETestRun, error) + ListByProject(ctx context.Context, projectID string, branch string, status string, environment string, from *time.Time, to *time.Time, page int, pageSize int) ([]domain.E2ETestRun, int, error) + HeatmapData(ctx context.Context, branch string, status string, runsPerProject int) ([]TestHeatmapRow, error) +} + +type E2ESpecResultRepository interface { + CreateBatch(ctx context.Context, specs []domain.E2ESpecResult) error + ListByRunID(ctx context.Context, runID string) ([]domain.E2ESpecResult, error) + ListFailedByRunID(ctx context.Context, runID string) ([]domain.E2ESpecResult, error) +} + type APIKeyAuthenticator interface { Authenticate(ctx context.Context, apiKey string) error WantedAPIKey() string diff --git a/internal/domain/e2e.go b/internal/domain/e2e.go new file mode 100644 index 0000000..1e9bc55 --- /dev/null +++ b/internal/domain/e2e.go @@ -0,0 +1,66 @@ +package domain + +import "time" + +type E2ERunStatus string + +const ( + E2ERunStatusPassed E2ERunStatus = "passed" + E2ERunStatusFailed E2ERunStatus = "failed" +) + +type E2ESpecState string + +const ( + E2ESpecStatePassed E2ESpecState = "passed" + E2ESpecStateFailed E2ESpecState = "failed" + E2ESpecStateSkipped E2ESpecState = "skipped" + E2ESpecStatePending E2ESpecState = "pending" + E2ESpecStateFlaky E2ESpecState = "flaky" +) + +type E2ETestRun struct { + ID string + ProjectID string + Branch string + CommitSHA string + Author string + TriggerType string + RunTimestamp time.Time + FrameworkVersion string + TestFramework string + PlatformType string + SuiteDescription string + SuitePath string + TotalSpecs int + PassedSpecs int + FailedSpecs int + SkippedSpecs int + FlakedSpecs int + PendingSpecs int + Interrupted bool + TimedOut bool + DurationMS int64 + Status E2ERunStatus + Environment *string + CreatedAt time.Time +} + +type E2ESpecResult struct { + ID string + E2ETestRunID string + SpecPath string + LeafNodeText string + State E2ESpecState + DurationMS int64 + FailureMessage *string + FailureLocationFile *string + FailureLocationLine *int +} + +func EvaluateE2ERunStatus(failedSpecs int, interrupted bool, timedOut bool) E2ERunStatus { + if failedSpecs == 0 && !interrupted && !timedOut { + return E2ERunStatusPassed + } + return E2ERunStatusFailed +} diff --git a/migrations/003_e2e_test_runs.sql b/migrations/003_e2e_test_runs.sql new file mode 100644 index 0000000..8aed377 --- /dev/null +++ b/migrations/003_e2e_test_runs.sql @@ -0,0 +1,58 @@ +-- +goose Up + +CREATE TABLE IF NOT EXISTS e2e_test_runs ( + id UUID PRIMARY KEY, + project_id UUID NOT NULL REFERENCES projects(id), + branch TEXT NOT NULL, + commit_sha TEXT NOT NULL, + author TEXT, + trigger_type TEXT NOT NULL CHECK (trigger_type IN ('push', 'pr', 'manual')), + run_timestamp TIMESTAMPTZ NOT NULL, + framework_version TEXT, + test_framework TEXT, + platform TEXT -- either web, android, or ios + suite_description TEXT NOT NULL, + suite_path TEXT NOT NULL, + total_specs INTEGER NOT NULL, + passed_specs INTEGER NOT NULL, + failed_specs INTEGER NOT NULL, + skipped_specs INTEGER NOT NULL, + flaked_specs INTEGER NOT NULL, + pending_specs INTEGER NOT NULL, + interrupted BOOLEAN NOT NULL DEFAULT FALSE, + timed_out BOOLEAN NOT NULL DEFAULT FALSE, + duration_ms BIGINT NOT NULL, + status TEXT NOT NULL CHECK (status IN ('passed', 'failed')), + environment environment_type, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS e2e_test_runs_project_branch_ts_idx + ON e2e_test_runs(project_id, branch, run_timestamp DESC); + +CREATE INDEX IF NOT EXISTS e2e_test_runs_project_default_lookup_idx + ON e2e_test_runs(project_id, run_timestamp DESC); + +CREATE INDEX IF NOT EXISTS e2e_test_runs_project_status_ts_idx + ON e2e_test_runs(project_id, status, run_timestamp DESC); + +CREATE TABLE IF NOT EXISTS e2e_test_spec_results ( + id UUID PRIMARY KEY, + e2e_run_id UUID NOT NULL REFERENCES e2e_test_runs(id) ON DELETE CASCADE, + spec_path TEXT NOT NULL, + leaf_node_text TEXT NOT NULL, + state TEXT NOT NULL CHECK (state IN ('passed', 'failed', 'skipped', 'pending', 'flaky')), + duration_ms BIGINT NOT NULL, + failure_message TEXT, + failure_location_file TEXT, + failure_location_line INTEGER +); + +CREATE INDEX IF NOT EXISTS e2e_test_spec_results_run_id_idx ON e2e_test_spec_results(e2e_run_id); +CREATE INDEX IF NOT EXISTS e2e_test_spec_results_state_idx ON e2e_test_spec_results(state); + +-- +goose Down +DROP TABLE IF EXISTS e2e_test_spec_results; +DROP TABLE IF EXISTS e2e_test_runs; +DROP TYPE IF EXISTS environment_type; +DROP TYPE IF EXISTS platform_type; \ No newline at end of file From 4df5dc8c4dbd02f6dce8fe3a48942ed9460bea44 Mon Sep 17 00:00:00 2001 From: rsultana1418 Date: Wed, 3 Jun 2026 11:53:46 -0600 Subject: [PATCH 02/15] removed unused imports --- cmd/coveragecli/main.go | 1 - 1 file changed, 1 deletion(-) diff --git a/cmd/coveragecli/main.go b/cmd/coveragecli/main.go index 8c71b99..e2d558c 100644 --- a/cmd/coveragecli/main.go +++ b/cmd/coveragecli/main.go @@ -7,7 +7,6 @@ import ( "flag" "fmt" "io" - "log" "net/http" "os" "os/exec" From f054d0fa979c0f14fdb2a3c07e74d070f3661e02 Mon Sep 17 00:00:00 2001 From: rsultana1418 Date: Fri, 5 Jun 2026 10:33:24 -0600 Subject: [PATCH 03/15] added unit tests --- cmd/coveragecli/main.go | 5 + cmd/coveragecli/main_test.go | 200 +++ .../playwright-report-fail-dummy.json | 225 ++++ .../playwright-report-pass-dummy.json | 173 +++ internal/application/e2e_usecase.go | 20 +- internal/application/e2e_usecase_test.go | 1113 +++++++++++++++++ internal/application/mock_application.go | 127 ++ 7 files changed, 1853 insertions(+), 10 deletions(-) create mode 100644 cmd/coveragecli/testdata/playwright-report-fail-dummy.json create mode 100644 cmd/coveragecli/testdata/playwright-report-pass-dummy.json create mode 100644 internal/application/e2e_usecase_test.go create mode 100644 internal/application/mock_application.go diff --git a/cmd/coveragecli/main.go b/cmd/coveragecli/main.go index e2d558c..6c564eb 100644 --- a/cmd/coveragecli/main.go +++ b/cmd/coveragecli/main.go @@ -593,6 +593,7 @@ func normalizePlaywrightReport(raw map[string]any) map[string]any { // collectSpecs recursively walks Playwright's nested suite tree, // accumulating containerHierarchyTexts as it descends, and normalises each leaf spec + // Suites can be nested N level deep and leaf specs can be at any level, so we need to recurse fully to find all specs and get their full hierarchy. var collectSpecs func(suites []any, hierarchy []string) []map[string]any collectSpecs = func(suites []any, hierarchy []string) []map[string]any { var out []map[string]any @@ -603,6 +604,8 @@ func normalizePlaywrightReport(raw map[string]any) map[string]any { } title := firstString(suiteMap, "title") currentHierarchy := hierarchy + // appends hierarchy with current suite title if it exists + // coppies all elements from hierarchy into new slice to avoid mutating the original slice in recursive calls if title != "" { currentHierarchy = append(append([]string{}, hierarchy...), title) } @@ -616,6 +619,7 @@ func normalizePlaywrightReport(raw map[string]any) map[string]any { for _, specItem := range firstSlice(suiteMap, "specs") { specMap, ok := specItem.(map[string]any) if !ok { + fmt.Printf("warning: skipping spec with unexpected structure: %v\n", specItem) continue } @@ -662,6 +666,7 @@ func normalizePlaywrightReport(raw map[string]any) map[string]any { } } + // copies currentHierarchy into new slice to avoid mutating the original slice in recursive calls hierarchyCopy := make([]any, len(currentHierarchy)) for i, h := range currentHierarchy { hierarchyCopy[i] = h diff --git a/cmd/coveragecli/main_test.go b/cmd/coveragecli/main_test.go index a3e92ee..1b68754 100644 --- a/cmd/coveragecli/main_test.go +++ b/cmd/coveragecli/main_test.go @@ -205,3 +205,203 @@ func TestParseVitestSummary_FromDummyFixture_WithFilters(t *testing.T) { t.Fatalf("unexpected pkg[1]: %+v", pkgs[1]) } } + +func loadTestData(t *testing.T, filename string) map[string]any { + t.Helper() + data, err := os.ReadFile(filepath.Join("testdata", filename)) + if err != nil { + t.Fatalf("failed to read test data %s: %v", filename, err) + } + var result map[string]any + if err := json.Unmarshal(data, &result); err != nil { + t.Fatalf("failed to parse test data %s: %v", filename, err) + } + return result +} + +func assertHierarchy(t *testing.T, spec map[string]any, expected []string) { + t.Helper() + raw, ok := spec["containerHierarchyTexts"].([]any) + if !ok { + t.Fatalf("containerHierarchyTexts: expected []any, got %T", spec["containerHierarchyTexts"]) + } + if len(raw) != len(expected) { + t.Fatalf("hierarchy length: expected %d, got %d (%v)", len(expected), len(raw), raw) + } + for i, want := range expected { + got, ok := raw[i].(string) + if !ok { + t.Fatalf("hierarchy[%d]: expected string, got %T", i, raw[i]) + } + if got != want { + t.Fatalf("hierarchy[%d]: expected %q, got %q", i, want, got) + } + } +} +func TestNormalizePlaywrightReport(t *testing.T) { + t.Parallel() + + t.Run("normalizes report with all tests passed", func(t *testing.T) { + t.Parallel() + + raw := loadTestData(t, "playwright-report-pass-dummy.json") + result := normalizePlaywrightReport(raw) + + if result["suiteDescription"] != "first title" { + t.Fatalf("field %q: expected %q, got %q", "suiteDescription", "first title", result["suiteDescription"]) + } + if result["suitePath"] != "test/e2e" { + t.Fatalf("field %q: expected %q, got %q", "suitePath", "test/e2e", result["suitePath"]) + } + if result["suitePath"] != "test/e2e" { + t.Fatalf("field %q: expected %q, got %q", "suitePath", "test/e2e", result["suitePath"]) + } + if result["frameworkVersion"] != "1.58.2" { + t.Fatalf("field %q: expected %q, got %q", "frameworkVersion", "1.58.2", result["frameworkVersion"]) + } + if rt, ok := result["reportType"].(*string); !ok || *rt != "playwright" { + t.Fatalf("field %q: expected %q, got %v", "reportType", "playwright", result["reportType"]) + } + if tf, ok := result["testFramework"].(*string); !ok || *tf != "playwright" { + t.Fatalf("field %q: expected %q, got %v", "testFramework", "playwright", result["testFramework"]) + } + + specs, ok := result["specReports"].([]map[string]any) + if !ok { + t.Fatalf("specReports: expected []map[string]any, got %T", result["specReports"]) + } + if len(specs) != 3 { + t.Fatalf("expected 3 specs, got %d", len(specs)) + } + + // Spec 0: top-level suite "first title" > spec "first title" + if specs[0]["leafNodeText"] != "first title" { + t.Fatalf("spec[0].leafNodeText: expected %q, got %q", "first title", specs[0]["leafNodeText"]) + } + if specs[0]["state"] != "passed" { + t.Fatalf("spec[0].state: expected %q, got %q", "passed", specs[0]["state"]) + } + + assertHierarchy(t, specs[0], []string{"first title"}) + + // Spec 1: "title 2" > "Title 2" > spec "Title 3" + if specs[1]["leafNodeText"] != "Title 3" { + t.Fatalf("spec[1].leafNodeText: expected %q, got %q", "Title 3", specs[1]["leafNodeText"]) + } + if specs[1]["state"] != "passed" { + t.Fatalf("spec[1].state: expected %q, got %q", "passed", specs[1]["state"]) + } + assertHierarchy(t, specs[1], []string{"title 2", "Title 2"}) + + // Spec 2: "title 2" > "Title 2" > spec "Title 4" + if specs[2]["leafNodeText"] != "Title 4" { + t.Fatalf("spec[2].leafNodeText: expected %q, got %q", "Title 4", specs[2]["leafNodeText"]) + } + if specs[2]["state"] != "passed" { + t.Fatalf("spec[2].state: expected %q, got %q", "passed", specs[2]["state"]) + } + assertHierarchy(t, specs[2], []string{"title 2", "Title 2"}) + + // No failures on any spec + for i, spec := range specs { + if _, hasFailure := spec["failure"]; hasFailure { + t.Fatalf("spec[%d] should not have a failure block", i) + } + } + }) + + t.Run("normalizes report with failed test and strips ANSI", func(t *testing.T) { + t.Parallel() + + raw := loadTestData(t, "playwright-report-fail-dummy.json") + result := normalizePlaywrightReport(raw) + + if result["suiteDescription"] != "first title" { + t.Fatalf("field %q: expected %q, got %q", "suiteDescription", "first title", result["suiteDescription"]) + } + if result["suitePath"] != "test/e2e" { + t.Fatalf("field %q: expected %q, got %q", "suitePath", "test/e2e", result["suitePath"]) + } + if result["frameworkVersion"] != "1.58.2" { + t.Fatalf("field %q: expected %q, got %q", "frameworkVersion", "1.58.2", result["frameworkVersion"]) + } + if rt, ok := result["reportType"].(*string); !ok || *rt != "playwright" { + t.Fatalf("field %q: expected %q, got %v", "reportType", "playwright", result["reportType"]) + } + if tf, ok := result["testFramework"].(*string); !ok || *tf != "playwright" { + t.Fatalf("field %q: expected %q, got %v", "testFramework", "playwright", result["testFramework"]) + } + + specs, ok := result["specReports"].([]map[string]any) + if !ok { + t.Fatalf("specReports: expected []map[string]any, got %T", result["specReports"]) + } + if len(specs) != 3 { + t.Fatalf("expected 3 specs, got %d", len(specs)) + } + + // Spec 0: passed + if specs[0]["leafNodeText"] != "first title" { + t.Fatalf("spec[0].leafNodeText: expected %q, got %q", "first title", specs[0]["leafNodeText"]) + } + if specs[0]["state"] != "passed" { + t.Fatalf("spec[0].state: expected %q, got %q", "passed", specs[0]["state"]) + } + if _, hasFailure := specs[0]["failure"]; hasFailure { + t.Fatal("passed spec should not have failure") + } + + // Spec 1: passed nested + if specs[1]["leafNodeText"] != "Title 3" { + t.Fatalf("spec[1].leafNodeText: expected %q, got %q", "Title 3", specs[1]["leafNodeText"]) + } + if specs[1]["state"] != "passed" { + t.Fatalf("spec[1].state: expected %q, got %q", "passed", specs[1]["state"]) + } + assertHierarchy(t, specs[1], []string{"title 4", "Title 4"}) + + // Spec 2: failed with ANSI-stripped error + if specs[2]["leafNodeText"] != "title 5" { + t.Fatalf("spec[2].leafNodeText: expected %q, got %q", "title 5", specs[2]["leafNodeText"]) + } + if specs[2]["state"] != "failed" { + t.Fatalf("spec[2].state: expected %q, got %q", "failed", specs[2]["state"]) + } + assertHierarchy(t, specs[2], []string{"title 4", "Title 4"}) + + failureRaw, hasFailure := specs[2]["failure"] + if !hasFailure { + t.Fatal("failed spec should have a failure block") + } + failure := failureRaw.(map[string]any) + msg := failure["message"].(string) + if msg != "Test timeout of 150000ms exceeded." { + t.Fatalf("expected ANSI-stripped message, got %q", msg) + } + }) +} + +func TestStripANSI(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input string + want string + }{ + {"strips color codes", "\x1b[31mError\x1b[39m", "Error"}, + {"strips multiple codes", "\x1b[31m\x1b[1mBold Red\x1b[22m\x1b[39m", "Bold Red"}, + {"no-op on clean string", "clean message", "clean message"}, + {"empty string", "", ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := stripANSI(tt.input) + if got != tt.want { + t.Fatalf("stripANSI(%q) = %q, want %q", tt.input, got, tt.want) + } + }) + } +} diff --git a/cmd/coveragecli/testdata/playwright-report-fail-dummy.json b/cmd/coveragecli/testdata/playwright-report-fail-dummy.json new file mode 100644 index 0000000..e4a0050 --- /dev/null +++ b/cmd/coveragecli/testdata/playwright-report-fail-dummy.json @@ -0,0 +1,225 @@ +{ + "config": { + "configFile": "test/playwright.config.ts", + "rootDir": "test/e2e", + "forbidOnly": false, + "fullyParallel": true, + "globalSetup": null, + "globalTeardown": "test/e2e/global-teardown.ts", + "globalTimeout": 0, + "grep": {}, + "grepInvert": null, + "maxFailures": 0, + "metadata": { + "actualWorkers": 6 + }, + "preserveOutput": "always", + "quiet": false, + "reporter": [ + [ + "json" + ] + ], + "reportSlowTests": { + "max": 5, + "threshold": 300000 + }, + "runAgents": "none", + "shard": null, + "tags": [], + "updateSnapshots": "missing", + "updateSourceMethod": "patch", + "version": "1.58.2", + "workers": 6 + }, + "suites": [ + { + "title": "first title", + "file": "file1", + "column": 0, + "line": 0, + "specs": [ + { + "title": "first title", + "ok": true, + "tags": [], + "tests": [ + { + "timeout": 150000, + "annotations": [], + "expectedStatus": "passed", + "projectId": "setup", + "projectName": "setup", + "results": [ + { + "workerIndex": 0, + "parallelIndex": 0, + "status": "passed", + "duration": 92, + "errors": [], + "stdout": [], + "stderr": [], + "retry": 0, + "startTime": "2026-05-21T20:23:50.219Z", + "annotations": [], + "attachments": [] + } + ], + "status": "expected" + } + ], + "id": "TestId", + "file": "file1", + "line": 33, + "column": 1 + } + ] + }, + { + "title": "title 4", + "file": "file4", + "column": 0, + "line": 0, + "specs": [], + "suites": [ + { + "title": "Title 4", + "file": "file4", + "line": 33, + "column": 6, + "specs": [ + { + "title": "Title 3", + "ok": true, + "tags": [], + "tests": [ + { + "timeout": 150000, + "annotations": [], + "expectedStatus": "passed", + "projectId": "happyPath", + "projectName": "happyPath", + "results": [ + { + "workerIndex": 1, + "parallelIndex": 0, + "status": "passed", + "duration": 1319, + "errors": [], + "stdout": [], + "stderr": [], + "retry": 0, + "startTime": "2026-05-21T20:23:51.292Z", + "annotations": [], + "attachments": [] + } + ], + "status": "expected" + } + ], + "id": "TestId", + "file": "file3", + "line": 46, + "column": 3 + }, + { + "title": "title 5", + "ok": false, + "tags": [], + "tests": [ + { + "timeout": 150000, + "annotations": [], + "expectedStatus": "passed", + "projectId": "happyPath", + "projectName": "happyPath", + "results": [ + { + "workerIndex": 2, + "parallelIndex": 1, + "status": "timedOut", + "duration": 150277, + "error": { + "message": "\u001b[31mTest timeout of 150000ms exceeded.\u001b[39m", + "stack": "\u001b[31mTest timeout of 150000ms exceeded.\u001b[39m" + }, + "errors": [ + { + "message": "\u001b[31mTest timeout of 150000ms exceeded.\u001b[39m" + }, + { + "location": { + "file": "/Users/rsultana/Documents/projects/customer-portal-v2/e2e/features/createProject/specs/cphappyPath.spec.ts", + "column": 76, + "line": 158 + }, + "message": "Error: \u001b[2mexpect(\u001b[22m\u001b[31mlocator\u001b[39m\u001b[2m).\u001b[22mtoHaveAttribute\u001b[2m(\u001b[22m\u001b[32mexpected\u001b[39m\u001b[2m)\u001b[22m failed\n\nLocator: getByTestId('plan-card-details').first()\nExpected: \u001b[32m\"\u001b[7mfals\u001b[27me\"\u001b[39m\nReceived: \u001b[31m\"\u001b[7mtru\u001b[27me\"\u001b[39m\n\nCall log:\n\u001b[2m - Expect \"toHaveAttribute\" with timeout 150000ms\u001b[22m\n\u001b[2m - waiting for getByTestId('plan-card-details').first()\u001b[22m\n\u001b[2m 109 × locator resolved to
\u001b[22m\n\u001b[2m - unexpected value \"true\"\u001b[22m\n\n\n\u001b[0m \u001b[90m 156 |\u001b[39m \u001b[90m// Collapse the plan card again.\u001b[39m\n \u001b[90m 157 |\u001b[39m \u001b[36mawait\u001b[39m selectors\u001b[33m.\u001b[39mpage3\u001b[33m.\u001b[39mtoggleDetailsButton(createProjectFlowPage)\u001b[33m.\u001b[39mclick()\u001b[33m;\u001b[39m\n\u001b[31m\u001b[1m>\u001b[22m\u001b[39m\u001b[90m 158 |\u001b[39m \u001b[36mawait\u001b[39m expect(selectors\u001b[33m.\u001b[39mpage3\u001b[33m.\u001b[39mplanCardDetails(createProjectFlowPage))\u001b[33m.\u001b[39mtoHaveAttribute(\u001b[32m'data-expanded'\u001b[39m\u001b[33m,\u001b[39m \u001b[32m'false'\u001b[39m)\u001b[33m;\u001b[39m\n \u001b[90m |\u001b[39m \u001b[31m\u001b[1m^\u001b[22m\u001b[39m\n \u001b[90m 159 |\u001b[39m })\u001b[33m;\u001b[39m\n \u001b[90m 160 |\u001b[39m\n \u001b[90m 161 |\u001b[39m \u001b[90m// Step 4: Select the second plan then verify review plan details before submitting.\u001b[39m\u001b[0m\n\u001b[2m at /Users/rsultana/Documents/projects/customer-portal-v2/e2e/features/createProject/specs/cphappyPath.spec.ts:158:76\u001b[22m\n\u001b[2m at /Users/rsultana/Documents/projects/customer-portal-v2/e2e/features/createProject/specs/cphappyPath.spec.ts:146:5\u001b[22m" + } + ], + "stdout": [], + "stderr": [], + "retry": 0, + "steps": [ + { + "title": "Verify plan card details expand/collapse with flights and stays", + "duration": 60 + }, + { + "title": "Verify flight drawer opens with correct flight details and closes", + "duration": 108353, + "error": { + "message": "Error: \u001b[2mexpect(\u001b[22m\u001b[31mlocator\u001b[39m\u001b[2m).\u001b[22mtoHaveAttribute\u001b[2m(\u001b[22m\u001b[32mexpected\u001b[39m\u001b[2m)\u001b[22m failed\n\nLocator: getByTestId('plan-card-details').first()\nExpected: \u001b[32m\"\u001b[7mfals\u001b[27me\"\u001b[39m\nReceived: \u001b[31m\"\u001b[7mtru\u001b[27me\"\u001b[39m\n\nCall log:\n\u001b[2m - Expect \"toHaveAttribute\" with timeout 150000ms\u001b[22m\n\u001b[2m - waiting for getByTestId('plan-card-details').first()\u001b[22m\n\u001b[2m 109 × locator resolved to
\u001b[22m\n\u001b[2m - unexpected value \"true\"\u001b[22m\n", + "stack": "Error: \u001b[2mexpect(\u001b[22m\u001b[31mlocator\u001b[39m\u001b[2m).\u001b[22mtoHaveAttribute\u001b[2m(\u001b[22m\u001b[32mexpected\u001b[39m\u001b[2m)\u001b[22m failed\n\nLocator: getByTestId('plan-card-details').first()\nExpected: \u001b[32m\"\u001b[7mfals\u001b[27me\"\u001b[39m\nReceived: \u001b[31m\"\u001b[7mtru\u001b[27me\"\u001b[39m\n\nCall log:\n\u001b[2m - Expect \"toHaveAttribute\" with timeout 150000ms\u001b[22m\n\u001b[2m - waiting for getByTestId('plan-card-details').first()\u001b[22m\n\u001b[2m 109 × locator resolved to
\u001b[22m\n\u001b[2m - unexpected value \"true\"\u001b[22m\n\n at /Users/rsultana/Documents/projects/customer-portal-v2/e2e/features/createProject/specs/cphappyPath.spec.ts:158:76\n at /Users/rsultana/Documents/projects/customer-portal-v2/e2e/features/createProject/specs/cphappyPath.spec.ts:146:5", + "location": { + "file": "/Users/rsultana/Documents/projects/customer-portal-v2/e2e/features/createProject/specs/cphappyPath.spec.ts", + "column": 76, + "line": 158 + }, + "snippet": "\u001b[0m \u001b[90m 156 |\u001b[39m \u001b[90m// Collapse the plan card again.\u001b[39m\n \u001b[90m 157 |\u001b[39m \u001b[36mawait\u001b[39m selectors\u001b[33m.\u001b[39mpage3\u001b[33m.\u001b[39mtoggleDetailsButton(createProjectFlowPage)\u001b[33m.\u001b[39mclick()\u001b[33m;\u001b[39m\n\u001b[31m\u001b[1m>\u001b[22m\u001b[39m\u001b[90m 158 |\u001b[39m \u001b[36mawait\u001b[39m expect(selectors\u001b[33m.\u001b[39mpage3\u001b[33m.\u001b[39mplanCardDetails(createProjectFlowPage))\u001b[33m.\u001b[39mtoHaveAttribute(\u001b[32m'data-expanded'\u001b[39m\u001b[33m,\u001b[39m \u001b[32m'false'\u001b[39m)\u001b[33m;\u001b[39m\n \u001b[90m |\u001b[39m \u001b[31m\u001b[1m^\u001b[22m\u001b[39m\n \u001b[90m 159 |\u001b[39m })\u001b[33m;\u001b[39m\n \u001b[90m 160 |\u001b[39m\n \u001b[90m 161 |\u001b[39m \u001b[90m// Step 4: Select the second plan then verify review plan details before submitting.\u001b[39m\u001b[0m" + } + } + ], + "startTime": "2026-05-21T20:23:54.493Z", + "annotations": [], + "attachments": [ + { + "name": "screenshot", + "contentType": "image/png", + "path": "/Users/rsultana/Documents/projects/customer-portal-v2/test-results/createProject-specs-cphapp-a083e-ultiple-departing-locations-happyPath/test-failed-1.png" + }, + { + "name": "video", + "contentType": "video/webm", + "path": "/Users/rsultana/Documents/projects/customer-portal-v2/test-results/createProject-specs-cphapp-a083e-ultiple-departing-locations-happyPath/video.webm" + }, + { + "name": "error-context", + "contentType": "text/markdown", + "path": "/Users/rsultana/Documents/projects/customer-portal-v2/test-results/createProject-specs-cphapp-a083e-ultiple-departing-locations-happyPath/error-context.md" + } + ] + } + ], + "status": "unexpected" + } + ], + "id": "4684ba4c3bb84464791f-7d29adc0735ca1f5b8a0", + "file": "features/createProject/specs/cphappyPath.spec.ts", + "line": 81, + "column": 3 + } + ] + } + ] + } + ], + "errors": [], + "stats": { + "startTime": "2026-05-21T20:23:49.756Z", + "duration": 155054.089, + "expected": 14, + "skipped": 0, + "unexpected": 1, + "flaky": 0 + } +} \ No newline at end of file diff --git a/cmd/coveragecli/testdata/playwright-report-pass-dummy.json b/cmd/coveragecli/testdata/playwright-report-pass-dummy.json new file mode 100644 index 0000000..b2c18a7 --- /dev/null +++ b/cmd/coveragecli/testdata/playwright-report-pass-dummy.json @@ -0,0 +1,173 @@ +{ + "config": { + "configFile": "test/playwright.config.ts", + "rootDir": "test/e2e", + "forbidOnly": false, + "fullyParallel": true, + "globalSetup": null, + "globalTeardown": "test/e2e/global-teardown.ts", + "globalTimeout": 0, + "grep": {}, + "grepInvert": null, + "maxFailures": 0, + "metadata": { + "actualWorkers": 6 + }, + "preserveOutput": "always", + "quiet": false, + "reporter": [ + [ + "json" + ] + ], + "reportSlowTests": { + "max": 5, + "threshold": 300000 + }, + "runAgents": "none", + "shard": null, + "tags": [], + "updateSnapshots": "missing", + "updateSourceMethod": "patch", + "version": "1.58.2", + "workers": 6 + }, + "suites": [ + { + "title": "first title", + "file": "file1", + "column": 0, + "line": 0, + "specs": [ + { + "title": "first title", + "ok": true, + "tags": [], + "tests": [ + { + "timeout": 150000, + "annotations": [], + "expectedStatus": "passed", + "projectId": "setup", + "projectName": "setup", + "results": [ + { + "workerIndex": 0, + "parallelIndex": 0, + "status": "passed", + "duration": 92, + "errors": [], + "stdout": [], + "stderr": [], + "retry": 0, + "startTime": "2026-05-21T20:23:50.219Z", + "annotations": [], + "attachments": [] + } + ], + "status": "expected" + } + ], + "id": "TestId", + "file": "file1", + "line": 33, + "column": 1 + } + ] + }, + { + "title": "title 2", + "file": "file2", + "column": 0, + "line": 0, + "specs": [], + "suites": [ + { + "title": "Title 2", + "file": "file2", + "line": 33, + "column": 6, + "specs": [ + { + "title": "Title 3", + "ok": true, + "tags": [], + "tests": [ + { + "timeout": 150000, + "annotations": [], + "expectedStatus": "passed", + "projectId": "happyPath", + "projectName": "happyPath", + "results": [ + { + "workerIndex": 1, + "parallelIndex": 0, + "status": "passed", + "duration": 1319, + "errors": [], + "stdout": [], + "stderr": [], + "retry": 0, + "startTime": "2026-05-21T20:23:51.292Z", + "annotations": [], + "attachments": [] + } + ], + "status": "expected" + } + ], + "id": "TestId", + "file": "file3", + "line": 46, + "column": 3 + }, + { + "title": "Title 4", + "ok": true, + "tags": [], + "tests": [ + { + "timeout": 150000, + "annotations": [], + "expectedStatus": "passed", + "projectId": "happyPath", + "projectName": "happyPath", + "results": [ + { + "workerIndex": 2, + "parallelIndex": 1, + "status": "passed", + "duration": 2586, + "errors": [], + "stdout": [], + "stderr": [], + "retry": 0, + "startTime": "2026-05-21T20:23:51.307Z", + "annotations": [], + "attachments": [] + } + ], + "status": "expected" + } + ], + "id": "TestId", + "file": "file3", + "line": 56, + "column": 3 + } + ] + } + ] + } + ], + "errors": [], + "stats": { + "startTime": "2026-05-21T20:23:49.756Z", + "duration": 155054.089, + "expected": 14, + "skipped": 0, + "unexpected": 1, + "flaky": 0 + } +} \ No newline at end of file diff --git a/internal/application/e2e_usecase.go b/internal/application/e2e_usecase.go index e56d63b..511de61 100644 --- a/internal/application/e2e_usecase.go +++ b/internal/application/e2e_usecase.go @@ -4,7 +4,6 @@ import ( "context" "errors" "fmt" - "log" "sort" "strings" "time" @@ -177,6 +176,7 @@ func (uc *IngestE2ERunUseCase) Execute(ctx context.Context, in IngestE2ERunInput ID: project.ID, ProjectKey: project.ProjectKey, Name: project.Name, + Group: project.Group, DefaultBranch: project.DefaultBranch, GlobalThresholdPercent: project.GlobalThresholdPercent, Created: created, @@ -194,7 +194,6 @@ func (uc *IngestE2ERunUseCase) Execute(ctx context.Context, in IngestE2ERunInput func (uc *IngestE2ERunUseCase) resolveOrCreateE2EProject(ctx context.Context, in IngestE2ERunInput) (domain.Project, bool, error) { project, err := uc.projects.GetByKey(ctx, in.ProjectKey) - log.Printf("resolveOrCreateE2EProject: GetByKey result: project=%+v, err=%v\n", project, err) if err == nil { return project, false, nil } @@ -347,12 +346,12 @@ func validateE2EIngestInput(in IngestE2ERunInput) error { if err := domain.ValidateTriggerType(in.TriggerType); err != nil { return NewInvalidArgument(err.Error(), map[string]any{"field": "triggerType"}) } - // if strings.TrimSpace(in.TestReport.SuiteDescription) == "" { - // return NewInvalidArgument("testReport.suiteDescription is required", map[string]any{"field": "testReport.suiteDescription"}) - // } - // if strings.TrimSpace(in.TestReport.SuitePath) == "" { - // return NewInvalidArgument("testReport.suitePath is required", map[string]any{"field": "testReport.suitePath"}) - // } + if strings.TrimSpace(in.TestReport.SuiteDescription) == "" { + return NewInvalidArgument("testReport.suiteDescription is required", map[string]any{"field": "testReport.suiteDescription"}) + } + if strings.TrimSpace(in.TestReport.SuitePath) == "" { + return NewInvalidArgument("testReport.suitePath is required", map[string]any{"field": "testReport.suitePath"}) + } if strings.TrimSpace(in.TestReport.FrameworkVersion) == "" { return NewInvalidArgument("testReport.frameworkVersion is required", map[string]any{"field": "testReport.frameworkVersion"}) } @@ -516,8 +515,8 @@ func (uc *ListE2ERunsUseCase) Execute(ctx context.Context, in ListE2ERunsInput) return ListE2ERunsOutput{}, NewInvalidArgument("status must be passed or failed", map[string]any{"field": "status"}) } environment := strings.ToLower(strings.TrimSpace(in.Environment)) - if environment != "" && environment != "test" && environment != "stage" && environment != "prod" && environment != "none" { - return ListE2ERunsOutput{}, NewInvalidArgument("environment must be one of: test, stage, prod, none", map[string]any{"field": "environment"}) + if environment != "" && environment != "test" && environment != "stage" && environment != "prod" { + return ListE2ERunsOutput{}, NewInvalidArgument("environment must be one of: test, stage, prod", map[string]any{"field": "environment"}) } runs, total, err := uc.runs.ListByProject(ctx, in.ProjectID, in.Branch, status, environment, in.From, in.To, page, pageSize) @@ -611,6 +610,7 @@ func (uc *GetLatestE2EComparisonUseCase) Execute(ctx context.Context, projectID ID: project.ID, ProjectKey: project.ProjectKey, Name: project.Name, + Group: project.Group, DefaultBranch: project.DefaultBranch, GlobalThresholdPercent: project.GlobalThresholdPercent, Created: false, diff --git a/internal/application/e2e_usecase_test.go b/internal/application/e2e_usecase_test.go new file mode 100644 index 0000000..a43a29b --- /dev/null +++ b/internal/application/e2e_usecase_test.go @@ -0,0 +1,1113 @@ +package application + +import ( + "context" + "errors" + "fmt" + "testing" + "time" + + "github.com/arxdsilva/opencoverage/internal/domain" +) + +func validE2EIngestInput() IngestE2ERunInput { + return IngestE2ERunInput{ + ProjectKey: "test/project", + ProjectName: "test-project", + ProjectGroup: StringPtr("frontend"), + DefaultBranch: "main", + Branch: "main", + CommitSHA: "abc123", + Author: "John", + TriggerType: "pr", + RunTimestamp: "2026-04-01T12:00:00Z", + Environment: StringPtr("test"), + TestReport: IngestReportBody{ + ReportType: "playwright", + TestFramework: "playwright", + FrameworkVersion: "1.0.0", + PlatformType: "web", + SuiteDescription: "E2E Tests", + SuitePath: "tests/e2e", + SuiteSucceeded: true, + SpecialSuiteFailureReasons: []string{"Auth Failure"}, + SpecReports: []IngestSpecReport{ + { + LeafNodeText: "Auth", + ContainerHierarchyTexts: []string{"Auth Failure"}, + State: "Passed", + RunTime: 2.00, + Failure: &IngestTestFailure{ + Message: "Auth Failure", + Location: &IngestTestLocation{ + FileName: "Auth", + LineNumber: 10, + }, + }, + }, + }, + }, + } +} + +func StringPtr(s string) *string { + return &s +} + +func TestIngestE2ERunUseCaseExecute(t *testing.T) { + runInput := IngestE2ERunInput{ + ProjectKey: "test/project", + ProjectName: "test-project", + ProjectGroup: StringPtr("frontend"), + DefaultBranch: "main", + Branch: "main", + CommitSHA: "abc123", + Author: "John", + TriggerType: "pr", + RunTimestamp: "2026-04-01T12:00:00Z", + Environment: StringPtr("test"), + TestReport: IngestReportBody{ + ReportType: "playwright", + TestFramework: "playwright", + FrameworkVersion: "1.0.0", + PlatformType: "web", + SuiteDescription: "E2E Tests", + SuitePath: "tests/e2e", + SuiteSucceeded: true, + SpecialSuiteFailureReasons: []string{"Auth Failure"}, + SpecReports: []IngestSpecReport{ + { + LeafNodeText: "Auth", + ContainerHierarchyTexts: []string{"Auth Failure"}, + State: "Passed", + RunTime: 2.00, + Failure: &IngestTestFailure{ + Message: "Auth Failure", + Location: &IngestTestLocation{ + FileName: "Auth", + LineNumber: 10, + }, + }, + }, + }, + }, + } + t.Run("Execute run with correct data", func(t *testing.T) { + projectRepo := &stubProjectRepository{ + existing: &domain.Project{ + ID: "proj1", + ProjectKey: "test/project", + Name: "test-project", + Group: StringPtr("frontend"), + DefaultBranch: "main", + GlobalThresholdPercent: 80, + CreatedAt: time.Date(2026, 4, 1, 12, 0, 0, 0, time.UTC), + UpdatedAt: time.Date(2026, 4, 1, 12, 0, 0, 0, time.UTC), + }, + } + runRepo := &stubE2ETestRunRepository{} + specResultRepo := &stubE2ESpecResultRepository{} + transactionManager := &stubTransactionManager{} + id := &stubIDGenerator{} + clock := &stubClock{} + uc := NewIngestE2ERunUseCase(projectRepo, runRepo, specResultRepo, transactionManager, id, clock) + + out, err := uc.Execute(context.Background(), runInput) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if out.Run.ID == "" { + t.Fatalf("expected run ID to be set") + } + if out.Project.ProjectKey != "test/project" { + t.Fatalf("expected project key to be test/project, got %s", out.Project.ProjectKey) + } + if out.Project.Name != "test-project" { + t.Fatalf("expected project name to be test-project, got %s", out.Project.Name) + } + if out.Project.Group == nil || *out.Project.Group != "frontend" { + t.Fatalf("expected project group to be frontend, got %v", out.Project.Group) + } + if out.Project.DefaultBranch != "main" { + t.Fatalf("expected default branch to be main, got %s", out.Project.DefaultBranch) + } + if out.Run.Branch != "main" { + t.Fatalf("expected run branch to be main, got %s", out.Run.Branch) + } + if out.Run.CommitSHA != "abc123" { + t.Fatalf("expected commit SHA to be abc123, got %s", out.Run.CommitSHA) + } + if out.Run.Author != "John" { + t.Fatalf("expected author to be John, got %s", out.Run.Author) + } + if out.Run.Branch != "main" { + t.Fatalf("expected branch to be main, got %s", out.Run.Branch) + } + if out.Run.Branch != "main" { + t.Fatalf("expected branch to be main, got %s", out.Run.Branch) + } + if out.Run.CommitSHA != "abc123" { + t.Fatalf("expected commit SHA to be abc123, got %s", out.Run.CommitSHA) + } + if out.Run.Author != "John" { + t.Fatalf("expected author to be John, got %s", out.Run.Author) + } + if out.Run.TriggerType != "pr" { + t.Fatalf("expected trigger type to be pr, got %s", out.Run.TriggerType) + } + if out.Run.Environment == nil || *out.Run.Environment != "test" { + t.Fatalf("expected environment to be test, got %v", out.Run.Environment) + } + if out.Run.TotalSpecs != 1 { + t.Fatalf("expected total specs to be 1, got %d", out.Run.TotalSpecs) + } + if out.Run.PassedSpecs != 1 { + t.Fatalf("expected passed specs to be 1, got %d", out.Run.PassedSpecs) + } + if out.Run.FailedSpecs != 0 { + t.Fatalf("expected failed specs to be 0, got %d", out.Run.FailedSpecs) + } + if out.Run.FlakedSpecs != 0 { + t.Fatalf("expected flaked specs to be 0, got %d", out.Run.FlakedSpecs) + } + if out.Run.SkippedSpecs != 0 { + t.Fatalf("expected skipped specs to be 0, got %d", out.Run.SkippedSpecs) + } + if out.Run.PassRatePercent != 100 { + t.Fatalf("expected pass rate percent to be 100, got %f", out.Run.PassRatePercent) + } + if out.Run.Status != "passed" { + t.Fatalf("expected run status to be passed, got %s", out.Run.Status) + } + if out.Comparison.BaselineSource != "latest_default_branch" { + t.Fatalf("expected baseline source to be latest_default_branch, got %s", out.Comparison.BaselineSource) + } + if out.Comparison.CurrentPassRatePercent != 100 { + t.Fatalf("expected current pass rate percent to be 100, got %f", out.Comparison.CurrentPassRatePercent) + } + if out.Comparison.PreviousPassRatePercent != nil { + t.Fatalf("expected previous pass rate percent to be nil, got %f", *out.Comparison.PreviousPassRatePercent) + } + if out.Comparison.DeltaPercent != nil { + t.Fatalf("expected delta percent to be nil, got %f", *out.Comparison.DeltaPercent) + } + if out.Comparison.Direction != "new" { + t.Fatalf("expected direction to be new, got %s", out.Comparison.Direction) + } + + }) + + t.Run("Execute return an error when validation fails", func(t *testing.T) { + projectRepo := &stubProjectRepository{} + runRepo := &stubE2ETestRunRepository{} + specResultRepo := &stubE2ESpecResultRepository{} + transactionManager := &stubTransactionManager{} + id := &stubIDGenerator{} + clock := &stubClock{} + uc := NewIngestE2ERunUseCase(projectRepo, runRepo, specResultRepo, transactionManager, id, clock) + _, err := uc.Execute(context.Background(), IngestE2ERunInput{}) + if err == nil { + t.Fatalf("expected error, got nil") + } + var appErr *AppError + if !errors.As(err, &appErr) { + t.Fatalf("expected AppError, got %T", err) + } + if appErr.Code != CodeInvalidArgument { + t.Fatalf("expected code to be invalid_argument, got %s", appErr.Code) + } + }) + + t.Run("Execute return an error when run time parse fails", func(t *testing.T) { + ProjectRepo := &stubProjectRepository{existing: &domain.Project{ID: "proj1"}, err: nil} + runRepo := &stubE2ETestRunRepository{} + specResultRepo := &stubE2ESpecResultRepository{} + transactionManager := &stubTransactionManager{} + id := &stubIDGenerator{} + clock := &stubClock{} + uc := NewIngestE2ERunUseCase(ProjectRepo, runRepo, specResultRepo, transactionManager, id, clock) + input := runInput + input.RunTimestamp = "invalid-timestamp" + _, err := uc.Execute(context.Background(), input) + if err == nil { + t.Fatalf("expected error, got nil") + } + var appErr *AppError + if !errors.As(err, &appErr) { + t.Fatalf("expected AppError, got %T", err) + } + }) + + t.Run("Execute return an error when create run fails", func(t *testing.T) { + projectRepo := &stubProjectRepository{existing: nil, err: fmt.Errorf("failed to create project"), project: domain.Project{}} + runRepo := &stubE2ETestRunRepository{} + specResultRepo := &stubE2ESpecResultRepository{} + transactionManager := &stubTransactionManager{} + id := &stubIDGenerator{} + clock := &stubClock{} + uc := NewIngestE2ERunUseCase(projectRepo, runRepo, specResultRepo, transactionManager, id, clock) + _, err := uc.Execute(context.Background(), runInput) + if err == nil { + t.Fatalf("expected error, got nil") + } + var appErr *AppError + if !errors.As(err, &appErr) { + t.Fatalf("expected AppError, got %T", err) + } + if appErr.Code != CodeInternal { + t.Fatalf("expected code to be internal, got %s", appErr.Code) + } + }) + + t.Run("Execute return and error when get latest run by branch fails", func(t *testing.T) { + ProjectRepo := &stubProjectRepository{existing: &domain.Project{ID: "proj1"}, err: nil} + runRepo := &stubE2ETestRunRepository{latestByBranchErr: fmt.Errorf("failed to get latest run by branch")} + specResultRepo := &stubE2ESpecResultRepository{} + transactionManager := &stubTransactionManager{} + id := &stubIDGenerator{} + clock := &stubClock{} + uc := NewIngestE2ERunUseCase(ProjectRepo, runRepo, specResultRepo, transactionManager, id, clock) + _, err := uc.Execute(context.Background(), runInput) + if err == nil { + t.Fatalf("expected error, got nil") + } + var appErr *AppError + if !errors.As(err, &appErr) { + t.Fatalf("expected AppError, got %T", err) + } + if appErr.Code != CodeInternal { + t.Fatalf("expected code to be internal, got %s", appErr.Code) + } + }) +} + +func TestIngestE2ERunResolveOrCreateE2EProject(t *testing.T) { + t.Run("Resolve existing project", func(t *testing.T) { + projectRepo := &stubProjectRepository{ + existing: &domain.Project{ + ID: "proj1", + ProjectKey: "test/project", + Name: "test-project", + Group: StringPtr("frontend"), + DefaultBranch: "main", + GlobalThresholdPercent: 80, + CreatedAt: time.Date(2026, 4, 1, 12, 0, 0, 0, time.UTC), + UpdatedAt: time.Date(2026, 4, 1, 12, 0, 0, 0, time.UTC), + }, + } + uc := NewIngestE2ERunUseCase(projectRepo, nil, nil, nil, nil, nil) + project, created, err := uc.resolveOrCreateE2EProject(context.Background(), IngestE2ERunInput{ProjectKey: "test/project"}) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if project != *projectRepo.existing { + t.Fatalf("expected project to be %+v, got %+v", *projectRepo.existing, project) + } + if created { + t.Fatalf("expected created to be false, got true") + } + }) + t.Run("Create project when do not exist", func(t *testing.T) { + projectRepo := &stubProjectRepository{ + existing: nil, + err: nil, + } + uc := NewIngestE2ERunUseCase(projectRepo, nil, nil, nil, &stubIDGenerator{}, &stubClock{}) + project, created, err := uc.resolveOrCreateE2EProject(context.Background(), IngestE2ERunInput{ + ProjectKey: "test/project", + ProjectName: "test-project", + ProjectGroup: StringPtr("frontend"), + DefaultBranch: "main", + }) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if !created { + t.Fatalf("expected created to be true, got false") + } + if project.ProjectKey != "test/project" { + t.Fatalf("expected project key test/project, got %s", project.ProjectKey) + } + if project.Name != "test-project" { + t.Fatalf("expected name test-project, got %s", project.Name) + } + if project.Group == nil || *project.Group != "frontend" { + t.Fatalf("expected group frontend, got %v", project.Group) + } + if project.DefaultBranch != "main" { + t.Fatalf("expected default branch main, got %s", project.DefaultBranch) + } + if project.ID == "" { + t.Fatal("expected project ID to be set") + } + }) + t.Run("Returns error when failed to load prpoject", func(t *testing.T) { + projectRepo := &stubProjectRepository{ + existing: nil, + err: fmt.Errorf("failed to load project"), + } + uc := NewIngestE2ERunUseCase(projectRepo, nil, nil, nil, &stubIDGenerator{}, &stubClock{}) + _, _, err := uc.resolveOrCreateE2EProject(context.Background(), IngestE2ERunInput{ProjectKey: "test/project"}) + if err == nil { + t.Fatalf("expected error, got nil") + } + }) + t.Run("Returns error when failed to create a project", func(t *testing.T) { + projectRep := &stubProjectRepository{ + existing: nil, + err: fmt.Errorf("failed to create project"), + } + uc := NewIngestE2ERunUseCase(projectRep, nil, nil, nil, &stubIDGenerator{}, &stubClock{}) + _, _, err := uc.resolveOrCreateE2EProject(context.Background(), IngestE2ERunInput{ + ProjectKey: "test/project", + ProjectName: "test-project", + ProjectGroup: StringPtr("frontend"), + DefaultBranch: "main", + }) + if err == nil { + t.Fatalf("expected error, got nil") + } + }) +} + +func TestIngestE2ERunBuildE2EEntities(t *testing.T) { + runInput := IngestE2ERunInput{ + ProjectKey: "test/project", + ProjectName: "test-project", + ProjectGroup: StringPtr("frontend"), + DefaultBranch: "main", + Branch: "main", + CommitSHA: "abc123", + Author: "John", + TriggerType: "pr", + RunTimestamp: "2026-04-01T12:00:00Z", + Environment: StringPtr("test"), + TestReport: IngestReportBody{ + ReportType: "playwright", + TestFramework: "playwright", + FrameworkVersion: "1.0.0", + PlatformType: "web", + SuiteDescription: "E2E Tests", + SuitePath: "tests/e2e", + SuiteSucceeded: true, + SpecialSuiteFailureReasons: []string{"Auth Failure"}, + SpecReports: []IngestSpecReport{ + { + LeafNodeText: "Auth", + ContainerHierarchyTexts: []string{"Auth Failure"}, + State: "Passed", + RunTime: 2.00, + Failure: &IngestTestFailure{ + Message: "Auth Failure", + Location: &IngestTestLocation{ + FileName: "Auth", + LineNumber: 10, + }, + }, + }, + }, + }, + } + + t.Run("Build the e2e entities with correct data", func(t *testing.T) { + projectRep := &stubProjectRepository{ + existing: nil, + project: domain.Project{ + ProjectKey: "test/project", + Name: "test-project", + Group: StringPtr("frontend"), + DefaultBranch: "main", + ID: "project-id", + }, + } + uc := NewIngestE2ERunUseCase(projectRep, nil, nil, nil, &stubIDGenerator{}, &stubClock{}) + run, specs := uc.buildE2EEntities("project-id", runInput, time.Date(2026, 4, 1, 12, 0, 0, 0, time.UTC)) + + if run.ProjectID != "project-id" { + t.Fatalf("expected project ID project-id, got %s", run.ProjectID) + } + if run.Branch != "main" { + t.Fatalf("expected branch main, got %s", run.Branch) + } + if run.CommitSHA != "abc123" { + t.Fatalf("expected commit SHA abc123, got %s", run.CommitSHA) + } + if run.Author != "John" { + t.Fatalf("expected author John, got %s", run.Author) + } + if run.TriggerType != "pr" { + t.Fatalf("expected trigger type pr, got %s", run.TriggerType) + } + if run.FrameworkVersion != "1.0.0" { + t.Fatalf("expected framework version 1.0.0, got %s", run.FrameworkVersion) + } + if run.TestFramework != "playwright" { + t.Fatalf("expected test framework playwright, got %s", run.TestFramework) + } + if run.PlatformType != "web" { + t.Fatalf("expected platform type web, got %s", run.PlatformType) + } + if run.SuiteDescription != "E2E Tests" { + t.Fatalf("expected suite description E2E Tests, got %s", run.SuiteDescription) + } + if run.SuitePath != "tests/e2e" { + t.Fatalf("expected suite path tests/e2e, got %s", run.SuitePath) + } + if run.TotalSpecs != 1 { + t.Fatalf("expected total specs 1, got %d", run.TotalSpecs) + } + if run.PassedSpecs != 1 { + t.Fatalf("expected passed specs 1, got %d", run.PassedSpecs) + } + if run.FailedSpecs != 0 { + t.Fatalf("expected failed specs 0, got %d", run.FailedSpecs) + } + if run.Status != domain.E2ERunStatusPassed { + t.Fatalf("expected status passed, got %s", run.Status) + } + if run.DurationMS != 2000 { + t.Fatalf("expected duration 2000ms, got %d", run.DurationMS) + } + if run.ID == "" { + t.Fatal("expected run ID to be set") + } + + if len(specs) != 1 { + t.Fatalf("expected 1 spec, got %d", len(specs)) + } + if specs[0].LeafNodeText != "Auth" { + t.Fatalf("expected leaf node text Auth, got %s", specs[0].LeafNodeText) + } + if specs[0].SpecPath != "Auth Failure > Auth" { + t.Fatalf("expected spec path 'Auth Failure > Auth', got %s", specs[0].SpecPath) + } + if specs[0].State != domain.E2ESpecStatePassed { + t.Fatalf("expected state passed, got %s", specs[0].State) + } + if specs[0].DurationMS != 2000 { + t.Fatalf("expected duration 2000ms, got %d", specs[0].DurationMS) + } + if specs[0].E2ETestRunID != run.ID { + t.Fatalf("expected spec run ID %s, got %s", run.ID, specs[0].E2ETestRunID) + } + }) +} + +func TestValidateE2EIngestInput(t *testing.T) { + test := []struct { + name string + mutate func(in *IngestE2ERunInput) + wantErr bool + wantField string + }{ + { + name: "valid input passes", + mutate: func(in *IngestE2ERunInput) {}, + wantErr: false, + }, + { + name: "empty projectKey returns error", + mutate: func(in *IngestE2ERunInput) { in.ProjectKey = "" }, + wantErr: true, + wantField: "projectKey", + }, + { + name: "empty branch returns error", + mutate: func(in *IngestE2ERunInput) { in.Branch = "" }, + wantErr: true, + wantField: "branch", + }, + { + name: "empty commitSha returns error", + mutate: func(in *IngestE2ERunInput) { in.CommitSHA = "" }, + wantErr: true, + wantField: "commitSha", + }, + { + name: "invalid triggerType returns error", + mutate: func(in *IngestE2ERunInput) { in.TriggerType = "invalid" }, + wantErr: true, + wantField: "triggerType", + }, + { + name: "empty frameworkVersion returns error", + mutate: func(in *IngestE2ERunInput) { in.TestReport.FrameworkVersion = "" }, + wantErr: true, + wantField: "testReport.frameworkVersion", + }, + { + name: "empty testFramework returns error", + mutate: func(in *IngestE2ERunInput) { in.TestReport.TestFramework = "" }, + wantErr: true, + wantField: "testReport.testFramework", + }, + { + name: "empty platformType returns error", + mutate: func(in *IngestE2ERunInput) { in.TestReport.PlatformType = "" }, + wantErr: true, + wantField: "testReport.platformType", + }, + { + name: "empty specReports returns error", + mutate: func(in *IngestE2ERunInput) { in.TestReport.SpecReports = nil }, + wantErr: true, + wantField: "testReport.specReports", + }, + { + name: "invalid spec state returns error", + mutate: func(in *IngestE2ERunInput) { + in.TestReport.SpecReports[0].State = "invalid" + }, + wantErr: true, + wantField: "testReport.specReports[0].state", + }, + { + name: "negative runTime returns error", + mutate: func(in *IngestE2ERunInput) { + in.TestReport.SpecReports[0].RunTime = -1 + }, + wantErr: true, + wantField: "testReport.specReports[0].runTime", + }, + { + name: "failed state without failure message returns error", + mutate: func(in *IngestE2ERunInput) { + in.TestReport.SpecReports[0].State = "failed" + in.TestReport.SpecReports[0].Failure = nil + }, + wantErr: true, + wantField: "testReport.specReports[0].failure.message", + }, + } + for _, tt := range test { + t.Run(tt.name, func(t *testing.T) { + in := validE2EIngestInput() + tt.mutate(&in) + err := validateE2EIngestInput(in) + if tt.wantErr && err == nil { + t.Error("expected error, got nil") + } + if !tt.wantErr && err != nil { + t.Errorf("expected no error, got %v", err) + } + if tt.wantErr && tt.wantField != "" { + appErr, ok := err.(*AppError) + if !ok { + t.Fatalf("expected *AppError, got %T", err) + } + field, _ := appErr.Details["field"].(string) + if field != tt.wantField { + t.Errorf("expected field=%q, got %q", tt.wantField, field) + } + } + }) + } +} + +func TestListE2ERunsExecute(t *testing.T) { + listed := []domain.E2ETestRun{ + { + ID: "run1", + ProjectID: "proj1", + Branch: "main", + CommitSHA: "abc123", + Author: "John", + TriggerType: "pr", + RunTimestamp: time.Date(2026, 4, 1, 12, 0, 0, 0, time.UTC), + FrameworkVersion: "1.0.0", + TestFramework: "playwright", + PlatformType: "web", + SuiteDescription: "e2e test", + SuitePath: "tests/e2e", + TotalSpecs: 10, + PassedSpecs: 10, + FailedSpecs: 0, + FlakedSpecs: 0, + SkippedSpecs: 0, + Interrupted: false, + TimedOut: false, + DurationMS: 2000, + Status: "passed", + Environment: StringPtr("test"), + CreatedAt: time.Date(2026, 4, 1, 12, 0, 0, 0, time.UTC), + }, + { + ID: "run2", + ProjectID: "proj2", + Branch: "main", + CommitSHA: "abc123", + Author: "Alex", + TriggerType: "pr", + RunTimestamp: time.Date(2026, 4, 1, 12, 0, 0, 0, time.UTC), + FrameworkVersion: "1.0.0", + TestFramework: "playwright", + PlatformType: "web", + SuiteDescription: "e2e test", + SuitePath: "tests/e2e", + TotalSpecs: 10, + PassedSpecs: 10, + FailedSpecs: 0, + FlakedSpecs: 0, + SkippedSpecs: 0, + Interrupted: false, + TimedOut: false, + DurationMS: 2000, + Status: "passed", + Environment: StringPtr("test"), + CreatedAt: time.Date(2026, 4, 1, 12, 0, 0, 0, time.UTC), + }, + { + ID: "run3", + ProjectID: "proj3", + Branch: "main", + CommitSHA: "abc123", + Author: "Alice", + TriggerType: "pr", + RunTimestamp: time.Date(2026, 4, 1, 12, 0, 0, 0, time.UTC), + FrameworkVersion: "1.0.0", + TestFramework: "playwright", + PlatformType: "web", + SuiteDescription: "e2e test", + SuitePath: "tests/e2e", + TotalSpecs: 10, + PassedSpecs: 9, + FailedSpecs: 1, + FlakedSpecs: 0, + SkippedSpecs: 0, + Interrupted: false, + TimedOut: false, + DurationMS: 2000, + Status: "failed", + Environment: StringPtr("test"), + CreatedAt: time.Date(2026, 4, 1, 12, 0, 0, 0, time.UTC)}, + } + t.Run("List runs with correct data", func(t *testing.T) { + from := time.Date(2026, 4, 1, 12, 0, 0, 0, time.UTC).Add(-time.Hour) + to := time.Date(2026, 4, 1, 12, 0, 0, 0, time.UTC).Add(time.Hour) + runRepo := &stubE2ETestRunRepository{listed: listed, listTotal: 3} + lc := NewListE2ERunsUseCase(runRepo) + out, err := lc.Execute(context.Background(), ListE2ERunsInput{ + ProjectID: "proj1", + Branch: "main", + Status: "passed", + Environment: "test", + From: &from, + To: &to, + Page: 1, + PageSize: 3, + }) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if len(out.Items) != 3 { + t.Fatalf("expected 3 items, got %d", len(out.Items)) + } + if out.Items[0].ID != "run1" { + t.Fatalf("expected first item ID run1, got %s", out.Items[0].ID) + } + if out.Items[0].Branch != "main" { + t.Fatalf("expected branch main, got %s", out.Items[0].Branch) + } + if out.Items[0].CommitSHA != "abc123" { + t.Fatalf("expected commit SHA abc123, got %s", out.Items[0].CommitSHA) + } + if out.Items[0].RunTimestamp != "2026-04-01T12:00:00Z" { + t.Fatalf("expected run timestamp 2026-04-01T12:00:00Z, got %s", out.Items[0].RunTimestamp) + } + if out.Items[0].Environment == nil || *out.Items[0].Environment != "test" { + t.Fatalf("expected environment test, got %v", out.Items[0].Environment) + } + if out.Items[0].TotalSpecs != 10 { + t.Fatalf("expected total specs 10, got %d", out.Items[0].TotalSpecs) + } + if out.Items[0].FailedSpecs != 0 { + t.Fatalf("expected failed specs 0, got %d", out.Items[0].FailedSpecs) + } + if out.Items[0].PassRatePercent != 100 { + t.Fatalf("expected pass rate percent 100, got %f", out.Items[0].PassRatePercent) + } + if out.Items[0].Status != "passed" { + t.Fatalf("expected status passed, got %s", out.Items[0].Status) + } + if out.Items[2].ID != "run3" { + t.Fatalf("expected third item ID run3, got %s", out.Items[2].ID) + } + if out.Items[2].FailedSpecs != 1 { + t.Fatalf("expected failed specs 1, got %d", out.Items[2].FailedSpecs) + } + if out.Items[2].PassRatePercent != 90 { + t.Fatalf("expected pass rate percent 90, got %f", out.Items[2].PassRatePercent) + } + if out.Items[2].Status != "failed" { + t.Fatalf("expected status failed, got %s", out.Items[2].Status) + } + if out.Pagination.Page != 1 { + t.Fatalf("expected page 1, got %d", out.Pagination.Page) + } + if out.Pagination.PageSize != 3 { + t.Fatalf("expected page size 3, got %d", out.Pagination.PageSize) + } + if out.Pagination.TotalItems != 3 { + t.Fatalf("expected total items 3, got %d", out.Pagination.TotalItems) + } + if out.Pagination.TotalPages != 1 { + t.Fatalf("expected total pages 1, got %d", out.Pagination.TotalPages) + } + }) + t.Run("Returns an error when status is not passed or failed", func(t *testing.T) { + runRepo := &stubE2ETestRunRepository{} + uc := NewListE2ERunsUseCase(runRepo) + _, err := uc.Execute(context.Background(), ListE2ERunsInput{ + ProjectID: "proj1", + Status: "invalid", + }) + if err == nil { + t.Fatalf("expected error, got nil") + } + var appErr *AppError + if !errors.As(err, &appErr) { + t.Fatalf("expected AppError, got %T", err) + } + if appErr.Code != CodeInvalidArgument { + t.Fatalf("expected code to be INVALID_ARGUMENT, got %s", appErr.Code) + } + field, _ := appErr.Details["field"].(string) + if field != "status" { + t.Fatalf("expected field status, got %s", field) + } + }) + t.Run("Returns an error when environment is not test, stage or prod", func(t *testing.T) { + runRepo := &stubE2ETestRunRepository{} + uc := NewListE2ERunsUseCase(runRepo) + _, err := uc.Execute(context.Background(), ListE2ERunsInput{ + ProjectID: "proj1", + Environment: "invalid", + }) + if err == nil { + t.Fatalf("expected error, got nil") + } + var appErr *AppError + if !errors.As(err, &appErr) { + t.Fatalf("expected AppError, got %T", err) + } + if appErr.Code != CodeInvalidArgument { + t.Fatalf("expected code to be INVALID_ARGUMENT, got %s", appErr.Code) + } + field, _ := appErr.Details["field"].(string) + if field != "environment" { + t.Fatalf("expected field environment, got %s", field) + } + }) + t.Run("Returns an error when fails to list the runs", func(t *testing.T) { + runRepo := &stubE2ETestRunRepository{listErr: fmt.Errorf("db error")} + uc := NewListE2ERunsUseCase(runRepo) + _, err := uc.Execute(context.Background(), ListE2ERunsInput{ + ProjectID: "proj1", + }) + if err == nil { + t.Fatalf("expected error, got nil") + } + var appErr *AppError + if !errors.As(err, &appErr) { + t.Fatalf("expected AppError, got %T", err) + } + if appErr.Code != CodeInternal { + t.Fatalf("expected code to be INTERNAL, got %s", appErr.Code) + } + }) +} + +func TestGetLatestE2EComparisonExecute(t *testing.T) { + t.Run("Execute successfully with baseline comparison", func(t *testing.T) { + projectRepo := &stubProjectRepository{ + project: domain.Project{ + ID: "proj1", + ProjectKey: "test/project", + Name: "test-project", + Group: StringPtr("frontend"), + DefaultBranch: "main", + GlobalThresholdPercent: 80, + CreatedAt: time.Date(2026, 4, 1, 12, 0, 0, 0, time.UTC), + UpdatedAt: time.Date(2026, 4, 1, 12, 0, 0, 0, time.UTC), + }, + } + runRepo := &stubE2ETestRunRepository{ + latestByProject: &domain.E2ETestRun{ + ID: "run1", + ProjectID: "proj1", + Branch: "feature", + CommitSHA: "abc123", + Author: "John", + TriggerType: "pr", + RunTimestamp: time.Date(2026, 4, 1, 12, 0, 0, 0, time.UTC), + FrameworkVersion: "1.0.0", + TestFramework: "playwright", + PlatformType: "web", + SuiteDescription: "e2e test", + SuitePath: "tests/e2e", + TotalSpecs: 10, + PassedSpecs: 9, + FailedSpecs: 1, + FlakedSpecs: 0, + SkippedSpecs: 0, + Interrupted: false, + TimedOut: false, + DurationMS: 2000, + Status: domain.E2ERunStatusFailed, + Environment: StringPtr("test"), + CreatedAt: time.Date(2026, 4, 1, 12, 0, 0, 0, time.UTC), + }, + latestByBranch: &domain.E2ETestRun{ + ID: "run2", + ProjectID: "proj1", + Branch: "main", + CommitSHA: "def456", + Author: "John", + TriggerType: "push", + RunTimestamp: time.Date(2026, 4, 2, 12, 0, 0, 0, time.UTC), + FrameworkVersion: "1.0.0", + TestFramework: "playwright", + PlatformType: "web", + SuiteDescription: "e2e test", + SuitePath: "tests/e2e", + TotalSpecs: 10, + PassedSpecs: 10, + FailedSpecs: 0, + FlakedSpecs: 0, + SkippedSpecs: 0, + Interrupted: false, + TimedOut: false, + DurationMS: 2000, + Status: domain.E2ERunStatusPassed, + Environment: StringPtr("test"), + CreatedAt: time.Date(2026, 4, 2, 12, 0, 0, 0, time.UTC), + }, + } + specRepo := &stubE2ESpecResultRepository{ + failedByRunID: []domain.E2ESpecResult{ + { + ID: "spec1", + E2ETestRunID: "run1", + SpecPath: "tests/e2e/auth.spec.ts", + LeafNodeText: "should login", + State: domain.E2ESpecStateFailed, + DurationMS: 1000, + FailureMessage: StringPtr("auth failed"), + }, + }, + } + uc := NewGetLatestE2EComparisonUseCase(projectRepo, runRepo, specRepo) + + out, err := uc.Execute(context.Background(), "proj1") + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if out.Project.ID != "proj1" { + t.Fatalf("expected project ID proj1, got %s", out.Project.ID) + } + if out.Project.ProjectKey != "test/project" { + t.Fatalf("expected project key test/project, got %s", out.Project.ProjectKey) + } + if out.Run.ID != "run1" { + t.Fatalf("expected run ID run1, got %s", out.Run.ID) + } + if out.Run.PassRatePercent != 90 { + t.Fatalf("expected pass rate 90, got %f", out.Run.PassRatePercent) + } + if out.Run.Status != "failed" { + t.Fatalf("expected status failed, got %s", out.Run.Status) + } + if out.Comparison.BaselineSource != "latest_default_branch" { + t.Fatalf("expected baseline source latest_default_branch, got %s", out.Comparison.BaselineSource) + } + if out.Comparison.CurrentPassRatePercent != 90 { + t.Fatalf("expected current pass rate 90, got %f", out.Comparison.CurrentPassRatePercent) + } + if out.Comparison.PreviousPassRatePercent == nil { + t.Fatal("expected previous pass rate to be set") + } + if *out.Comparison.PreviousPassRatePercent != 100 { + t.Fatalf("expected previous pass rate 100, got %f", *out.Comparison.PreviousPassRatePercent) + } + if out.Comparison.DeltaPercent == nil { + t.Fatal("expected delta to be set") + } + if *out.Comparison.DeltaPercent != -10 { + t.Fatalf("expected delta -10, got %f", *out.Comparison.DeltaPercent) + } + if out.Comparison.Direction != "down" { + t.Fatalf("expected direction down, got %s", out.Comparison.Direction) + } + if out.Comparison.NewFailures != 0 { + t.Fatalf("expected new failures 0, got %d", out.Comparison.NewFailures) + } + if out.Comparison.ResolvedFailures != 0 { + t.Fatalf("expected resolved failures 0, got %d", out.Comparison.ResolvedFailures) + } + if len(out.FailedSpecs) != 1 { + t.Fatalf("expected 1 failed spec, got %d", len(out.FailedSpecs)) + } + if out.FailedSpecs[0].SpecPath != "tests/e2e/auth.spec.ts" { + t.Fatalf("expected spec path tests/e2e/auth.spec.ts, got %s", out.FailedSpecs[0].SpecPath) + } + if out.FailedSpecs[0].FailureMessage != "auth failed" { + t.Fatalf("expected failure message auth failed, got %s", out.FailedSpecs[0].FailureMessage) + } + }) + t.Run("Return an error when failure to get project", func(t *testing.T) { + projectRepo := &stubProjectRepository{err: fmt.Errorf("db error")} + runRepo := &stubE2ETestRunRepository{} + specRepo := &stubE2ESpecResultRepository{} + uc := NewGetLatestE2EComparisonUseCase(projectRepo, runRepo, specRepo) + + _, err := uc.Execute(context.Background(), "proj1") + if err == nil { + t.Fatalf("expected error, got nil") + } + var appErr *AppError + if !errors.As(err, &appErr) { + t.Fatalf("expected AppError, got %T", err) + } + if appErr.Code != CodeInternal { + t.Fatalf("expected code to be INTERNAL, got %s", appErr.Code) + } + }) + t.Run("Return an error when failure to get latest by project", func(t *testing.T) { + projectRepo := &stubProjectRepository{ + project: domain.Project{ID: "proj1", DefaultBranch: "main"}, + } + runRepo := &stubE2ETestRunRepository{latestByProjectErr: fmt.Errorf("db error")} + specRepo := &stubE2ESpecResultRepository{} + uc := NewGetLatestE2EComparisonUseCase(projectRepo, runRepo, specRepo) + + _, err := uc.Execute(context.Background(), "proj1") + if err == nil { + t.Fatalf("expected error, got nil") + } + var appErr *AppError + if !errors.As(err, &appErr) { + t.Fatalf("expected AppError, got %T", err) + } + if appErr.Code != CodeInternal { + t.Fatalf("expected code to be INTERNAL, got %s", appErr.Code) + } + }) + t.Run("Return an error when failure to get latest by project and branch", func(t *testing.T) { + projectRepo := &stubProjectRepository{ + project: domain.Project{ID: "proj1", DefaultBranch: "main"}, + } + runRepo := &stubE2ETestRunRepository{ + latestByProject: &domain.E2ETestRun{ + ID: "run1", + ProjectID: "proj1", + TotalSpecs: 10, + PassedSpecs: 9, + FailedSpecs: 1, + Status: domain.E2ERunStatusFailed, + }, + latestByBranchErr: fmt.Errorf("db error"), + } + specRepo := &stubE2ESpecResultRepository{} + uc := NewGetLatestE2EComparisonUseCase(projectRepo, runRepo, specRepo) + + _, err := uc.Execute(context.Background(), "proj1") + if err == nil { + t.Fatalf("expected error, got nil") + } + var appErr *AppError + if !errors.As(err, &appErr) { + t.Fatalf("expected AppError, got %T", err) + } + if appErr.Code != CodeInternal { + t.Fatalf("expected code to be INTERNAL, got %s", appErr.Code) + } + }) + t.Run("Return an error when failure to list failed by run ID", func(t *testing.T) { + projectRepo := &stubProjectRepository{ + project: domain.Project{ID: "proj1", DefaultBranch: "main"}, + } + runRepo := &stubE2ETestRunRepository{ + latestByProject: &domain.E2ETestRun{ + ID: "run1", + ProjectID: "proj1", + TotalSpecs: 10, + PassedSpecs: 9, + FailedSpecs: 1, + Status: domain.E2ERunStatusFailed, + }, + } + specRepo := &stubE2ESpecResultRepository{failedByRunIDErr: fmt.Errorf("db error")} + uc := NewGetLatestE2EComparisonUseCase(projectRepo, runRepo, specRepo) + + _, err := uc.Execute(context.Background(), "proj1") + if err == nil { + t.Fatalf("expected error, got nil") + } + var appErr *AppError + if !errors.As(err, &appErr) { + t.Fatalf("expected AppError, got %T", err) + } + if appErr.Code != CodeInternal { + t.Fatalf("expected code to be INTERNAL, got %s", appErr.Code) + } + }) +} + +func TestGetE2EHeatmapExecute(t *testing.T) { + t.Run("Execute heatmap successfully", func(t *testing.T) { + uc := NewGetE2EHeatmapUseCase(&stubE2ETestRunRepository{ + heatmapRows: []TestHeatmapRow{ + { + RunID: "abc123", + ProjectID: "Proj1", + ProjectName: "project-1", + ProjectGroup: "frontend", + ProjectKey: "project/project-1", + Branch: "main", + CommitSHA: "abc123", + RunTimestamp: time.Date(2026, 4, 1, 12, 0, 0, 0, time.UTC), + PassedSpecs: 1, + TotalSpecs: 2, + Status: "failed", + Environment: StringPtr("test"), + }, + }, + }) + out, err := uc.Execute(context.Background(), E2EHeatmapInput{ + Branch: "main", + Status: "failed", + RunsPerProject: 10, + }) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + fmt.Println(out.Groups) + if len(out.Groups) != 1 { + t.Fatalf("expected 1 group, got %d", len(out.Groups)) + } + if out.Groups[0].GroupName != "frontend" { + t.Fatalf("expected group name frontend, got %s", out.Groups[0].GroupName) + } + if out.Groups[0].Projects[0].ProjectName != "project-1" { + t.Fatalf("expected project name project-1, got %s", out.Groups[0].Projects[0].ProjectName) + } + if out.Groups[0].Projects[0].ProjectKey != "project/project-1" { + t.Fatalf("expected project key project/project-1, got %s", out.Groups[0].Projects[0].ProjectKey) + } + if out.Groups[0].Projects[0].Runs[0].ID != "abc123" { + t.Fatalf("expected run ID abc123, got %s", out.Groups[0].Projects[0].Runs[0].ID) + } + if out.Groups[0].Projects[0].Runs[0].PassRatePercent != 50 { + t.Fatalf("expected pass rate percent 50, got %f", out.Groups[0].Projects[0].Runs[0].PassRatePercent) + } + if out.Groups[0].Projects[0].Runs[0].Environment == nil || *out.Groups[0].Projects[0].Runs[0].Environment != "test" { + t.Fatalf("expected environment test, got %v", out.Groups[0].Projects[0].Runs[0].Environment) + } + if out.Groups[0].Projects[0].Runs[0].RunTimestamp != "2026-04-01T12:00:00Z" { + t.Fatalf("expected run timestamp 2026-04-01T12:00:00Z, got %s", out.Groups[0].Projects[0].Runs[0].RunTimestamp) + } + if out.Groups[0].Projects[0].Runs[0].Status != "failed" { + t.Fatalf("expected status failed, got %s", out.Groups[0].Projects[0].Runs[0].Status) + } + }) +} diff --git a/internal/application/mock_application.go b/internal/application/mock_application.go new file mode 100644 index 0000000..0c28523 --- /dev/null +++ b/internal/application/mock_application.go @@ -0,0 +1,127 @@ +package application + +import ( + "context" + "time" + + "github.com/arxdsilva/opencoverage/internal/domain" +) + +// --- E2ETestRunRepository stub --- + +type stubE2ETestRunRepository struct { + created domain.E2ETestRun + createErr error + + latestByBranch *domain.E2ETestRun + latestByBranchErr error + + latestByProject *domain.E2ETestRun + latestByProjectErr error + + byID *domain.E2ETestRun + byIDErr error + + listed []domain.E2ETestRun + listTotal int + listErr error + + heatmapRows []TestHeatmapRow + heatmapErr error + + // captured args for assertions + capturedBranch string + capturedStatus string +} + +func (s *stubE2ETestRunRepository) Create(ctx context.Context, run domain.E2ETestRun) (domain.E2ETestRun, error) { + if s.createErr != nil { + return domain.E2ETestRun{}, s.createErr + } + s.created = run + return run, nil +} + +func (s *stubE2ETestRunRepository) GetLatestByProjectAndBranch(ctx context.Context, projectID string, branch string) (domain.E2ETestRun, error) { + s.capturedBranch = branch + if s.latestByBranchErr != nil { + return domain.E2ETestRun{}, s.latestByBranchErr + } + if s.latestByBranch == nil { + return domain.E2ETestRun{}, domain.ErrNotFound + } + return *s.latestByBranch, nil +} + +func (s *stubE2ETestRunRepository) GetLatestByProject(ctx context.Context, projectID string) (domain.E2ETestRun, error) { + if s.latestByProjectErr != nil { + return domain.E2ETestRun{}, s.latestByProjectErr + } + if s.latestByProject == nil { + return domain.E2ETestRun{}, domain.ErrNotFound + } + return *s.latestByProject, nil +} + +func (s *stubE2ETestRunRepository) GetByID(ctx context.Context, projectID string, runID string) (domain.E2ETestRun, error) { + if s.byIDErr != nil { + return domain.E2ETestRun{}, s.byIDErr + } + if s.byID == nil { + return domain.E2ETestRun{}, domain.ErrNotFound + } + return *s.byID, nil +} + +func (s *stubE2ETestRunRepository) ListByProject(ctx context.Context, projectID string, branch string, status string, environment string, from *time.Time, to *time.Time, page int, pageSize int) ([]domain.E2ETestRun, int, error) { + s.capturedBranch = branch + s.capturedStatus = status + if s.listErr != nil { + return nil, 0, s.listErr + } + return s.listed, s.listTotal, nil +} + +func (s *stubE2ETestRunRepository) HeatmapData(ctx context.Context, branch string, status string, runsPerProject int) ([]TestHeatmapRow, error) { + s.capturedBranch = branch + s.capturedStatus = status + if s.heatmapErr != nil { + return nil, s.heatmapErr + } + return s.heatmapRows, nil +} + +// --- E2ESpecResultRepository stub --- + +type stubE2ESpecResultRepository struct { + createBatchErr error + createdSpecs []domain.E2ESpecResult + + byRunID []domain.E2ESpecResult + byRunIDErr error + + failedByRunID []domain.E2ESpecResult + failedByRunIDErr error +} + +func (s *stubE2ESpecResultRepository) CreateBatch(ctx context.Context, specs []domain.E2ESpecResult) error { + if s.createBatchErr != nil { + return s.createBatchErr + } + s.createdSpecs = specs + return nil +} + +func (s *stubE2ESpecResultRepository) ListByRunID(ctx context.Context, runID string) ([]domain.E2ESpecResult, error) { + if s.byRunIDErr != nil { + return nil, s.byRunIDErr + } + return s.byRunID, nil +} + +func (s *stubE2ESpecResultRepository) ListFailedByRunID(ctx context.Context, runID string) ([]domain.E2ESpecResult, error) { + if s.failedByRunIDErr != nil { + return nil, s.failedByRunIDErr + } + return s.failedByRunID, nil +} \ No newline at end of file From ec43874ae5ba575a91bf6a3dda210f7964e0d9da Mon Sep 17 00:00:00 2001 From: rsultana1418 Date: Fri, 5 Jun 2026 12:40:01 -0600 Subject: [PATCH 04/15] fixed typo errors --- cmd/coveragecli/main.go | 8 +++++--- internal/adapters/postgres/e2e_spec_result_repository.go | 2 +- internal/adapters/postgres/e2e_test_run_repository.go | 6 +++--- migrations/003_e2e_test_runs.sql | 6 +++--- 4 files changed, 12 insertions(+), 10 deletions(-) diff --git a/cmd/coveragecli/main.go b/cmd/coveragecli/main.go index 6c564eb..91b4587 100644 --- a/cmd/coveragecli/main.go +++ b/cmd/coveragecli/main.go @@ -457,7 +457,7 @@ func runE2EUpload(args []string) { var report map[string]any if err := json.Unmarshal(rawReport, &report); err != nil { - exitErr("parse e2e report json", err) + exitErr("parse e2e report json", err) } var group *string @@ -597,6 +597,7 @@ func normalizePlaywrightReport(raw map[string]any) map[string]any { var collectSpecs func(suites []any, hierarchy []string) []map[string]any collectSpecs = func(suites []any, hierarchy []string) []map[string]any { var out []map[string]any + // iterates over all the suites branches at the current level for _, item := range suites { suiteMap, ok := item.(map[string]any) if !ok { @@ -610,7 +611,9 @@ func normalizePlaywrightReport(raw map[string]any) map[string]any { currentHierarchy = append(append([]string{}, hierarchy...), title) } - // Recurse into nested suites first. + // Recurse into nested suites leaves first. + // as the suites can be nested N level deep + // uses recursive calls to collect all leaf specs if nested := firstSlice(suiteMap, "suites"); len(nested) > 0 { out = append(out, collectSpecs(nested, currentHierarchy)...) } @@ -686,7 +689,6 @@ func normalizePlaywrightReport(raw map[string]any) map[string]any { } return out } - result["specReports"] = collectSpecs(suites, nil) return result } diff --git a/internal/adapters/postgres/e2e_spec_result_repository.go b/internal/adapters/postgres/e2e_spec_result_repository.go index f019147..bfa9da7 100644 --- a/internal/adapters/postgres/e2e_spec_result_repository.go +++ b/internal/adapters/postgres/e2e_spec_result_repository.go @@ -122,7 +122,7 @@ func (r *E2ESpecResultRepository) ListFailedByRunID(ctx context.Context, runID s } if err := rows.Err(); err != nil { - return nil, fmt.Errorf("iterate failed integration spec rows: %w", err) + return nil, fmt.Errorf("iterate failed e2e spec rows: %w", err) } return specs, nil diff --git a/internal/adapters/postgres/e2e_test_run_repository.go b/internal/adapters/postgres/e2e_test_run_repository.go index acff266..b8ccb79 100644 --- a/internal/adapters/postgres/e2e_test_run_repository.go +++ b/internal/adapters/postgres/e2e_test_run_repository.go @@ -266,7 +266,7 @@ func (r *E2ETestRunRepository) ListByProject(ctx context.Context, projectID stri rows, err := q.Query(ctx, listSQL, args...) if err != nil { - return nil, 0, fmt.Errorf("list integration runs: %w", err) + return nil, 0, fmt.Errorf("list e2e runs: %w", err) } defer rows.Close() @@ -299,13 +299,13 @@ func (r *E2ETestRunRepository) ListByProject(ctx context.Context, projectID stri &run.Environment, &run.CreatedAt, ); err != nil { - return nil, 0, fmt.Errorf("scan integration run: %w", err) + return nil, 0, fmt.Errorf("scan e2e run: %w", err) } runs = append(runs, run) } if err := rows.Err(); err != nil { - return nil, 0, fmt.Errorf("iterate integration run rows: %w", err) + return nil, 0, fmt.Errorf("iterate e2e run rows: %w", err) } return runs, total, nil diff --git a/migrations/003_e2e_test_runs.sql b/migrations/003_e2e_test_runs.sql index 8aed377..64b5689 100644 --- a/migrations/003_e2e_test_runs.sql +++ b/migrations/003_e2e_test_runs.sql @@ -10,7 +10,7 @@ CREATE TABLE IF NOT EXISTS e2e_test_runs ( run_timestamp TIMESTAMPTZ NOT NULL, framework_version TEXT, test_framework TEXT, - platform TEXT -- either web, android, or ios + platform TEXT, -- either web, android, or ios suite_description TEXT NOT NULL, suite_path TEXT NOT NULL, total_specs INTEGER NOT NULL, @@ -54,5 +54,5 @@ CREATE INDEX IF NOT EXISTS e2e_test_spec_results_state_idx ON e2e_test_spec_resu -- +goose Down DROP TABLE IF EXISTS e2e_test_spec_results; DROP TABLE IF EXISTS e2e_test_runs; -DROP TYPE IF EXISTS environment_type; -DROP TYPE IF EXISTS platform_type; \ No newline at end of file +DROP TABLE IF EXISTS e2e_test_spec_results; +DROP TABLE IF EXISTS e2e_test_runs; \ No newline at end of file From 6a6253ad175aa05133673424b048f52b743ffac0 Mon Sep 17 00:00:00 2001 From: rsultana1418 Date: Fri, 5 Jun 2026 12:52:25 -0600 Subject: [PATCH 05/15] replaced the dummy playwright report with minimal fields for test --- .../playwright-report-fail-dummy.json | 179 +----------------- .../playwright-report-pass-dummy.json | 128 +------------ 2 files changed, 19 insertions(+), 288 deletions(-) diff --git a/cmd/coveragecli/testdata/playwright-report-fail-dummy.json b/cmd/coveragecli/testdata/playwright-report-fail-dummy.json index e4a0050..13522a6 100644 --- a/cmd/coveragecli/testdata/playwright-report-fail-dummy.json +++ b/cmd/coveragecli/testdata/playwright-report-fail-dummy.json @@ -1,225 +1,66 @@ { "config": { - "configFile": "test/playwright.config.ts", "rootDir": "test/e2e", - "forbidOnly": false, - "fullyParallel": true, - "globalSetup": null, - "globalTeardown": "test/e2e/global-teardown.ts", - "globalTimeout": 0, - "grep": {}, - "grepInvert": null, - "maxFailures": 0, - "metadata": { - "actualWorkers": 6 - }, - "preserveOutput": "always", - "quiet": false, - "reporter": [ - [ - "json" - ] - ], - "reportSlowTests": { - "max": 5, - "threshold": 300000 - }, - "runAgents": "none", - "shard": null, - "tags": [], - "updateSnapshots": "missing", - "updateSourceMethod": "patch", - "version": "1.58.2", - "workers": 6 + "version": "1.58.2" }, "suites": [ { "title": "first title", - "file": "file1", - "column": 0, - "line": 0, "specs": [ { "title": "first title", - "ok": true, - "tags": [], "tests": [ { - "timeout": 150000, - "annotations": [], - "expectedStatus": "passed", - "projectId": "setup", - "projectName": "setup", "results": [ { - "workerIndex": 0, - "parallelIndex": 0, - "status": "passed", - "duration": 92, - "errors": [], - "stdout": [], - "stderr": [], - "retry": 0, - "startTime": "2026-05-21T20:23:50.219Z", - "annotations": [], - "attachments": [] + "duration": 92 } ], "status": "expected" } - ], - "id": "TestId", - "file": "file1", - "line": 33, - "column": 1 + ] } ] }, { "title": "title 4", - "file": "file4", - "column": 0, - "line": 0, "specs": [], "suites": [ { "title": "Title 4", - "file": "file4", - "line": 33, - "column": 6, "specs": [ { "title": "Title 3", - "ok": true, - "tags": [], "tests": [ { - "timeout": 150000, - "annotations": [], - "expectedStatus": "passed", - "projectId": "happyPath", - "projectName": "happyPath", "results": [ { - "workerIndex": 1, - "parallelIndex": 0, - "status": "passed", - "duration": 1319, - "errors": [], - "stdout": [], - "stderr": [], - "retry": 0, - "startTime": "2026-05-21T20:23:51.292Z", - "annotations": [], - "attachments": [] + "duration": 1319 } ], "status": "expected" } - ], - "id": "TestId", - "file": "file3", - "line": 46, - "column": 3 + ] }, { "title": "title 5", - "ok": false, - "tags": [], "tests": [ { - "timeout": 150000, - "annotations": [], - "expectedStatus": "passed", - "projectId": "happyPath", - "projectName": "happyPath", "results": [ { - "workerIndex": 2, - "parallelIndex": 1, - "status": "timedOut", "duration": 150277, "error": { - "message": "\u001b[31mTest timeout of 150000ms exceeded.\u001b[39m", - "stack": "\u001b[31mTest timeout of 150000ms exceeded.\u001b[39m" - }, - "errors": [ - { - "message": "\u001b[31mTest timeout of 150000ms exceeded.\u001b[39m" - }, - { - "location": { - "file": "/Users/rsultana/Documents/projects/customer-portal-v2/e2e/features/createProject/specs/cphappyPath.spec.ts", - "column": 76, - "line": 158 - }, - "message": "Error: \u001b[2mexpect(\u001b[22m\u001b[31mlocator\u001b[39m\u001b[2m).\u001b[22mtoHaveAttribute\u001b[2m(\u001b[22m\u001b[32mexpected\u001b[39m\u001b[2m)\u001b[22m failed\n\nLocator: getByTestId('plan-card-details').first()\nExpected: \u001b[32m\"\u001b[7mfals\u001b[27me\"\u001b[39m\nReceived: \u001b[31m\"\u001b[7mtru\u001b[27me\"\u001b[39m\n\nCall log:\n\u001b[2m - Expect \"toHaveAttribute\" with timeout 150000ms\u001b[22m\n\u001b[2m - waiting for getByTestId('plan-card-details').first()\u001b[22m\n\u001b[2m 109 × locator resolved to
\u001b[22m\n\u001b[2m - unexpected value \"true\"\u001b[22m\n\n\n\u001b[0m \u001b[90m 156 |\u001b[39m \u001b[90m// Collapse the plan card again.\u001b[39m\n \u001b[90m 157 |\u001b[39m \u001b[36mawait\u001b[39m selectors\u001b[33m.\u001b[39mpage3\u001b[33m.\u001b[39mtoggleDetailsButton(createProjectFlowPage)\u001b[33m.\u001b[39mclick()\u001b[33m;\u001b[39m\n\u001b[31m\u001b[1m>\u001b[22m\u001b[39m\u001b[90m 158 |\u001b[39m \u001b[36mawait\u001b[39m expect(selectors\u001b[33m.\u001b[39mpage3\u001b[33m.\u001b[39mplanCardDetails(createProjectFlowPage))\u001b[33m.\u001b[39mtoHaveAttribute(\u001b[32m'data-expanded'\u001b[39m\u001b[33m,\u001b[39m \u001b[32m'false'\u001b[39m)\u001b[33m;\u001b[39m\n \u001b[90m |\u001b[39m \u001b[31m\u001b[1m^\u001b[22m\u001b[39m\n \u001b[90m 159 |\u001b[39m })\u001b[33m;\u001b[39m\n \u001b[90m 160 |\u001b[39m\n \u001b[90m 161 |\u001b[39m \u001b[90m// Step 4: Select the second plan then verify review plan details before submitting.\u001b[39m\u001b[0m\n\u001b[2m at /Users/rsultana/Documents/projects/customer-portal-v2/e2e/features/createProject/specs/cphappyPath.spec.ts:158:76\u001b[22m\n\u001b[2m at /Users/rsultana/Documents/projects/customer-portal-v2/e2e/features/createProject/specs/cphappyPath.spec.ts:146:5\u001b[22m" - } - ], - "stdout": [], - "stderr": [], - "retry": 0, - "steps": [ - { - "title": "Verify plan card details expand/collapse with flights and stays", - "duration": 60 - }, - { - "title": "Verify flight drawer opens with correct flight details and closes", - "duration": 108353, - "error": { - "message": "Error: \u001b[2mexpect(\u001b[22m\u001b[31mlocator\u001b[39m\u001b[2m).\u001b[22mtoHaveAttribute\u001b[2m(\u001b[22m\u001b[32mexpected\u001b[39m\u001b[2m)\u001b[22m failed\n\nLocator: getByTestId('plan-card-details').first()\nExpected: \u001b[32m\"\u001b[7mfals\u001b[27me\"\u001b[39m\nReceived: \u001b[31m\"\u001b[7mtru\u001b[27me\"\u001b[39m\n\nCall log:\n\u001b[2m - Expect \"toHaveAttribute\" with timeout 150000ms\u001b[22m\n\u001b[2m - waiting for getByTestId('plan-card-details').first()\u001b[22m\n\u001b[2m 109 × locator resolved to
\u001b[22m\n\u001b[2m - unexpected value \"true\"\u001b[22m\n", - "stack": "Error: \u001b[2mexpect(\u001b[22m\u001b[31mlocator\u001b[39m\u001b[2m).\u001b[22mtoHaveAttribute\u001b[2m(\u001b[22m\u001b[32mexpected\u001b[39m\u001b[2m)\u001b[22m failed\n\nLocator: getByTestId('plan-card-details').first()\nExpected: \u001b[32m\"\u001b[7mfals\u001b[27me\"\u001b[39m\nReceived: \u001b[31m\"\u001b[7mtru\u001b[27me\"\u001b[39m\n\nCall log:\n\u001b[2m - Expect \"toHaveAttribute\" with timeout 150000ms\u001b[22m\n\u001b[2m - waiting for getByTestId('plan-card-details').first()\u001b[22m\n\u001b[2m 109 × locator resolved to
\u001b[22m\n\u001b[2m - unexpected value \"true\"\u001b[22m\n\n at /Users/rsultana/Documents/projects/customer-portal-v2/e2e/features/createProject/specs/cphappyPath.spec.ts:158:76\n at /Users/rsultana/Documents/projects/customer-portal-v2/e2e/features/createProject/specs/cphappyPath.spec.ts:146:5", - "location": { - "file": "/Users/rsultana/Documents/projects/customer-portal-v2/e2e/features/createProject/specs/cphappyPath.spec.ts", - "column": 76, - "line": 158 - }, - "snippet": "\u001b[0m \u001b[90m 156 |\u001b[39m \u001b[90m// Collapse the plan card again.\u001b[39m\n \u001b[90m 157 |\u001b[39m \u001b[36mawait\u001b[39m selectors\u001b[33m.\u001b[39mpage3\u001b[33m.\u001b[39mtoggleDetailsButton(createProjectFlowPage)\u001b[33m.\u001b[39mclick()\u001b[33m;\u001b[39m\n\u001b[31m\u001b[1m>\u001b[22m\u001b[39m\u001b[90m 158 |\u001b[39m \u001b[36mawait\u001b[39m expect(selectors\u001b[33m.\u001b[39mpage3\u001b[33m.\u001b[39mplanCardDetails(createProjectFlowPage))\u001b[33m.\u001b[39mtoHaveAttribute(\u001b[32m'data-expanded'\u001b[39m\u001b[33m,\u001b[39m \u001b[32m'false'\u001b[39m)\u001b[33m;\u001b[39m\n \u001b[90m |\u001b[39m \u001b[31m\u001b[1m^\u001b[22m\u001b[39m\n \u001b[90m 159 |\u001b[39m })\u001b[33m;\u001b[39m\n \u001b[90m 160 |\u001b[39m\n \u001b[90m 161 |\u001b[39m \u001b[90m// Step 4: Select the second plan then verify review plan details before submitting.\u001b[39m\u001b[0m" - } - } - ], - "startTime": "2026-05-21T20:23:54.493Z", - "annotations": [], - "attachments": [ - { - "name": "screenshot", - "contentType": "image/png", - "path": "/Users/rsultana/Documents/projects/customer-portal-v2/test-results/createProject-specs-cphapp-a083e-ultiple-departing-locations-happyPath/test-failed-1.png" - }, - { - "name": "video", - "contentType": "video/webm", - "path": "/Users/rsultana/Documents/projects/customer-portal-v2/test-results/createProject-specs-cphapp-a083e-ultiple-departing-locations-happyPath/video.webm" - }, - { - "name": "error-context", - "contentType": "text/markdown", - "path": "/Users/rsultana/Documents/projects/customer-portal-v2/test-results/createProject-specs-cphapp-a083e-ultiple-departing-locations-happyPath/error-context.md" - } - ] + "message": "\u001b[31mTest timeout of 150000ms exceeded.\u001b[39m" + } } ], "status": "unexpected" } - ], - "id": "4684ba4c3bb84464791f-7d29adc0735ca1f5b8a0", - "file": "features/createProject/specs/cphappyPath.spec.ts", - "line": 81, - "column": 3 + ] } ] } ] } - ], - "errors": [], - "stats": { - "startTime": "2026-05-21T20:23:49.756Z", - "duration": 155054.089, - "expected": 14, - "skipped": 0, - "unexpected": 1, - "flaky": 0 - } -} \ No newline at end of file + ] +} diff --git a/cmd/coveragecli/testdata/playwright-report-pass-dummy.json b/cmd/coveragecli/testdata/playwright-report-pass-dummy.json index b2c18a7..594b1ac 100644 --- a/cmd/coveragecli/testdata/playwright-report-pass-dummy.json +++ b/cmd/coveragecli/testdata/playwright-report-pass-dummy.json @@ -1,173 +1,63 @@ { "config": { - "configFile": "test/playwright.config.ts", "rootDir": "test/e2e", - "forbidOnly": false, - "fullyParallel": true, - "globalSetup": null, - "globalTeardown": "test/e2e/global-teardown.ts", - "globalTimeout": 0, - "grep": {}, - "grepInvert": null, - "maxFailures": 0, - "metadata": { - "actualWorkers": 6 - }, - "preserveOutput": "always", - "quiet": false, - "reporter": [ - [ - "json" - ] - ], - "reportSlowTests": { - "max": 5, - "threshold": 300000 - }, - "runAgents": "none", - "shard": null, - "tags": [], - "updateSnapshots": "missing", - "updateSourceMethod": "patch", - "version": "1.58.2", - "workers": 6 + "version": "1.58.2" }, "suites": [ { "title": "first title", - "file": "file1", - "column": 0, - "line": 0, "specs": [ { "title": "first title", - "ok": true, - "tags": [], "tests": [ { - "timeout": 150000, - "annotations": [], - "expectedStatus": "passed", - "projectId": "setup", - "projectName": "setup", "results": [ { - "workerIndex": 0, - "parallelIndex": 0, - "status": "passed", - "duration": 92, - "errors": [], - "stdout": [], - "stderr": [], - "retry": 0, - "startTime": "2026-05-21T20:23:50.219Z", - "annotations": [], - "attachments": [] + "duration": 92 } ], "status": "expected" } - ], - "id": "TestId", - "file": "file1", - "line": 33, - "column": 1 + ] } ] }, { "title": "title 2", - "file": "file2", - "column": 0, - "line": 0, "specs": [], "suites": [ { "title": "Title 2", - "file": "file2", - "line": 33, - "column": 6, "specs": [ { "title": "Title 3", - "ok": true, - "tags": [], "tests": [ { - "timeout": 150000, - "annotations": [], - "expectedStatus": "passed", - "projectId": "happyPath", - "projectName": "happyPath", "results": [ { - "workerIndex": 1, - "parallelIndex": 0, - "status": "passed", - "duration": 1319, - "errors": [], - "stdout": [], - "stderr": [], - "retry": 0, - "startTime": "2026-05-21T20:23:51.292Z", - "annotations": [], - "attachments": [] + "duration": 1319 } ], "status": "expected" } - ], - "id": "TestId", - "file": "file3", - "line": 46, - "column": 3 + ] }, { "title": "Title 4", - "ok": true, - "tags": [], "tests": [ { - "timeout": 150000, - "annotations": [], - "expectedStatus": "passed", - "projectId": "happyPath", - "projectName": "happyPath", "results": [ { - "workerIndex": 2, - "parallelIndex": 1, - "status": "passed", - "duration": 2586, - "errors": [], - "stdout": [], - "stderr": [], - "retry": 0, - "startTime": "2026-05-21T20:23:51.307Z", - "annotations": [], - "attachments": [] + "duration": 2586 } ], "status": "expected" } - ], - "id": "TestId", - "file": "file3", - "line": 56, - "column": 3 + ] } ] } ] } - ], - "errors": [], - "stats": { - "startTime": "2026-05-21T20:23:49.756Z", - "duration": 155054.089, - "expected": 14, - "skipped": 0, - "unexpected": 1, - "flaky": 0 - } -} \ No newline at end of file + ] +} From 735937bee8f4a5f7dc000e0b57d8d0c6c7fd487b Mon Sep 17 00:00:00 2001 From: rsultana1418 Date: Mon, 8 Jun 2026 11:45:50 -0600 Subject: [PATCH 06/15] replaced logs with slog and added check in e2e migration file --- cmd/coveragecli/main.go | 28 +++++++++++++++------------- migrations/003_e2e_test_runs.sql | 2 +- 2 files changed, 16 insertions(+), 14 deletions(-) diff --git a/cmd/coveragecli/main.go b/cmd/coveragecli/main.go index 91b4587..298922c 100644 --- a/cmd/coveragecli/main.go +++ b/cmd/coveragecli/main.go @@ -7,6 +7,7 @@ import ( "flag" "fmt" "io" + "log/slog" "net/http" "os" "os/exec" @@ -190,7 +191,7 @@ func runNPMUpload(args []string) { thresh = threshold } - fmt.Printf("summary: metric=%s totalCoveragePercent=%.2f consideredFiles=%d generatedPackages=%d\n", *metric, total, consideredFiles, len(packages)) + slog.Info("summary", "metric", *metric, "totalCoveragePercent", total, "consideredFiles", consideredFiles, "generatedPackages", len(packages)) payload := ingestPayload{ ProjectKey: *projectKey, @@ -220,7 +221,7 @@ func runNPMUpload(args []string) { if err := os.WriteFile(payloadOut, body, 0o644); err != nil { exitErr("write payload", err) } - fmt.Printf("payload written: %s\n", payloadOut) + slog.Info("payload written", "path", payloadOut) } if *dryRun { @@ -237,8 +238,8 @@ func runNPMUpload(args []string) { exitErr("upload", fmt.Errorf("ERR_UPLOAD_FAILED: %w", err)) } - fmt.Printf("upload status: %d\n", status) - fmt.Printf("upload response: %s\n", strings.TrimSpace(string(respBody))) + slog.Info("upload status", "status", status) + slog.Info("upload response", "response", strings.TrimSpace(string(respBody))) if status >= http.StatusBadRequest { exitErr("upload", fmt.Errorf("ERR_UPLOAD_FAILED: server returned status %d", status)) @@ -307,7 +308,7 @@ func runCoverageUpload(args []string) { if err := os.WriteFile(*out, body, 0o644); err != nil { exitErr("write payload file", err) } - fmt.Printf("payload written: %s\n", *out) + slog.Info("payload written", "path", *out) if !*upload { return @@ -320,8 +321,9 @@ func runCoverageUpload(args []string) { if err != nil { exitErr("upload", err) } - fmt.Printf("upload status: %d\n", status) - fmt.Printf("upload response: %s\n", strings.TrimSpace(string(respBody))) + + slog.Info("upload status", "status", status) + slog.Info("upload response", "response", strings.TrimSpace(string(respBody))) } func runIntegrationUpload(args []string) { @@ -401,8 +403,8 @@ func runIntegrationUpload(args []string) { exitErr("upload integration report", err) } - fmt.Printf("upload status: %d\n", status) - fmt.Printf("upload response: %s\n", strings.TrimSpace(string(respBody))) + slog.Info("upload status", "status", status) + slog.Info("upload response", "response", strings.TrimSpace(string(respBody))) var parsed uploadResponse if err := json.Unmarshal(respBody, &parsed); err == nil { @@ -410,7 +412,7 @@ func runIntegrationUpload(args []string) { if parsed.Comparison.DeltaPercent != nil { delta = fmt.Sprintf("%.2f", *parsed.Comparison.DeltaPercent) } - fmt.Printf("summary: status=%s passRatePercent=%.2f deltaPercent=%s\n", parsed.Run.Status, parsed.Run.PassRatePercent, delta) + slog.Info("summary", "status", parsed.Run.Status, "passRatePercent", parsed.Run.PassRatePercent, "deltaPercent", delta) } if status >= http.StatusBadRequest { @@ -515,7 +517,7 @@ func runE2EUpload(args []string) { if parsed.Comparison.DeltaPercent != nil { delta = fmt.Sprintf("%.2f", *parsed.Comparison.DeltaPercent) } - fmt.Printf("summary: status=%s passRatePercent=%.2f deltaPercent=%s\n", parsed.Run.Status, parsed.Run.PassRatePercent, delta) + slog.Info("summary", "status", parsed.Run.Status, "passRatePercent", parsed.Run.PassRatePercent, "deltaPercent", delta) } if status >= http.StatusBadRequest { @@ -611,7 +613,7 @@ func normalizePlaywrightReport(raw map[string]any) map[string]any { currentHierarchy = append(append([]string{}, hierarchy...), title) } - // Recurse into nested suites leaves first. + // Recurse into nested suites leaves first. // as the suites can be nested N level deep // uses recursive calls to collect all leaf specs if nested := firstSlice(suiteMap, "suites"); len(nested) > 0 { @@ -622,7 +624,7 @@ func normalizePlaywrightReport(raw map[string]any) map[string]any { for _, specItem := range firstSlice(suiteMap, "specs") { specMap, ok := specItem.(map[string]any) if !ok { - fmt.Printf("warning: skipping spec with unexpected structure: %v\n", specItem) + slog.Warn("skipping spec with unexpected structure", "specItem", specItem) continue } diff --git a/migrations/003_e2e_test_runs.sql b/migrations/003_e2e_test_runs.sql index 64b5689..2958faf 100644 --- a/migrations/003_e2e_test_runs.sql +++ b/migrations/003_e2e_test_runs.sql @@ -10,7 +10,7 @@ CREATE TABLE IF NOT EXISTS e2e_test_runs ( run_timestamp TIMESTAMPTZ NOT NULL, framework_version TEXT, test_framework TEXT, - platform TEXT, -- either web, android, or ios + platform TEXT CHECK (platform IN ('web', 'android', 'ios')), -- either web, android, or ios suite_description TEXT NOT NULL, suite_path TEXT NOT NULL, total_specs INTEGER NOT NULL, From fd59222ed2fecb12d83ac3ee9932673eb3ad9231 Mon Sep 17 00:00:00 2001 From: rsultana1418 Date: Mon, 8 Jun 2026 11:55:20 -0600 Subject: [PATCH 07/15] removed duplicate table drop query --- migrations/003_e2e_test_runs.sql | 2 -- 1 file changed, 2 deletions(-) diff --git a/migrations/003_e2e_test_runs.sql b/migrations/003_e2e_test_runs.sql index 2958faf..f6df350 100644 --- a/migrations/003_e2e_test_runs.sql +++ b/migrations/003_e2e_test_runs.sql @@ -53,6 +53,4 @@ CREATE INDEX IF NOT EXISTS e2e_test_spec_results_state_idx ON e2e_test_spec_resu -- +goose Down DROP TABLE IF EXISTS e2e_test_spec_results; -DROP TABLE IF EXISTS e2e_test_runs; -DROP TABLE IF EXISTS e2e_test_spec_results; DROP TABLE IF EXISTS e2e_test_runs; \ No newline at end of file From 66bc156c2eff3622844e264735afe1b583d71d52 Mon Sep 17 00:00:00 2001 From: rsultana1418 Date: Mon, 8 Jun 2026 15:03:08 -0600 Subject: [PATCH 08/15] moved check constraint from the table --- migrations/003_e2e_test_runs.sql | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/migrations/003_e2e_test_runs.sql b/migrations/003_e2e_test_runs.sql index f6df350..654cf2c 100644 --- a/migrations/003_e2e_test_runs.sql +++ b/migrations/003_e2e_test_runs.sql @@ -6,11 +6,11 @@ CREATE TABLE IF NOT EXISTS e2e_test_runs ( branch TEXT NOT NULL, commit_sha TEXT NOT NULL, author TEXT, - trigger_type TEXT NOT NULL CHECK (trigger_type IN ('push', 'pr', 'manual')), + trigger_type TEXT NOT NULL, run_timestamp TIMESTAMPTZ NOT NULL, framework_version TEXT, test_framework TEXT, - platform TEXT CHECK (platform IN ('web', 'android', 'ios')), -- either web, android, or ios + platform TEXT, suite_description TEXT NOT NULL, suite_path TEXT NOT NULL, total_specs INTEGER NOT NULL, @@ -22,7 +22,7 @@ CREATE TABLE IF NOT EXISTS e2e_test_runs ( interrupted BOOLEAN NOT NULL DEFAULT FALSE, timed_out BOOLEAN NOT NULL DEFAULT FALSE, duration_ms BIGINT NOT NULL, - status TEXT NOT NULL CHECK (status IN ('passed', 'failed')), + status TEXT NOT NULL, environment environment_type, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); @@ -36,12 +36,21 @@ CREATE INDEX IF NOT EXISTS e2e_test_runs_project_default_lookup_idx CREATE INDEX IF NOT EXISTS e2e_test_runs_project_status_ts_idx ON e2e_test_runs(project_id, status, run_timestamp DESC); +ALTER TABLE e2e_test_runs + ADD CONSTRAINT chk_e2e_test_runs_trigger_type CHECK (trigger_type IN ('push', 'pr', 'manual')); + +ALTER TABLE e2e_test_runs + ADD CONSTRAINT chk_e2e_test_runs_platform CHECK (platform IN ('web', 'android', 'ios')); + +ALTER TABLE e2e_test_runs + ADD CONSTRAINT chk_e2e_test_runs_status CHECK (status IN ('passed', 'failed')); + CREATE TABLE IF NOT EXISTS e2e_test_spec_results ( id UUID PRIMARY KEY, e2e_run_id UUID NOT NULL REFERENCES e2e_test_runs(id) ON DELETE CASCADE, spec_path TEXT NOT NULL, leaf_node_text TEXT NOT NULL, - state TEXT NOT NULL CHECK (state IN ('passed', 'failed', 'skipped', 'pending', 'flaky')), + state TEXT NOT NULL, duration_ms BIGINT NOT NULL, failure_message TEXT, failure_location_file TEXT, @@ -51,6 +60,9 @@ CREATE TABLE IF NOT EXISTS e2e_test_spec_results ( CREATE INDEX IF NOT EXISTS e2e_test_spec_results_run_id_idx ON e2e_test_spec_results(e2e_run_id); CREATE INDEX IF NOT EXISTS e2e_test_spec_results_state_idx ON e2e_test_spec_results(state); +ALTER TABLE e2e_test_spec_results + ADD CONSTRAINT chk_e2e_test_spec_results_state CHECK (state IN ('passed', 'failed', 'skipped', 'pending', 'flaky')); + -- +goose Down DROP TABLE IF EXISTS e2e_test_spec_results; DROP TABLE IF EXISTS e2e_test_runs; \ No newline at end of file From e23bb0d810c532ce4d48036694daf44e7f12481d Mon Sep 17 00:00:00 2001 From: rsultana1418 Date: Mon, 15 Jun 2026 11:46:55 -0600 Subject: [PATCH 09/15] added spec type to e2e tests report --- cmd/coveragecli/main.go | 17 +++++ cmd/frontend/web/assets/e2e.js | 31 ++++++-- cmd/frontend/web/e2eTest.html | 6 ++ internal/adapters/http/handlers.go | 1 + .../postgres/e2e_spec_result_repository.go | 13 ++-- .../postgres/e2e_test_run_repository.go | 7 +- internal/application/e2e_usecase.go | 14 +++- internal/application/e2e_usecase_test.go | 71 +++++++++++++++++++ internal/application/integration_usecase.go | 1 + internal/application/mock_application.go | 4 +- internal/application/ports.go | 2 +- internal/domain/e2e.go | 1 + migrations/004_add_spec_type.sql | 14 ++++ 13 files changed, 165 insertions(+), 17 deletions(-) create mode 100644 migrations/004_add_spec_type.sql diff --git a/cmd/coveragecli/main.go b/cmd/coveragecli/main.go index 298922c..5f28aee 100644 --- a/cmd/coveragecli/main.go +++ b/cmd/coveragecli/main.go @@ -630,6 +630,8 @@ func normalizePlaywrightReport(raw map[string]any) map[string]any { // Use the last test result (accounts for retries). tests := firstSlice(specMap, "tests") + file := firstString(suiteMap, "file") + spec_type := "happyPath" state := "skipped" runTime := 0.0 var failureBlock map[string]any @@ -668,6 +670,19 @@ func normalizePlaywrightReport(raw map[string]any) map[string]any { } } } + + // save the spec_type + // spec_type can be either happyPath or negativePath + spec_type = firstString(testMap, "projectId") + if strings.Contains(file, "happyPath") || strings.Contains(file, "setup") { + spec_type = "happyPath" + } else if strings.Contains(file, "negativePath") { + spec_type = "negativePath" + } else if firstString(testMap, "projectId") == "happyPath" || firstString(testMap, "projectId") == "negativePath" { + spec_type = firstString(testMap, "projectId") + } + + fmt.Println("spec_type: ", spec_type) } } @@ -682,6 +697,8 @@ func normalizePlaywrightReport(raw map[string]any) map[string]any { "containerHierarchyTexts": hierarchyCopy, "state": state, "runTime": runTime, + "suite_type": firstString(suiteMap, "type"), + "specType": spec_type, } if failureBlock != nil { normalized["failure"] = failureBlock diff --git a/cmd/frontend/web/assets/e2e.js b/cmd/frontend/web/assets/e2e.js index e570868..2eeb9b7 100644 --- a/cmd/frontend/web/assets/e2e.js +++ b/cmd/frontend/web/assets/e2e.js @@ -12,6 +12,7 @@ const e2eBranchFilter = document.getElementById('e2eBranchFilter'); const e2eStatusFilter = document.getElementById('e2eStatusFilter'); const e2eEnvironmentFilter = document.getElementById('e2eEnvironmentFilter'); const e2ePlatformFilter = document.getElementById('e2ePlatformFilter'); +const e2eSpecTypeFilter = document.getElementById('e2eSpecTypeFilter'); const e2eReload = document.getElementById('e2eReload'); const e2eAutoRefreshInterval = document.getElementById('e2eAutoRefreshInterval'); const e2eAutoRefreshStatus = document.getElementById('e2eAutoRefreshStatus'); @@ -82,6 +83,9 @@ e2eEnvironmentFilter.addEventListener('change', async () => { e2ePlatformFilter.addEventListener('change', async () => { await loadE2ERuns(selectedProjectId); }); +e2eSpecTypeFilter.addEventListener('change', async () => { + await loadE2ERuns(selectedProjectId); +}); e2eReload.addEventListener('click', async () => { await runWithButtonBusy(e2eReload, 'Reload', 'Reloading...', async () => { await loadE2EScreen(selectedProjectId, { preferredRunId: selectedE2ERunId }); @@ -610,6 +614,7 @@ async function loadE2ERuns(projectId, preferredRunId = null) { const selectedStatus = e2eStatusFilter.value || ''; const selectedEnvironment = e2eEnvironmentFilter.value || ''; const selectedPlatform = e2ePlatformFilter.value || ''; + const selectedSpecType = e2eSpecTypeFilter.value || ''; url.searchParams.set('branch', selectedBranch); if (selectedStatus) { url.searchParams.set('status', selectedStatus); @@ -620,6 +625,9 @@ async function loadE2ERuns(projectId, preferredRunId = null) { if (selectedPlatform) { url.searchParams.set('platform', selectedPlatform); } + if (selectedSpecType) { + url.searchParams.set('specType', selectedSpecType); + } const res = await fetch(url.toString()); if (!res.ok) throw new Error(`failed to load E2E runs (${res.status})`); @@ -632,7 +640,8 @@ async function loadE2ERuns(projectId, preferredRunId = null) { const currentStatus = e2eStatusFilter.value || ''; const currentEnvironment = e2eEnvironmentFilter.value || ''; const currentPlatform = e2ePlatformFilter.value || ''; - if (currentBranch !== selectedBranch || currentStatus !== selectedStatus || currentEnvironment !== selectedEnvironment || currentPlatform !== selectedPlatform) return; + const currentSpecType = e2eSpecTypeFilter.value || ''; + if (currentBranch !== selectedBranch || currentStatus !== selectedStatus || currentEnvironment !== selectedEnvironment || currentPlatform !== selectedPlatform || currentSpecType !== selectedSpecType) return; currentE2ERunItems = items; const passedRuns = items.filter((run) => run.status === 'passed').length; @@ -698,7 +707,7 @@ async function loadE2ERuns(projectId, preferredRunId = null) { selectedE2ERunId = null; e2eRunChain.innerHTML = `

${err.message}

`; e2eRunsBody.innerHTML = `${err.message}`; - e2eFailedSpecsBody.innerHTML = 'Failed to load selected run details.'; + e2eFailedSpecsBody.innerHTML = 'Failed to load selected run details.'; e2ePassRate.textContent = '-'; } } @@ -779,16 +788,24 @@ async function loadE2ERunDetails(projectId, runId) { if (!res.ok) throw new Error(`failed to load E2E run details (${res.status})`); const data = await res.json(); const failedSpecs = data.failedSpecs || []; - - if (failedSpecs.length === 0) { - e2eFailedSpecsBody.innerHTML = 'No failed specs for this run.'; + const selectedSpecType = e2eSpecTypeFilter.value; + const filtered = selectedSpecType + ? failedSpecs.filter(s => s.specType === selectedSpecType) + : failedSpecs; + + if (filtered.length === 0) { + const msg = failedSpecs.length === 0 + ? 'No failed specs for this run.' + : `No failed specs matching spec type "${escapeHtml(selectedSpecType)}".`; + e2eFailedSpecsBody.innerHTML = `${msg}`; return; } - for (const failed of failedSpecs) { + for (const failed of filtered) { const tr = document.createElement('tr'); tr.innerHTML = ` ${escapeHtml(failed.specPath || '-')} + ${escapeHtml(failed.specType || '-')} ${escapeHtml(failed.failureMessage || '-')} ${escapeHtml(failed.file || '-')} ${failed.line || '-'} @@ -796,7 +813,7 @@ async function loadE2ERunDetails(projectId, runId) { e2eFailedSpecsBody.appendChild(tr); } } catch (err) { - e2eFailedSpecsBody.innerHTML = `${err.message}`; + e2eFailedSpecsBody.innerHTML = `${err.message}`; } } diff --git a/cmd/frontend/web/e2eTest.html b/cmd/frontend/web/e2eTest.html index e1dcaa3..ee5360c 100644 --- a/cmd/frontend/web/e2eTest.html +++ b/cmd/frontend/web/e2eTest.html @@ -106,6 +106,11 @@

Select a project

+
@@ -152,6 +157,7 @@

Failed Specs (Selected Run)

Spec Path + Spec Type Message File Line diff --git a/internal/adapters/http/handlers.go b/internal/adapters/http/handlers.go index 192683e..ce5e534 100644 --- a/internal/adapters/http/handlers.go +++ b/internal/adapters/http/handlers.go @@ -386,6 +386,7 @@ func (h *Handler) ListE2ERuns(w http.ResponseWriter, r *http.Request) { Branch: q.Get("branch"), Status: q.Get("status"), Environment: q.Get("environment"), + SpecType: q.Get("specType"), From: from, To: to, Page: page, diff --git a/internal/adapters/postgres/e2e_spec_result_repository.go b/internal/adapters/postgres/e2e_spec_result_repository.go index bfa9da7..b698cba 100644 --- a/internal/adapters/postgres/e2e_spec_result_repository.go +++ b/internal/adapters/postgres/e2e_spec_result_repository.go @@ -26,9 +26,9 @@ func (r *E2ESpecResultRepository) CreateBatch(ctx context.Context, specs []domai _, err := q.Exec(ctx, ` INSERT INTO e2e_test_spec_results ( id, e2e_run_id, spec_path, leaf_node_text, state, duration_ms, - failure_message, failure_location_file, failure_location_line + failure_message, failure_location_file, failure_location_line, spec_type ) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) `, spec.ID, spec.E2ETestRunID, @@ -39,6 +39,7 @@ func (r *E2ESpecResultRepository) CreateBatch(ctx context.Context, specs []domai spec.FailureMessage, spec.FailureLocationFile, spec.FailureLocationLine, + spec.SpecType, ) if err != nil { return fmt.Errorf("insert e2e spec result: %w", err) @@ -52,7 +53,8 @@ func (r *E2ESpecResultRepository) ListByRunID(ctx context.Context, runID string) q := getQuerier(ctx, r.pool) rows, err := q.Query(ctx, ` SELECT id, e2e_run_id, spec_path, leaf_node_text, state, duration_ms, - failure_message, failure_location_file, failure_location_line + failure_message, failure_location_file, failure_location_line, + COALESCE(spec_type, 'happyPath') AS spec_type FROM e2e_test_spec_results WHERE e2e_run_id = $1 ORDER BY spec_path ASC @@ -75,6 +77,7 @@ func (r *E2ESpecResultRepository) ListByRunID(ctx context.Context, runID string) &spec.FailureMessage, &spec.FailureLocationFile, &spec.FailureLocationLine, + &spec.SpecType, ); err != nil { return nil, fmt.Errorf("scan e2e spec result: %w", err) } @@ -92,7 +95,8 @@ func (r *E2ESpecResultRepository) ListFailedByRunID(ctx context.Context, runID s q := getQuerier(ctx, r.pool) rows, err := q.Query(ctx, ` SELECT id, e2e_run_id, spec_path, leaf_node_text, state, duration_ms, - failure_message, failure_location_file, failure_location_line + failure_message, failure_location_file, failure_location_line, + COALESCE(spec_type, 'happyPath') AS spec_type FROM e2e_test_spec_results WHERE e2e_run_id = $1 AND state IN ('failed', 'flaky') ORDER BY spec_path ASC @@ -115,6 +119,7 @@ func (r *E2ESpecResultRepository) ListFailedByRunID(ctx context.Context, runID s &spec.FailureMessage, &spec.FailureLocationFile, &spec.FailureLocationLine, + &spec.SpecType, ); err != nil { return nil, fmt.Errorf("scan failed e2e spec result: %w", err) } diff --git a/internal/adapters/postgres/e2e_test_run_repository.go b/internal/adapters/postgres/e2e_test_run_repository.go index b8ccb79..69270cc 100644 --- a/internal/adapters/postgres/e2e_test_run_repository.go +++ b/internal/adapters/postgres/e2e_test_run_repository.go @@ -208,7 +208,7 @@ func (r *E2ETestRunRepository) GetByID(ctx context.Context, projectID string, ru return run, nil } -func (r *E2ETestRunRepository) ListByProject(ctx context.Context, projectID string, branch string, status string, environment string, from *time.Time, to *time.Time, page int, pageSize int) ([]domain.E2ETestRun, int, error) { +func (r *E2ETestRunRepository) ListByProject(ctx context.Context, projectID string, branch string, status string, environment string, specType string, from *time.Time, to *time.Time, page int, pageSize int) ([]domain.E2ETestRun, int, error) { q := getQuerier(ctx, r.pool) offset := (page - 1) * pageSize @@ -235,6 +235,11 @@ func (r *E2ETestRunRepository) ListByProject(ctx context.Context, projectID stri idx++ } } + if specType != "" { + where += fmt.Sprintf(" AND EXISTS (SELECT 1 FROM e2e_test_spec_results s WHERE s.e2e_run_id = e2e_test_runs.id AND s.spec_type = $%d)", idx) + args = append(args, specType) + idx++ + } if from != nil { where += fmt.Sprintf(" AND run_timestamp >= $%d", idx) args = append(args, *from) diff --git a/internal/application/e2e_usecase.go b/internal/application/e2e_usecase.go index 511de61..9fa2cde 100644 --- a/internal/application/e2e_usecase.go +++ b/internal/application/e2e_usecase.go @@ -41,6 +41,7 @@ type IngestSpecReport struct { LeafNodeText string `json:"leafNodeText"` ContainerHierarchyTexts []string `json:"containerHierarchyTexts"` State string `json:"state"` + SpecType string `json:"specType,omitempty"` RunTime float64 `json:"runTime"` Failure *IngestTestFailure `json:"failure,omitempty"` } @@ -292,6 +293,7 @@ func (uc *IngestE2ERunUseCase) buildE2EEntities(projectID string, in IngestE2ERu SpecPath: specPath, LeafNodeText: spec.LeafNodeText, State: normalizedState, + SpecType: spec.SpecType, DurationMS: durationMS, FailureMessage: failureMessage, FailureLocationFile: failureFile, @@ -375,6 +377,9 @@ func validateE2EIngestInput(in IngestE2ERunInput) error { if normalizeTestState(spec.State) == domain.E2ESpecStateFailed && (spec.Failure == nil || strings.TrimSpace(spec.Failure.Message) == "") { return NewInvalidArgument("failure.message is required when state is failed", map[string]any{"field": fmt.Sprintf("testReport.specReports[%d].failure.message", i)}) } + if spec.SpecType != "" && spec.SpecType != "happyPath" && spec.SpecType != "negativePath" { + return NewInvalidArgument("specType must be happyPath or negativePath", map[string]any{"field": fmt.Sprintf("testReport.specReports[%d].specType", i)}) + } } return nil @@ -446,7 +451,7 @@ func failedE2ESpecsFromResults(specs []domain.E2ESpecResult) []FailedSpecRespons if spec.State != domain.E2ESpecStateFailed && spec.State != domain.E2ESpecStateFlaky { continue } - failed := FailedSpecResponse{SpecPath: spec.SpecPath} + failed := FailedSpecResponse{SpecPath: spec.SpecPath, SpecType: string(spec.SpecType)} if spec.FailureMessage != nil { failed.FailureMessage = *spec.FailureMessage } @@ -467,6 +472,7 @@ type ListE2ERunsInput struct { Branch string Status string Environment string + SpecType string From *time.Time To *time.Time Page int @@ -518,8 +524,12 @@ func (uc *ListE2ERunsUseCase) Execute(ctx context.Context, in ListE2ERunsInput) if environment != "" && environment != "test" && environment != "stage" && environment != "prod" { return ListE2ERunsOutput{}, NewInvalidArgument("environment must be one of: test, stage, prod", map[string]any{"field": "environment"}) } + specType := strings.TrimSpace(in.SpecType) + if specType != "" && specType != "happyPath" && specType != "negativePath" { + return ListE2ERunsOutput{}, NewInvalidArgument("specType must be happyPath or negativePath", map[string]any{"field": "specType"}) + } - runs, total, err := uc.runs.ListByProject(ctx, in.ProjectID, in.Branch, status, environment, in.From, in.To, page, pageSize) + runs, total, err := uc.runs.ListByProject(ctx, in.ProjectID, in.Branch, status, environment, specType, in.From, in.To, page, pageSize) if err != nil { return ListE2ERunsOutput{}, NewInternal("failed to list E2E runs", err) } diff --git a/internal/application/e2e_usecase_test.go b/internal/application/e2e_usecase_test.go index a43a29b..8c4f9f3 100644 --- a/internal/application/e2e_usecase_test.go +++ b/internal/application/e2e_usecase_test.go @@ -491,6 +491,26 @@ func TestIngestE2ERunBuildE2EEntities(t *testing.T) { t.Fatalf("expected spec run ID %s, got %s", run.ID, specs[0].E2ETestRunID) } }) + + t.Run("Build entities with explicit specType", func(t *testing.T) { + uc := NewIngestE2ERunUseCase(nil, nil, nil, nil, &stubIDGenerator{}, &stubClock{}) + input := runInput + input.TestReport.SpecReports = []IngestSpecReport{ + { + LeafNodeText: "Error handling", + State: "Passed", + SpecType: "negativePath", + RunTime: 1.0, + }, + } + _, specs := uc.buildE2EEntities("project-id", input, time.Date(2026, 4, 1, 12, 0, 0, 0, time.UTC)) + if len(specs) != 1 { + t.Fatalf("expected 1 spec, got %d", len(specs)) + } + if specs[0].SpecType != "negativePath" { + t.Fatalf("expected spec type negativePath, got %s", specs[0].SpecType) + } + }) } func TestValidateE2EIngestInput(t *testing.T) { @@ -578,6 +598,35 @@ func TestValidateE2EIngestInput(t *testing.T) { wantErr: true, wantField: "testReport.specReports[0].failure.message", }, + { + name: "invalid specType returns error", + mutate: func(in *IngestE2ERunInput) { + in.TestReport.SpecReports[0].SpecType = "invalid" + }, + wantErr: true, + wantField: "testReport.specReports[0].specType", + }, + { + name: "happyPath specType passes validation", + mutate: func(in *IngestE2ERunInput) { + in.TestReport.SpecReports[0].SpecType = "happyPath" + }, + wantErr: false, + }, + { + name: "negativePath specType passes validation", + mutate: func(in *IngestE2ERunInput) { + in.TestReport.SpecReports[0].SpecType = "negativePath" + }, + wantErr: false, + }, + { + name: "empty specType passes validation", + mutate: func(in *IngestE2ERunInput) { + in.TestReport.SpecReports[0].SpecType = "" + }, + wantErr: false, + }, } for _, tt := range test { t.Run(tt.name, func(t *testing.T) { @@ -798,6 +847,28 @@ func TestListE2ERunsExecute(t *testing.T) { t.Fatalf("expected field environment, got %s", field) } }) + t.Run("Returns an error when specType is invalid", func(t *testing.T) { + runRepo := &stubE2ETestRunRepository{} + uc := NewListE2ERunsUseCase(runRepo) + _, err := uc.Execute(context.Background(), ListE2ERunsInput{ + ProjectID: "proj1", + SpecType: "invalid", + }) + if err == nil { + t.Fatalf("expected error, got nil") + } + var appErr *AppError + if !errors.As(err, &appErr) { + t.Fatalf("expected AppError, got %T", err) + } + if appErr.Code != CodeInvalidArgument { + t.Fatalf("expected code to be INVALID_ARGUMENT, got %s", appErr.Code) + } + field, _ := appErr.Details["field"].(string) + if field != "specType" { + t.Fatalf("expected field specType, got %s", field) + } + }) t.Run("Returns an error when fails to list the runs", func(t *testing.T) { runRepo := &stubE2ETestRunRepository{listErr: fmt.Errorf("db error")} uc := NewListE2ERunsUseCase(runRepo) diff --git a/internal/application/integration_usecase.go b/internal/application/integration_usecase.go index 61990db..2f1e4aa 100644 --- a/internal/application/integration_usecase.go +++ b/internal/application/integration_usecase.go @@ -83,6 +83,7 @@ type IntegrationComparisonResponse struct { type FailedSpecResponse struct { SpecPath string `json:"specPath"` + SpecType string `json:"specType,omitempty"` FailureMessage string `json:"failureMessage"` File string `json:"file,omitempty"` Line int `json:"line,omitempty"` diff --git a/internal/application/mock_application.go b/internal/application/mock_application.go index 0c28523..3f3628c 100644 --- a/internal/application/mock_application.go +++ b/internal/application/mock_application.go @@ -73,7 +73,7 @@ func (s *stubE2ETestRunRepository) GetByID(ctx context.Context, projectID string return *s.byID, nil } -func (s *stubE2ETestRunRepository) ListByProject(ctx context.Context, projectID string, branch string, status string, environment string, from *time.Time, to *time.Time, page int, pageSize int) ([]domain.E2ETestRun, int, error) { +func (s *stubE2ETestRunRepository) ListByProject(ctx context.Context, projectID string, branch string, status string, environment string, specType string, from *time.Time, to *time.Time, page int, pageSize int) ([]domain.E2ETestRun, int, error) { s.capturedBranch = branch s.capturedStatus = status if s.listErr != nil { @@ -124,4 +124,4 @@ func (s *stubE2ESpecResultRepository) ListFailedByRunID(ctx context.Context, run return nil, s.failedByRunIDErr } return s.failedByRunID, nil -} \ No newline at end of file +} diff --git a/internal/application/ports.go b/internal/application/ports.go index 1fca483..6fe9e1e 100644 --- a/internal/application/ports.go +++ b/internal/application/ports.go @@ -64,7 +64,7 @@ type E2ETestRunRepository interface { GetLatestByProjectAndBranch(ctx context.Context, projectID string, branch string) (domain.E2ETestRun, error) GetLatestByProject(ctx context.Context, projectID string) (domain.E2ETestRun, error) GetByID(ctx context.Context, projectID string, runID string) (domain.E2ETestRun, error) - ListByProject(ctx context.Context, projectID string, branch string, status string, environment string, from *time.Time, to *time.Time, page int, pageSize int) ([]domain.E2ETestRun, int, error) + ListByProject(ctx context.Context, projectID string, branch string, status string, environment string, specType string, from *time.Time, to *time.Time, page int, pageSize int) ([]domain.E2ETestRun, int, error) HeatmapData(ctx context.Context, branch string, status string, runsPerProject int) ([]TestHeatmapRow, error) } diff --git a/internal/domain/e2e.go b/internal/domain/e2e.go index 1e9bc55..4bad7f6 100644 --- a/internal/domain/e2e.go +++ b/internal/domain/e2e.go @@ -52,6 +52,7 @@ type E2ESpecResult struct { SpecPath string LeafNodeText string State E2ESpecState + SpecType string DurationMS int64 FailureMessage *string FailureLocationFile *string diff --git a/migrations/004_add_spec_type.sql b/migrations/004_add_spec_type.sql new file mode 100644 index 0000000..0c183a2 --- /dev/null +++ b/migrations/004_add_spec_type.sql @@ -0,0 +1,14 @@ +-- +goose Up + +ALTER TABLE e2e_test_spec_results + ADD COLUMN spec_type TEXT CHECK (spec_type IN ('happyPath', 'negativePath')); + +CREATE INDEX IF NOT EXISTS e2e_test_spec_results_spec_type_idx + ON e2e_test_spec_results(e2e_run_id, spec_type) ; + +-- +goose Down + +DROP INDEX IF EXISTS e2e_test_spec_results_spec_type_idx; + +ALTER TABLE e2e_test_spec_results + DROP COLUMN IF EXISTS spec_type; \ No newline at end of file From 9263034cecd805bd506f30311c8f04370e07bcf8 Mon Sep 17 00:00:00 2001 From: RaihanSultana Date: Tue, 16 Jun 2026 10:29:53 -0600 Subject: [PATCH 10/15] added spec type filter in the dashboard and heatmap --- cmd/coveragecli/main.go | 6 +++-- cmd/frontend/web/assets/e2e.js | 5 ++++ cmd/frontend/web/e2eTest.html | 5 ++++ internal/adapters/http/handlers.go | 1 + .../postgres/e2e_test_run_repository.go | 7 +++++- internal/application/e2e_usecase.go | 25 +++++++++++++++---- internal/application/mock_application.go | 2 +- internal/application/ports.go | 2 +- migrations/004_add_spec_type.sql | 2 +- 9 files changed, 44 insertions(+), 11 deletions(-) diff --git a/cmd/coveragecli/main.go b/cmd/coveragecli/main.go index 5f28aee..03a4685 100644 --- a/cmd/coveragecli/main.go +++ b/cmd/coveragecli/main.go @@ -672,9 +672,11 @@ func normalizePlaywrightReport(raw map[string]any) map[string]any { } // save the spec_type - // spec_type can be either happyPath or negativePath + // spec_type can be either setup, happyPath or negativePath spec_type = firstString(testMap, "projectId") - if strings.Contains(file, "happyPath") || strings.Contains(file, "setup") { + if strings.Contains(file, "setup") { + spec_type = "setup" + } else if strings.Contains(file, "happyPath") { spec_type = "happyPath" } else if strings.Contains(file, "negativePath") { spec_type = "negativePath" diff --git a/cmd/frontend/web/assets/e2e.js b/cmd/frontend/web/assets/e2e.js index 2eeb9b7..9d845ff 100644 --- a/cmd/frontend/web/assets/e2e.js +++ b/cmd/frontend/web/assets/e2e.js @@ -25,6 +25,7 @@ const closeE2EHeatmap = document.getElementById('closeE2EHeatmap'); const e2eHeatmapOverlay = document.getElementById('e2eHeatmapOverlay'); const heatmapBranchFilter = document.getElementById('heatmapBranchFilter'); const heatmapStatusFilter = document.getElementById('heatmapStatusFilter'); +const heatmapSpecTypeFilter = document.getElementById('heatmapSpecTypeFilter'); const heatmapReload = document.getElementById('heatmapReload'); const e2eHeatmap = document.getElementById('e2eHeatmap'); const appShell = document.getElementById('appShell'); @@ -105,6 +106,9 @@ heatmapBranchFilter.addEventListener('change', async () => { heatmapStatusFilter.addEventListener('change', async () => { await loadHeatmap(); }); +heatmapSpecTypeFilter.addEventListener('change', async () => { + await loadHeatmap(); +}); heatmapReload.addEventListener('click', async () => { await runWithButtonBusy(heatmapReload, 'Reload', 'Reloading...', async () => { await loadHeatmap(); @@ -861,6 +865,7 @@ async function loadHeatmap() { url.searchParams.set('runsPerProject', '10'); if (heatmapBranchFilter.value) url.searchParams.set('branch', heatmapBranchFilter.value); if (heatmapStatusFilter.value) url.searchParams.set('status', heatmapStatusFilter.value); + if (heatmapSpecTypeFilter.value) url.searchParams.set('specType', heatmapSpecTypeFilter.value); const res = await fetch(url.toString()); if (!res.ok) throw new Error(`heatmap request failed (${res.status})`); diff --git a/cmd/frontend/web/e2eTest.html b/cmd/frontend/web/e2eTest.html index ee5360c..9cf7b2b 100644 --- a/cmd/frontend/web/e2eTest.html +++ b/cmd/frontend/web/e2eTest.html @@ -187,6 +187,11 @@

E2E Heatmap

+ diff --git a/internal/adapters/http/handlers.go b/internal/adapters/http/handlers.go index ce5e534..3aac20e 100644 --- a/internal/adapters/http/handlers.go +++ b/internal/adapters/http/handlers.go @@ -560,6 +560,7 @@ func (h *Handler) GetE2EHeatmap(w http.ResponseWriter, r *http.Request) { out, err := h.getE2EHeatmap.Execute(r.Context(), application.E2EHeatmapInput{ Branch: q.Get("branch"), Status: q.Get("status"), + SpecType: q.Get("specType"), RunsPerProject: runsPerProject, }) if err != nil { diff --git a/internal/adapters/postgres/e2e_test_run_repository.go b/internal/adapters/postgres/e2e_test_run_repository.go index 69270cc..2f45b43 100644 --- a/internal/adapters/postgres/e2e_test_run_repository.go +++ b/internal/adapters/postgres/e2e_test_run_repository.go @@ -316,7 +316,7 @@ func (r *E2ETestRunRepository) ListByProject(ctx context.Context, projectID stri return runs, total, nil } -func (r *E2ETestRunRepository) HeatmapData(ctx context.Context, branch string, status string, runsPerProject int) ([]application.TestHeatmapRow, error) { +func (r *E2ETestRunRepository) HeatmapData(ctx context.Context, branch string, status string, specType string, runsPerProject int) ([]application.TestHeatmapRow, error) { q := getQuerier(ctx, r.pool) where := "WHERE 1=1" @@ -333,6 +333,11 @@ func (r *E2ETestRunRepository) HeatmapData(ctx context.Context, branch string, s args = append(args, status) idx++ } + if specType != "" { + where += fmt.Sprintf(" AND EXISTS (SELECT 1 FROM e2e_test_spec_results s WHERE s.e2e_run_id = itr.id AND s.spec_type = $%d)", idx) + args = append(args, specType) + idx++ + } args = append(args, runsPerProject) diff --git a/internal/application/e2e_usecase.go b/internal/application/e2e_usecase.go index 9fa2cde..355ff5a 100644 --- a/internal/application/e2e_usecase.go +++ b/internal/application/e2e_usecase.go @@ -119,6 +119,13 @@ func NewIngestE2ERunUseCase( } } +var validSpecTypes = map[string]bool{ + "": true, + "happyPath": true, + "negativePath": true, + "setup": true, +} + func (uc *IngestE2ERunUseCase) Execute(ctx context.Context, in IngestE2ERunInput) (IngestE2ERunOutput, error) { if err := validateE2EIngestInput(in); err != nil { return IngestE2ERunOutput{}, err @@ -377,8 +384,9 @@ func validateE2EIngestInput(in IngestE2ERunInput) error { if normalizeTestState(spec.State) == domain.E2ESpecStateFailed && (spec.Failure == nil || strings.TrimSpace(spec.Failure.Message) == "") { return NewInvalidArgument("failure.message is required when state is failed", map[string]any{"field": fmt.Sprintf("testReport.specReports[%d].failure.message", i)}) } - if spec.SpecType != "" && spec.SpecType != "happyPath" && spec.SpecType != "negativePath" { - return NewInvalidArgument("specType must be happyPath or negativePath", map[string]any{"field": fmt.Sprintf("testReport.specReports[%d].specType", i)}) + + if !validSpecTypes[spec.SpecType] { + return NewInvalidArgument("specType must be happyPath, negativePath, or setup", map[string]any{"field": fmt.Sprintf("testReport.specReports[%d].specType", i)}) } } @@ -525,8 +533,9 @@ func (uc *ListE2ERunsUseCase) Execute(ctx context.Context, in ListE2ERunsInput) return ListE2ERunsOutput{}, NewInvalidArgument("environment must be one of: test, stage, prod", map[string]any{"field": "environment"}) } specType := strings.TrimSpace(in.SpecType) - if specType != "" && specType != "happyPath" && specType != "negativePath" { - return ListE2ERunsOutput{}, NewInvalidArgument("specType must be happyPath or negativePath", map[string]any{"field": "specType"}) + + if !validSpecTypes[specType] { + return ListE2ERunsOutput{}, NewInvalidArgument("specType must be happyPath, negativePath, or setup", map[string]any{"field": "specType"}) } runs, total, err := uc.runs.ListByProject(ctx, in.ProjectID, in.Branch, status, environment, specType, in.From, in.To, page, pageSize) @@ -670,6 +679,7 @@ func (uc *GetE2ERunUseCase) Execute(ctx context.Context, projectID string, runID type E2EHeatmapInput struct { Branch string Status string + SpecType string RunsPerProject int } @@ -699,7 +709,12 @@ func (uc *GetE2EHeatmapUseCase) Execute(ctx context.Context, in E2EHeatmapInput) return GetE2EHeatmapOutput{}, NewInvalidArgument("status must be passed or failed", map[string]any{"field": "status"}) } - rows, err := uc.runs.HeatmapData(ctx, in.Branch, status, runsPerProject) + spec_type := strings.TrimSpace(in.SpecType) + if !validSpecTypes[spec_type] { + return GetE2EHeatmapOutput{}, NewInvalidArgument("specType must be happyPath, negativePath, or setup", map[string]any{"field": "specType"}) + } + + rows, err := uc.runs.HeatmapData(ctx, in.Branch, status, spec_type, runsPerProject) if err != nil { return GetE2EHeatmapOutput{}, NewInternal("failed to load heatmap data", err) } diff --git a/internal/application/mock_application.go b/internal/application/mock_application.go index 3f3628c..091abb4 100644 --- a/internal/application/mock_application.go +++ b/internal/application/mock_application.go @@ -82,7 +82,7 @@ func (s *stubE2ETestRunRepository) ListByProject(ctx context.Context, projectID return s.listed, s.listTotal, nil } -func (s *stubE2ETestRunRepository) HeatmapData(ctx context.Context, branch string, status string, runsPerProject int) ([]TestHeatmapRow, error) { +func (s *stubE2ETestRunRepository) HeatmapData(ctx context.Context, branch string, status string, specType string, runsPerProject int) ([]TestHeatmapRow, error) { s.capturedBranch = branch s.capturedStatus = status if s.heatmapErr != nil { diff --git a/internal/application/ports.go b/internal/application/ports.go index 6fe9e1e..0df19d3 100644 --- a/internal/application/ports.go +++ b/internal/application/ports.go @@ -65,7 +65,7 @@ type E2ETestRunRepository interface { GetLatestByProject(ctx context.Context, projectID string) (domain.E2ETestRun, error) GetByID(ctx context.Context, projectID string, runID string) (domain.E2ETestRun, error) ListByProject(ctx context.Context, projectID string, branch string, status string, environment string, specType string, from *time.Time, to *time.Time, page int, pageSize int) ([]domain.E2ETestRun, int, error) - HeatmapData(ctx context.Context, branch string, status string, runsPerProject int) ([]TestHeatmapRow, error) + HeatmapData(ctx context.Context, branch string, status string, spec_type string, runsPerProject int) ([]TestHeatmapRow, error) } type E2ESpecResultRepository interface { diff --git a/migrations/004_add_spec_type.sql b/migrations/004_add_spec_type.sql index 0c183a2..d26262d 100644 --- a/migrations/004_add_spec_type.sql +++ b/migrations/004_add_spec_type.sql @@ -1,7 +1,7 @@ -- +goose Up ALTER TABLE e2e_test_spec_results - ADD COLUMN spec_type TEXT CHECK (spec_type IN ('happyPath', 'negativePath')); + ADD COLUMN spec_type TEXT CHECK (spec_type IN ('setup', 'happyPath', 'negativePath')) ; CREATE INDEX IF NOT EXISTS e2e_test_spec_results_spec_type_idx ON e2e_test_spec_results(e2e_run_id, spec_type) ; From 233b0fb158328d7f57223ada9c7bf8cf3e3acbf7 Mon Sep 17 00:00:00 2001 From: RaihanSultana Date: Tue, 16 Jun 2026 10:37:13 -0600 Subject: [PATCH 11/15] updated the success ratio --- cmd/frontend/web/assets/e2e.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cmd/frontend/web/assets/e2e.js b/cmd/frontend/web/assets/e2e.js index 9d845ff..4170b70 100644 --- a/cmd/frontend/web/assets/e2e.js +++ b/cmd/frontend/web/assets/e2e.js @@ -653,9 +653,9 @@ async function loadE2ERuns(projectId, preferredRunId = null) { if (passedRuns === 0 && failedRuns === 0) { e2ePassRate.textContent = '-'; } else if (failedRuns === 0) { - e2ePassRate.textContent = '∞%'; + e2ePassRate.textContent = '100%'; } else { - e2ePassRate.textContent = `${((passedRuns / failedRuns) * 100).toFixed(2)}%`; + e2ePassRate.textContent = `${((passedRuns / (passedRuns + failedRuns)) * 100).toFixed(2)}%`; } if (items.length === 0) { From 9c4268f6f64eee24540c12636ec3aade824e0ebb Mon Sep 17 00:00:00 2001 From: RaihanSultana Date: Tue, 16 Jun 2026 10:48:57 -0600 Subject: [PATCH 12/15] refactor to save the spec type --- cmd/coveragecli/main.go | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/cmd/coveragecli/main.go b/cmd/coveragecli/main.go index 03a4685..8a8dd8b 100644 --- a/cmd/coveragecli/main.go +++ b/cmd/coveragecli/main.go @@ -671,20 +671,21 @@ func normalizePlaywrightReport(raw map[string]any) map[string]any { } } - // save the spec_type // spec_type can be either setup, happyPath or negativePath - spec_type = firstString(testMap, "projectId") - if strings.Contains(file, "setup") { + // checks if the file name contains "setup", "happyPath" or "negativePath" to determine the spec_type + // fall back to checking the projectId + projectID := firstString(testMap, "projectId") + + switch { + case strings.Contains(file, "setup"): spec_type = "setup" - } else if strings.Contains(file, "happyPath") { + case strings.Contains(file, "happyPath"): spec_type = "happyPath" - } else if strings.Contains(file, "negativePath") { + case strings.Contains(file, "negativePath"): spec_type = "negativePath" - } else if firstString(testMap, "projectId") == "happyPath" || firstString(testMap, "projectId") == "negativePath" { - spec_type = firstString(testMap, "projectId") + default: + spec_type = projectID } - - fmt.Println("spec_type: ", spec_type) } } From 4d4e3fbb1fda874a52b4745056d263e631b90a17 Mon Sep 17 00:00:00 2001 From: RaihanSultana Date: Tue, 16 Jun 2026 11:09:20 -0600 Subject: [PATCH 13/15] updated failed spec query --- cmd/frontend/web/assets/e2e.js | 14 +++----------- .../postgres/e2e_spec_result_repository.go | 6 ++---- 2 files changed, 5 insertions(+), 15 deletions(-) diff --git a/cmd/frontend/web/assets/e2e.js b/cmd/frontend/web/assets/e2e.js index 4170b70..0373e90 100644 --- a/cmd/frontend/web/assets/e2e.js +++ b/cmd/frontend/web/assets/e2e.js @@ -792,20 +792,12 @@ async function loadE2ERunDetails(projectId, runId) { if (!res.ok) throw new Error(`failed to load E2E run details (${res.status})`); const data = await res.json(); const failedSpecs = data.failedSpecs || []; - const selectedSpecType = e2eSpecTypeFilter.value; - const filtered = selectedSpecType - ? failedSpecs.filter(s => s.specType === selectedSpecType) - : failedSpecs; - - if (filtered.length === 0) { - const msg = failedSpecs.length === 0 - ? 'No failed specs for this run.' - : `No failed specs matching spec type "${escapeHtml(selectedSpecType)}".`; - e2eFailedSpecsBody.innerHTML = `${msg}`; + if (failedSpecs.length === 0) { + e2eFailedSpecsBody.innerHTML = 'No failed specs for this run.'; return; } - for (const failed of filtered) { + for (const failed of failedSpecs) { const tr = document.createElement('tr'); tr.innerHTML = ` ${escapeHtml(failed.specPath || '-')} diff --git a/internal/adapters/postgres/e2e_spec_result_repository.go b/internal/adapters/postgres/e2e_spec_result_repository.go index b698cba..9fbc37c 100644 --- a/internal/adapters/postgres/e2e_spec_result_repository.go +++ b/internal/adapters/postgres/e2e_spec_result_repository.go @@ -53,8 +53,7 @@ func (r *E2ESpecResultRepository) ListByRunID(ctx context.Context, runID string) q := getQuerier(ctx, r.pool) rows, err := q.Query(ctx, ` SELECT id, e2e_run_id, spec_path, leaf_node_text, state, duration_ms, - failure_message, failure_location_file, failure_location_line, - COALESCE(spec_type, 'happyPath') AS spec_type + failure_message, failure_location_file, failure_location_line, spec_type FROM e2e_test_spec_results WHERE e2e_run_id = $1 ORDER BY spec_path ASC @@ -95,8 +94,7 @@ func (r *E2ESpecResultRepository) ListFailedByRunID(ctx context.Context, runID s q := getQuerier(ctx, r.pool) rows, err := q.Query(ctx, ` SELECT id, e2e_run_id, spec_path, leaf_node_text, state, duration_ms, - failure_message, failure_location_file, failure_location_line, - COALESCE(spec_type, 'happyPath') AS spec_type + failure_message, failure_location_file, failure_location_line, spec_type FROM e2e_test_spec_results WHERE e2e_run_id = $1 AND state IN ('failed', 'flaky') ORDER BY spec_path ASC From c5bf43fc916bfa71da2c55c6d4fe5a565fce3f35 Mon Sep 17 00:00:00 2001 From: RaihanSultana Date: Wed, 17 Jun 2026 15:42:49 -0600 Subject: [PATCH 14/15] resolved pr comment - refactored spec type normalization and frontend --- cmd/coveragecli/main.go | 4 +++- cmd/frontend/web/assets/e2e.js | 10 +++++----- internal/application/e2e_usecase.go | 11 ++++++----- internal/application/e2e_usecase_test.go | 11 +++++++++++ internal/application/ports.go | 2 +- migrations/004_add_spec_type.sql | 2 +- 6 files changed, 27 insertions(+), 13 deletions(-) diff --git a/cmd/coveragecli/main.go b/cmd/coveragecli/main.go index 8a8dd8b..ba7d052 100644 --- a/cmd/coveragecli/main.go +++ b/cmd/coveragecli/main.go @@ -683,8 +683,10 @@ func normalizePlaywrightReport(raw map[string]any) map[string]any { spec_type = "happyPath" case strings.Contains(file, "negativePath"): spec_type = "negativePath" - default: + case projectID == "happypath" || projectID == "negativePath" || projectID == "setup": spec_type = projectID + default: + spec_type = "happyPath" } } } diff --git a/cmd/frontend/web/assets/e2e.js b/cmd/frontend/web/assets/e2e.js index 0373e90..43d0de3 100644 --- a/cmd/frontend/web/assets/e2e.js +++ b/cmd/frontend/web/assets/e2e.js @@ -383,7 +383,7 @@ async function loadProjects() { e2eRunChain.innerHTML = '

No projects match current filters.

'; e2eRunsBody.innerHTML = 'No projects match current filters.'; } - e2eFailedSpecsBody.innerHTML = 'No run selected.'; + e2eFailedSpecsBody.innerHTML = 'No run selected.'; e2eStatus.textContent = '-'; e2eStatus.className = 'value'; e2ePassRate.textContent = '-'; @@ -494,7 +494,7 @@ async function ensureSelectedProjectIsVisible() { e2eScreenProjectMeta.textContent = 'Adjust group and search filters to select a project.'; e2eRunChain.innerHTML = '

No projects match current filters.

'; e2eRunsBody.innerHTML = 'No projects match current filters.'; - e2eFailedSpecsBody.innerHTML = 'No run selected.'; + e2eFailedSpecsBody.innerHTML = 'No run selected.'; e2eStatus.textContent = '-'; e2eStatus.className = 'value'; e2ePassRate.textContent = '-'; @@ -569,7 +569,7 @@ async function loadE2EScreen(projectId, options = {}) { if (!projectId) { e2eRunChain.innerHTML = '

Select a project to view its run chain.

'; e2eRunsBody.innerHTML = 'Select a project first.'; - e2eFailedSpecsBody.innerHTML = 'No run selected.'; + e2eFailedSpecsBody.innerHTML = 'No run selected.'; return; } @@ -665,7 +665,7 @@ async function loadE2ERuns(projectId, preferredRunId = null) { e2eFailedSpecsCount.textContent = '-'; e2eRunChain.innerHTML = '

No E2E runs found for current filters.

'; e2eRunsBody.innerHTML = 'No E2E runs found.'; - e2eFailedSpecsBody.innerHTML = 'No run selected.'; + e2eFailedSpecsBody.innerHTML = 'No run selected.'; return; } @@ -793,7 +793,7 @@ async function loadE2ERunDetails(projectId, runId) { const data = await res.json(); const failedSpecs = data.failedSpecs || []; if (failedSpecs.length === 0) { - e2eFailedSpecsBody.innerHTML = 'No failed specs for this run.'; + e2eFailedSpecsBody.innerHTML = 'No failed specs for this run.'; return; } diff --git a/internal/application/e2e_usecase.go b/internal/application/e2e_usecase.go index 355ff5a..11476da 100644 --- a/internal/application/e2e_usecase.go +++ b/internal/application/e2e_usecase.go @@ -385,7 +385,8 @@ func validateE2EIngestInput(in IngestE2ERunInput) error { return NewInvalidArgument("failure.message is required when state is failed", map[string]any{"field": fmt.Sprintf("testReport.specReports[%d].failure.message", i)}) } - if !validSpecTypes[spec.SpecType] { + specType := strings.TrimSpace(spec.SpecType) + if !validSpecTypes[specType] { return NewInvalidArgument("specType must be happyPath, negativePath, or setup", map[string]any{"field": fmt.Sprintf("testReport.specReports[%d].specType", i)}) } } @@ -709,12 +710,12 @@ func (uc *GetE2EHeatmapUseCase) Execute(ctx context.Context, in E2EHeatmapInput) return GetE2EHeatmapOutput{}, NewInvalidArgument("status must be passed or failed", map[string]any{"field": "status"}) } - spec_type := strings.TrimSpace(in.SpecType) - if !validSpecTypes[spec_type] { - return GetE2EHeatmapOutput{}, NewInvalidArgument("specType must be happyPath, negativePath, or setup", map[string]any{"field": "specType"}) + specType := strings.TrimSpace(in.SpecType) + if !validSpecTypes[specType] { + return GetE2EHeatmapOutput{}, NewInvalidArgument("specType must be one of: happyPath, negativePath, setup (or empty)", map[string]any{"field": "specType"}) } - rows, err := uc.runs.HeatmapData(ctx, in.Branch, status, spec_type, runsPerProject) + rows, err := uc.runs.HeatmapData(ctx, in.Branch, status, specType, runsPerProject) if err != nil { return GetE2EHeatmapOutput{}, NewInternal("failed to load heatmap data", err) } diff --git a/internal/application/e2e_usecase_test.go b/internal/application/e2e_usecase_test.go index 8c4f9f3..17ea771 100644 --- a/internal/application/e2e_usecase_test.go +++ b/internal/application/e2e_usecase_test.go @@ -81,6 +81,7 @@ func TestIngestE2ERunUseCaseExecute(t *testing.T) { ContainerHierarchyTexts: []string{"Auth Failure"}, State: "Passed", RunTime: 2.00, + SpecType: "happyPath", Failure: &IngestTestFailure{ Message: "Auth Failure", Location: &IngestTestLocation{ @@ -627,6 +628,13 @@ func TestValidateE2EIngestInput(t *testing.T) { }, wantErr: false, }, + { + name: "setup specType passes validation", + mutate: func(in *IngestE2ERunInput) { + in.TestReport.SpecReports[0].SpecType = "setup" + }, + wantErr: false, + }, } for _, tt := range test { t.Run(tt.name, func(t *testing.T) { @@ -740,6 +748,7 @@ func TestListE2ERunsExecute(t *testing.T) { Branch: "main", Status: "passed", Environment: "test", + SpecType: "happyPath", From: &from, To: &to, Page: 1, @@ -809,6 +818,7 @@ func TestListE2ERunsExecute(t *testing.T) { _, err := uc.Execute(context.Background(), ListE2ERunsInput{ ProjectID: "proj1", Status: "invalid", + SpecType: "happyPath", }) if err == nil { t.Fatalf("expected error, got nil") @@ -1147,6 +1157,7 @@ func TestGetE2EHeatmapExecute(t *testing.T) { out, err := uc.Execute(context.Background(), E2EHeatmapInput{ Branch: "main", Status: "failed", + SpecType: "happyPath", RunsPerProject: 10, }) if err != nil { diff --git a/internal/application/ports.go b/internal/application/ports.go index 0df19d3..6cda445 100644 --- a/internal/application/ports.go +++ b/internal/application/ports.go @@ -65,7 +65,7 @@ type E2ETestRunRepository interface { GetLatestByProject(ctx context.Context, projectID string) (domain.E2ETestRun, error) GetByID(ctx context.Context, projectID string, runID string) (domain.E2ETestRun, error) ListByProject(ctx context.Context, projectID string, branch string, status string, environment string, specType string, from *time.Time, to *time.Time, page int, pageSize int) ([]domain.E2ETestRun, int, error) - HeatmapData(ctx context.Context, branch string, status string, spec_type string, runsPerProject int) ([]TestHeatmapRow, error) + HeatmapData(ctx context.Context, branch string, status string, specType string, runsPerProject int) ([]TestHeatmapRow, error) } type E2ESpecResultRepository interface { diff --git a/migrations/004_add_spec_type.sql b/migrations/004_add_spec_type.sql index d26262d..c83cfd3 100644 --- a/migrations/004_add_spec_type.sql +++ b/migrations/004_add_spec_type.sql @@ -1,7 +1,7 @@ -- +goose Up ALTER TABLE e2e_test_spec_results - ADD COLUMN spec_type TEXT CHECK (spec_type IN ('setup', 'happyPath', 'negativePath')) ; + ADD COLUMN spec_type TEXT NOT NULL DEFAULT '' CHECK (spec_type IN ('setup', 'happyPath', 'negativePath', '')) ; CREATE INDEX IF NOT EXISTS e2e_test_spec_results_spec_type_idx ON e2e_test_spec_results(e2e_run_id, spec_type) ; From 620a3f7958dc92f5e2ab7be6d3f713de95cd1eae Mon Sep 17 00:00:00 2001 From: RaihanSultana Date: Wed, 24 Jun 2026 11:47:32 -0600 Subject: [PATCH 15/15] support appium junit report --- cmd/coveragecli/main.go | 217 ++++++++++++++++-- cmd/coveragecli/main_test.go | 89 +++++++ .../testdata/appium-junit-report.xml | 25 ++ 3 files changed, 313 insertions(+), 18 deletions(-) create mode 100644 cmd/coveragecli/testdata/appium-junit-report.xml diff --git a/cmd/coveragecli/main.go b/cmd/coveragecli/main.go index ba7d052..1045d10 100644 --- a/cmd/coveragecli/main.go +++ b/cmd/coveragecli/main.go @@ -3,6 +3,7 @@ package main import ( "bytes" "encoding/json" + "encoding/xml" "errors" "flag" "fmt" @@ -12,6 +13,7 @@ import ( "os" "os/exec" "path" + "path/filepath" "regexp" "sort" "strconv" @@ -114,6 +116,57 @@ type metricAgg struct { Total float64 } +// JUnit XML structs — shared between Playwright and Appium JUnit reports. +// JUnitTestSuites represents the root element in JUnit XML. +type JUnitTestSuites struct { + XMLName xml.Name `xml:"testsuites"` + Name string `xml:"name,attr,omitempty"` + Tests int `xml:"tests,attr,omitempty"` + Failures int `xml:"failures,attr,omitempty"` + Errors int `xml:"errors,attr,omitempty"` + Time float64 `xml:"time,attr,omitempty"` + TestSuites []JUnitTestSuite `xml:"testsuite"` +} + +// JUnitTestSuite represents a single element in JUnit XML. +type JUnitTestSuite struct { + Name string `xml:"name,attr"` + Tests int `xml:"tests,attr,omitempty"` + Failures int `xml:"failures,attr,omitempty"` + Errors int `xml:"errors,attr,omitempty"` + Skipped int `xml:"skipped,attr,omitempty"` + Time float64 `xml:"time,attr,omitempty"` + Timestamp string `xml:"timestamp,attr,omitempty"` + Hostname string `xml:"hostname,attr,omitempty"` + Properties []JUnitProperty `xml:"properties>property,omitempty"` + TestCases []JUnitTestCase `xml:"testcase"` + SystemOut string `xml:"system-out,omitempty"` +} + +type JUnitTestCase struct { + Classname string `xml:"classname,attr,omitempty"` + Name string `xml:"name,attr"` + Time float64 `xml:"time,attr,omitempty"` + Status string `xml:"status,attr,omitempty"` + Failure *JUnitFailure `xml:"failure,omitempty"` + Skipped *JUnitSkipped `xml:"skipped,omitempty"` +} + +type JUnitFailure struct { + Message string `xml:"message,attr,omitempty"` + Type string `xml:"type,attr,omitempty"` + Body string `xml:",chardata"` +} + +type JUnitSkipped struct { + Message string `xml:"message,attr,omitempty"` +} + +type JUnitProperty struct { + Name string `xml:"name,attr"` + Value string `xml:"value,attr"` +} + func main() { if len(os.Args) > 1 { switch os.Args[1] { @@ -437,7 +490,7 @@ func runE2EUpload(args []string) { triggerType := fs.String("trigger-type", "manual", "Trigger type: push|pr|manual") environment := fs.String("environment", "", "Environment: test|stage|prod (optional)") runTimestamp := fs.String("run-timestamp", time.Now().UTC().Format(time.RFC3339), "Run timestamp (RFC3339)") - platformType := fs.String("platform-type", "web", "Platform type: web|android|ios") + if err := fs.Parse(args); err != nil { exitErr("parse flags", err) } @@ -457,9 +510,16 @@ func runE2EUpload(args []string) { exitErr("read e2e report", err) } - var report map[string]any - if err := json.Unmarshal(rawReport, &report); err != nil { - exitErr("parse e2e report json", err) + // Detect file format from extension + ext := strings.ToLower(filepath.Ext(*reportPath)) + var isXML bool + switch ext { + case ".xml": + isXML = true + case ".json": + isXML = false + default: + exitErr("validate input", fmt.Errorf("unsupported file extension %q: expected .json or .xml", ext)) } var group *string @@ -475,17 +535,35 @@ func runE2EUpload(args []string) { env = environment } - // Normalize report structure based on report type - var normalizeReport map[string]any - switch *reportType { - case "playwright": - normalizeReport = normalizePlaywrightReport(report) - case "appium": - normalizeReport = normalizeAppiumReport(report) - default: - exitErr("validate input", fmt.Errorf("unsupported report type: %s", *reportType)) + // Normalize report structure based on report type and file format + var normalizedReport map[string]any + if isXML { + var junitData JUnitTestSuites + if err := xml.Unmarshal(rawReport, &junitData); err != nil { + exitErr("parse e2e report xml", err) + } + switch *reportType { + case "playwright": + normalizedReport = normalizePlaywrightJUnit(junitData) + case "appium": + normalizedReport = normalizeAppiumJUnit(junitData) + default: + exitErr("validate input", fmt.Errorf("unsupported report type: %s", *reportType)) + } + } else { + var report map[string]any + if err := json.Unmarshal(rawReport, &report); err != nil { + exitErr("parse e2e report json", err) + } + switch *reportType { + case "playwright": + normalizedReport = normalizePlaywrightReport(report) + case "appium": + exitErr("validate input", fmt.Errorf("appium JSON report format is not yet supported; use JUnit XML (.xml)")) + default: + exitErr("validate input", fmt.Errorf("unsupported report type: %s", *reportType)) + } } - normalizeReport["platformType"] = *platformType payload := e2ePayload{ ProjectKey: *projectKey, @@ -498,7 +576,7 @@ func runE2EUpload(args []string) { TriggerType: *triggerType, RunTimestamp: *runTimestamp, Environment: env, - TestReport: normalizeReport, + TestReport: normalizedReport, } body, err := json.MarshalIndent(payload, "", " ") @@ -717,12 +795,115 @@ func normalizePlaywrightReport(raw map[string]any) map[string]any { return result } -func normalizeAppiumReport(raw map[string]any) map[string]any { - // not implemented yet - exitErr("normalize report", fmt.Errorf("appium report normalization not implemented yet")) +// normalizePlaywrightJUnit converts a Playwright JUnit XML report into the normalized map[string]any structure. +// Playwright JUnit uses classname format: "file › Suite Title › Nested Suite" +func normalizePlaywrightJUnit(data JUnitTestSuites) map[string]any { + fmt.Errorf("Playwright JUnit XML normalization is not yet implemented") return nil } +// normalizeAppiumJUnit converts an Appium JUnit XML report into the normalized map[string]any structure. +// Appium JUnit uses classname format: "com.package.ClassName" (dot-separated) +func normalizeAppiumJUnit(data JUnitTestSuites) map[string]any { + result := make(map[string]any) + testFramework := "appium" + result["reportType"] = &testFramework + result["testFramework"] = &testFramework + + // Use top-level testsuites name as suiteDescription + if data.Name != "" { + result["suiteDescription"] = data.Name + } + result["suitePath"] = data.TestSuites[0].TestCases[0].Classname + result["frameworkVersion"] = "" + + // Extract platform metadata from first testsuite's properties + // Set default platform type for Appium + platformType := "android" + if len(data.TestSuites) > 0 { + for _, prop := range data.TestSuites[0].Properties { + switch prop.Name { + case "platformName": + platformType = strings.ToLower(prop.Value) + case "automationName": + result["frameworkVersion"] = prop.Value + } + } + } + result["platformType"] = platformType + fmt.Println("normalize: ", result["platformType"]) + + var specReports []map[string]any + for _, suite := range data.TestSuites { + for _, tc := range suite.TestCases { + // Appium classname format: "com.package.tests.Login.LoginPass" (split on ".") + var hierarchy []any + if tc.Classname != "" { + parts := strings.Split(tc.Classname, ".") + for _, p := range parts { + if p != "" { + hierarchy = append(hierarchy, p) + } + } + } + + // Determine state from failure/skipped elements or status attribute + state := "passed" + if tc.Failure != nil { + state = "failed" + } else if tc.Skipped != nil { + state = "skipped" + } else if tc.Status != "" { + // Some Appium/TestNG reporters include a status attribute + switch strings.ToLower(tc.Status) { + case "passed": + state = "passed" + case "failed": + state = "failed" + case "skipped": + state = "skipped" + } + } + + // Determine specType from classname keywords + specType := "happyPath" + classLower := strings.ToLower(tc.Classname) + switch { + case strings.Contains(classLower, "setup"): + specType = "setup" + case strings.Contains(classLower, "happyPath"): + specType = "happyPath" + case strings.Contains(classLower, "negativepath"): + specType = "negativePath" + default: + specType = "happyPath" + } + + spec := map[string]any{ + "leafNodeText": tc.Name, + "containerHierarchyTexts": hierarchy, + "state": state, + "runTime": tc.Time, + "suite_type": suite.Name, + "specType": specType, + } + + if tc.Failure != nil { + failure := map[string]any{ + "message": tc.Failure.Message, + } + if tc.Failure.Body != "" { + failure["stackTrace"] = strings.TrimSpace(tc.Failure.Body) + } + spec["failure"] = failure + } + specReports = append(specReports, spec) + } + } + result["specReports"] = specReports + return result +} + // stripANSI removes ANSI escape codes from a string. // This is useful to clean up error messages from Playwright which may include ANSI codes for coloring. func stripANSI(s string) string { diff --git a/cmd/coveragecli/main_test.go b/cmd/coveragecli/main_test.go index 1b68754..cb2c400 100644 --- a/cmd/coveragecli/main_test.go +++ b/cmd/coveragecli/main_test.go @@ -2,6 +2,7 @@ package main import ( "encoding/json" + "encoding/xml" "os" "path/filepath" "testing" @@ -405,3 +406,91 @@ func TestStripANSI(t *testing.T) { }) } } + +func TestNormalizeAppiumJUnit(t *testing.T) { + t.Parallel() + + raw, err := os.ReadFile(filepath.Join("testdata", "appium-junit-report.xml")) + if err != nil { + t.Fatalf("read fixture: %v", err) + } + + var data JUnitTestSuites + if err := xml.Unmarshal(raw, &data); err != nil { + t.Fatalf("unmarshal xml: %v", err) + } + + result := normalizeAppiumJUnit(data) + + // Verify metadata + if rt, ok := result["reportType"].(*string); !ok || *rt != "appium" { + t.Fatalf("reportType = %v, want *appium", result["reportType"]) + } + if result["suiteDescription"] != "Appium Android Test Suite" { + t.Fatalf("suiteDescription = %v, want 'Appium Android Test Suite'", result["suiteDescription"]) + } + if result["platformType"] != "android" { + t.Fatalf("platformType = %v, want android", result["platformType"]) + } + if result["frameworkVersion"] != "UiAutomator2" { + t.Fatalf("frameworkVersion = %v, want UiAutomator2", result["frameworkVersion"]) + } + + specs, ok := result["specReports"].([]map[string]any) + if !ok { + t.Fatalf("specReports is not []map[string]any") + } + if len(specs) != 3 { + t.Fatalf("specReports count = %d, want 3", len(specs)) + } + + tests := []struct { + name string + state string + specType string + time float64 + }{ + {"Test1", "passed", "happyPath", 12.542}, + {"Test2", "failed", "happyPath", 9.118}, + {"Test3", "passed", "happyPath", 20.658}, + } + + for i, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + spec := specs[i] + if spec["leafNodeText"] != tt.name { + t.Errorf("spec[%d] leafNodeText = %v, want %v", i, spec["leafNodeText"], tt.name) + } + if spec["state"] != tt.state { + t.Errorf("spec[%d] state = %v, want %v", i, spec["state"], tt.state) + } + if spec["specType"] != tt.specType { + t.Errorf("spec[%d] specType = %v, want %v", i, spec["specType"], tt.specType) + } + if spec["runTime"] != tt.time { + t.Errorf("spec[%d] runTime = %v, want %v", i, spec["runTime"], tt.time) + } + // Verify hierarchy + hier, ok := spec["containerHierarchyTexts"].([]any) + if !ok || len(hier) == 0 { + t.Errorf("spec[%d] containerHierarchyTexts empty", i) + } + if len(hier) > 0 && hier[0] != "Tests" { + t.Errorf("spec[%d] hierarchy[0] = %v, want Tests", i, hier[0]) + } + }) + } + + // Verify failure block on the failed spec + failedSpec := specs[1] + failure, ok := failedSpec["failure"].(map[string]any) + if !ok { + t.Fatalf("failed spec has no failure block") + } + if failure["message"] != "Assertion failed: Expected error message not displayed" { + t.Errorf("failure message = %v", failure["message"]) + } + if _, ok := failure["stackTrace"]; !ok { + t.Errorf("failure missing stackTrace") + } +} diff --git a/cmd/coveragecli/testdata/appium-junit-report.xml b/cmd/coveragecli/testdata/appium-junit-report.xml new file mode 100644 index 0000000..bd1fbe4 --- /dev/null +++ b/cmd/coveragecli/testdata/appium-junit-report.xml @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file