Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion .gitattributes
Original file line number Diff line number Diff line change
@@ -1 +1,7 @@
*.sh text eol=lf
# 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
60 changes: 60 additions & 0 deletions .github/workflows/compliance-lhci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
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: Ensure Gradle wrapper is executable
run: chmod +x ./gradlew
- 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/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 stopA11yWar

# Upload artifacts to view reports
- name: Upload LHCI artifacts
if: always()
uses: actions/upload-artifact@v4
with:
name: lhci-results
path: lhci-results/**
5 changes: 4 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -42,4 +42,7 @@ bin/
### Mac OS ###
.DS_Store

gradle.properties
gradle.properties

### Lighthouse ###
.lighthouseci
46 changes: 46 additions & 0 deletions .lighthouserc.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
{
"ci": {
"collect": {
"numberOfRuns": 1,
"startServerCommand": "",
"puppeteerScript": "./opendcs-web-client/src/test/508/scripts/lhci-login.js",
"url": [
"http://localhost:7001/",
"http://localhost:7001/login"
],
"settings": {
"disableStorageReset": true,
"chromeFlags": "--headless=new --disable-gpu --no-sandbox --disable-dev-shm-usage --window-size=1366,768",
"output": [
"html",
"json"
]
}
},
"assert": {
"preset": "lighthouse:recommended",
"assertions": {
"categories:accessibility": [
"error",
{
"minScore": 0.9
}
],
"installable-manifest": "off",
"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"
}
},
"upload": {
"target": "filesystem",
"outputDir": "./lhci-results"
}
}
}
172 changes: 171 additions & 1 deletion build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -78,4 +78,174 @@ ext.sonarVersion = {
standardOutput = stdout
}
return stdout.toString().trim() + "+"
}
}

// =====================================
// Accessibility (LHCI)
// =====================================
import org.gradle.internal.os.OperatingSystem

ext {
A11Y_PORT = (project.findProperty("a11yPort") ?: "7001") 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
}

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 in ${libsDir}. Run assemble first.")
wars.sort { a, b -> b.lastModified() <=> a.lastModified() }.first()
}

String toComposePath(File f) {
// Compose accepts Windows absolute paths; normalize slashes to be safe
return f.absolutePath.replace("\\", "/")
}

tasks.register("serveWarForA11y") {
group = "verification"
description = "Bring up tomcat (a11yweb) via docker compose, mounting the built WAR."

doLast {
// ensure docker compose available
try {
exec {
commandLine OperatingSystem.current().isWindows()
? ["cmd","/c","docker","compose","version"]
: ["bash","-lc","docker compose version"]
standardOutput = new ByteArrayOutputStream()
}
} catch (ignore) {
throw new GradleException("`docker compose` not found. Install/enable Docker Desktop.")
}

// 1) build WAR from the web client module
def ui = findUiProject()
exec {
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","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"]
}

// then start a11yweb with the override (as we already do)
exec {
commandLine OperatingSystem.current().isWindows()
? ["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"]
}

// 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 "---- logs (tail) ----"
exec {
ignoreExitValue true
commandLine OperatingSystem.current().isWindows()
? ["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 at ${A11Y_URL}")
}
println "Server is up: ${A11Y_URL}"
}
}

tasks.register("stopA11yWar") {
group = "verification"
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","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 on the mounted WAR via a11yweb."
dependsOn("serveWarForA11y")

// 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 = [
"/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}"
}

// 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","stopA11yStack")
}

tasks.register("copyA11yReports") {
group = "verification"
doLast {
def out = file("${buildDir}/reports/a11y")
out.mkdirs()
copy { from file("lhci-results"); include "**/*.html","**/*.json"; into out }
println "LHCI reports copied to ${out}"
}
}
8 changes: 4 additions & 4 deletions docker-compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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: .
Expand Down Expand Up @@ -67,4 +67,4 @@ services:
volumes:
- ./compose_files/servers.json:/tmp/servers.json
volumes:
db_data: # Define the named volume for data persistence
db_data: # Define the named volume for data persistence
Loading
Loading