diff --git a/CODEOWNERS b/.github/CODEOWNERS similarity index 100% rename from CODEOWNERS rename to .github/CODEOWNERS diff --git a/SECURITY.md b/.github/SECURITY.md similarity index 100% rename from SECURITY.md rename to .github/SECURITY.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 000000000..483bf7f4a --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,82 @@ +--- +name: CI + +on: + pull_request: + workflow_dispatch: + +concurrency: + group: ci-${{ github.event.number || github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + unit-tests: + name: Unit tests + runs-on: ubuntu-slim + steps: + - name: Checkout repo + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false + + - name: Set up Node.js + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version-file: package.json + + - name: Configure sustainable npm + uses: lowlydba/sustainable-npm@31d51025884f424f58f22e4e6578178bb4e79632 # v3.0.0 + + - name: Install NPM dependencies + run: npm ci + + - name: Run tests + run: npm test + + a11y: + name: Accessibility (WCAG 2.2 AA) + runs-on: ubuntu-latest + steps: + - name: Checkout repo + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false + + - name: Set up Node.js + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version-file: package.json + + - name: Configure sustainable npm + uses: lowlydba/sustainable-npm@31d51025884f424f58f22e4e6578178bb4e79632 # v3.0.0 + + - name: Install NPM dependencies + run: npm ci + + - name: Build + run: npm run build + + - name: Install Playwright browser + run: npx playwright install chromium --with-deps + + - name: Run accessibility tests + run: npm run test:a11y + + query-tests: + name: DuckDB query smoke tests + uses: ./.github/workflows/query-tests.yml + permissions: + contents: read + + are-we-good: + name: are-we-good + runs-on: ubuntu-slim + needs: [unit-tests, a11y, query-tests] + if: always() + steps: + - uses: lowlydba/are-we-good@bb8ee9e793e4233fac1992bb880e2a28bed7f42f # v1.0.3 + with: + jobs: ${{ toJSON(needs) }} diff --git a/.github/workflows/query-tests.yml b/.github/workflows/query-tests.yml new file mode 100644 index 000000000..39205b183 --- /dev/null +++ b/.github/workflows/query-tests.yml @@ -0,0 +1,37 @@ +--- +name: Query Tests + +on: + workflow_call: # reusable — called from ci.yml on PRs + schedule: + - cron: '0 6 * * 1' # Monday 06:00 UTC — catches data-side breakage between releases + workflow_dispatch: + +concurrency: + group: query-tests-${{ github.run_id }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + duckdb: + name: DuckDB query smoke tests + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Checkout repo + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false + + - name: Set up Python + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version: '3.12' + + - name: Install duckdb + run: pip install duckdb + + - name: Run query smoke tests + run: python scripts/test_queries.py diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml deleted file mode 100644 index 6eef4def7..000000000 --- a/.github/workflows/test.yml +++ /dev/null @@ -1,37 +0,0 @@ ---- -name: Test - -on: - pull_request: - workflow_dispatch: - -concurrency: - group: test-${{ github.event.number || github.ref }} - cancel-in-progress: true - -permissions: - contents: read - -jobs: - test: - name: Unit tests - runs-on: ubuntu-slim - steps: - - name: Checkout repo - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - with: - persist-credentials: false - - - name: Set up Node.js - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 - with: - node-version-file: package.json - - - name: Configure sustainable npm - uses: lowlydba/sustainable-npm@31d51025884f424f58f22e4e6578178bb4e79632 # v3.0.0 - - - name: Install NPM dependencies - run: npm ci - - - name: Run tests - run: npm test diff --git a/README.md b/README.md index edb45b31c..d8ca86d6c 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # Overture Documentation +[![WCAG 2.2 AA](https://img.shields.io/badge/WCAG_2.2-AA-green)](https://www.w3.org/WAI/WCAG22/quickref/) + This repository uses [Docusaurus](https://docusaurus.io/) to publish the documentation pages seen at [docs.overturemaps.org](https://docs.overturemaps.org) ## Structure diff --git a/__tests__/a11y.test.mjs b/__tests__/a11y.test.mjs new file mode 100644 index 000000000..03bf94c12 --- /dev/null +++ b/__tests__/a11y.test.mjs @@ -0,0 +1,121 @@ +/** + * WCAG 2.2 AA accessibility tests using Node's built-in test runner. + * Requires a built `build/` directory — run `npm run build` first. + * Run with: npm run test:a11y + */ + +import { test, before, after } from 'node:test'; +import assert from 'node:assert/strict'; +import { createServer } from 'node:http'; +import { readFile, access } from 'node:fs/promises'; +import { extname, join, resolve, relative, isAbsolute } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { chromium } from 'playwright'; +import AxeBuilder from '@axe-core/playwright'; + +const __dirname = fileURLToPath(new URL('.', import.meta.url)); +const BUILD_DIR = join(__dirname, '..', 'build'); +const PORT = 3778; +const BASE_URL = `http://localhost:${PORT}`; + +const MIME = { + '.html': 'text/html', + '.js': 'application/javascript', + '.mjs': 'application/javascript', + '.css': 'text/css', + '.png': 'image/png', + '.svg': 'image/svg+xml', + '.woff2': 'font/woff2', + '.json': 'application/json', +}; + +let server, browser; + +before(async () => { + await access(join(BUILD_DIR, 'index.html')).catch(() => { + throw new Error(`build/index.html not found — run 'npm run build' before running accessibility tests`); + }); + + // minimal static file server, falls back to index.html for Docusaurus client-side routing + server = createServer(async (req, res) => { + const urlPath = (req.url ?? '/').split('?')[0]; + const filePath = resolve(join(BUILD_DIR, urlPath === '/' ? 'index.html' : urlPath)); + // relative() is cross-platform; startsWith(BUILD_DIR + '/') broke on Windows backslashes + const rel = relative(BUILD_DIR, filePath); + if (rel.startsWith('..') || isAbsolute(rel)) { + res.writeHead(403); + res.end('Forbidden'); + return; + } + try { + const data = await readFile(filePath); + res.writeHead(200, { 'Content-Type': MIME[extname(filePath)] ?? 'application/octet-stream' }); + res.end(data); + } catch { + // SPA fallback only for route-like requests (no extension); a missing + // asset (.js/.css/.png) must 404 so broken builds aren't masked. + if (extname(filePath) === '') { + try { + const data = await readFile(join(BUILD_DIR, 'index.html')); + res.writeHead(200, { 'Content-Type': 'text/html' }); + res.end(data); + return; + } catch { /* fall through to 404 */ } + } + res.writeHead(404); + res.end('Not found'); + } + }); + await new Promise((resolve) => server.listen(PORT, resolve)); + browser = await chromium.launch(); +}); + +after(async () => { + await browser?.close(); + if (server) await new Promise((resolve) => server.close(resolve)); +}); + +function formatViolations(violations, label) { + if (violations.length === 0) return; + const detail = violations + .map((v) => + `[${v.impact}] ${v.id}: ${v.description}\n` + + v.nodes.map((n) => + ` • ${n.target}` + + (n.failureSummary ? `\n ${n.failureSummary.replace(/\n/g, '\n ')}` : '') + ).join('\n') + ) + .join('\n\n'); + assert.fail(`${violations.length} WCAG 2.2 AA violation(s) — ${label}:\n\n${detail}`); +} + +async function runAxe(url) { + const context = await browser.newContext(); + const page = await context.newPage(); + try { + await page.goto(`${BASE_URL}${url}`, { waitUntil: 'domcontentloaded' }); + await page.waitForSelector('main', { timeout: 10000 }); + const { violations } = await new AxeBuilder({ page }) + .withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'wcag22aa']) + .analyze(); + return violations; + } finally { + await context.close(); + } +} + +const PAGES = [ + { path: '/', label: 'homepage' }, + { path: '/docs/', label: 'docs index' }, + { path: '/getting-data/', label: 'quickstart (QueryBuilder)' }, + { path: '/guides/', label: 'guides index' }, + { path: '/examples/', label: 'examples index' }, + { path: '/blog/', label: 'blog index' }, + { path: '/community/', label: 'community (custom table)' }, +]; + +for (const { path, label } of PAGES) { + test(`${label} passes WCAG 2.2 AA`, async () => { + formatViolations(await runAxe(path), label); + }); +} diff --git a/blog/2026-03-12-explorer-got-pretty.mdx b/blog/2026-03-12-explorer-got-pretty.mdx index 8085d0033..742db69d4 100644 --- a/blog/2026-03-12-explorer-got-pretty.mdx +++ b/blog/2026-03-12-explorer-got-pretty.mdx @@ -17,6 +17,7 @@ That was the right call for a launch. But as Explorer has matured and more peopl