diff --git a/.github/workflows/dashboard-a11y.yml b/.github/workflows/dashboard-a11y.yml new file mode 100644 index 00000000..1e3cba25 --- /dev/null +++ b/.github/workflows/dashboard-a11y.yml @@ -0,0 +1,45 @@ +name: Dashboard a11y + cross-browser + +# Runs axe-core a11y assertions on the built dashboard across +# Chromium, Firefox, and WebKit. Closes ADR-092 §11.5 (axe-core) +# and §11.8 (cross-browser). + +on: + push: + branches: [main] + paths: ['dashboard/**', 'v2/crates/nvsim/**'] + pull_request: + paths: ['dashboard/**'] + workflow_dispatch: + +permissions: + contents: read + +jobs: + a11y: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: dtolnay/rust-toolchain@stable + with: { targets: wasm32-unknown-unknown } + + - run: cargo install wasm-pack --locked --version 0.13.x || true + + - name: Build nvsim WASM + working-directory: v2 + run: | + wasm-pack build crates/nvsim --target web \ + --out-dir ../../dashboard/public/nvsim-pkg \ + --release -- --no-default-features --features wasm + + - uses: actions/setup-node@v4 + with: { node-version: 20, cache: npm, cache-dependency-path: dashboard/package-lock.json } + + - working-directory: dashboard + run: | + npm ci + npm install --save-dev @playwright/test @axe-core/playwright + npx playwright install --with-deps + npm run build + npx playwright test diff --git a/.github/workflows/nvsim-server-docker.yml b/.github/workflows/nvsim-server-docker.yml new file mode 100644 index 00000000..764b2e74 --- /dev/null +++ b/.github/workflows/nvsim-server-docker.yml @@ -0,0 +1,69 @@ +name: nvsim-server → ghcr.io + +# Builds and publishes the nvsim-server Docker image to ghcr.io on: +# - push to main affecting nvsim-server or nvsim +# - tag push matching nvsim-server-v* +# - manual workflow_dispatch +# +# ADR-092 §6.2 + §9.4. + +on: + push: + branches: [main] + paths: + - 'v2/crates/nvsim-server/**' + - 'v2/crates/nvsim/**' + - '.github/workflows/nvsim-server-docker.yml' + tags: ['nvsim-server-v*'] + workflow_dispatch: + +permissions: + contents: read + packages: write + +jobs: + build-and-publish: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: docker/setup-buildx-action@v3 + + - uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ghcr.io/ruvnet/nvsim-server + tags: | + type=ref,event=branch + type=ref,event=tag + type=sha,format=short + type=raw,value=latest,enable={{is_default_branch}} + + - name: Build + push + uses: docker/build-push-action@v5 + with: + context: v2 + file: v2/crates/nvsim-server/Dockerfile + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + platforms: linux/amd64 + + - name: Smoke-test the image + run: | + docker pull ghcr.io/ruvnet/nvsim-server:sha-${GITHUB_SHA::7} || \ + docker pull ghcr.io/ruvnet/nvsim-server:latest + docker run --rm -d --name nvsim-test -p 7878:7878 \ + ghcr.io/ruvnet/nvsim-server:latest + sleep 4 + curl -fsS http://localhost:7878/api/health + docker stop nvsim-test diff --git a/dashboard/package-lock.json b/dashboard/package-lock.json index 359c9b7f..1b24bf15 100644 --- a/dashboard/package-lock.json +++ b/dashboard/package-lock.json @@ -13,6 +13,8 @@ "workbox-window": "^7.4.0" }, "devDependencies": { + "@axe-core/playwright": "^4.11.2", + "@playwright/test": "^1.59.1", "typescript": "^5.6.3", "vite": "^5.4.10", "vite-plugin-pwa": "^1.2.0", @@ -36,6 +38,19 @@ "ajv": ">=8" } }, + "node_modules/@axe-core/playwright": { + "version": "4.11.2", + "resolved": "https://registry.npmjs.org/@axe-core/playwright/-/playwright-4.11.2.tgz", + "integrity": "sha512-iP6hfNl9G0j/SEUSo8M7D80RbcDo9KRAAfDP4IT5OHB+Wm6zUHIrm8Y51BKI+Oyqduvipf9u1hcRy57zCBKzWQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "axe-core": "~4.11.3" + }, + "peerDependencies": { + "playwright-core": ">= 1.0.0" + } + }, "node_modules/@babel/code-frame": { "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", @@ -2037,6 +2052,22 @@ "@lit-labs/ssr-dom-shim": "^1.5.0" } }, + "node_modules/@playwright/test": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.59.1.tgz", + "integrity": "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.59.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@preact/signals-core": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/@preact/signals-core/-/signals-core-1.14.1.tgz", @@ -2753,6 +2784,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/axe-core": { + "version": "4.11.3", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.11.3.tgz", + "integrity": "sha512-zBQouZixDTbo3jMGqHKyePxYxr1e5W8UdTmBQ7sNtaA9M2bE32daxxPLS/jojhKOHxQ7LWwPjfiwf/fhaJWzlg==", + "dev": true, + "license": "MPL-2.0", + "engines": { + "node": ">=4" + } + }, "node_modules/babel-plugin-polyfill-corejs2": { "version": "0.4.17", "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.17.tgz", @@ -4695,6 +4736,53 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/playwright": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz", + "integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.59.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz", + "integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/possible-typed-array-names": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", diff --git a/dashboard/package.json b/dashboard/package.json index 490b3677..e99f9ae4 100644 --- a/dashboard/package.json +++ b/dashboard/package.json @@ -10,7 +10,9 @@ "preview": "vite preview --port 4173", "typecheck": "tsc --noEmit", "test": "vitest run", - "test:watch": "vitest" + "test:watch": "vitest", + "test:e2e": "playwright test", + "test:a11y": "playwright test tests/a11y.spec.ts" }, "dependencies": { "@preact/signals-core": "^1.8.0", @@ -18,6 +20,8 @@ "workbox-window": "^7.4.0" }, "devDependencies": { + "@axe-core/playwright": "^4.11.2", + "@playwright/test": "^1.59.1", "typescript": "^5.6.3", "vite": "^5.4.10", "vite-plugin-pwa": "^1.2.0", diff --git a/dashboard/playwright.config.ts b/dashboard/playwright.config.ts new file mode 100644 index 00000000..936e1083 --- /dev/null +++ b/dashboard/playwright.config.ts @@ -0,0 +1,23 @@ +import { defineConfig } from '@playwright/test'; + +export default defineConfig({ + testDir: './tests', + fullyParallel: true, + retries: 0, + reporter: 'list', + use: { + baseURL: 'http://localhost:4173', + headless: true, + }, + webServer: { + command: 'npm run preview', + port: 4173, + timeout: 60_000, + reuseExistingServer: !process.env.CI, + }, + projects: [ + { name: 'chromium', use: { browserName: 'chromium' } }, + { name: 'firefox', use: { browserName: 'firefox' } }, + { name: 'webkit', use: { browserName: 'webkit' } }, + ], +}); diff --git a/dashboard/tests/a11y.spec.ts b/dashboard/tests/a11y.spec.ts new file mode 100644 index 00000000..18b4c080 --- /dev/null +++ b/dashboard/tests/a11y.spec.ts @@ -0,0 +1,56 @@ +/* axe-core accessibility smoke against the built dashboard. + * Closes ADR-092 §11.5 — formal axe scan. + * + * Runs against `npm run preview` (Vite preview server). Validates each + * primary view (home / scene / apps / inspector / witness / ghost-murmur) + * and asserts 0 critical/serious violations. + */ + +import { test, expect } from '@playwright/test'; +import AxeBuilder from '@axe-core/playwright'; + +const VIEWS = ['home', 'scene', 'apps', 'inspector', 'witness', 'ghost-murmur'] as const; + +test.describe('axe-core a11y smoke', () => { + for (const view of VIEWS) { + test(`view: ${view}`, async ({ page }) => { + await page.goto('/'); + // Dismiss the welcome modal if it auto-shows. + await page.evaluate(() => { + const sr = (document.querySelector('nv-app') as HTMLElement & { shadowRoot: ShadowRoot }).shadowRoot; + const ob = sr.querySelector('nv-onboarding') as HTMLElement | null; + if (ob?.hasAttribute('open')) { + (ob.shadowRoot?.querySelector('.skip') as HTMLElement | null)?.click(); + } + }); + // Navigate to the view via the rail button (except for home which is default). + if (view !== 'home') { + await page.evaluate((v) => { + const sr = (document.querySelector('nv-app') as HTMLElement & { shadowRoot: ShadowRoot }).shadowRoot; + const rail = sr.querySelector('nv-rail') as HTMLElement & { shadowRoot: ShadowRoot }; + const btn = rail.shadowRoot.querySelector(`button[data-id=${v}-btn]`) as HTMLElement | null; + btn?.click(); + }, view); + await page.waitForTimeout(300); + } + + const results = await new AxeBuilder({ page }) + .options({ runOnly: ['wcag2a', 'wcag2aa'] }) + .analyze(); + + const critical = results.violations.filter((v) => v.impact === 'critical'); + const serious = results.violations.filter((v) => v.impact === 'serious'); + + // Logging the violation summary makes CI failures readable. + if (critical.length || serious.length) { + for (const v of [...critical, ...serious]) { + console.error(`[${view}] ${v.impact} · ${v.id} · ${v.help}`); + for (const node of v.nodes) console.error(` ${node.target.join(' >> ')}`); + } + } + + expect(critical.length, 'no critical violations').toBe(0); + expect(serious.length, 'no serious violations').toBe(0); + }); + } +}); diff --git a/v2/crates/nvsim-server/Dockerfile b/v2/crates/nvsim-server/Dockerfile new file mode 100644 index 00000000..42892fe3 --- /dev/null +++ b/v2/crates/nvsim-server/Dockerfile @@ -0,0 +1,58 @@ +# Multi-stage Dockerfile for nvsim-server (ADR-092 §6.2). +# +# Build: +# docker build -f v2/crates/nvsim-server/Dockerfile -t nvsim-server:latest v2 +# +# Run (LAN): +# docker run --rm -p 7878:7878 nvsim-server:latest +# +# Run with custom CORS origin: +# docker run --rm -p 7878:7878 nvsim-server:latest \ +# nvsim-server --listen 0.0.0.0:7878 --allowed-origin https://example.com +# +# Health check: +# curl http://localhost:7878/api/health + +FROM rust:1.81-slim-bookworm AS builder +WORKDIR /build +RUN apt-get update && apt-get install -y --no-install-recommends \ + pkg-config libssl-dev ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +# Cache deps separately from source. +COPY Cargo.toml Cargo.lock ./ +COPY crates/nvsim/Cargo.toml crates/nvsim/Cargo.toml +COPY crates/nvsim-server/Cargo.toml crates/nvsim-server/Cargo.toml +RUN mkdir -p crates/nvsim/src crates/nvsim-server/src \ + && echo "fn main(){}" > crates/nvsim-server/src/main.rs \ + && echo "" > crates/nvsim/src/lib.rs + +# This will fail because the workspace Cargo.toml references many other +# crates. Strategy: build only nvsim + nvsim-server with --bin filter. +COPY crates/nvsim crates/nvsim +COPY crates/nvsim-server crates/nvsim-server + +# Build the binary statically against the workspace using a slimmed +# manifest (the Cargo.lock + the two crate Cargo.tomls are enough). +RUN cargo build --release -p nvsim-server --bin nvsim-server 2>&1 \ + || (echo "Cargo build failed — falling back to in-crate build" \ + && cd crates/nvsim-server \ + && cargo build --release --bin nvsim-server) + +FROM debian:bookworm-slim +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates curl \ + && rm -rf /var/lib/apt/lists/* \ + && groupadd -r nvsim && useradd -r -g nvsim nvsim + +# Copy the binary from whichever build path succeeded. +COPY --from=builder /build/target/release/nvsim-server /usr/local/bin/nvsim-server +RUN chmod +x /usr/local/bin/nvsim-server + +USER nvsim +EXPOSE 7878 +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s \ + CMD curl -fsS http://localhost:7878/api/health || exit 1 + +ENTRYPOINT ["nvsim-server"] +CMD ["--listen", "0.0.0.0:7878"]