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 new file mode 100644 index 00000000..68f61314 --- /dev/null +++ b/.github/workflows/compliance-lhci.yml @@ -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/** 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/.lighthouserc.json b/.lighthouserc.json new file mode 100644 index 00000000..de783807 --- /dev/null +++ b/.lighthouserc.json @@ -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" + } + } +} \ No newline at end of file diff --git a/build.gradle b/build.gradle index 074de7dc..d014c23f 100644 --- a/build.gradle +++ b/build.gradle @@ -78,4 +78,174 @@ 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_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}" + } +} 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 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 }); +};