From b789552f5e3871507a1cdec2abd044155936d8a6 Mon Sep 17 00:00:00 2001 From: danceratopz Date: Fri, 9 Jan 2026 16:15:07 +0100 Subject: [PATCH 1/6] libhive: add log byte offsets to ClientInfo Add LogOffsets field to ClientInfo to track byte offsets into the client log file for the portion relevant to each test case. This enables log filtering when a single client container serves multiple tests (e.g., in the enginex simulator with many-test-to-single-client mapping). - Add LogOffsets *TestLogOffsets to ClientInfo struct - Add LogFileSize helper function to get current log size - Set LogOffsets.Begin when client container starts (api.go) - Set LogOffsets.End when test ends (testmanager.go) --- internal/libhive/api.go | 3 +++ internal/libhive/data.go | 7 ++++++- internal/libhive/testmanager.go | 19 +++++++++++++++++++ 3 files changed, 28 insertions(+), 1 deletion(-) diff --git a/internal/libhive/api.go b/internal/libhive/api.go index bf90da9356..7ffaefe83a 100644 --- a/internal/libhive/api.go +++ b/internal/libhive/api.go @@ -296,12 +296,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, } diff --git a/internal/libhive/data.go b/internal/libhive/data.go index 905b4c3598..b73975b45f 100644 --- a/internal/libhive/data.go +++ b/internal/libhive/data.go @@ -170,7 +170,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/testmanager.go b/internal/libhive/testmanager.go index 460c352bcd..d06a1be981 100644 --- a/internal/libhive/testmanager.go +++ b/internal/libhive/testmanager.go @@ -505,6 +505,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 +543,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 { From 02615cedfec796e57b21c0285f9952e007d5d940 Mon Sep 17 00:00:00 2001 From: danceratopz Date: Mon, 12 Jan 2026 13:37:30 +0100 Subject: [PATCH 2/6] libhive: add registerMultiTestNode API endpoint for client reuse --- internal/libhive/api.go | 64 ++++++++++ internal/libhive/testmanager.go | 12 ++ internal/libhive/testmanager_test.go | 179 +++++++++++++++++++++++++++ 3 files changed, 255 insertions(+) create mode 100644 internal/libhive/testmanager_test.go diff --git a/internal/libhive/api.go b/internal/libhive/api.go index 7ffaefe83a..9af9c7f38a 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") @@ -424,6 +425,69 @@ 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 + } + + 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/testmanager.go b/internal/libhive/testmanager.go index d06a1be981..c7a11a5e5c 100644 --- a/internal/libhive/testmanager.go +++ b/internal/libhive/testmanager.go @@ -173,6 +173,18 @@ 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 + } + 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() diff --git a/internal/libhive/testmanager_test.go b/internal/libhive/testmanager_test.go new file mode 100644 index 0000000000..37981d0e37 --- /dev/null +++ b/internal/libhive/testmanager_test.go @@ -0,0 +1,179 @@ +package libhive + +import ( + "context" + "net" + "net/http" + "os" + "path/filepath" + "testing" +) + +// noopBackend is a minimal ContainerBackend for testing. +type noopBackend struct{} + +func (noopBackend) Build(context.Context, Builder) error { return nil } +func (noopBackend) SetHiveInstanceInfo(string, string) {} +func (noopBackend) GetDockerClient() interface{} { return nil } +func (noopBackend) ServeAPI(context.Context, http.Handler) (APIServer, error) { return nil, nil } +func (noopBackend) CreateContainer(context.Context, string, ContainerOptions) (string, error) { + return "", nil +} +func (noopBackend) StartContainer(context.Context, string, ContainerOptions) (*ContainerInfo, error) { + return nil, nil +} +func (noopBackend) DeleteContainer(string) error { return nil } +func (noopBackend) PauseContainer(string) error { return nil } +func (noopBackend) UnpauseContainer(string) error { return nil } +func (noopBackend) RunProgram(context.Context, string, []string) (*ExecInfo, error) { + return nil, nil +} +func (noopBackend) NetworkNameToID(string) (string, error) { return "", nil } +func (noopBackend) CreateNetwork(string) (string, error) { return "", nil } +func (noopBackend) RemoveNetwork(string) error { return nil } +func (noopBackend) ContainerIP(string, string) (net.IP, error) { return nil, nil } +func (noopBackend) ConnectContainer(string, string) error { return nil } +func (noopBackend) DisconnectContainer(string, string) error { return nil } + +func TestRegisterMultiTestNode(t *testing.T) { + // Set up a TestManager with a temporary log directory. + logDir := t.TempDir() + config := SimEnv{LogDir: logDir} + tm := &TestManager{ + config: config, + backend: noopBackend{}, + runningTestSuites: make(map[TestSuiteID]*TestSuite), + runningTestCases: make(map[TestID]*TestCase), + results: make(map[TestSuiteID]*TestSuite), + networks: make(map[TestSuiteID]map[string]string), + } + + // Create a test suite. + suiteID, err := tm.StartTestSuite("test-suite", "test suite description") + if err != nil { + t.Fatal("StartTestSuite:", err) + } + + // Create source and target tests. + 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 a fake log file with some content. + clientLogDir := filepath.Join(logDir, "test-client") + if err := os.MkdirAll(clientLogDir, 0755); err != nil { + t.Fatal("MkdirAll:", err) + } + logFilePath := filepath.Join(clientLogDir, "client-abc123.log") + initialContent := []byte("line 1\nline 2\nline 3\n") + if err := os.WriteFile(logFilePath, initialContent, 0644); err != nil { + t.Fatal("WriteFile:", err) + } + + // Register a client with the source test (simulates startClient). + logPath := "test-client/client-abc123.log" + sourceClient := &ClientInfo{ + ID: "abc123", + IP: "192.168.1.1", + Name: "test-client", + LogFile: logPath, + LogOffsets: &TestLogOffsets{Begin: 0}, + wait: func() {}, // non-nil: source test owns the lifecycle + } + if err := tm.RegisterNode(sourceTestID, "abc123", sourceClient); err != nil { + t.Fatal("RegisterNode (source):", err) + } + + // Simulate registerMultiTestNode: register client with target test (wait=nil). + currentLogSize := logFileSize(logFilePath) + multiTestClient := &ClientInfo{ + ID: "abc123", + IP: "192.168.1.1", + Name: "test-client", + LogFile: logPath, + LogOffsets: &TestLogOffsets{Begin: currentLogSize}, + // wait is nil: target test should NOT stop the container + } + if err := tm.RegisterNode(targetTestID, "abc123", multiTestClient); err != nil { + t.Fatal("RegisterNode (target):", err) + } + + // 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 the target test — client should NOT be stopped (wait=nil). + targetResult := &TestResult{Pass: true, Details: "target test passed"} + if err := tm.EndTest(suiteID, targetTestID, targetResult); err != nil { + t.Fatal("EndTest (target):", err) + } + + // Verify: target test is no longer running. + if _, running := tm.IsTestRunning(targetTestID); running { + t.Fatal("Target test should no longer be running") + } + + // Verify: the source test's client should still be running. + sourceCase, running := tm.IsTestRunning(sourceTestID) + if !running { + t.Fatal("Source test should still be running") + } + sourceClientInfo := sourceCase.ClientInfo["abc123"] + if sourceClientInfo == nil { + t.Fatal("Source client info should exist") + } + if sourceClientInfo.wait == nil { + t.Fatal("Source client's wait should NOT be nil (it owns lifecycle)") + } + + // Verify: log byte offsets were set correctly on the multi-test client. + if multiTestClient.LogOffsets.Begin != int64(len(initialContent)) { + t.Fatalf("Multi-test client LogOffsets.Begin = %d, want %d", + multiTestClient.LogOffsets.Begin, len(initialContent)) + } + expectedEnd := int64(len(initialContent) + len(additionalContent)) + if multiTestClient.LogOffsets.End != expectedEnd { + t.Fatalf("Multi-test client LogOffsets.End = %d, want %d", + multiTestClient.LogOffsets.End, expectedEnd) + } + + // End the source test — client SHOULD be stopped (wait != nil). + sourceResult := &TestResult{Pass: true, Details: "source test passed"} + if err := tm.EndTest(suiteID, sourceTestID, sourceResult); err != nil { + t.Fatal("EndTest (source):", err) + } + + // Verify: source client LogOffsets.End was also captured. + if sourceClient.LogOffsets.End != expectedEnd { + t.Fatalf("Source client LogOffsets.End = %d, want %d", + sourceClient.LogOffsets.End, expectedEnd) + } +} + +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)) + } +} From 319ebe5b5c6af67a89847f2ba09151babb66709c Mon Sep 17 00:00:00 2001 From: danceratopz Date: Fri, 6 Mar 2026 16:47:50 +0100 Subject: [PATCH 3/6] libhive: flag multi-test lifecycle tests via a `MultiTestContext` field --- internal/libhive/api.go | 7 +++++++ internal/libhive/data.go | 4 ++++ internal/libhive/testmanager.go | 12 ++++++++++++ 3 files changed, 23 insertions(+) diff --git a/internal/libhive/api.go b/internal/libhive/api.go index 9af9c7f38a..688ed41846 100644 --- a/internal/libhive/api.go +++ b/internal/libhive/api.go @@ -484,6 +484,13 @@ func (api *simAPI) registerMultiTestNode(w http.ResponseWriter, r *http.Request) 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) } diff --git a/internal/libhive/data.go b/internal/libhive/data.go index b73975b45f..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. diff --git a/internal/libhive/testmanager.go b/internal/libhive/testmanager.go index c7a11a5e5c..68d31338e6 100644 --- a/internal/libhive/testmanager.go +++ b/internal/libhive/testmanager.go @@ -223,6 +223,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() From b125aa2c2799c101e15bd078fade446374db9ce9 Mon Sep 17 00:00:00 2001 From: danceratopz Date: Mon, 12 Jan 2026 13:38:13 +0100 Subject: [PATCH 4/6] hiveview: add byte range highlighting for multi-test client logs --- cmd/hiveview/assets/lib/app-suite.js | 11 +++- cmd/hiveview/assets/lib/app-viewer.js | 81 ++++++++++++++++++++++----- cmd/hiveview/assets/lib/routes.js | 6 +- 3 files changed, 81 insertions(+), 17 deletions(-) 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(); } From aa244cc58b50d809f1ceafc04379a00cdde8d68b Mon Sep 17 00:00:00 2001 From: danceratopz Date: Tue, 17 Mar 2026 16:18:16 +0100 Subject: [PATCH 5/6] libhive: fix data race in `IsTestInSuite` on `suite.TestCases` map read - `StartTest` and `EndTest` write to `suite.TestCases` under `testCaseMutex`. - `IsTestInSuite` read from the same map without holding `testCaseMutex`. - Acquire `testCaseMutex.RLock()` before the map read to fix the race. --- internal/libhive/testmanager.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/internal/libhive/testmanager.go b/internal/libhive/testmanager.go index 68d31338e6..9c737d7d99 100644 --- a/internal/libhive/testmanager.go +++ b/internal/libhive/testmanager.go @@ -181,6 +181,8 @@ func (manager *TestManager) IsTestInSuite(testSuite TestSuiteID, test TestID) (* if !ok { return nil, false } + manager.testCaseMutex.RLock() + defer manager.testCaseMutex.RUnlock() tc, ok := suite.TestCases[test] return tc, ok } From ec9d4cb246472b345e264a94eeafce1af64e10ef Mon Sep 17 00:00:00 2001 From: danceratopz Date: Tue, 17 Mar 2026 17:16:06 +0100 Subject: [PATCH 6/6] libhive: rewrite testmanager_test as external test package Use `package libhive_test` with `fakes.NewContainerBackend` to avoid the import cycle that `noopBackend` worked around. Client start and multi-test node registration now go through the HTTP API via `httptest.NewServer` so the `wait` callback is set by real code paths. Container lifecycle is verified through a `DeleteContainer` hook and log offsets through `tm.Results()`. `TestLogFileSize` moves to its own internal-package file since it tests an unexported function and has no external dependencies. --- internal/libhive/logfilesize_test.go | 25 +++ internal/libhive/testmanager_test.go | 236 ++++++++++++++------------- 2 files changed, 151 insertions(+), 110 deletions(-) create mode 100644 internal/libhive/logfilesize_test.go 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_test.go b/internal/libhive/testmanager_test.go index 37981d0e37..c4b210d0ad 100644 --- a/internal/libhive/testmanager_test.go +++ b/internal/libhive/testmanager_test.go @@ -1,60 +1,43 @@ -package libhive +package libhive_test import ( - "context" - "net" + "bytes" + "encoding/json" + "fmt" + "mime/multipart" "net/http" + "net/http/httptest" "os" "path/filepath" "testing" -) - -// noopBackend is a minimal ContainerBackend for testing. -type noopBackend struct{} -func (noopBackend) Build(context.Context, Builder) error { return nil } -func (noopBackend) SetHiveInstanceInfo(string, string) {} -func (noopBackend) GetDockerClient() interface{} { return nil } -func (noopBackend) ServeAPI(context.Context, http.Handler) (APIServer, error) { return nil, nil } -func (noopBackend) CreateContainer(context.Context, string, ContainerOptions) (string, error) { - return "", nil -} -func (noopBackend) StartContainer(context.Context, string, ContainerOptions) (*ContainerInfo, error) { - return nil, nil -} -func (noopBackend) DeleteContainer(string) error { return nil } -func (noopBackend) PauseContainer(string) error { return nil } -func (noopBackend) UnpauseContainer(string) error { return nil } -func (noopBackend) RunProgram(context.Context, string, []string) (*ExecInfo, error) { - return nil, nil -} -func (noopBackend) NetworkNameToID(string) (string, error) { return "", nil } -func (noopBackend) CreateNetwork(string) (string, error) { return "", nil } -func (noopBackend) RemoveNetwork(string) error { return nil } -func (noopBackend) ContainerIP(string, string) (net.IP, error) { return nil, nil } -func (noopBackend) ConnectContainer(string, string) error { return nil } -func (noopBackend) DisconnectContainer(string, string) error { return nil } + "github.com/ethereum/hive/internal/fakes" + "github.com/ethereum/hive/internal/libhive" + "github.com/ethereum/hive/internal/simapi" +) func TestRegisterMultiTestNode(t *testing.T) { - // Set up a TestManager with a temporary log directory. + // 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() - config := SimEnv{LogDir: logDir} - tm := &TestManager{ - config: config, - backend: noopBackend{}, - runningTestSuites: make(map[TestSuiteID]*TestSuite), - runningTestCases: make(map[TestID]*TestCase), - results: make(map[TestSuiteID]*TestSuite), - networks: make(map[TestSuiteID]map[string]string), - } + 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 a test suite. + // Create suite and tests via exported Go methods. suiteID, err := tm.StartTestSuite("test-suite", "test suite description") if err != nil { t.Fatal("StartTestSuite:", err) } - - // Create source and target tests. sourceTestID, err := tm.StartTest(suiteID, "source-test", "source test") if err != nil { t.Fatal("StartTest (source):", err) @@ -64,44 +47,24 @@ func TestRegisterMultiTestNode(t *testing.T) { t.Fatal("StartTest (target):", err) } - // Create a fake log file with some content. + // 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) } - logFilePath := filepath.Join(clientLogDir, "client-abc123.log") + + // 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 a client with the source test (simulates startClient). - logPath := "test-client/client-abc123.log" - sourceClient := &ClientInfo{ - ID: "abc123", - IP: "192.168.1.1", - Name: "test-client", - LogFile: logPath, - LogOffsets: &TestLogOffsets{Begin: 0}, - wait: func() {}, // non-nil: source test owns the lifecycle - } - if err := tm.RegisterNode(sourceTestID, "abc123", sourceClient); err != nil { - t.Fatal("RegisterNode (source):", err) - } - - // Simulate registerMultiTestNode: register client with target test (wait=nil). - currentLogSize := logFileSize(logFilePath) - multiTestClient := &ClientInfo{ - ID: "abc123", - IP: "192.168.1.1", - Name: "test-client", - LogFile: logPath, - LogOffsets: &TestLogOffsets{Begin: currentLogSize}, - // wait is nil: target test should NOT stop the container - } - if err := tm.RegisterNode(targetTestID, "abc123", multiTestClient); err != nil { - t.Fatal("RegisterNode (target):", 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") @@ -112,68 +75,121 @@ func TestRegisterMultiTestNode(t *testing.T) { f.Write(additionalContent) f.Close() - // End the target test — client should NOT be stopped (wait=nil). - targetResult := &TestResult{Pass: true, Details: "target test passed"} + // 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) } - - // Verify: target test is no longer running. - if _, running := tm.IsTestRunning(targetTestID); running { - t.Fatal("Target test should no longer be running") + if len(deletedContainers) != 0 { + t.Fatalf("Expected no container deletions after ending target test, got %v", deletedContainers) } - // Verify: the source test's client should still be running. - sourceCase, running := tm.IsTestRunning(sourceTestID) - if !running { - t.Fatal("Source test should still be running") + // 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) } - sourceClientInfo := sourceCase.ClientInfo["abc123"] - if sourceClientInfo == nil { - t.Fatal("Source client info should exist") + if len(deletedContainers) != 1 || deletedContainers[0] != containerID { + t.Fatalf("Expected container %q to be deleted, got %v", containerID, deletedContainers) } - if sourceClientInfo.wait == nil { - t.Fatal("Source client's wait should NOT be nil (it owns lifecycle)") + + // 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") } - // Verify: log byte offsets were set correctly on the multi-test client. - if multiTestClient.LogOffsets.Begin != int64(len(initialContent)) { - t.Fatalf("Multi-test client LogOffsets.Begin = %d, want %d", - multiTestClient.LogOffsets.Begin, len(initialContent)) + // 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 multiTestClient.LogOffsets.End != expectedEnd { - t.Fatalf("Multi-test client LogOffsets.End = %d, want %d", - multiTestClient.LogOffsets.End, expectedEnd) + if targetClient.LogOffsets.End != expectedEnd { + t.Fatalf("Target client LogOffsets.End = %d, want %d", + targetClient.LogOffsets.End, expectedEnd) } - // End the source test — client SHOULD be stopped (wait != nil). - sourceResult := &TestResult{Pass: true, Details: "source test passed"} - if err := tm.EndTest(suiteID, sourceTestID, sourceResult); err != nil { - t.Fatal("EndTest (source):", err) + // 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) } - - // Verify: source client LogOffsets.End was also captured. if sourceClient.LogOffsets.End != expectedEnd { t.Fatalf("Source client LogOffsets.End = %d, want %d", sourceClient.LogOffsets.End, expectedEnd) } } -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) +// 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) } - // 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) + 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 got := logFileSize(tmpFile); got != int64(len(content)) { - t.Fatalf("logFileSize = %d, want %d", got, len(content)) + if resp.StatusCode != http.StatusOK { + t.Fatalf("registerMultiTestNode returned status %d", resp.StatusCode) } }