From c457a034577037655ead7ea325f5dffbf5b8d07a Mon Sep 17 00:00:00 2001 From: Ben Date: Thu, 17 Sep 2026 18:02:14 -0600 Subject: [PATCH 1/4] Explain deployment capability bootstrap --- src/main/resources/web/app.js | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/main/resources/web/app.js b/src/main/resources/web/app.js index 0545f2c..7d59393 100644 --- a/src/main/resources/web/app.js +++ b/src/main/resources/web/app.js @@ -2836,10 +2836,14 @@ function deploymentTargets() { function renderDeploymentEligibility() { const eligible = deploymentTargets(); const connected = allNodeItems.filter(node => node.online); + const incompatible = connected.filter(node => !node.acceptedCapabilities.includes('plugin.deploy.v1')); const batches = Math.ceil(eligible.length / MAX_OPERATION_TARGETS); + const bootstrap = incompatible.length + ? ` · ${incompatible.length} connected ${incompatible.length === 1 ? 'node needs' : 'nodes need'} a one-time VotingPlugin update with verified staging support` + : ''; text(deploymentEligibility, `${eligible.length}/${connected.length} connected nodes eligible` - + (batches > 1 ? ` · ${batches} bounded deployment batches` : '')); - deploymentEligibility.className = `pill ${eligible.length ? 'online' : 'neutral'}`; + + bootstrap + (batches > 1 ? ` · ${batches} bounded deployment batches` : '')); + deploymentEligibility.className = `pill ${eligible.length ? 'online' : connected.length ? 'warning' : 'neutral'}`; deployPlugin.disabled = !authenticated || logoutInFlight || deploymentInFlight || !deploymentJar.files?.length || eligible.length === 0 || batches > MAX_DEPLOYMENT_BATCHES; } From 37a164be08baacd9049c9d3b8cb125d501ad4a77 Mon Sep 17 00:00:00 2001 From: Ben Date: Thu, 17 Sep 2026 18:02:32 -0600 Subject: [PATCH 2/4] Cover deployment bootstrap guidance --- .../votingplugin/control/http/ControlHttpServerTest.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java b/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java index ec15a05..fcba650 100644 --- a/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java +++ b/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java @@ -156,6 +156,8 @@ class ControlHttpServerTest { "Deployment eligibility retries must remain bounded by the target limit."); assertTrue(script.body().contains("Unavailable nodes skipped:")); assertTrue(script.body().contains("No deployment batches were submitted.")); + assertTrue(script.body().contains("nodes need a one-time VotingPlugin update with verified staging support")); + assertTrue(script.body().contains("connected.length ? 'warning' : 'neutral'")); assertTrue(script.body().contains("backendItemsTruncated")); assertTrue(script.body().contains("topologyComplete: !truncatedNodeIds.has(proxyId)")); assertTrue(script.body().contains("proxyReady: network.proxyReady")); From 5626f55ca4c401baef2995cfa4fddf5af1125bc4 Mon Sep 17 00:00:00 2001 From: Ben Date: Thu, 17 Sep 2026 18:06:44 -0600 Subject: [PATCH 3/4] Fix deployment bootstrap WebUI assertion --- .../votingplugin/control/http/ControlHttpServerTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java b/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java index fcba650..3a3a127 100644 --- a/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java +++ b/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java @@ -156,7 +156,7 @@ class ControlHttpServerTest { "Deployment eligibility retries must remain bounded by the target limit."); assertTrue(script.body().contains("Unavailable nodes skipped:")); assertTrue(script.body().contains("No deployment batches were submitted.")); - assertTrue(script.body().contains("nodes need a one-time VotingPlugin update with verified staging support")); + assertTrue(script.body().contains("a one-time VotingPlugin update with verified staging support")); assertTrue(script.body().contains("connected.length ? 'warning' : 'neutral'")); assertTrue(script.body().contains("backendItemsTruncated")); assertTrue(script.body().contains("topologyComplete: !truncatedNodeIds.has(proxyId)")); From cdc3e8c39d68b58277d895f939a0f381fd4ca04d Mon Sep 17 00:00:00 2001 From: Ben Date: Thu, 17 Sep 2026 21:40:42 -0600 Subject: [PATCH 4/4] Exercise verified staging eligibility behavior --- .../control/http/ControlHttpServerTest.java | 104 +++++++++++++++++- 1 file changed, 102 insertions(+), 2 deletions(-) diff --git a/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java b/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java index 3a3a127..48ca0c1 100644 --- a/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java +++ b/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java @@ -31,6 +31,7 @@ import java.util.zip.ZipEntry; import java.util.zip.ZipOutputStream; import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.TimeUnit; import com.sun.net.httpserver.Headers; import com.sun.net.httpserver.HttpContext; import com.sun.net.httpserver.HttpExchange; @@ -72,6 +73,107 @@ class ControlHttpServerTest { } } + @Test void deploymentEligibilityRendersEligibleBootstrapAndWarningStates() throws Exception { + org.junit.jupiter.api.Assumptions.assumeTrue(nodeAvailable(), + "Node.js is required to execute the WebUI behavior regression"); + + String app; + try (InputStream input = ControlHttpServerTest.class.getResourceAsStream("/web/app.js")) { + assertNotNull(input); + app = new String(input.readAllBytes(), StandardCharsets.UTF_8); + } + int start = app.indexOf("function deploymentTargets()"); + int end = app.indexOf("function selectNodePage(", start); + assertTrue(start >= 0 && end > start, "deployment eligibility functions must remain discoverable"); + String actualFunctions = app.substring(start, end); + + String harness = """ + const MAX_OPERATION_TARGETS = 100; + const MAX_DEPLOYMENT_BATCHES = 100; + let authenticated = true; + let logoutInFlight = false; + let deploymentInFlight = false; + const deploymentJar = {files: [{}]}; + const deploymentEligibility = {textContent: '', className: ''}; + const deployPlugin = {disabled: false}; + function text(element, value) { element.textContent = value; return element; } + let allNodeItems = []; + """ + actualFunctions + """ + function capture(nodes) { + allNodeItems = nodes; + renderDeploymentEligibility(); + return { + text: deploymentEligibility.textContent, + className: deploymentEligibility.className, + disabled: deployPlugin.disabled + }; + } + const capable = {online: true, acceptedCapabilities: ['plugin.deploy.v1']}; + const oldA = {online: true, acceptedCapabilities: []}; + const oldB = {online: true, acceptedCapabilities: ['config.files.v1']}; + const mixed = capture([capable, oldA, oldB]); + const bootstrap = capture([oldA, oldB, {online: true, acceptedCapabilities: []}]); + const empty = capture([]); + process.stdout.write(JSON.stringify({mixed, bootstrap, empty})); + """; + + Process process = new ProcessBuilder("node", "-e", harness).redirectErrorStream(true).start(); + String output; + try { + if (!process.waitFor(5, TimeUnit.SECONDS)) fail("WebUI eligibility test timed out"); + output = new String(process.getInputStream().readAllBytes(), StandardCharsets.UTF_8); + assertEquals(0, process.exitValue(), output); + } finally { + terminateProcess(process); + } + + JsonNode states = json.readTree(output); + assertEquals("1/3 connected nodes eligible · 2 connected nodes need a one-time VotingPlugin update with verified staging support", + states.path("mixed").path("text").asText()); + assertEquals("pill online", states.path("mixed").path("className").asText()); + assertFalse(states.path("mixed").path("disabled").asBoolean()); + + assertEquals("0/3 connected nodes eligible · 3 connected nodes need a one-time VotingPlugin update with verified staging support", + states.path("bootstrap").path("text").asText()); + assertEquals("pill warning", states.path("bootstrap").path("className").asText()); + assertTrue(states.path("bootstrap").path("disabled").asBoolean()); + + assertEquals("0/0 connected nodes eligible", states.path("empty").path("text").asText()); + assertEquals("pill neutral", states.path("empty").path("className").asText()); + assertTrue(states.path("empty").path("disabled").asBoolean()); + } + + private static boolean nodeAvailable() { + Process process = null; + try { + process = new ProcessBuilder("node", "--version").redirectErrorStream(true).start(); + if (!process.waitFor(5, TimeUnit.SECONDS)) return false; + return process.exitValue() == 0; + } catch (IOException | InterruptedException failure) { + if (failure instanceof InterruptedException) Thread.currentThread().interrupt(); + return false; + } finally { + terminateProcess(process); + } + } + + private static void terminateProcess(Process process) { + if (process == null || !process.isAlive()) return; + process.destroyForcibly(); + boolean interrupted = false; + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(1); + while (process.isAlive()) { + long remaining = deadline - System.nanoTime(); + if (remaining <= 0) break; + try { + if (process.waitFor(remaining, TimeUnit.NANOSECONDS)) break; + } catch (InterruptedException failure) { + interrupted = true; + } + } + if (interrupted) Thread.currentThread().interrupt(); + } + @Test void artifactInputClosesWhenResponseHeadersFail() { AtomicBoolean closed = new AtomicBoolean(); InputStream input = new ByteArrayInputStream(new byte[] {1}) { @@ -156,8 +258,6 @@ class ControlHttpServerTest { "Deployment eligibility retries must remain bounded by the target limit."); assertTrue(script.body().contains("Unavailable nodes skipped:")); assertTrue(script.body().contains("No deployment batches were submitted.")); - assertTrue(script.body().contains("a one-time VotingPlugin update with verified staging support")); - assertTrue(script.body().contains("connected.length ? 'warning' : 'neutral'")); assertTrue(script.body().contains("backendItemsTruncated")); assertTrue(script.body().contains("topologyComplete: !truncatedNodeIds.has(proxyId)")); assertTrue(script.body().contains("proxyReady: network.proxyReady"));