From f65d7a586a8f4c68c8d2081b58135311e9671973 Mon Sep 17 00:00:00 2001 From: Charles Graham Date: Sun, 12 Oct 2025 13:14:23 -0500 Subject: [PATCH 01/13] Add base lighthouse config --- .lighthouserc.json | 42 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 .lighthouserc.json diff --git a/.lighthouserc.json b/.lighthouserc.json new file mode 100644 index 00000000..c74b8e10 --- /dev/null +++ b/.lighthouserc.json @@ -0,0 +1,42 @@ +{ + "$schema": "https://raw.githubusercontent.com/GoogleChrome/lighthouse-ci/main/docs/lighthouserc-schema.json", + "ci": { + "collect": { + "numberOfRuns": 1, + "startServerCommand": "", + "url": [ + "http://localhost:8080/portal/login", + "http://localhost:8080/portal/error" + ], + "settings": { + "disableStorageReset": true, + "chromeFlags": "--headless=new --disable-gpu --no-sandbox --disable-dev-shm-usage --window-size=1366,768" + } + }, + "assert": { + "preset": "lighthouse:recommended", + "assertions": { + "categories:accessibility": [ + "error", + { + "minScore": 0.9 + } + ], + "color-contrast": "error", + "image-alt": "error", + "label": "error", + "link-name": "error", + "meta-viewport": "error", + "heading-order": "error", + "html-has-lang": "error", + "document-title": "error", + "region": "error", + "aria-*": "error" + } + }, + "upload": { + "target": "filesystem", + "outputDir": "./lhci-results" + } + } +} \ No newline at end of file From f36cfa155120548a6f3b44018884773c4890a36f Mon Sep 17 00:00:00 2001 From: Charles Graham Date: Sun, 12 Oct 2025 13:15:38 -0500 Subject: [PATCH 02/13] Add gradle config - Not working --- build.gradle | 131 ++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 130 insertions(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index 074de7dc..091f80fb 100644 --- a/build.gradle +++ b/build.gradle @@ -78,4 +78,133 @@ ext.sonarVersion = { standardOutput = stdout } return stdout.toString().trim() + "+" -} \ No newline at end of file +} +// ===================================== +// Compliance Tasks +// ===================================== + +import org.gradle.internal.os.OperatingSystem + +ext { + A11Y_PORT = (project.findProperty("a11yPort") ?: "8080") as String + A11Y_DOCKER_IMAGE = (project.findProperty("a11yDockerImage") ?: "tomcat:9.0-jdk17-temurin") as String + A11Y_CONTAINER = (project.findProperty("a11yContainer") ?: "opendcs-a11y") as String +} + +/** Find/override the WAR module */ +Project findWarProject() { + if (project.hasProperty("a11yWarProject")) return project.project(project.property("a11yWarProject") as String) + def candidate = rootProject.subprojects.find { sp -> sp.tasks.findByName("war") != null } + if (candidate) return candidate + throw new GradleException("No WAR subproject found. Pass -Pa11yWarProject=':your-web-module'.") +} + +File resolveWarFile(Project warProject) { + def libsDir = new File(warProject.buildDir, "libs") + def wars = libsDir.listFiles()?.findAll { it.name.toLowerCase().endsWith(".war") } ?: [] + if (wars.isEmpty()) throw new GradleException("No WAR found under ${libsDir}. Run assemble first.") + wars.sort { a, b -> b.lastModified() <=> a.lastModified() }.first() +} + +tasks.register("serveWarForA11y") { + group = "verification" + description = "Run the built WAR in Docker Tomcat for Lighthouse scans." + doLast { + def warProject = findWarProject() + + // Build WAR + exec { + workingDir rootProject.projectDir + commandLine OperatingSystem.current().isWindows() + ? ["cmd","/c","./gradlew","${warProject.path}:assemble"] + : ["bash","-lc","./gradlew ${warProject.path}:assemble"] + } + + def warFile = resolveWarFile(warProject) + + // Ensure docker exists for Windows or macOS/Linux with bash + try { + exec { + commandLine OperatingSystem.current().isWindows() ? ["cmd","/c","docker","--version"] : ["bash","-lc","docker --version"] + standardOutput = new ByteArrayOutputStream() + } + } catch (ignore) { + throw new GradleException("Docker not found. Install Docker Desktop (or use Option B below).") + } + + // Run fresh on each run + exec { + ignoreExitValue true + commandLine OperatingSystem.current().isWindows() + ? ["cmd","/c","docker","rm","-f", A11Y_CONTAINER] + : ["bash","-lc","docker rm -f ${A11Y_CONTAINER} || true"] + } + + // Run Tomcat with the WAR mounted as /usr/local/tomcat/webapps/portal.war + def runCmd = "docker run -d --name ${A11Y_CONTAINER} -p ${A11Y_PORT}:8080 " + + "-v \"${warFile.absolutePath}\":/usr/local/tomcat/webapps/portal.war:ro " + + "${A11Y_DOCKER_IMAGE}" + exec { + commandLine OperatingSystem.current().isWindows() + ? ["cmd","/c", runCmd] + : ["bash","-lc", runCmd] + } + + // Wait for server to come up + println "Waiting for server to start on port ${A11Y_PORT}..." + def ok = false + for (int i=0;i<90;i++) { + try { + new URL("http://localhost:${A11Y_PORT}/portal/").openConnection().with { + connectTimeout = 2000; readTimeout = 2000; connect() + } + ok = true; break + } catch (ignored) { Thread.sleep(1000) } + } + if (!ok) throw new GradleException("Tomcat failed to start on port ${A11Y_PORT}") + println "Server is up: http://localhost:${A11Y_PORT}/portal/" + } +} + +tasks.register("stopA11yServer") { + group = "verification" + description = "Stop and remove the Docker Tomcat container." + doLast { + exec { + ignoreExitValue true + commandLine OperatingSystem.current().isWindows() + ? ["cmd","/c","docker","rm","-f", A11Y_CONTAINER] + : ["bash","-lc","docker rm -f ${A11Y_CONTAINER} || true"] + } + } +} +tasks.register("a11yScan", Exec) { + group = "verification" + description = "Run Lighthouse CI using .lighthouserc.json" + dependsOn("serveWarForA11y") + + // ?Determine where chrome lives: + // ?Try provided env path first, then common locations + def chromePath = System.getenv("CHROME_PATH") + if (!chromePath) { + def candidates = [ + "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", // macOS + "/usr/bin/google-chrome", // Linux + "/usr/bin/chromium-browser", "/usr/bin/chromium", + "C:/Program Files/Google/Chrome/Application/chrome.exe" // Windows + ] + chromePath = candidates.find { new File(it).exists() } + } + if (chromePath) { + environment "CHROME_PATH", chromePath + println "Using Chrome at: ${chromePath}" + } else { + println "CHROME_PATH not set and no known Chrome path found; LHCI will auto-detect." + } + + commandLine org.gradle.internal.os.OperatingSystem.current().isWindows() + ? ["cmd","/c","npx","--yes","@lhci/cli@0.13.x","autorun"] + : ["bash","-lc","npx --yes @lhci/cli@0.13.x autorun"] + + finalizedBy("stopA11yServer") +} From 6d743a068791ed503d4c5e03cda6b502197c7fdf Mon Sep 17 00:00:00 2001 From: Charles Graham Date: Sun, 12 Oct 2025 13:15:47 -0500 Subject: [PATCH 03/13] Add initial compliance workflow --- .github/workflows/compliance-lhci.yml | 58 +++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 .github/workflows/compliance-lhci.yml diff --git a/.github/workflows/compliance-lhci.yml b/.github/workflows/compliance-lhci.yml new file mode 100644 index 00000000..ae7585c2 --- /dev/null +++ b/.github/workflows/compliance-lhci.yml @@ -0,0 +1,58 @@ +name: 508-a11y-lighthouse + +on: + pull_request: + types: [opened, synchronize, reopened] + +permissions: + contents: read + +jobs: + lhci: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Java + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "17" + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: "20" + + - name: Cache Gradle + uses: gradle/actions/setup-gradle@v3 + + - name: Build project (WAR) + run: ./gradlew build --stacktrace + + # Start Tomcat to serve for Lighthouse + - name: Start app server (webapp-runner) + run: ./gradlew serveWarForA11y + + # Wait for the app to respond + - name: Wait for server + run: npx --yes wait-on@7 http-get://localhost:8080/portal/login + + # Run Lighthouse CI, fail if thresholds not met + - name: Run Lighthouse CI + run: npx --yes @lhci/cli@0.13.x autorun + + # Cleanup after ourselves + - name: Stop server + if: always() + run: ./gradlew stopA11yServer + + # Upload artifacts to view reports + - name: Upload LHCI artifacts + if: always() + uses: actions/upload-artifact@v4 + with: + name: lhci-results + path: lhci-results/** From 7d99b7fec4128370456dff9137ae955b1cac14a1 Mon Sep 17 00:00:00 2001 From: "Charles Graham, SWT" Date: Sun, 12 Oct 2025 14:56:36 -0500 Subject: [PATCH 04/13] revert root build.gradle to original state --- build.gradle | 131 +-------------------------------------------------- 1 file changed, 1 insertion(+), 130 deletions(-) diff --git a/build.gradle b/build.gradle index 091f80fb..074de7dc 100644 --- a/build.gradle +++ b/build.gradle @@ -78,133 +78,4 @@ ext.sonarVersion = { standardOutput = stdout } return stdout.toString().trim() + "+" -} -// ===================================== -// Compliance Tasks -// ===================================== - -import org.gradle.internal.os.OperatingSystem - -ext { - A11Y_PORT = (project.findProperty("a11yPort") ?: "8080") as String - A11Y_DOCKER_IMAGE = (project.findProperty("a11yDockerImage") ?: "tomcat:9.0-jdk17-temurin") as String - A11Y_CONTAINER = (project.findProperty("a11yContainer") ?: "opendcs-a11y") as String -} - -/** Find/override the WAR module */ -Project findWarProject() { - if (project.hasProperty("a11yWarProject")) return project.project(project.property("a11yWarProject") as String) - def candidate = rootProject.subprojects.find { sp -> sp.tasks.findByName("war") != null } - if (candidate) return candidate - throw new GradleException("No WAR subproject found. Pass -Pa11yWarProject=':your-web-module'.") -} - -File resolveWarFile(Project warProject) { - def libsDir = new File(warProject.buildDir, "libs") - def wars = libsDir.listFiles()?.findAll { it.name.toLowerCase().endsWith(".war") } ?: [] - if (wars.isEmpty()) throw new GradleException("No WAR found under ${libsDir}. Run assemble first.") - wars.sort { a, b -> b.lastModified() <=> a.lastModified() }.first() -} - -tasks.register("serveWarForA11y") { - group = "verification" - description = "Run the built WAR in Docker Tomcat for Lighthouse scans." - doLast { - def warProject = findWarProject() - - // Build WAR - exec { - workingDir rootProject.projectDir - commandLine OperatingSystem.current().isWindows() - ? ["cmd","/c","./gradlew","${warProject.path}:assemble"] - : ["bash","-lc","./gradlew ${warProject.path}:assemble"] - } - - def warFile = resolveWarFile(warProject) - - // Ensure docker exists for Windows or macOS/Linux with bash - try { - exec { - commandLine OperatingSystem.current().isWindows() ? ["cmd","/c","docker","--version"] : ["bash","-lc","docker --version"] - standardOutput = new ByteArrayOutputStream() - } - } catch (ignore) { - throw new GradleException("Docker not found. Install Docker Desktop (or use Option B below).") - } - - // Run fresh on each run - exec { - ignoreExitValue true - commandLine OperatingSystem.current().isWindows() - ? ["cmd","/c","docker","rm","-f", A11Y_CONTAINER] - : ["bash","-lc","docker rm -f ${A11Y_CONTAINER} || true"] - } - - // Run Tomcat with the WAR mounted as /usr/local/tomcat/webapps/portal.war - def runCmd = "docker run -d --name ${A11Y_CONTAINER} -p ${A11Y_PORT}:8080 " + - "-v \"${warFile.absolutePath}\":/usr/local/tomcat/webapps/portal.war:ro " + - "${A11Y_DOCKER_IMAGE}" - exec { - commandLine OperatingSystem.current().isWindows() - ? ["cmd","/c", runCmd] - : ["bash","-lc", runCmd] - } - - // Wait for server to come up - println "Waiting for server to start on port ${A11Y_PORT}..." - def ok = false - for (int i=0;i<90;i++) { - try { - new URL("http://localhost:${A11Y_PORT}/portal/").openConnection().with { - connectTimeout = 2000; readTimeout = 2000; connect() - } - ok = true; break - } catch (ignored) { Thread.sleep(1000) } - } - if (!ok) throw new GradleException("Tomcat failed to start on port ${A11Y_PORT}") - println "Server is up: http://localhost:${A11Y_PORT}/portal/" - } -} - -tasks.register("stopA11yServer") { - group = "verification" - description = "Stop and remove the Docker Tomcat container." - doLast { - exec { - ignoreExitValue true - commandLine OperatingSystem.current().isWindows() - ? ["cmd","/c","docker","rm","-f", A11Y_CONTAINER] - : ["bash","-lc","docker rm -f ${A11Y_CONTAINER} || true"] - } - } -} -tasks.register("a11yScan", Exec) { - group = "verification" - description = "Run Lighthouse CI using .lighthouserc.json" - dependsOn("serveWarForA11y") - - // ?Determine where chrome lives: - // ?Try provided env path first, then common locations - def chromePath = System.getenv("CHROME_PATH") - if (!chromePath) { - def candidates = [ - "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", // macOS - "/usr/bin/google-chrome", // Linux - "/usr/bin/chromium-browser", "/usr/bin/chromium", - "C:/Program Files/Google/Chrome/Application/chrome.exe" // Windows - ] - chromePath = candidates.find { new File(it).exists() } - } - if (chromePath) { - environment "CHROME_PATH", chromePath - println "Using Chrome at: ${chromePath}" - } else { - println "CHROME_PATH not set and no known Chrome path found; LHCI will auto-detect." - } - - commandLine org.gradle.internal.os.OperatingSystem.current().isWindows() - ? ["cmd","/c","npx","--yes","@lhci/cli@0.13.x","autorun"] - : ["bash","-lc","npx --yes @lhci/cli@0.13.x autorun"] - - finalizedBy("stopA11yServer") -} +} \ No newline at end of file From b78bfbf1551fa17e61eec3422e7a0cff1be3f2d7 Mon Sep 17 00:00:00 2001 From: "Charles Graham, SWT" Date: Sun, 12 Oct 2025 14:56:55 -0500 Subject: [PATCH 05/13] Update lighthouse port/paths and ensure output files for local testing --- .lighthouserc.json | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/.lighthouserc.json b/.lighthouserc.json index c74b8e10..aac80c63 100644 --- a/.lighthouserc.json +++ b/.lighthouserc.json @@ -5,12 +5,16 @@ "numberOfRuns": 1, "startServerCommand": "", "url": [ - "http://localhost:8080/portal/login", - "http://localhost:8080/portal/error" + "http://localhost:7001/login", + "http://localhost:7001/error" ], "settings": { "disableStorageReset": true, - "chromeFlags": "--headless=new --disable-gpu --no-sandbox --disable-dev-shm-usage --window-size=1366,768" + "chromeFlags": "--headless=new --disable-gpu --no-sandbox --disable-dev-shm-usage --window-size=1366,768", + "output": [ + "html", + "json" + ] } }, "assert": { From 0f590ee0a754672b50aac23a7649c77a048f0d48 Mon Sep 17 00:00:00 2001 From: "Charles Graham, SWT" Date: Sun, 12 Oct 2025 15:03:19 -0500 Subject: [PATCH 06/13] Add corrected gradlew tasks for lighthouse CI --- build.gradle | 162 ++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 161 insertions(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index 074de7dc..717a49a4 100644 --- a/build.gradle +++ b/build.gradle @@ -78,4 +78,164 @@ ext.sonarVersion = { standardOutput = stdout } return stdout.toString().trim() + "+" -} \ No newline at end of file +} + +// ===================================== +// Accessibility (LHCI) +// ===================================== +import org.gradle.internal.os.OperatingSystem + +ext { + A11Y_PORT = (project.findProperty("a11yPort") ?: "7001") as String + A11Y_DOCKER_IMAGE = (project.findProperty("a11yDockerImage") ?: "tomcat:9.0-jdk17-temurin") as String + A11Y_CONTAINER = (project.findProperty("a11yContainer") ?: "opendcs-a11y") as String +} + +/** Which WAR to test: default to the JSP UI */ +Project findWarProject() { + if (project.hasProperty("a11yWarProject")) return project.project(project.property("a11yWarProject") as String) + def ui = rootProject.findProject(":opendcs-web-client") + if (ui != null) return ui + throw new GradleException("No WAR module resolved. Pass -Pa11yWarProject=':opendcs-web-client'.") +} + +File resolveWarFile(Project warProject) { + def libsDir = new File(warProject.buildDir, "libs") + def wars = libsDir.listFiles()?.findAll { it.name.toLowerCase().endsWith(".war") } ?: [] + if (wars.isEmpty()) throw new GradleException("No WAR found under ${libsDir}. Run assemble first.") + wars.sort { a, b -> b.lastModified() <=> a.lastModified() }.first() +} + +tasks.register("serveWarForA11y") { + group = "verification" + description = "Run the built WAR in Docker Tomcat for Lighthouse scans." + doLast { + def warProject = findWarProject() + + // build the WAR we will deploy + exec { + workingDir rootProject.projectDir + commandLine OperatingSystem.current().isWindows() + ? ["cmd","/c",".\\gradlew","${warProject.path}:assemble"] + : ["bash","-lc","./gradlew ${warProject.path}:assemble"] + } + + def warFile = resolveWarFile(warProject) + + // ensure docker + try { + exec { + commandLine OperatingSystem.current().isWindows() + ? ["cmd","/c","docker","--version"] + : ["bash","-lc","docker --version"] + standardOutput = new ByteArrayOutputStream() + } + } catch (ignore) { + throw new GradleException("Docker not found. Install Docker Desktop.") + } + + // reset container + exec { + ignoreExitValue true + commandLine OperatingSystem.current().isWindows() + ? ["cmd","/c","docker","rm","-f", A11Y_CONTAINER] + : ["bash","-lc","docker rm -f ${A11Y_CONTAINER} || true"] + } + + // run Tomcat + mount as ROOT.war; expose host port 7001 -> 8080 + def runCmd = "docker run -d --name ${A11Y_CONTAINER} -p ${A11Y_PORT}:8080 " + + "-v \"${warFile.absolutePath}\":/usr/local/tomcat/webapps/ROOT.war:ro " + + "${A11Y_DOCKER_IMAGE}" + println "Launching: ${runCmd}" + exec { + commandLine OperatingSystem.current().isWindows() + ? ["cmd","/c", runCmd] + : ["bash","-lc", runCmd] + } + + // wait until the app actually responds (not just Tomcat up) + println "Waiting for Tomcat + app on http://localhost:${A11Y_PORT}/ ..." + def ready = false + for (int i=0; i<180; i++) { // up to 3 min for first JSP compile + def head = new ByteArrayOutputStream() + exec { + ignoreExitValue true + standardOutput = head + commandLine OperatingSystem.current().isWindows() + ? ["cmd","/c","docker","exec", A11Y_CONTAINER, "curl","-sSI","http://localhost:8080/"] + : ["bash","-lc","docker exec ${A11Y_CONTAINER} curl -sSI http://localhost:8080/"] + } + def h = head.toString() + if (h.startsWith("HTTP/1.1 200") || h.startsWith("HTTP/1.1 302")) { ready = true; break } + Thread.sleep(1000) + } + if (!ready) { + println "----- Tomcat logs (tail) -----" + exec { + ignoreExitValue true + commandLine OperatingSystem.current().isWindows() + ? ["cmd","/c","docker","logs", "--tail","200", A11Y_CONTAINER] + : ["bash","-lc","docker logs --tail 200 ${A11Y_CONTAINER} || true"] + } + throw new GradleException("App did not become ready on http://localhost:${A11Y_PORT}/") + } + println "Server is up: http://localhost:${A11Y_PORT}/" + } +} + +tasks.register("stopA11yServer") { + group = "verification" + description = "Stop and remove the Docker Tomcat container." + doLast { + exec { + ignoreExitValue true + commandLine OperatingSystem.current().isWindows() + ? ["cmd","/c","docker","rm","-f", A11Y_CONTAINER] + : ["bash","-lc","docker rm -f ${A11Y_CONTAINER} || true"] + } + } +} + +tasks.register("a11yScan", Exec) { + group = "verification" + description = "Run Lighthouse CI using .lighthouserc.json" + dependsOn("serveWarForA11y") + + // Find Chrome for Windows/macOS/Linux; LHCI can also auto-detect + def chromePath = System.getenv("CHROME_PATH") + if (!chromePath) { + def candidates = [ + "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", + "/usr/bin/google-chrome", "/usr/bin/chromium-browser", "/usr/bin/chromium", + "C:/Program Files/Google/Chrome/Application/chrome.exe" + ] + chromePath = candidates.find { new File(it).exists() } + } + if (chromePath) { + environment "CHROME_PATH", chromePath + println "Using Chrome at: ${chromePath}" + } + + // run LHCI (asserts will cause non-zero exit -> build fails) + commandLine OperatingSystem.current().isWindows() + ? ["cmd","/c","npx","--yes","@lhci/cli@0.13.x","autorun"] + : ["bash","-lc","npx --yes @lhci/cli@0.13.x autorun"] + + finalizedBy("copyA11yReports", "stopA11yServer") +} + +// gather artifacts into build/reports/a11y for local + CI publishing +tasks.register("copyA11yReports") { + group = "verification" + description = "Copy LHCI output to build/reports/a11y" + doLast { + def out = file("${project.buildDir}/reports/a11y") + out.mkdirs() + copy { + from file("lhci-results") + include "**/*.html", "**/*.json" + into out + } + println "LHCI reports copied to ${out}" + } +} From 9d437e8b2826fce8f738348acbed4edbb7dc3bd1 Mon Sep 17 00:00:00 2001 From: "Charles Graham, SWT" Date: Sun, 12 Oct 2025 15:03:40 -0500 Subject: [PATCH 07/13] add minor corrections to lighthouse config --- .lighthouserc.json | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/.lighthouserc.json b/.lighthouserc.json index aac80c63..df0fa7fe 100644 --- a/.lighthouserc.json +++ b/.lighthouserc.json @@ -1,12 +1,12 @@ { - "$schema": "https://raw.githubusercontent.com/GoogleChrome/lighthouse-ci/main/docs/lighthouserc-schema.json", "ci": { "collect": { "numberOfRuns": 1, "startServerCommand": "", "url": [ + "http://localhost:7001/", "http://localhost:7001/login", - "http://localhost:7001/error" + "http://localhost:7001/login.jsp" ], "settings": { "disableStorageReset": true, @@ -34,8 +34,7 @@ "heading-order": "error", "html-has-lang": "error", "document-title": "error", - "region": "error", - "aria-*": "error" + "region": "error" } }, "upload": { From d77ae374040c945cff8907aad5533e053f44088e Mon Sep 17 00:00:00 2001 From: "Charles Graham, SWT" Date: Sun, 12 Oct 2025 15:12:13 -0500 Subject: [PATCH 08/13] Correct port for github action + format --- .github/workflows/compliance-lhci.yml | 102 +++++++++++++------------- 1 file changed, 51 insertions(+), 51 deletions(-) diff --git a/.github/workflows/compliance-lhci.yml b/.github/workflows/compliance-lhci.yml index ae7585c2..b30eb8e8 100644 --- a/.github/workflows/compliance-lhci.yml +++ b/.github/workflows/compliance-lhci.yml @@ -1,58 +1,58 @@ name: 508-a11y-lighthouse on: - pull_request: - types: [opened, synchronize, reopened] + pull_request: + types: [opened, synchronize, reopened] permissions: - contents: read + contents: read jobs: - lhci: - runs-on: ubuntu-latest - - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Setup Java - uses: actions/setup-java@v4 - with: - distribution: temurin - java-version: "17" - - - name: Setup Node - uses: actions/setup-node@v4 - with: - node-version: "20" - - - name: Cache Gradle - uses: gradle/actions/setup-gradle@v3 - - - name: Build project (WAR) - run: ./gradlew build --stacktrace - - # Start Tomcat to serve for Lighthouse - - name: Start app server (webapp-runner) - run: ./gradlew serveWarForA11y - - # Wait for the app to respond - - name: Wait for server - run: npx --yes wait-on@7 http-get://localhost:8080/portal/login - - # Run Lighthouse CI, fail if thresholds not met - - name: Run Lighthouse CI - run: npx --yes @lhci/cli@0.13.x autorun - - # Cleanup after ourselves - - name: Stop server - if: always() - run: ./gradlew stopA11yServer - - # Upload artifacts to view reports - - name: Upload LHCI artifacts - if: always() - uses: actions/upload-artifact@v4 - with: - name: lhci-results - path: lhci-results/** + lhci: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Java + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "17" + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: "20" + + - name: Cache Gradle + uses: gradle/actions/setup-gradle@v3 + + - name: Build project (WAR) + run: ./gradlew build --stacktrace + + # Start Tomcat to serve for Lighthouse + - name: Start app server (webapp-runner) + run: ./gradlew serveWarForA11y + + # Wait for the app to respond + - name: Wait for server + run: npx --yes wait-on@7 http-get://localhost:7001/portal/login + + # Run Lighthouse CI, fail if thresholds not met + - name: Run Lighthouse CI + run: npx --yes @lhci/cli@0.13.x autorun + + # Cleanup after ourselves + - name: Stop server + if: always() + run: ./gradlew stopA11yServer + + # Upload artifacts to view reports + - name: Upload LHCI artifacts + if: always() + uses: actions/upload-artifact@v4 + with: + name: lhci-results + path: lhci-results/** From b2ec78f73c4492f17797396690768ed39ed9ff07 Mon Sep 17 00:00:00 2001 From: "Charles Graham, SWT" Date: Sun, 12 Oct 2025 22:32:47 -0500 Subject: [PATCH 09/13] remove missing page, remove expected manifest (PWA) for JSP --- .lighthouserc.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.lighthouserc.json b/.lighthouserc.json index df0fa7fe..5a9c66fd 100644 --- a/.lighthouserc.json +++ b/.lighthouserc.json @@ -5,8 +5,7 @@ "startServerCommand": "", "url": [ "http://localhost:7001/", - "http://localhost:7001/login", - "http://localhost:7001/login.jsp" + "http://localhost:7001/login" ], "settings": { "disableStorageReset": true, @@ -26,6 +25,7 @@ "minScore": 0.9 } ], + "installable-manifest": "off", "color-contrast": "error", "image-alt": "error", "label": "error", From 64afdda7da8a400df0e058eb1eecde4bf51a7b32 Mon Sep 17 00:00:00 2001 From: "Charles Graham, SWT" Date: Sun, 12 Oct 2025 23:11:03 -0500 Subject: [PATCH 10/13] Convert to docker-compose runner --- .gitignore | 5 +- build.gradle | 158 +++++++++++++++++++++++++++------------------------ 2 files changed, 88 insertions(+), 75 deletions(-) diff --git a/.gitignore b/.gitignore index 8290ccbb..675cc359 100644 --- a/.gitignore +++ b/.gitignore @@ -42,4 +42,7 @@ bin/ ### Mac OS ### .DS_Store -gradle.properties \ No newline at end of file +gradle.properties + +### Lighthouse ### +.lighthouseci \ No newline at end of file diff --git a/build.gradle b/build.gradle index 717a49a4..fe5c5531 100644 --- a/build.gradle +++ b/build.gradle @@ -87,121 +87,133 @@ import org.gradle.internal.os.OperatingSystem ext { A11Y_PORT = (project.findProperty("a11yPort") ?: "7001") as String - A11Y_DOCKER_IMAGE = (project.findProperty("a11yDockerImage") ?: "tomcat:9.0-jdk17-temurin") as String - A11Y_CONTAINER = (project.findProperty("a11yContainer") ?: "opendcs-a11y") as String + A11Y_PROJECT = (project.findProperty("a11yProject") ?: "opendcs-a11y") as String + A11Y_URL = (project.findProperty("a11yUrl") ?: "http://localhost:${A11Y_PORT}/") as String + A11Y_COMPOSE_BASE = (project.findProperty("a11yComposeBase") ?: "docker-compose.yaml") as String } -/** Which WAR to test: default to the JSP UI */ -Project findWarProject() { - if (project.hasProperty("a11yWarProject")) return project.project(project.property("a11yWarProject") as String) - def ui = rootProject.findProject(":opendcs-web-client") - if (ui != null) return ui - throw new GradleException("No WAR module resolved. Pass -Pa11yWarProject=':opendcs-web-client'.") +Project findUiProject() { + def p = rootProject.findProject(":opendcs-web-client") + if (p == null) throw new GradleException("Missing :opendcs-web-client module.") + return p } File resolveWarFile(Project warProject) { def libsDir = new File(warProject.buildDir, "libs") def wars = libsDir.listFiles()?.findAll { it.name.toLowerCase().endsWith(".war") } ?: [] - if (wars.isEmpty()) throw new GradleException("No WAR found under ${libsDir}. Run assemble first.") + if (wars.isEmpty()) throw new GradleException("No WAR in ${libsDir}. Run assemble first.") wars.sort { a, b -> b.lastModified() <=> a.lastModified() }.first() } -tasks.register("serveWarForA11y") { - group = "verification" - description = "Run the built WAR in Docker Tomcat for Lighthouse scans." - doLast { - def warProject = findWarProject() - - // build the WAR we will deploy - exec { - workingDir rootProject.projectDir - commandLine OperatingSystem.current().isWindows() - ? ["cmd","/c",".\\gradlew","${warProject.path}:assemble"] - : ["bash","-lc","./gradlew ${warProject.path}:assemble"] - } +String toComposePath(File f) { + // Compose accepts Windows absolute paths; normalize slashes to be safe + return f.absolutePath.replace("\\", "/") +} - def warFile = resolveWarFile(warProject) +tasks.register("serveStackForA11y") { + group = "verification" + description = "Bring up tomcat (a11yweb) via docker compose, mounting the built WAR." - // ensure docker + doLast { + // ensure docker compose available try { exec { commandLine OperatingSystem.current().isWindows() - ? ["cmd","/c","docker","--version"] - : ["bash","-lc","docker --version"] + ? ["cmd","/c","docker","compose","version"] + : ["bash","-lc","docker compose version"] standardOutput = new ByteArrayOutputStream() } } catch (ignore) { - throw new GradleException("Docker not found. Install Docker Desktop.") + throw new GradleException("`docker compose` not found. Install/enable Docker Desktop.") } - // reset container + // 1) build WAR from the web client module + def ui = findUiProject() exec { - ignoreExitValue true + workingDir rootProject.projectDir + commandLine OperatingSystem.current().isWindows() + ? ["cmd","/c",".\\gradlew","${ui.path}:assemble"] + : ["bash","-lc","./gradlew ${ui.path}:assemble"] + } + def warFile = resolveWarFile(ui) + def warPath = toComposePath(warFile) + + // 2) write a throwaway compose override that runs plain Tomcat with the WAR mounted as ROOT.war + def a11yDir = file("${buildDir}/a11y"); a11yDir.mkdirs() + def overrideFile = new File(a11yDir, "docker-compose.a11y.override.yaml") + overrideFile.text = """ +services: + a11yweb: + image: tomcat:9.0-jdk17-temurin + ports: + - "${A11Y_PORT}:8080" + volumes: + - "${warPath}:/usr/local/tomcat/webapps/ROOT.war:ro" + restart: unless-stopped +""".stripIndent() + + // before starting a11yweb + exec { + // bring up db + migration + api from your root compose commandLine OperatingSystem.current().isWindows() - ? ["cmd","/c","docker","rm","-f", A11Y_CONTAINER] - : ["bash","-lc","docker rm -f ${A11Y_CONTAINER} || true"] + ? ["cmd","/c","docker","compose","-p",A11Y_PROJECT,"-f",A11Y_COMPOSE_BASE,"up","-d","db","migration","api"] + : ["bash","-lc","docker compose -p ${A11Y_PROJECT} -f ${A11Y_COMPOSE_BASE} up -d db migration api"] } - // run Tomcat + mount as ROOT.war; expose host port 7001 -> 8080 - def runCmd = "docker run -d --name ${A11Y_CONTAINER} -p ${A11Y_PORT}:8080 " + - "-v \"${warFile.absolutePath}\":/usr/local/tomcat/webapps/ROOT.war:ro " + - "${A11Y_DOCKER_IMAGE}" - println "Launching: ${runCmd}" + // then start a11yweb with the override (as we already do) exec { commandLine OperatingSystem.current().isWindows() - ? ["cmd","/c", runCmd] - : ["bash","-lc", runCmd] + ? ["cmd","/c","docker","compose","-p",A11Y_PROJECT,"-f",A11Y_COMPOSE_BASE,"-f",overrideFile.absolutePath,"up","-d","a11yweb"] + : ["bash","-lc","docker compose -p ${A11Y_PROJECT} -f ${A11Y_COMPOSE_BASE} -f ${overrideFile.absolutePath} up -d a11yweb"] } - // wait until the app actually responds (not just Tomcat up) - println "Waiting for Tomcat + app on http://localhost:${A11Y_PORT}/ ..." - def ready = false - for (int i=0; i<180; i++) { // up to 3 min for first JSP compile - def head = new ByteArrayOutputStream() - exec { - ignoreExitValue true - standardOutput = head - commandLine OperatingSystem.current().isWindows() - ? ["cmd","/c","docker","exec", A11Y_CONTAINER, "curl","-sSI","http://localhost:8080/"] - : ["bash","-lc","docker exec ${A11Y_CONTAINER} curl -sSI http://localhost:8080/"] - } - def h = head.toString() - if (h.startsWith("HTTP/1.1 200") || h.startsWith("HTTP/1.1 302")) { ready = true; break } + // 4) wait until the app responds + println "Waiting for ${A11Y_URL} ..." + boolean ready = false + for (int i=0; i<180; i++) { + def out = new ByteArrayOutputStream() + def cmd = OperatingSystem.current().isWindows() + ? ["cmd","/c","curl","-sSI","--max-time","2", A11Y_URL] + : ["bash","-lc","curl -sSI --max-time 2 ${A11Y_URL}"] + exec { ignoreExitValue true; standardOutput = out; commandLine cmd } + def head = out.toString() + if (head.startsWith("HTTP/1.1 200") || head.startsWith("HTTP/1.1 302")) { ready = true; break } Thread.sleep(1000) } if (!ready) { - println "----- Tomcat logs (tail) -----" + println "---- logs (tail) ----" exec { ignoreExitValue true commandLine OperatingSystem.current().isWindows() - ? ["cmd","/c","docker","logs", "--tail","200", A11Y_CONTAINER] - : ["bash","-lc","docker logs --tail 200 ${A11Y_CONTAINER} || true"] + ? ["cmd","/c","docker","compose","-p",A11Y_PROJECT,"-f",A11Y_COMPOSE_BASE,"-f",overrideFile.absolutePath,"logs","--tail","200","a11yweb"] + : ["bash","-lc","docker compose -p ${A11Y_PROJECT} -f ${A11Y_COMPOSE_BASE} -f ${overrideFile.absolutePath} logs --tail 200 a11yweb || true"] } - throw new GradleException("App did not become ready on http://localhost:${A11Y_PORT}/") + throw new GradleException("App did not become ready at ${A11Y_URL}") } - println "Server is up: http://localhost:${A11Y_PORT}/" + println "Server is up: ${A11Y_URL}" } } -tasks.register("stopA11yServer") { +tasks.register("stopA11yStack") { group = "verification" - description = "Stop and remove the Docker Tomcat container." + description = "Shutdown a11yweb service from compose." doLast { + def overrideFile = file("${buildDir}/a11y/docker-compose.a11y.override.yaml") exec { ignoreExitValue true commandLine OperatingSystem.current().isWindows() - ? ["cmd","/c","docker","rm","-f", A11Y_CONTAINER] - : ["bash","-lc","docker rm -f ${A11Y_CONTAINER} || true"] + ? ["cmd","/c","docker","compose","-p",A11Y_PROJECT,"-f",A11Y_COMPOSE_BASE,"-f",overrideFile.absolutePath,"down","-v"] + : ["bash","-lc","docker compose -p ${A11Y_PROJECT} -f ${A11Y_COMPOSE_BASE} -f ${overrideFile.absolutePath} down -v || true"] } } } tasks.register("a11yScan", Exec) { group = "verification" - description = "Run Lighthouse CI using .lighthouserc.json" - dependsOn("serveWarForA11y") + description = "Run Lighthouse CI on the mounted WAR via a11yweb." + dependsOn("serveStackForA11y") - // Find Chrome for Windows/macOS/Linux; LHCI can also auto-detect + // tell Lighthouse what URL to hit via .lighthouserc.json (or override with LHCI_* env vars) def chromePath = System.getenv("CHROME_PATH") if (!chromePath) { def candidates = [ @@ -216,26 +228,24 @@ tasks.register("a11yScan", Exec) { println "Using Chrome at: ${chromePath}" } - // run LHCI (asserts will cause non-zero exit -> build fails) + // LHCI will read .lighthouserc.json; to force the URL, you can set LHCI_COLLECT_URL + if (project.findProperty("a11yUrl")) { + environment "LHCI_COLLECT_URL", A11Y_URL + } + commandLine OperatingSystem.current().isWindows() ? ["cmd","/c","npx","--yes","@lhci/cli@0.13.x","autorun"] : ["bash","-lc","npx --yes @lhci/cli@0.13.x autorun"] - finalizedBy("copyA11yReports", "stopA11yServer") + finalizedBy("copyA11yReports","stopA11yStack") } -// gather artifacts into build/reports/a11y for local + CI publishing tasks.register("copyA11yReports") { group = "verification" - description = "Copy LHCI output to build/reports/a11y" doLast { - def out = file("${project.buildDir}/reports/a11y") + def out = file("${buildDir}/reports/a11y") out.mkdirs() - copy { - from file("lhci-results") - include "**/*.html", "**/*.json" - into out - } + copy { from file("lhci-results"); include "**/*.html","**/*.json"; into out } println "LHCI reports copied to ${out}" } } From 0e7cbb87f65e83f4ac036137992096c541172183 Mon Sep 17 00:00:00 2001 From: "Charles Graham, SWT" Date: Sun, 12 Oct 2025 23:22:09 -0500 Subject: [PATCH 11/13] Fix task name to match action --- build.gradle | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/build.gradle b/build.gradle index fe5c5531..d014c23f 100644 --- a/build.gradle +++ b/build.gradle @@ -110,7 +110,7 @@ String toComposePath(File f) { return f.absolutePath.replace("\\", "/") } -tasks.register("serveStackForA11y") { +tasks.register("serveWarForA11y") { group = "verification" description = "Bring up tomcat (a11yweb) via docker compose, mounting the built WAR." @@ -194,7 +194,7 @@ services: } } -tasks.register("stopA11yStack") { +tasks.register("stopA11yWar") { group = "verification" description = "Shutdown a11yweb service from compose." doLast { @@ -211,7 +211,7 @@ tasks.register("stopA11yStack") { tasks.register("a11yScan", Exec) { group = "verification" description = "Run Lighthouse CI on the mounted WAR via a11yweb." - dependsOn("serveStackForA11y") + dependsOn("serveWarForA11y") // tell Lighthouse what URL to hit via .lighthouserc.json (or override with LHCI_* env vars) def chromePath = System.getenv("CHROME_PATH") From 77cf508a3295420a4fa077945606c976cc5c76f3 Mon Sep 17 00:00:00 2001 From: "Charles Graham, SWT" Date: Mon, 13 Oct 2025 00:40:19 -0500 Subject: [PATCH 12/13] Fix new line/other items that prevented `act` from running locally for testing GH actions. --- .gitattributes | 8 +++++++- .github/workflows/compliance-lhci.yml | 6 ++++-- docker-compose.yaml | 8 ++++---- 3 files changed, 15 insertions(+), 7 deletions(-) diff --git a/.gitattributes b/.gitattributes index 526c8a38..2c3ad1d9 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1 +1,7 @@ -*.sh text eol=lf \ No newline at end of file +# Scripts must be LF on Linux runners +*.sh text eol=lf +gradlew text eol=lf + +# Windows scripts keep CRLF +*.bat text eol=crlf +*.cmd text eol=crlf \ No newline at end of file diff --git a/.github/workflows/compliance-lhci.yml b/.github/workflows/compliance-lhci.yml index b30eb8e8..68f61314 100644 --- a/.github/workflows/compliance-lhci.yml +++ b/.github/workflows/compliance-lhci.yml @@ -29,6 +29,8 @@ jobs: - name: Cache Gradle uses: gradle/actions/setup-gradle@v3 + - name: Ensure Gradle wrapper is executable + run: chmod +x ./gradlew - name: Build project (WAR) run: ./gradlew build --stacktrace @@ -38,7 +40,7 @@ jobs: # Wait for the app to respond - name: Wait for server - run: npx --yes wait-on@7 http-get://localhost:7001/portal/login + run: npx --yes wait-on@7 http-get://localhost:7001/login # Run Lighthouse CI, fail if thresholds not met - name: Run Lighthouse CI @@ -47,7 +49,7 @@ jobs: # Cleanup after ourselves - name: Stop server if: always() - run: ./gradlew stopA11yServer + run: ./gradlew stopA11yWar # Upload artifacts to view reports - name: Upload LHCI artifacts diff --git a/docker-compose.yaml b/docker-compose.yaml index 20560549..e314a528 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -27,14 +27,14 @@ services: - DATABASE_URL=jdbc:postgresql://db:5432/dcs - placeholder_NUM_TS_TABLES=1 - placeholder_NUM_TEXT_TABLES=1 - command: ["/schema.sh"] + command: ["sh", "/schema.sh"] volumes: - ./compose_files/schema.sh:/schema.sh:ro restart: no depends_on: db: - condition: service_healthy - + condition: service_healthy + api: build: context: . @@ -67,4 +67,4 @@ services: volumes: - ./compose_files/servers.json:/tmp/servers.json volumes: - db_data: # Define the named volume for data persistence \ No newline at end of file + db_data: # Define the named volume for data persistence From 6b51992c8624c0b6cd8f793a46e3c482f4a39d83 Mon Sep 17 00:00:00 2001 From: "Charles Graham, SWT" Date: Mon, 13 Oct 2025 00:40:40 -0500 Subject: [PATCH 13/13] Add lighthouse login control middleware --- .lighthouserc.json | 1 + .../src/test/508/scripts/lhci-login.js | 69 +++++++++++++++++++ 2 files changed, 70 insertions(+) create mode 100644 opendcs-web-client/src/test/508/scripts/lhci-login.js diff --git a/.lighthouserc.json b/.lighthouserc.json index 5a9c66fd..de783807 100644 --- a/.lighthouserc.json +++ b/.lighthouserc.json @@ -3,6 +3,7 @@ "collect": { "numberOfRuns": 1, "startServerCommand": "", + "puppeteerScript": "./opendcs-web-client/src/test/508/scripts/lhci-login.js", "url": [ "http://localhost:7001/", "http://localhost:7001/login" diff --git a/opendcs-web-client/src/test/508/scripts/lhci-login.js b/opendcs-web-client/src/test/508/scripts/lhci-login.js new file mode 100644 index 00000000..62d268f1 --- /dev/null +++ b/opendcs-web-client/src/test/508/scripts/lhci-login.js @@ -0,0 +1,69 @@ +// lhci-login.js +// Script to log into a webapp before running Lighthouse scans. +// Usage: LHCI calls this before collecting each URL. +// Reads creds and base from env to avoid hardcoding. +const BASE = process.env.A11Y_BASE || "http://localhost:7001"; +const USER = process.env.A11Y_USER || "app"; +const PASS = process.env.A11Y_PASS || "app_pass"; + +module.exports = async ({ page, url }) => { + await page.goto(`${BASE}/login`, { + waitUntil: "networkidle2", + timeout: 120000, + }); + + // Try common names/ids first and fall back to best effort queries. + const userSelCandidates = [ + "#username", + 'input[name="username"]', + 'input[type="text"]', + ]; + const passSelCandidates = [ + "#password", + 'input[name="password"]', + 'input[type="password"]', + ]; + const submitSelCandidates = [ + 'button[type="submit"]', + 'input[type="submit"]', + "button#login", + 'button[name="login"]', + ]; + + async function findFirst(selList) { + for (const s of selList) { + const el = await page.$(s); + if (el) return s; + } + return null; + } + + const userSel = await findFirst(userSelCandidates); + const passSel = await findFirst(passSelCandidates); + const submitSel = await findFirst(submitSelCandidates); + + if (!userSel || !passSel || !submitSel) { + throw new Error( + `Login selectors not found. Found -> user:${!!userSel} pass:${!!passSel} submit:${!!submitSel}` + ); + } + + await page.click(userSel, { clickCount: 3 }); + await page.type(userSel, USER, { delay: 10 }); + await page.click(passSel, { clickCount: 3 }); + await page.type(passSel, PASS, { delay: 10 }); + + await Promise.all([ + page.click(submitSel), + page.waitForNavigation({ waitUntil: "networkidle2", timeout: 120000 }), + ]); + + // Make sure it logged in + const cur = page.url(); + if (/\/login(\b|\/|$)/i.test(cur)) { + throw new Error(`Still on login page after submit -> ${cur}`); + } + + // Move onto the target URL + await page.goto(url, { waitUntil: "networkidle2", timeout: 120000 }); +};