diff --git a/cmd/hiveview/assets/lib/app-suite.js b/cmd/hiveview/assets/lib/app-suite.js index 01111f30dc..40d104ab23 100644 --- a/cmd/hiveview/assets/lib/app-suite.js +++ b/cmd/hiveview/assets/lib/app-suite.js @@ -327,9 +327,18 @@ function formatClientLogsList(suiteData, testIndex, clientInfo) { for (let instanceID in clientInfo) { let instanceInfo = clientInfo[instanceID]; let logfile = routes.resultsRoot + instanceInfo.logFile; - let url = routes.clientLog(suiteData.suiteID, suiteData.name, testIndex, logfile); + let url = routes.clientLog( + suiteData.suiteID, + suiteData.name, + testIndex, + logfile, + instanceInfo.logOffsets // Pass byte offsets if available + ); let link = html.makeLink(url, instanceInfo.name); link.classList.add('log-link'); + if (instanceInfo.logOffsets) { + link.title = `Filtered: bytes ${instanceInfo.logOffsets.begin}-${instanceInfo.logOffsets.end}`; + } links.push(link.outerHTML); } return links.join(', '); diff --git a/cmd/hiveview/assets/lib/app-viewer.js b/cmd/hiveview/assets/lib/app-viewer.js index 887492638b..919b4eceac 100644 --- a/cmd/hiveview/assets/lib/app-viewer.js +++ b/cmd/hiveview/assets/lib/app-viewer.js @@ -18,6 +18,18 @@ $(document).ready(function () { line = parseInt(window.location.hash.substr(2)); } + // Check for byte range parameters (for multi-test client log highlighting). + let byteBegin = queryParam('begin'); + let byteEnd = queryParam('end'); + let byteRange = null; + if (byteBegin !== null && byteEnd !== null) { + let begin = parseInt(byteBegin, 10); + let end = parseInt(byteEnd, 10); + if (!isNaN(begin) && !isNaN(end)) { + byteRange = { begin, end }; + } + } + // Get suite context. let suiteFile = queryParam('suiteid'); let suiteName = queryParam('suitename'); @@ -42,7 +54,7 @@ $(document).ready(function () { if (file) { $('#fileload').val(file); showText('Loading file...'); - fetchFile(file, line); + fetchFile(file, line, byteRange); return; } @@ -50,27 +62,31 @@ $(document).ready(function () { showText(document.getElementById('exampletext').innerHTML); }); -// setHL sets the highlight on a line number. -function setHL(num, scroll) { +// setHL sets the highlight on a line number or range. +function setHL(startNum, scroll, endNum) { // out with the old $('.highlighted').removeClass('highlighted'); - if (!num) { + if (!startNum) { return; } let contentArea = document.getElementById('file-content'); let gutter = document.getElementById('gutter'); - let numElem = gutter.children[num - 1]; - if (!numElem) { - console.error('invalid line number:', num); - return; + endNum = endNum || startNum; // Single line if no end specified + + for (let num = startNum; num <= endNum; num++) { + let numElem = gutter.children[num - 1]; + let lineElem = contentArea.children[num - 1]; + if (numElem) $(numElem).addClass('highlighted'); + if (lineElem) $(lineElem).addClass('highlighted'); } - // in with the new - let lineElem = contentArea.children[num - 1]; - $(numElem).addClass('highlighted'); - $(lineElem).addClass('highlighted'); + if (scroll) { - numElem.scrollIntoView(); + let contextLines = 5; // Show a few lines before the highlight for context. + let scrollTarget = Math.max(0, startNum - 1 - contextLines); + if (gutter.children[scrollTarget]) { + gutter.children[scrollTarget].scrollIntoView({ behavior: 'smooth', block: 'start' }); + } } } @@ -168,7 +184,7 @@ function lineNumberClicked() { } // fetchFile loads up a new file to view -async function fetchFile(url, line /* optional jump to line */ ) { +async function fetchFile(url, line, byteRange) { let resultsRE = new RegExp('^' + routes.resultsRoot); let text; try { @@ -181,7 +197,42 @@ async function fetchFile(url, line /* optional jump to line */ ) { let title = url.replace(resultsRE, ''); showTitle(null, title); showText(text); - setHL(line, true); + + // Highlight byte range if provided, otherwise use line number + if (byteRange) { + let lineRange = byteRangeToLineNumbers(text, byteRange); + setHL(lineRange.start, true, lineRange.end); + } else { + setHL(line, true); + } +} + +// byteRangeToLineNumbers converts byte offsets to line numbers. +// Encodes text to UTF-8 bytes once, then counts newlines (0x0A) directly. +// The range [begin, end) is exclusive on end, so a trailing newline at +// position end-1 does not extend the highlight into the next line. +function byteRangeToLineNumbers(text, byteRange) { + const encoder = new TextEncoder(); + const bytes = encoder.encode(text); + + let startLine = 1, endLine = 1, foundStart = false; + const newlineByte = 0x0A; // '\n' in UTF-8 + + for (let bytePos = 0; bytePos < bytes.length && bytePos < byteRange.end; bytePos++) { + if (!foundStart && bytePos >= byteRange.begin) { + startLine = endLine; + foundStart = true; + } + if (bytes[bytePos] === newlineByte) { + endLine++; + } + } + // If end falls right after a newline, the line counter has already been + // bumped to the next line which belongs to the following test. Back up. + if (byteRange.end > 0 && byteRange.end <= bytes.length && bytes[byteRange.end - 1] === newlineByte) { + endLine = Math.max(startLine, endLine - 1); + } + return { start: startLine, end: endLine }; } // fetchTestLog loads the suite file and displays the output of a test. diff --git a/cmd/hiveview/assets/lib/routes.js b/cmd/hiveview/assets/lib/routes.js index d96e1c2d1d..d2f30e27ca 100644 --- a/cmd/hiveview/assets/lib/routes.js +++ b/cmd/hiveview/assets/lib/routes.js @@ -20,13 +20,17 @@ export function testLog(suiteID, suiteName, testIndex) { return 'viewer.html?' + params.toString(); } -export function clientLog(suiteID, suiteName, testIndex, file) { +export function clientLog(suiteID, suiteName, testIndex, file, logOffsets) { let params = new URLSearchParams({ 'suiteid': suiteID, 'suitename': suiteName, 'testid': testIndex, 'file': file, }); + if (logOffsets) { + params.set('begin', logOffsets.begin); + params.set('end', logOffsets.end); + } return 'viewer.html?' + params.toString(); } diff --git a/internal/libhive/api.go b/internal/libhive/api.go index bf90da9356..688ed41846 100644 --- a/internal/libhive/api.go +++ b/internal/libhive/api.go @@ -40,6 +40,7 @@ func newSimulationAPI(b ContainerBackend, env SimEnv, tm *TestManager, hive Hive router.HandleFunc("/testsuite/{suite}/test/{test}/node/{node}", api.stopClient).Methods("DELETE") router.HandleFunc("/testsuite/{suite}/test/{test}/node/{node}/pause", api.pauseClient).Methods("POST") router.HandleFunc("/testsuite/{suite}/test/{test}/node/{node}/pause", api.unpauseClient).Methods("DELETE") + router.HandleFunc("/testsuite/{suite}/test/{test}/node/{node}/register/{target}", api.registerMultiTestNode).Methods("POST") router.HandleFunc("/testsuite/{suite}/test", api.startTest).Methods("POST") // post because the delete http verb does not always support a message body router.HandleFunc("/testsuite/{suite}/test/{test}", api.endTest).Methods("POST") @@ -296,12 +297,15 @@ func (api *simAPI) startClient(w http.ResponseWriter, r *http.Request) { // Start it! info, err := api.backend.StartContainer(ctx, containerID, options) if info != nil { + // Capture the current log file size as the starting offset for this test. + logBegin := logFileSize(logFilePath) clientInfo := &ClientInfo{ ID: info.ID, IP: info.IP, Name: clientDef.Name, InstantiatedAt: time.Now(), LogFile: logPath, + LogOffsets: &TestLogOffsets{Begin: logBegin}, wait: info.Wait, } @@ -421,6 +425,76 @@ func (api *simAPI) unpauseClient(w http.ResponseWriter, r *http.Request) { } } +// registerMultiTestNode registers a client from one test with another test. +// This enables client reuse across multiple tests while maintaining proper +// clientInfo association for UI visibility in each individual test. +func (api *simAPI) registerMultiTestNode(w http.ResponseWriter, r *http.Request) { + suiteID, sourceTestID, err := api.requestSuiteAndTest(r) + if err != nil { + serveError(w, err, http.StatusBadRequest) + return + } + + vars := mux.Vars(r) + node := vars["node"] + targetStr := vars["target"] + targetTestID, err := strconv.Atoi(targetStr) + if err != nil { + serveError(w, fmt.Errorf("invalid target test ID: %v", err), http.StatusBadRequest) + return + } + + // Verify the target test belongs to this suite. + if _, ok := api.tm.IsTestInSuite(suiteID, TestID(targetTestID)); !ok { + serveError(w, fmt.Errorf("target test %d not in suite %d", targetTestID, suiteID), http.StatusBadRequest) + return + } + + // Get the client info from the source test. + nodeInfo, err := api.tm.GetNodeInfo(suiteID, sourceTestID, node) + if err != nil { + slog.Error("API: can't find node", "node", node, "sourceTest", sourceTestID, "error", err) + serveError(w, err, http.StatusNotFound) + return + } + + // Create a copy of the client info without the wait function, so the target + // test won't stop the container when it ends. A new LogOffsets is created with + // Begin set to the current log file size, giving each test its own byte range. + var logOffsets *TestLogOffsets + if nodeInfo.LogFile != "" { + logFilePath := filepath.Join(api.tm.config.LogDir, filepath.FromSlash(nodeInfo.LogFile)) + logOffsets = &TestLogOffsets{Begin: logFileSize(logFilePath)} + } + multiTestNodeInfo := &ClientInfo{ + ID: nodeInfo.ID, + IP: nodeInfo.IP, + Name: nodeInfo.Name, + InstantiatedAt: nodeInfo.InstantiatedAt, + LogFile: nodeInfo.LogFile, + LogOffsets: logOffsets, + // wait is intentionally nil - target tests shouldn't stop the client + } + + // Register the client with the target test. + err = api.tm.RegisterNode(TestID(targetTestID), node, multiTestNodeInfo) + if err != nil { + slog.Error("API: can't register multi-test node", "node", node, "targetTest", targetTestID, "error", err) + serveError(w, err, http.StatusInternalServerError) + return + } + + // Mark the source test as a multi-test context (lifecycle owner). + if err := api.tm.SetMultiTestContext(sourceTestID); err != nil { + slog.Error("API: can't set multi-test context", "sourceTest", sourceTestID, "error", err) + serveError(w, err, http.StatusInternalServerError) + return + } + + slog.Info("API: multi-test node registered", "node", node, "sourceTest", sourceTestID, "targetTest", targetTestID) + serveOK(w) +} + // getNodeStatus returns the status of a client container. func (api *simAPI) getNodeStatus(w http.ResponseWriter, r *http.Request) { suiteID, testID, err := api.requestSuiteAndTest(r) diff --git a/internal/libhive/data.go b/internal/libhive/data.go index 905b4c3598..f0b0f01cf4 100644 --- a/internal/libhive/data.go +++ b/internal/libhive/data.go @@ -146,6 +146,10 @@ type TestCase struct { End time.Time `json:"end"` SummaryResult TestResult `json:"summaryResult"` // The result of the whole test case. ClientInfo map[string]*ClientInfo `json:"clientInfo"` // Info about each client. + + // MultiTestContext is true when this test case is the lifecycle owner + // for clients shared across multiple tests (via registerMultiTestNode). + MultiTestContext bool `json:"multiTestContext,omitempty"` } // TestResult represents the result of a test case. @@ -170,7 +174,12 @@ type ClientInfo struct { IP string `json:"ip"` Name string `json:"name"` InstantiatedAt time.Time `json:"instantiatedAt"` - LogFile string `json:"logFile"` //Absolute path to the logfile. + LogFile string `json:"logFile"` // Relative path to the logfile. + + // LogOffsets contains byte offsets into LogFile for the portion of the log + // relevant to this test case. This enables filtering log output when a single + // client container serves multiple tests. + LogOffsets *TestLogOffsets `json:"logOffsets,omitempty"` wait func() } diff --git a/internal/libhive/logfilesize_test.go b/internal/libhive/logfilesize_test.go new file mode 100644 index 0000000000..140da15eaa --- /dev/null +++ b/internal/libhive/logfilesize_test.go @@ -0,0 +1,25 @@ +package libhive + +import ( + "os" + "path/filepath" + "testing" +) + +func TestLogFileSize(t *testing.T) { + // Non-existent file should return 0. + if got := logFileSize("/nonexistent/path/file.log"); got != 0 { + t.Fatalf("logFileSize(nonexistent) = %d, want 0", got) + } + + // Create a temp file with known content. + tmpFile := filepath.Join(t.TempDir(), "test.log") + content := []byte("hello world\n") + if err := os.WriteFile(tmpFile, content, 0644); err != nil { + t.Fatal("WriteFile:", err) + } + + if got := logFileSize(tmpFile); got != int64(len(content)) { + t.Fatalf("logFileSize = %d, want %d", got, len(content)) + } +} diff --git a/internal/libhive/testmanager.go b/internal/libhive/testmanager.go index 460c352bcd..9c737d7d99 100644 --- a/internal/libhive/testmanager.go +++ b/internal/libhive/testmanager.go @@ -173,6 +173,20 @@ func (manager *TestManager) IsTestSuiteRunning(testSuite TestSuiteID) (*TestSuit return suite, ok } +// IsTestInSuite checks if a test belongs to the given running suite. +func (manager *TestManager) IsTestInSuite(testSuite TestSuiteID, test TestID) (*TestCase, bool) { + manager.testSuiteMutex.RLock() + defer manager.testSuiteMutex.RUnlock() + suite, ok := manager.runningTestSuites[testSuite] + if !ok { + return nil, false + } + manager.testCaseMutex.RLock() + defer manager.testCaseMutex.RUnlock() + tc, ok := suite.TestCases[test] + return tc, ok +} + // IsTestRunning checks if the test is still running and returns it if so. func (manager *TestManager) IsTestRunning(test TestID) (*TestCase, bool) { manager.testCaseMutex.RLock() @@ -211,6 +225,18 @@ func (manager *TestManager) Terminate() error { return nil } +// SetMultiTestContext marks a test case as a multi-test lifecycle owner. +func (manager *TestManager) SetMultiTestContext(test TestID) error { + manager.testCaseMutex.Lock() + defer manager.testCaseMutex.Unlock() + tc, ok := manager.runningTestCases[test] + if !ok { + return ErrNoSuchTestCase + } + tc.MultiTestContext = true + return nil +} + // GetNodeInfo gets some info on a client belonging to some test func (manager *TestManager) GetNodeInfo(testSuite TestSuiteID, test TestID, nodeID string) (*ClientInfo, error) { manager.testCaseMutex.RLock() @@ -505,6 +531,16 @@ func (manager *TestManager) StartTest(testSuiteID TestSuiteID, name string, desc return newCaseID, nil } +// logFileSize returns the current size of a log file in bytes. +// Returns 0 if the file doesn't exist or cannot be read. +func logFileSize(path string) int64 { + info, err := os.Stat(path) + if err != nil { + return 0 + } + return info.Size() +} + // EndTest finishes the test case func (manager *TestManager) EndTest(suiteID TestSuiteID, testID TestID, result *TestResult) error { manager.testCaseMutex.Lock() @@ -533,6 +569,15 @@ func (manager *TestManager) EndTest(suiteID TestSuiteID, testID TestID, result * } testCase.SummaryResult = *result + // Capture log end offsets for all clients before stopping them. + // This enables log filtering when a client serves multiple tests. + for _, v := range testCase.ClientInfo { + if v.LogOffsets != nil && manager.config.LogDir != "" { + logFilePath := filepath.Join(manager.config.LogDir, filepath.FromSlash(v.LogFile)) + v.LogOffsets.End = logFileSize(logFilePath) + } + } + // Stop running clients. for _, v := range testCase.ClientInfo { if v.wait != nil { diff --git a/internal/libhive/testmanager_test.go b/internal/libhive/testmanager_test.go new file mode 100644 index 0000000000..c4b210d0ad --- /dev/null +++ b/internal/libhive/testmanager_test.go @@ -0,0 +1,195 @@ +package libhive_test + +import ( + "bytes" + "encoding/json" + "fmt" + "mime/multipart" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "github.com/ethereum/hive/internal/fakes" + "github.com/ethereum/hive/internal/libhive" + "github.com/ethereum/hive/internal/simapi" +) + +func TestRegisterMultiTestNode(t *testing.T) { + // Track container deletions to verify lifecycle behavior. + var deletedContainers []string + backend := fakes.NewContainerBackend(&fakes.BackendHooks{ + DeleteContainer: func(containerID string) error { + deletedContainers = append(deletedContainers, containerID) + return nil + }, + }) + + logDir := t.TempDir() + clients := []*libhive.ClientDefinition{{Name: "test-client", Image: "test-client-image"}} + tm := libhive.NewTestManager(libhive.SimEnv{LogDir: logDir}, backend, clients, libhive.HiveInfo{}) + handler := tm.API() + srv := httptest.NewServer(handler) + defer srv.Close() + + // Create suite and tests via exported Go methods. + suiteID, err := tm.StartTestSuite("test-suite", "test suite description") + if err != nil { + t.Fatal("StartTestSuite:", err) + } + sourceTestID, err := tm.StartTest(suiteID, "source-test", "source test") + if err != nil { + t.Fatal("StartTest (source):", err) + } + targetTestID, err := tm.StartTest(suiteID, "target-test", "target test") + if err != nil { + t.Fatal("StartTest (target):", err) + } + + // Create the client log directory (file is written after we know the container ID). + clientLogDir := filepath.Join(logDir, "test-client") + if err := os.MkdirAll(clientLogDir, 0755); err != nil { + t.Fatal("MkdirAll:", err) + } + + // Start a client on the source test via HTTP (this sets wait via the backend). + containerID := startClientHTTP(t, srv.URL, suiteID, sourceTestID) + + // Write initial log content now that we know the container ID. + logFilePath := filepath.Join(clientLogDir, "client-"+containerID+".log") + initialContent := []byte("line 1\nline 2\nline 3\n") + if err := os.WriteFile(logFilePath, initialContent, 0644); err != nil { + t.Fatal("WriteFile:", err) + } + + // Register multi-test node on target test via HTTP. + registerMultiTestNodeHTTP(t, srv.URL, suiteID, sourceTestID, containerID, targetTestID) + + // Append more content to the log (simulates client activity during target test). + additionalContent := []byte("line 4\nline 5\n") + f, err := os.OpenFile(logFilePath, os.O_APPEND|os.O_WRONLY, 0644) + if err != nil { + t.Fatal("OpenFile:", err) + } + f.Write(additionalContent) + f.Close() + + // End target test — container should NOT be deleted (wait is nil for multi-test copy). + targetResult := &libhive.TestResult{Pass: true, Details: "target test passed"} + if err := tm.EndTest(suiteID, targetTestID, targetResult); err != nil { + t.Fatal("EndTest (target):", err) + } + if len(deletedContainers) != 0 { + t.Fatalf("Expected no container deletions after ending target test, got %v", deletedContainers) + } + + // End source test — container SHOULD be deleted (wait is non-nil, source owns lifecycle). + sourceResult := &libhive.TestResult{Pass: true, Details: "source test passed"} + if err := tm.EndTest(suiteID, sourceTestID, sourceResult); err != nil { + t.Fatal("EndTest (source):", err) + } + if len(deletedContainers) != 1 || deletedContainers[0] != containerID { + t.Fatalf("Expected container %q to be deleted, got %v", containerID, deletedContainers) + } + + // End the suite so results are available. + if err := tm.EndTestSuite(suiteID); err != nil { + t.Fatal("EndTestSuite:", err) + } + + // Verify log offsets via Results(). + results := tm.Results() + suite, ok := results[suiteID] + if !ok { + t.Fatal("Suite not found in results") + } + + // Check target test's client log offsets. + targetCase := suite.TestCases[targetTestID] + if targetCase == nil { + t.Fatal("Target test case not found in results") + } + targetClient := targetCase.ClientInfo[containerID] + if targetClient == nil { + t.Fatal("Target client info not found") + } + if targetClient.LogOffsets.Begin != int64(len(initialContent)) { + t.Fatalf("Target client LogOffsets.Begin = %d, want %d", + targetClient.LogOffsets.Begin, len(initialContent)) + } + expectedEnd := int64(len(initialContent) + len(additionalContent)) + if targetClient.LogOffsets.End != expectedEnd { + t.Fatalf("Target client LogOffsets.End = %d, want %d", + targetClient.LogOffsets.End, expectedEnd) + } + + // Check source test's client log offsets. + sourceCase := suite.TestCases[sourceTestID] + if sourceCase == nil { + t.Fatal("Source test case not found in results") + } + sourceClient := sourceCase.ClientInfo[containerID] + if sourceClient == nil { + t.Fatal("Source client info not found") + } + if sourceClient.LogOffsets.Begin != 0 { + t.Fatalf("Source client LogOffsets.Begin = %d, want 0", sourceClient.LogOffsets.Begin) + } + if sourceClient.LogOffsets.End != expectedEnd { + t.Fatalf("Source client LogOffsets.End = %d, want %d", + sourceClient.LogOffsets.End, expectedEnd) + } +} + +// startClientHTTP starts a client on the given test via the HTTP API and returns the container ID. +func startClientHTTP(t *testing.T, baseURL string, suiteID libhive.TestSuiteID, testID libhive.TestID) string { + t.Helper() + + config, err := json.Marshal(simapi.NodeConfig{Client: "test-client"}) + if err != nil { + t.Fatal("marshal NodeConfig:", err) + } + + var body bytes.Buffer + writer := multipart.NewWriter(&body) + writer.WriteField("config", string(config)) + writer.Close() + + url := fmt.Sprintf("%s/testsuite/%d/test/%d/node", baseURL, suiteID, testID) + resp, err := http.Post(url, writer.FormDataContentType(), &body) + if err != nil { + t.Fatal("POST startClient:", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Fatalf("startClient returned status %d", resp.StatusCode) + } + + var nodeResp simapi.StartNodeResponse + if err := json.NewDecoder(resp.Body).Decode(&nodeResp); err != nil { + t.Fatal("decode StartNodeResponse:", err) + } + if nodeResp.ID == "" { + t.Fatal("startClient returned empty container ID") + } + return nodeResp.ID +} + +// registerMultiTestNodeHTTP registers an existing node with a target test via the HTTP API. +func registerMultiTestNodeHTTP(t *testing.T, baseURL string, suiteID libhive.TestSuiteID, sourceTestID libhive.TestID, nodeID string, targetTestID libhive.TestID) { + t.Helper() + + url := fmt.Sprintf("%s/testsuite/%d/test/%d/node/%s/register/%d", + baseURL, suiteID, sourceTestID, nodeID, targetTestID) + resp, err := http.Post(url, "", nil) + if err != nil { + t.Fatal("POST registerMultiTestNode:", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Fatalf("registerMultiTestNode returned status %d", resp.StatusCode) + } +}