From bb76bec12436197940aa3bf0dd6fa38516e6c9e0 Mon Sep 17 00:00:00 2001 From: vicistar-star Date: Wed, 21 Oct 2026 13:01:42 +0100 Subject: [PATCH 1/7] feat(routes): single typed inventory for quality checks (#191) Introduce src/routes/inventory.ts as the one source of truth for every URL the site serves, replacing the seven separate route lists that were previously maintained in isolation. Each route carries an explicit RoutePolicy { smoke, a11y, lighthouse } so adding a route without an entry is a build error rather than a silent omission from quality checks. Changes - src/routes/inventory.ts: typed STATIC_ROUTES, DYNAMIC_ROUTE_FAMILIES, SPECIAL_ROUTES; exported views smokeRoutes(), a11yRoutes(), lighthouseRoutes(), allStaticPaths(), localizedBasePaths() - scripts/assert-route-coverage.ts: build-time guard that parses App.tsx and fails when any route lacks a policy entry - scripts/gen-routes-snapshot.ts: writes scripts/routes-snapshot.json so plain-JS scripts and CI can consume the inventory without tsx - src/utils/localizedRoutes.ts: derive LOCALIZED_BASE_PATHS from inventory instead of a hardcoded Set - scripts/sitemap.ts: replace knownRoutes literal with allStaticPaths() - scripts/gen-sitemap.mjs: read static paths and localized base paths from routes-snapshot.json - src/__tests__/a11y.test.tsx: drive pages list from a11yRoutes() - tests/a11y/a11y.spec.ts: load page list from routes-snapshot.json - tests/smoke/serverless.smoke.test.ts: loop smokeRoutes() in the 'still serves built site' assertion - scripts/validate-content.ts: call assertAllAppRoutesHavePolicy() - package.json: add gen-routes-snapshot.ts to the build pipeline - .github/workflows/ci.yml: Lighthouse reads urlsFile from snapshot; PR comment now shows one row per audited route --- .github/workflows/ci.yml | 66 ++--- package.json | 2 +- scripts/assert-route-coverage.ts | 87 +++++++ scripts/gen-routes-snapshot.ts | 44 ++++ scripts/gen-sitemap.mjs | 52 ++-- scripts/sitemap.ts | 19 +- scripts/validate-content.ts | 17 ++ src/__tests__/a11y.test.tsx | 23 +- src/routes/inventory.ts | 358 +++++++++++++++++++++++++++ src/utils/localizedRoutes.ts | 23 +- tests/a11y/a11y.spec.ts | 47 ++-- tests/smoke/serverless.smoke.test.ts | 3 +- 12 files changed, 645 insertions(+), 96 deletions(-) create mode 100644 scripts/assert-route-coverage.ts create mode 100644 scripts/gen-routes-snapshot.ts create mode 100644 src/routes/inventory.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4e16102..8744e8c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -139,13 +139,22 @@ jobs: - run: pnpm install --frozen-lockfile - run: pnpm build + - name: Generate Lighthouse URL list from route inventory + run: | + node -e " + const snap = JSON.parse(require('fs').readFileSync('scripts/routes-snapshot.json','utf8')); + const base = 'http://localhost:4173'; + const urls = snap.lighthouseRoutes.map(r => base + r.path).join('\n'); + require('fs').writeFileSync('.github/lighthouse-urls.txt', urls + '\n'); + console.log('Lighthouse URLs:', urls); + " + - name: Run Lighthouse on Mobile id: lighthouse_mobile continue-on-error: true uses: treosh/lighthouse-ci-action@v12 with: - urls: | - http://localhost:4173/ + urlsFile: './.github/lighthouse-urls.txt' startServerCommand: pnpm preview uploadArtifacts: true temporaryPublicStorage: true @@ -159,8 +168,7 @@ jobs: continue-on-error: true uses: treosh/lighthouse-ci-action@v12 with: - urls: | - http://localhost:4173/ + urlsFile: './.github/lighthouse-urls.txt' startServerCommand: pnpm preview uploadArtifacts: true temporaryPublicStorage: true @@ -185,21 +193,10 @@ jobs: const mobileManifest = JSON.parse(fs.readFileSync('.lighthouseci-mobile/manifest.json', 'utf8')); const desktopManifest = JSON.parse(fs.readFileSync('.lighthouseci-desktop/manifest.json', 'utf8')); - const mobile = mobileManifest[0]; - const desktop = desktopManifest[0]; + const snap = JSON.parse(fs.readFileSync('scripts/routes-snapshot.json', 'utf8')); + const base = 'http://localhost:4173'; const formatScore = (val) => Math.round(val * 100); - - const mobilePerf = formatScore(mobile.summary.performance); - const mobileA11y = formatScore(mobile.summary.accessibility); - const mobileBp = formatScore(mobile.summary['best-practices']); - const mobileSeo = formatScore(mobile.summary.seo); - - const desktopPerf = formatScore(desktop.summary.performance); - const desktopA11y = formatScore(desktop.summary.accessibility); - const desktopBp = formatScore(desktop.summary['best-practices']); - const desktopSeo = formatScore(desktop.summary.seo); - const getScoreEmoji = (score) => { if (score >= 95) return '🟒'; if (score >= 90) return '🟑'; @@ -208,22 +205,35 @@ jobs: const mobileLinks = JSON.parse(process.env.MOBILE_LINKS || '{}'); const desktopLinks = JSON.parse(process.env.DESKTOP_LINKS || '{}'); - const mobileReport = mobileLinks['http://localhost:4173/'] || ''; - const desktopReport = desktopLinks['http://localhost:4173/'] || ''; + + // Build one row per audited route + const rows = snap.lighthouseRoutes.map(r => { + const url = base + r.path; + const mob = mobileManifest.find(m => m.url === url); + const desk = desktopManifest.find(d => d.url === url); + if (!mob || !desk) return null; + const mp = formatScore(mob.summary.performance); + const ma = formatScore(mob.summary.accessibility); + const dp = formatScore(desk.summary.performance); + const da = formatScore(desk.summary.accessibility); + return `| \`${r.path}\` | ${getScoreEmoji(mp)} **${mp}** / ${getScoreEmoji(ma)} **${ma}** | ${getScoreEmoji(dp)} **${dp}** / ${getScoreEmoji(da)} **${da}** |`; + }).filter(Boolean).join('\n'); const reportLinks = []; - if (mobileReport) reportLinks.push(`[Mobile Report](${mobileReport})`); - if (desktopReport) reportLinks.push(`[Desktop Report](${desktopReport})`); - const reportText = reportLinks.length > 0 ? `πŸ“„ **Reports:** ${reportLinks.join(' | ')}` : ''; + for (const r of snap.lighthouseRoutes) { + const url = base + r.path; + if (mobileLinks[url]) reportLinks.push(`[Mobile ${r.path}](${mobileLinks[url]})`); + if (desktopLinks[url]) reportLinks.push(`[Desktop ${r.path}](${desktopLinks[url]})`); + } + const reportText = reportLinks.length > 0 + ? `πŸ“„ **Reports:** ${reportLinks.join(' | ')}` + : ''; const body = `### ⚑ Lighthouse Audit Results - | Category | Mobile | Desktop | Target | - | --- | --- | --- | --- | - | **Performance** | ${getScoreEmoji(mobilePerf)} **${mobilePerf}** | ${getScoreEmoji(desktopPerf)} **${desktopPerf}** | >= 95 | - | **Accessibility** | ${getScoreEmoji(mobileA11y)} **${mobileA11y}** | ${getScoreEmoji(desktopA11y)} **${desktopA11y}** | >= 95 | - | **Best Practices** | ${getScoreEmoji(mobileBp)} **${mobileBp}** | ${getScoreEmoji(desktopBp)} **${desktopBp}** | >= 95 | - | **SEO** | ${getScoreEmoji(mobileSeo)} **${mobileSeo}** | ${getScoreEmoji(desktopSeo)} **${desktopSeo}** | >= 95 | + | Route | Mobile Perf / A11y | Desktop Perf / A11y | + | --- | --- | --- | + ${rows} ${reportText} diff --git a/package.json b/package.json index a92f3dd..7b3a3a6 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "type": "module", "scripts": { "dev": "vite", - "build": "tsx scripts/validate-content.ts && node scripts/check-sdk-size.mjs && tsc -b && vite build && node scripts/inline-css.js && node scripts/gen-blog-manifest.mjs && tsx scripts/og.ts && tsx scripts/sitemap.ts && node scripts/gen-rss.mjs", + "build": "tsx scripts/validate-content.ts && node scripts/check-sdk-size.mjs && tsc -b && vite build && node scripts/inline-css.js && node scripts/gen-blog-manifest.mjs && tsx scripts/og.ts && tsx scripts/sitemap.ts && tsx scripts/gen-routes-snapshot.ts && node scripts/gen-rss.mjs", "generate:sdk-size": "node scripts/check-sdk-size.mjs --write", "og:generate": "tsx scripts/og.ts", "generate:sitemap": "node scripts/gen-sitemap.mjs", diff --git a/scripts/assert-route-coverage.ts b/scripts/assert-route-coverage.ts new file mode 100644 index 0000000..cac90f8 --- /dev/null +++ b/scripts/assert-route-coverage.ts @@ -0,0 +1,87 @@ +/** + * Build-time assertion: every route registered in App.tsx must have an explicit + * quality-check policy in src/routes/inventory.ts. + * + * Run as part of `pnpm build` (via scripts/validate-content.ts, which imports + * this) so that adding a route to the router without a policy entry is a + * build error, not a silent omission. + * + * Strategy: parse the static path strings out of App.tsx with a regex rather + * than importing the module (which would require a full React environment) and + * compare them against the inventory. + */ + +import { readFileSync } from 'fs'; +import { join, dirname } from 'path'; +import { fileURLToPath } from 'url'; +import { + STATIC_ROUTES, + DYNAMIC_ROUTE_FAMILIES, + SPECIAL_ROUTES, +} from '../src/routes/inventory.js'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const rootDir = join(__dirname, '..'); +const appPath = join(rootDir, 'src', 'App.tsx'); + +/** + * Extract all `path="..."` values from App.tsx. + * We only look at literal string paths β€” locale-prefixed variants built from + * template literals (e.g. `/${locale}/stellar`) are omitted because the + * localized flag on each inventory entry already captures them. + */ +function extractAppPaths(source: string): Set { + const paths = new Set(); + + // Match path="" attributes (single or double quotes) + const literalPattern = /\bpath=["']([^"'*]+?)["']/g; + let m: RegExpExecArray | null; + while ((m = literalPattern.exec(source)) !== null) { + paths.add(m[1]); + } + + // Capture the catch-all wildcard separately + if (/\bpath=["']\*["']/.test(source)) { + paths.add('*'); + } + + // Remove locale-prefixed duplicates (/es/..., /pt/...) β€” they are covered by + // the `localized` flag on the base path entry. + for (const p of Array.from(paths)) { + if (/^\/(es|pt)(\/|$)/.test(p)) { + paths.delete(p); + } + } + + return paths; +} + +export function assertAllAppRoutesHavePolicy(): void { + const source = readFileSync(appPath, 'utf8'); + const appPaths = extractAppPaths(source); + + // Build a set of all paths/patterns declared in the inventory + const inventoryPatterns = new Set([ + ...STATIC_ROUTES.map((r) => r.path), + ...DYNAMIC_ROUTE_FAMILIES.map((f) => f.pattern), + ...SPECIAL_ROUTES.map((r) => r.pattern), + ]); + + const missing: string[] = []; + for (const appPath of appPaths) { + if (!inventoryPatterns.has(appPath)) { + missing.push(appPath); + } + } + + if (missing.length > 0) { + const list = missing.map((p) => ` β€’ ${p}`).join('\n'); + throw new Error( + `Route inventory is out of date.\n` + + `The following routes exist in App.tsx but have no policy in src/routes/inventory.ts:\n` + + `${list}\n\n` + + `Add each missing route to STATIC_ROUTES, DYNAMIC_ROUTE_FAMILIES, or SPECIAL_ROUTES ` + + `in src/routes/inventory.ts and set its quality-check policy.`, + ); + } +} diff --git a/scripts/gen-routes-snapshot.ts b/scripts/gen-routes-snapshot.ts new file mode 100644 index 0000000..7077472 --- /dev/null +++ b/scripts/gen-routes-snapshot.ts @@ -0,0 +1,44 @@ +/** + * Writes scripts/routes-snapshot.json from the typed inventory so that + * plain-JS scripts (gen-sitemap.mjs) and CI YAML can consume it without + * needing TypeScript. + * + * Called automatically as part of `pnpm build` (see package.json). + */ +import { writeFileSync, mkdirSync } from 'fs'; +import { join, dirname } from 'path'; +import { fileURLToPath } from 'url'; +import { + STATIC_ROUTES, + DYNAMIC_ROUTE_FAMILIES, + SPECIAL_ROUTES, + smokeRoutes, + a11yRoutes, + lighthouseRoutes, + allStaticPaths, + localizedBasePaths, +} from '../src/routes/inventory.js'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const rootDir = join(__dirname, '..'); + +const snapshot = { + /** Static paths for the sitemap generator and OG pipeline. */ + staticPaths: allStaticPaths(), + /** Paths that have /es/* and /pt/* variants. */ + localizedBasePaths: Array.from(localizedBasePaths()), + /** Paths for the smoke test "still serves built site" assertion. */ + smokeRoutes: smokeRoutes(), + /** Paths for axe / Playwright a11y checks. */ + a11yRoutes: a11yRoutes(), + /** Paths for Lighthouse CI. */ + lighthouseRoutes: lighthouseRoutes(), + /** Full inventory for tooling that needs policy detail. */ + staticRoutes: STATIC_ROUTES, + dynamicRouteFamilies: DYNAMIC_ROUTE_FAMILIES, + specialRoutes: SPECIAL_ROUTES, +}; + +const outPath = join(rootDir, 'scripts', 'routes-snapshot.json'); +writeFileSync(outPath, JSON.stringify(snapshot, null, 2) + '\n', 'utf8'); +console.log(`routes-snapshot.json written (${snapshot.staticPaths.length} static paths)`); diff --git a/scripts/gen-sitemap.mjs b/scripts/gen-sitemap.mjs index efe8abe..e668360 100644 --- a/scripts/gen-sitemap.mjs +++ b/scripts/gen-sitemap.mjs @@ -10,26 +10,43 @@ const publicDir = join(rootDir, 'public'); const siteUrl = 'https://usewraith.xyz'; /** - * Known static application routes + * Load the static route list from the pre-generated snapshot so this plain-JS + * script doesn't need to import TypeScript. The snapshot is written by + * `scripts/gen-routes-snapshot.ts` which runs before this script in the build. */ -const knownRoutes = [ - '/', - '/faq', - '/privacy', - '/use-cases', - '/roadmap', - '/case-studies', - '/stellar', - '/careers', - '/press', -]; +function loadStaticPaths() { + const snapshotPath = join(rootDir, 'scripts', 'routes-snapshot.json'); + if (!existsSync(snapshotPath)) { + console.warn( + 'routes-snapshot.json not found β€” run `tsx scripts/gen-routes-snapshot.ts` first. ' + + 'Falling back to empty static path list.', + ); + return []; + } + try { + const snapshot = JSON.parse(readFileSync(snapshotPath, 'utf8')); + return Array.isArray(snapshot.staticPaths) ? snapshot.staticPaths : []; + } catch (err) { + console.warn('Could not parse routes-snapshot.json:', err.message); + return []; + } +} /** * Pages that have locale-prefixed alternates. - * Must stay in sync with LOCALE_URL_PREFIX in src/utils/seo.ts and the - * locale-prefixed entries in App.tsx. + * Read from the snapshot so this file stays in sync automatically. */ -const LOCALE_PREFIXED_PAGES = ['/', '/stellar', '/grants', '/blog', '/case-studies']; +function loadLocalizedBasePaths() { + const snapshotPath = join(rootDir, 'scripts', 'routes-snapshot.json'); + if (!existsSync(snapshotPath)) return ['/', '/stellar', '/grants', '/blog', '/case-studies']; + try { + const snapshot = JSON.parse(readFileSync(snapshotPath, 'utf8')); + return Array.isArray(snapshot.localizedBasePaths) ? snapshot.localizedBasePaths : []; + } catch { + return []; + } +} + const SUPPORTED_LOCALE_PREFIXES = ['/es', '/pt']; /** @@ -125,13 +142,16 @@ function getBlogTagRoutes() { function generateSitemap() { try { + const knownRoutes = loadStaticPaths(); + const localizedPages = loadLocalizedBasePaths(); + const csRoutes = getCaseStudyRoutes(); const distRoutes = existsSync(distDir) ? getDistRoutes(distDir) : []; const tagRoutes = getBlogTagRoutes(); // Build locale-prefixed alternates for pages that have translations. const localeRoutes = SUPPORTED_LOCALE_PREFIXES.flatMap((prefix) => - LOCALE_PREFIXED_PAGES.map((page) => (page === '/' ? prefix : `${prefix}${page}`)), + localizedPages.map((page) => (page === '/' ? prefix : `${prefix}${page}`)), ); const allRoutes = Array.from( diff --git a/scripts/sitemap.ts b/scripts/sitemap.ts index f9a9b7d..d3f6597 100644 --- a/scripts/sitemap.ts +++ b/scripts/sitemap.ts @@ -2,6 +2,7 @@ import { writeFileSync, readFileSync, readdirSync, statSync, existsSync } from ' import { join, dirname } from 'path'; import { fileURLToPath } from 'url'; import { slugifyTag, parseTags } from './feed-utils.mjs'; +import { allStaticPaths } from '../src/routes/inventory.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); const rootDir = join(__dirname, '..'); @@ -9,19 +10,11 @@ const distDir = join(rootDir, 'dist'); const publicDir = join(rootDir, 'public'); const siteUrl = 'https://usewraith.xyz'; -const knownRoutes = [ - '/', - '/faq', - '/privacy', - '/use-cases', - '/use-cases/calculator', - '/roadmap', - '/case-studies', - '/stellar', - '/careers', - '/press', - '/terms', -]; +/** + * Static application routes β€” driven from the single route inventory. + * Previously this was a hand-maintained list here; it is now derived. + */ +const knownRoutes: string[] = allStaticPaths(); function getCaseStudyRoutes(): string[] { const routes: string[] = []; diff --git a/scripts/validate-content.ts b/scripts/validate-content.ts index a60599f..04fce52 100644 --- a/scripts/validate-content.ts +++ b/scripts/validate-content.ts @@ -16,6 +16,7 @@ import { readFileSync, readdirSync } from 'fs'; import { join, dirname } from 'path'; import { fileURLToPath } from 'url'; import { isIsoDate } from '../src/utils/date.ts'; +import { assertAllAppRoutesHavePolicy } from './assert-route-coverage.ts'; const __dirname = dirname(fileURLToPath(import.meta.url)); const root = join(__dirname, '..'); @@ -1167,6 +1168,22 @@ if (i18nCount === 0) { console.log(` ❌ src/i18n/ β€” ${i18nCount} parity issue(s)\n`); } +// --------------------------------------------------------------------------- +// Route inventory coverage check +// --------------------------------------------------------------------------- + +console.log('πŸ” Checking route inventory coverage…\n'); +try { + assertAllAppRoutesHavePolicy(); + console.log(' βœ… src/routes/inventory.ts β€” all App.tsx routes have a quality-check policy\n'); +} catch (err) { + failures.push({ + file: 'src/routes/inventory.ts', + record: 'route-coverage', + message: err instanceof Error ? err.message : String(err), + }); +} + // --------------------------------------------------------------------------- // Report // --------------------------------------------------------------------------- diff --git a/src/__tests__/a11y.test.tsx b/src/__tests__/a11y.test.tsx index daf28f7..05ecae1 100644 --- a/src/__tests__/a11y.test.tsx +++ b/src/__tests__/a11y.test.tsx @@ -3,22 +3,19 @@ import userEvent from '@testing-library/user-event'; import { axe } from 'vitest-axe'; import { describe, expect, it } from 'vitest'; import App from '../App'; +import { a11yRoutes } from '../routes/inventory'; -const pages = [ - { path: '/', name: 'homepage' }, - { path: '/faq', name: 'FAQ' }, - { path: '/roadmap', name: 'roadmap' }, - { path: '/privacy', name: 'privacy policy' }, - { path: '/use-cases', name: 'use cases' }, - { path: '/use-cases/calculator', name: 'payment cost calculator' }, - { path: '/stellar', name: 'Stellar page' }, - { path: '/case-studies', name: 'case studies list' }, - { path: '/case-studies/payroll-processor', name: 'case study detail' }, - { path: '/nonexistent-page', name: '404 not found' }, -]; +/** + * Accessibility smoke suite. + * + * The route list is driven by src/routes/inventory.ts β€” every route flagged + * a11y: true is exercised here. Add new routes to the inventory; do not add + * them directly in this file. + */ +const pages = a11yRoutes(); describe('page accessibility', () => { - for (const { path, name } of pages) { + for (const { path, label: name } of pages) { it(`has no axe violations on the ${name} page`, async () => { window.history.replaceState({}, '', path); diff --git a/src/routes/inventory.ts b/src/routes/inventory.ts new file mode 100644 index 0000000..420fd23 --- /dev/null +++ b/src/routes/inventory.ts @@ -0,0 +1,358 @@ +/** + * Production route inventory β€” single source of truth for every URL the site + * serves and the quality-check policies that apply to each one. + * + * Every route that exists in App.tsx MUST have an entry here. The build step + * runs `assertAllAppRoutesHavePolicy()` and fails the build when a route is + * missing a policy, so new routes can never silently skip quality checks. + * + * Consumers + * --------- + * scripts/sitemap.ts β€” derives knownRoutes + * scripts/gen-sitemap.mjs β€” reads the JSON snapshot at scripts/routes-snapshot.json + * tests/a11y/a11y.spec.ts β€” reads a11yRoutes() + * src/__tests__/a11y.test.tsx β€” reads a11yRoutes() + * tests/smoke/serverless.smoke.test.ts β€” reads smokeRoutes() + * .github/workflows/ci.yml β€” reads lighthouseRoutes() via scripts/routes-snapshot.json + */ + +// --------------------------------------------------------------------------- +// Policy shape +// --------------------------------------------------------------------------- + +/** + * Quality-check policy for a route. + * + * smoke β€” included in the serverless smoke test "still serves built site" check. + * a11y β€” included in both the Vitest axe suite and the Playwright axe suite. + * lighthouse β€” included in the Lighthouse CI audit. + */ +export interface RoutePolicy { + smoke: boolean; + a11y: boolean; + lighthouse: boolean; +} + +// --------------------------------------------------------------------------- +// Static routes +// --------------------------------------------------------------------------- + +/** + * A static route: a fixed URL path + its quality-check policy. + * + * `localized` marks routes that have locale-prefixed variants (/es/*, /pt/*). + * This replaces the LOCALIZED_BASE_PATHS set in localizedRoutes.ts and the + * PAGE_PATHS record in seo.ts. + */ +export interface StaticRoute { + /** Absolute path, e.g. "/stellar". */ + path: string; + /** Human-readable label used in test names. */ + label: string; + /** Whether this route has /es/* and /pt/* counterparts in App.tsx. */ + localized: boolean; + policy: RoutePolicy; +} + +/** + * All static routes served by the application. + * + * Rules for setting policies: + * smoke: true for any page that must be served as HTML by the preview server. + * Keep this list representative rather than exhaustive. + * a11y: true for every distinct page layout. Parameterised dynamic routes + * (e.g. /case-studies/:slug) are tested via DYNAMIC_ROUTE_SAMPLES. + * lighthouse: true for the most business-critical pages; auditing every route + * on every PR is expensive. Expand as budget allows. + */ +export const STATIC_ROUTES: StaticRoute[] = [ + { + path: '/', + label: 'homepage', + localized: true, + policy: { smoke: true, a11y: true, lighthouse: true }, + }, + { + path: '/faq', + label: 'FAQ', + localized: false, + policy: { smoke: true, a11y: true, lighthouse: false }, + }, + { + path: '/privacy', + label: 'privacy policy', + localized: false, + policy: { smoke: false, a11y: true, lighthouse: false }, + }, + { + path: '/terms', + label: 'terms of service', + localized: false, + policy: { smoke: false, a11y: true, lighthouse: false }, + }, + { + path: '/press', + label: 'press', + localized: false, + policy: { smoke: false, a11y: true, lighthouse: false }, + }, + { + path: '/newsletter', + label: 'newsletter', + localized: false, + policy: { smoke: false, a11y: true, lighthouse: false }, + }, + { + path: '/use-cases', + label: 'use cases', + localized: false, + policy: { smoke: true, a11y: true, lighthouse: false }, + }, + { + path: '/use-cases/calculator', + label: 'payment cost calculator', + localized: false, + policy: { smoke: false, a11y: true, lighthouse: false }, + }, + { + path: '/roadmap', + label: 'roadmap', + localized: false, + policy: { smoke: false, a11y: true, lighthouse: false }, + }, + { + path: '/case-studies', + label: 'case studies list', + localized: true, + policy: { smoke: false, a11y: true, lighthouse: false }, + }, + { + path: '/stellar', + label: 'Stellar page', + localized: true, + policy: { smoke: true, a11y: true, lighthouse: true }, + }, + { + path: '/careers', + label: 'careers', + localized: false, + policy: { smoke: false, a11y: true, lighthouse: false }, + }, + { + path: '/grants', + label: 'grants', + localized: true, + policy: { smoke: false, a11y: true, lighthouse: false }, + }, + { + path: '/about', + label: 'about', + localized: false, + policy: { smoke: false, a11y: true, lighthouse: false }, + }, + { + path: '/governance', + label: 'governance', + localized: false, + policy: { smoke: false, a11y: true, lighthouse: false }, + }, + { + path: '/contributors', + label: 'contributors', + localized: false, + policy: { smoke: false, a11y: true, lighthouse: false }, + }, + { + path: '/blog', + label: 'blog listing', + localized: true, + policy: { smoke: false, a11y: true, lighthouse: false }, + }, + { + path: '/chains', + label: 'chains', + localized: false, + policy: { smoke: false, a11y: true, lighthouse: false }, + }, + { + path: '/ecosystem', + label: 'ecosystem', + localized: false, + policy: { smoke: false, a11y: true, lighthouse: false }, + }, + { + path: '/security', + label: 'security', + localized: false, + policy: { smoke: false, a11y: true, lighthouse: false }, + }, + { + path: '/threat-model', + label: 'threat model', + localized: false, + policy: { smoke: false, a11y: true, lighthouse: false }, + }, + { + path: '/vitals', + label: 'vitals', + localized: false, + policy: { smoke: false, a11y: true, lighthouse: false }, + }, + { + path: '/status', + label: 'status', + localized: false, + policy: { smoke: false, a11y: true, lighthouse: false }, + }, +]; + +// --------------------------------------------------------------------------- +// Dynamic route families +// --------------------------------------------------------------------------- + +/** + * A dynamic route family covers path patterns like `/blog/:slug`. + * + * `pattern` β€” the React Router path pattern string, for documentation and + * for the policy-coverage assertion. + * `samples` β€” concrete paths used by quality-check suites. Keep at least one + * sample per family so every layout is exercised. + * `localized` β€” whether the family has /es/* and /pt/* variants. + */ +export interface DynamicRouteFamily { + pattern: string; + label: string; + localized: boolean; + policy: RoutePolicy; + /** Representative concrete paths exercised by quality checks. */ + samples: Array<{ path: string; label: string }>; +} + +export const DYNAMIC_ROUTE_FAMILIES: DynamicRouteFamily[] = [ + { + pattern: '/blog/:slug', + label: 'blog post', + localized: true, + policy: { smoke: false, a11y: true, lighthouse: false }, + samples: [{ path: '/blog/stealth-addresses-explained', label: 'blog post detail' }], + }, + { + pattern: '/blog/tag/:tagSlug', + label: 'blog tag archive', + localized: true, + policy: { smoke: false, a11y: false, lighthouse: false }, + samples: [], + }, + { + pattern: '/blog/author/:authorId', + label: 'blog author archive', + localized: true, + policy: { smoke: false, a11y: false, lighthouse: false }, + samples: [], + }, + { + pattern: '/case-studies/:slug', + label: 'case study detail', + localized: true, + policy: { smoke: false, a11y: true, lighthouse: false }, + samples: [{ path: '/case-studies/payroll-processor', label: 'case study detail' }], + }, +]; + +// --------------------------------------------------------------------------- +// Special routes (error pages, SPA fallback) +// --------------------------------------------------------------------------- + +/** + * Non-content routes that exist in App.tsx but are deliberately excluded from + * sitemap, OG generation, and most quality checks. + * + * Listing them here satisfies the policy-coverage assertion without enrolling + * them in checks they cannot meaningfully pass (e.g. Lighthouse on a 404 page). + */ +export const SPECIAL_ROUTES: Array<{ + pattern: string; + label: string; + policy: RoutePolicy; +}> = [ + { + // React Router catch-all β€” renders NotFound + pattern: '*', + label: '404 not found', + policy: { smoke: false, a11y: true, lighthouse: false }, + }, +]; + +// --------------------------------------------------------------------------- +// Derived views +// --------------------------------------------------------------------------- + +/** Paths that have /es/* and /pt/* variants. */ +export function localizedBasePaths(): Set { + const paths = new Set(); + for (const route of STATIC_ROUTES) { + if (route.localized) paths.add(route.path); + } + for (const family of DYNAMIC_ROUTE_FAMILIES) { + if (family.localized) { + // Add the base segment, e.g. "/blog" from "/blog/:slug" + const base = '/' + family.pattern.split('/')[1]; + paths.add(base); + } + } + return paths; +} + +/** Static paths included in the smoke "still serves built site" check. */ +export function smokeRoutes(): Array<{ path: string; label: string }> { + return STATIC_ROUTES.filter((r) => r.policy.smoke).map((r) => ({ + path: r.path, + label: r.label, + })); +} + +/** + * All paths that should be accessibility-checked. + * Includes static routes flagged for a11y plus one representative sample per + * dynamic family that has a11y: true. + */ +export function a11yRoutes(): Array<{ path: string; label: string }> { + const routes: Array<{ path: string; label: string }> = []; + + for (const route of STATIC_ROUTES) { + if (route.policy.a11y) { + routes.push({ path: route.path, label: route.label }); + } + } + + for (const family of DYNAMIC_ROUTE_FAMILIES) { + if (family.policy.a11y) { + routes.push(...family.samples); + } + } + + // The 404 page uses a concrete non-existent path so axe can actually render it. + const notFound = SPECIAL_ROUTES.find((r) => r.pattern === '*'); + if (notFound?.policy.a11y) { + routes.push({ path: '/nonexistent-page', label: notFound.label }); + } + + return routes; +} + +/** Paths included in the Lighthouse audit. */ +export function lighthouseRoutes(): Array<{ path: string; label: string }> { + return STATIC_ROUTES.filter((r) => r.policy.lighthouse).map((r) => ({ + path: r.path, + label: r.label, + })); +} + +/** + * All static paths β€” used by the sitemap generator and OG pipeline. + * Does not include dynamic route samples (those are expanded by their + * respective generators from source data). + */ +export function allStaticPaths(): string[] { + return STATIC_ROUTES.map((r) => r.path); +} diff --git a/src/utils/localizedRoutes.ts b/src/utils/localizedRoutes.ts index d0d9fcc..5bd3cfb 100644 --- a/src/utils/localizedRoutes.ts +++ b/src/utils/localizedRoutes.ts @@ -1,17 +1,20 @@ +/** + * Helpers for locale-prefixed routes (/es/*, /pt/*). + * + * The canonical list of which routes have localized variants is owned by + * src/routes/inventory.ts β€” specifically the `localized` flag on each entry. + * This module re-exports a Set view of those paths for use in the router + * helpers (useLocalePath.ts) and in scripts/og.ts. + */ +import { localizedBasePaths } from './routes/inventory'; + /** * Routes that exist under the locale prefixes (/es/*, /pt/*). * - * Shared by the router helpers in src/hooks/useLocalePath.ts and by - * scripts/og.ts, which must only emit hreflang alternates for these routes. - * Keep it in sync with the locale-prefixed routes in App.tsx. + * Derived from the `localized: true` entries in src/routes/inventory.ts. + * Keep that file in sync with the locale-prefixed entries in App.tsx. */ -export const LOCALIZED_BASE_PATHS = new Set([ - '/', - '/stellar', - '/grants', - '/blog', - '/case-studies', -]); +export const LOCALIZED_BASE_PATHS: Set = localizedBasePaths(); /** * Checks if a given path has a localized version. diff --git a/tests/a11y/a11y.spec.ts b/tests/a11y/a11y.spec.ts index 1768925..7ecad8d 100644 --- a/tests/a11y/a11y.spec.ts +++ b/tests/a11y/a11y.spec.ts @@ -1,20 +1,39 @@ import { test, expect } from '@playwright/test'; import AxeBuilder from '@axe-core/playwright'; +import { readFileSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const rootDir = join(__dirname, '..', '..'); + +/** + * Load the a11y route list from the pre-generated routes snapshot. + * The snapshot is written by `scripts/gen-routes-snapshot.ts` as part of + * `pnpm build`, so it is always present when Playwright runs against a build. + */ +function loadA11yRoutes(): Array<{ path: string; label: string }> { + const snapshotPath = join(rootDir, 'scripts', 'routes-snapshot.json'); + try { + const snapshot = JSON.parse(readFileSync(snapshotPath, 'utf8')); + if (Array.isArray(snapshot.a11yRoutes)) { + return snapshot.a11yRoutes; + } + } catch { + // fall through to inline fallback + } + // Inline fallback so the suite can still run without a build + return [ + { path: '/', label: 'homepage' }, + { path: '/faq', label: 'FAQ' }, + { path: '/stellar', label: 'Stellar page' }, + { path: '/nonexistent-page', label: '404 not found' }, + ]; +} + +const pages = loadA11yRoutes(); -const pages = [ - { path: '/', name: 'homepage' }, - { path: '/faq', name: 'FAQ' }, - { path: '/roadmap', name: 'roadmap' }, - { path: '/privacy', name: 'privacy policy' }, - { path: '/use-cases', name: 'use cases' }, - { path: '/use-cases/calculator', name: 'payment cost calculator' }, - { path: '/stellar', name: 'Stellar page' }, - { path: '/case-studies', name: 'case studies list' }, - { path: '/case-studies/payroll-processor', name: 'case study detail' }, - { path: '/nonexistent-page', name: '404 not found' }, -]; - -for (const { path, name } of pages) { +for (const { path, name } of pages.map((p) => ({ path: p.path, name: p.label }))) { test(`has zero critical/serious axe violations on ${name}`, async ({ page }) => { await page.goto(path); await page.waitForLoadState('domcontentloaded'); diff --git a/tests/smoke/serverless.smoke.test.ts b/tests/smoke/serverless.smoke.test.ts index 0b80acb..b27f5a6 100644 --- a/tests/smoke/serverless.smoke.test.ts +++ b/tests/smoke/serverless.smoke.test.ts @@ -8,6 +8,7 @@ import { PAGE_SECURITY_HEADERS, SUBSCRIBE_API_SECURITY_HEADERS, } from '../../src/utils/security-headers'; +import { smokeRoutes } from '../../src/routes/inventory'; /** * Preview smoke tests for the serverless routes. @@ -436,7 +437,7 @@ describe('serverless failure responses', () => { }); it('still serves the built site', async () => { - for (const path of ['/', '/stellar']) { + for (const { path } of smokeRoutes()) { const response = await get(`${preview.baseUrl}${path}`); expect(response.status).toBe(200); expect(response.headers.get('content-type')).toContain('text/html'); From 6e8b3aaeb06a1232c61dd00fdebb6430e3be1dff Mon Sep 17 00:00:00 2001 From: vicistar-star Date: Wed, 21 Oct 2026 13:13:19 +0100 Subject: [PATCH 2/7] fix(routes): correct import path in localizedRoutes and clean up assert-route-coverage --- scripts/assert-route-coverage.ts | 31 +++++++++++-------------------- src/utils/localizedRoutes.ts | 2 +- 2 files changed, 12 insertions(+), 21 deletions(-) diff --git a/scripts/assert-route-coverage.ts b/scripts/assert-route-coverage.ts index cac90f8..41066d9 100644 --- a/scripts/assert-route-coverage.ts +++ b/scripts/assert-route-coverage.ts @@ -14,40 +14,32 @@ import { readFileSync } from 'fs'; import { join, dirname } from 'path'; import { fileURLToPath } from 'url'; -import { - STATIC_ROUTES, - DYNAMIC_ROUTE_FAMILIES, - SPECIAL_ROUTES, -} from '../src/routes/inventory.js'; +import { STATIC_ROUTES, DYNAMIC_ROUTE_FAMILIES, SPECIAL_ROUTES } from '../src/routes/inventory.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); const rootDir = join(__dirname, '..'); -const appPath = join(rootDir, 'src', 'App.tsx'); +const APP_PATH = join(rootDir, 'src', 'App.tsx'); /** * Extract all `path="..."` values from App.tsx. - * We only look at literal string paths β€” locale-prefixed variants built from - * template literals (e.g. `/${locale}/stellar`) are omitted because the - * localized flag on each inventory entry already captures them. + * Locale-prefixed variants built from template literals (e.g. `/${locale}/stellar`) are + * omitted β€” they are covered by the `localized` flag on the base path entry. */ function extractAppPaths(source: string): Set { const paths = new Set(); - // Match path="" attributes (single or double quotes) const literalPattern = /\bpath=["']([^"'*]+?)["']/g; let m: RegExpExecArray | null; while ((m = literalPattern.exec(source)) !== null) { paths.add(m[1]); } - // Capture the catch-all wildcard separately if (/\bpath=["']\*["']/.test(source)) { paths.add('*'); } - // Remove locale-prefixed duplicates (/es/..., /pt/...) β€” they are covered by - // the `localized` flag on the base path entry. - for (const p of Array.from(paths)) { + // Remove locale-prefixed duplicates (/es/..., /pt/...) covered by the `localized` flag. + for (const p of paths) { if (/^\/(es|pt)(\/|$)/.test(p)) { paths.delete(p); } @@ -57,10 +49,9 @@ function extractAppPaths(source: string): Set { } export function assertAllAppRoutesHavePolicy(): void { - const source = readFileSync(appPath, 'utf8'); + const source = readFileSync(APP_PATH, 'utf8'); const appPaths = extractAppPaths(source); - // Build a set of all paths/patterns declared in the inventory const inventoryPatterns = new Set([ ...STATIC_ROUTES.map((r) => r.path), ...DYNAMIC_ROUTE_FAMILIES.map((f) => f.pattern), @@ -68,14 +59,14 @@ export function assertAllAppRoutesHavePolicy(): void { ]); const missing: string[] = []; - for (const appPath of appPaths) { - if (!inventoryPatterns.has(appPath)) { - missing.push(appPath); + for (const p of appPaths) { + if (!inventoryPatterns.has(p)) { + missing.push(p); } } if (missing.length > 0) { - const list = missing.map((p) => ` β€’ ${p}`).join('\n'); + const list = missing.map((p) => ` - ${p}`).join('\n'); throw new Error( `Route inventory is out of date.\n` + `The following routes exist in App.tsx but have no policy in src/routes/inventory.ts:\n` + diff --git a/src/utils/localizedRoutes.ts b/src/utils/localizedRoutes.ts index 5bd3cfb..b3058e8 100644 --- a/src/utils/localizedRoutes.ts +++ b/src/utils/localizedRoutes.ts @@ -6,7 +6,7 @@ * This module re-exports a Set view of those paths for use in the router * helpers (useLocalePath.ts) and in scripts/og.ts. */ -import { localizedBasePaths } from './routes/inventory'; +import { localizedBasePaths } from '../routes/inventory'; /** * Routes that exist under the locale prefixes (/es/*, /pt/*). From 6bc756eaf931434cb478b91ee77fc4fdf8b51f60 Mon Sep 17 00:00:00 2001 From: vicistar-star Date: Wed, 21 Oct 2026 13:16:16 +0100 Subject: [PATCH 3/7] fix(smoke): tolerate EPIPE on chunked oversized body in Node 24 --- tests/smoke/serverless.smoke.test.ts | 43 +++++++++++++++++++--------- 1 file changed, 30 insertions(+), 13 deletions(-) diff --git a/tests/smoke/serverless.smoke.test.ts b/tests/smoke/serverless.smoke.test.ts index b27f5a6..fec70eb 100644 --- a/tests/smoke/serverless.smoke.test.ts +++ b/tests/smoke/serverless.smoke.test.ts @@ -226,20 +226,37 @@ describe('POST /api/subscribe', () => { }, }); - const response = await fetch(subscribeUrl, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'x-vercel-forwarded-for': '198.51.100.250', - }, - body, - duplex: 'half', - signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), - } as RequestInit & { duplex: 'half' }); + // Node 24's undici fetch sometimes throws EPIPE instead of returning a + // response when the server closes the connection mid-stream (the correct + // server behaviour for an oversized body). Either outcome proves the limit + // was enforced: a 413 response, or the connection being closed before the + // full body was accepted. + let response: Response | undefined; + try { + response = await fetch(subscribeUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-vercel-forwarded-for': '198.51.100.250', + }, + body, + duplex: 'half', + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + } as RequestInit & { duplex: 'half' }); + } catch (err) { + // EPIPE / fetch failed means the server closed the connection β€” the + // payload was rejected, which is the correct outcome. + const isEpipe = + err instanceof TypeError && + (err.message.includes('fetch failed') || err.message.includes('EPIPE')); + if (!isEpipe) throw err; + } - expect(response.status).toBe(413); - expect(await response.json()).toMatchObject({ code: 'payload_too_large' }); - expectCommonSecurityHeaders(response, SUBSCRIBE_API_SECURITY_HEADERS); + if (response !== undefined) { + expect(response.status).toBe(413); + expect(await response.json()).toMatchObject({ code: 'payload_too_large' }); + expectCommonSecurityHeaders(response, SUBSCRIBE_API_SECURITY_HEADERS); + } expect((await recordedUpstreamCalls()).length).toBe(before); }); From f8a9ca5bac6b60eedc9cffada4b1d00f40cf935f Mon Sep 17 00:00:00 2001 From: vicistar-star Date: Wed, 21 Oct 2026 13:20:52 +0100 Subject: [PATCH 4/7] fix(a11y): fix dt/dd structure, contrast on green/red badges, title timing on threat-model - Security.tsx: wrap audits grid in
to fix dlitem violation - Security.tsx: darken bounty active badge text (#22c55e -> #166534) - Vitals.tsx: darken rating badge and DNT badge green text (#22c55e -> #166534) - Vitals.tsx: darken Blocked label text (#ee7d77 -> #9b1c1c) - a11y.spec.ts: wait for document.title before running axe to fix flaky document-title violation on lazy-loaded pages --- src/pages/Security.tsx | 6 +++--- src/pages/Vitals.tsx | 8 ++++---- tests/a11y/a11y.spec.ts | 3 +++ 3 files changed, 10 insertions(+), 7 deletions(-) diff --git a/src/pages/Security.tsx b/src/pages/Security.tsx index e20aaa3..ccbab9f 100644 --- a/src/pages/Security.tsx +++ b/src/pages/Security.tsx @@ -254,7 +254,7 @@ export default function Security() { Security Audits
-
+
Last Completed @@ -285,7 +285,7 @@ export default function Security() {
{audits.nextScheduled}
-
+
diff --git a/src/pages/Vitals.tsx b/src/pages/Vitals.tsx index 9fd6118..910d3cd 100644 --- a/src/pages/Vitals.tsx +++ b/src/pages/Vitals.tsx @@ -18,7 +18,7 @@ function getRatingBadge(rating: MetricRating) { case 'good': return { label: 'GOOD', - colorClass: 'border-[#22c55e]/40 bg-[#22c55e]/15 text-[#22c55e]', + colorClass: 'border-[#22c55e]/40 bg-[#22c55e]/15 text-[#166534]', dotClass: 'bg-[#22c55e]', }; case 'needs-improvement': @@ -30,7 +30,7 @@ function getRatingBadge(rating: MetricRating) { case 'poor': return { label: 'POOR', - colorClass: 'border-[#ee7d77]/40 bg-[#ee7d77]/15 text-[#ee7d77]', + colorClass: 'border-[#ee7d77]/40 bg-[#ee7d77]/15 text-[#9b1c1c]', dotClass: 'bg-[#ee7d77]', }; } @@ -104,7 +104,7 @@ export default function Vitals() { className={`inline-flex items-center gap-2 border px-3 py-1 font-mono text-[11px] uppercase tracking-[1px] ${ dntEnabled ? 'border-[#c4c7c5]/50 bg-[#c4c7c5]/10 text-[#c4c7c5]' - : 'border-[#22c55e]/40 bg-[#22c55e]/15 text-[#22c55e]' + : 'border-[#22c55e]/40 bg-[#22c55e]/15 text-[#166534]' }`} > {tile.label} {tile.blocked && ( - + Blocked )} diff --git a/tests/a11y/a11y.spec.ts b/tests/a11y/a11y.spec.ts index 7ecad8d..89cc085 100644 --- a/tests/a11y/a11y.spec.ts +++ b/tests/a11y/a11y.spec.ts @@ -37,6 +37,9 @@ for (const { path, name } of pages.map((p) => ({ path: p.path, name: p.label })) test(`has zero critical/serious axe violations on ${name}`, async ({ page }) => { await page.goto(path); await page.waitForLoadState('domcontentloaded'); + // Wait for the page to be populated by Helmet before running axe, + // so document-title violations are not triggered by lazy-load timing. + await page.waitForFunction(() => document.title.length > 0, { timeout: 5_000 }).catch(() => {}); const results = await new AxeBuilder({ page }).analyze(); const critical = results.violations.filter( From 25807307a351633465e0668b9b9b419b1a1fd25a Mon Sep 17 00:00:00 2001 From: vicistar-star <ogazivictor17@gmail.com> Date: Thu, 22 Oct 2026 12:50:24 +0100 Subject: [PATCH 5/7] fix(ci): generate and track routes-snapshot.json; fix Lighthouse job The Lighthouse job read scripts/routes-snapshot.json before it existed, causing ENOENT before any audit ran. - Wire tsx scripts/gen-routes-snapshot.ts into the pnpm build script so the snapshot is always regenerated from the typed inventory on every build. - Commit the generated snapshot so the file is present on checkout without needing to run the full build first in CI. Also bring the Lighthouse job up to date with the fixes landed on wave-10/lighthouse-budgets: - Start pnpm preview --host explicitly; gate on tcp:localhost:4173 with wait-on instead of the unsupported startServerCommand input. - Remove continue-on-error, uploadArtifacts, temporaryPublicStorage. - Add lhci-mobile.json; update lhci-desktop.json with assert block gating accessibility, best-practices, seo at >= 0.95. - Upload .lighthouseci-mobile/ and .lighthouseci-desktop/ as the lighthouse-reports artifact (if: always(), 14-day retention). - Add issues: write permission; add continue-on-error to the PR comment step to survive fork permission restrictions. - Expand PR comment table to show all four categories per route. --- .github/lhci-desktop.json | 7 + .github/lhci-mobile.json | 14 ++ .github/workflows/ci.yml | 87 ++++--- package.json | 2 +- scripts/routes-snapshot.json | 461 +++++++++++++++++++++++++++++++++++ 5 files changed, 535 insertions(+), 36 deletions(-) create mode 100644 .github/lhci-mobile.json create mode 100644 scripts/routes-snapshot.json diff --git a/.github/lhci-desktop.json b/.github/lhci-desktop.json index 3d0626b..8ae0ca2 100644 --- a/.github/lhci-desktop.json +++ b/.github/lhci-desktop.json @@ -5,6 +5,13 @@ "settings": { "preset": "desktop" } + }, + "assert": { + "assertions": { + "categories:accessibility": ["error", { "minScore": 0.95 }], + "categories:best-practices": ["error", { "minScore": 0.95 }], + "categories:seo": ["error", { "minScore": 0.95 }] + } } } } diff --git a/.github/lhci-mobile.json b/.github/lhci-mobile.json new file mode 100644 index 0000000..919df5e --- /dev/null +++ b/.github/lhci-mobile.json @@ -0,0 +1,14 @@ +{ + "ci": { + "collect": { + "numberOfRuns": 1 + }, + "assert": { + "assertions": { + "categories:accessibility": ["error", { "minScore": 0.95 }], + "categories:best-practices": ["error", { "minScore": 0.95 }], + "categories:seo": ["error", { "minScore": 0.95 }] + } + } + } +} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8744e8c..65e6423 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -129,6 +129,7 @@ jobs: runs-on: ubuntu-latest permissions: pull-requests: write + issues: write steps: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v4 @@ -149,44 +150,51 @@ jobs: console.log('Lighthouse URLs:', urls); " + - name: Start preview server + run: pnpm preview --host & + - name: Wait for preview server + run: npx --yes wait-on tcp:localhost:4173 --timeout 30000 + - name: Run Lighthouse on Mobile id: lighthouse_mobile - continue-on-error: true uses: treosh/lighthouse-ci-action@v12 with: urlsFile: './.github/lighthouse-urls.txt' - startServerCommand: pnpm preview - uploadArtifacts: true - temporaryPublicStorage: true + configPath: './.github/lhci-mobile.json' - - name: Save Mobile Manifest - if: steps.lighthouse_mobile.outcome == 'success' + - name: Save Mobile Reports + if: always() run: cp -r .lighthouseci .lighthouseci-mobile - name: Run Lighthouse on Desktop id: lighthouse_desktop - continue-on-error: true uses: treosh/lighthouse-ci-action@v12 with: urlsFile: './.github/lighthouse-urls.txt' - startServerCommand: pnpm preview - uploadArtifacts: true - temporaryPublicStorage: true configPath: './.github/lhci-desktop.json' - - name: Save Desktop Manifest - if: steps.lighthouse_desktop.outcome == 'success' + - name: Save Desktop Reports + if: always() run: cp -r .lighthouseci .lighthouseci-desktop + - name: Upload Lighthouse Reports + if: always() + uses: actions/upload-artifact@v4 + with: + name: lighthouse-reports + path: | + .lighthouseci-mobile/ + .lighthouseci-desktop/ + if-no-files-found: ignore + retention-days: 14 + - name: Post Comment on PR + continue-on-error: true uses: actions/github-script@v7 if: >- github.event_name == 'pull_request' && - steps.lighthouse_mobile.outcome == 'success' && - steps.lighthouse_desktop.outcome == 'success' - env: - MOBILE_LINKS: ${{ steps.lighthouse_mobile.outputs.links }} - DESKTOP_LINKS: ${{ steps.lighthouse_desktop.outputs.links }} + (steps.lighthouse_mobile.outcome == 'success' || steps.lighthouse_mobile.outcome == 'failure') && + (steps.lighthouse_desktop.outcome == 'success' || steps.lighthouse_desktop.outcome == 'failure') with: script: | const fs = require('fs'); @@ -196,16 +204,21 @@ jobs: const snap = JSON.parse(fs.readFileSync('scripts/routes-snapshot.json', 'utf8')); const base = 'http://localhost:4173'; + // Thresholds must stay in sync with lhci-mobile.json / lhci-desktop.json. + // Performance is not gated in CI due to GitHub-hosted runner variance. + const BUDGET = 95; const formatScore = (val) => Math.round(val * 100); + const getPerfEmoji = (score) => { + if (score >= 90) return '🟒'; + if (score >= 70) return '🟑'; + return 'πŸ”΄'; + }; const getScoreEmoji = (score) => { - if (score >= 95) return '🟒'; + if (score >= BUDGET) return '🟒'; if (score >= 90) return '🟑'; return 'πŸ”΄'; }; - const mobileLinks = JSON.parse(process.env.MOBILE_LINKS || '{}'); - const desktopLinks = JSON.parse(process.env.DESKTOP_LINKS || '{}'); - // Build one row per audited route const rows = snap.lighthouseRoutes.map(r => { const url = base + r.path; @@ -214,30 +227,34 @@ jobs: if (!mob || !desk) return null; const mp = formatScore(mob.summary.performance); const ma = formatScore(mob.summary.accessibility); + const mb = formatScore(mob.summary['best-practices']); + const ms = formatScore(mob.summary.seo); const dp = formatScore(desk.summary.performance); const da = formatScore(desk.summary.accessibility); - return `| \`${r.path}\` | ${getScoreEmoji(mp)} **${mp}** / ${getScoreEmoji(ma)} **${ma}** | ${getScoreEmoji(dp)} **${dp}** / ${getScoreEmoji(da)} **${da}** |`; + const db = formatScore(desk.summary['best-practices']); + const ds = formatScore(desk.summary.seo); + return `| \`${r.path}\` | ${getPerfEmoji(mp)} **${mp}** / ${getScoreEmoji(ma)} **${ma}** / ${getScoreEmoji(mb)} **${mb}** / ${getScoreEmoji(ms)} **${ms}** | ${getPerfEmoji(dp)} **${dp}** / ${getScoreEmoji(da)} **${da}** / ${getScoreEmoji(db)} **${db}** / ${getScoreEmoji(ds)} **${ds}** |`; }).filter(Boolean).join('\n'); - const reportLinks = []; - for (const r of snap.lighthouseRoutes) { - const url = base + r.path; - if (mobileLinks[url]) reportLinks.push(`[Mobile ${r.path}](${mobileLinks[url]})`); - if (desktopLinks[url]) reportLinks.push(`[Desktop ${r.path}](${desktopLinks[url]})`); - } - const reportText = reportLinks.length > 0 - ? `πŸ“„ **Reports:** ${reportLinks.join(' | ')}` - : ''; + const allA11yBpSeo = [...mobileManifest, ...desktopManifest].every(m => + formatScore(m.summary.accessibility) >= BUDGET && + formatScore(m.summary['best-practices']) >= BUDGET && + formatScore(m.summary.seo) >= BUDGET + ); + + const statusLine = allA11yBpSeo + ? 'βœ… Accessibility, Best Practices, and SEO meet the β‰₯ 95 budget across all audited routes.' + : '❌ One or more gated categories are below 95 β€” this job will block merge.'; const body = `### ⚑ Lighthouse Audit Results - | Route | Mobile Perf / A11y | Desktop Perf / A11y | + ${statusLine} + + | Route | Mobile Perf / A11y / BP / SEO | Desktop Perf / A11y / BP / SEO | | --- | --- | --- | ${rows} - ${reportText} - - *Audit ran against the latest build on preview server.*`; + > Performance is not gated in CI β€” GitHub-hosted runner scores vary Β±20 pts. Full reports are saved as the \`lighthouse-reports\` artifact for manual review.`; github.rest.issues.createComment({ issue_number: context.issue.number, diff --git a/package.json b/package.json index a45c957..c39979c 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "type": "module", "scripts": { "dev": "vite", - "build": "tsx scripts/validate-content.ts && node scripts/check-sdk-size.mjs && tsc -b && vite build && node scripts/check-chunk-budget.mjs && node scripts/inline-css.js && node scripts/gen-blog-manifest.mjs && tsx scripts/og.ts && tsx scripts/sitemap.ts && node scripts/gen-rss.mjs", + "build": "tsx scripts/validate-content.ts && node scripts/check-sdk-size.mjs && tsc -b && vite build && node scripts/check-chunk-budget.mjs && node scripts/inline-css.js && node scripts/gen-blog-manifest.mjs && tsx scripts/og.ts && tsx scripts/sitemap.ts && node scripts/gen-rss.mjs && tsx scripts/gen-routes-snapshot.ts", "check:chunk-budget": "node scripts/check-chunk-budget.mjs", "generate:sdk-size": "node scripts/check-sdk-size.mjs --write", "og:generate": "tsx scripts/og.ts", diff --git a/scripts/routes-snapshot.json b/scripts/routes-snapshot.json new file mode 100644 index 0000000..aa2344c --- /dev/null +++ b/scripts/routes-snapshot.json @@ -0,0 +1,461 @@ +{ + "staticPaths": [ + "/", + "/faq", + "/privacy", + "/terms", + "/press", + "/newsletter", + "/use-cases", + "/use-cases/calculator", + "/roadmap", + "/case-studies", + "/stellar", + "/careers", + "/grants", + "/about", + "/governance", + "/contributors", + "/blog", + "/chains", + "/ecosystem", + "/security", + "/threat-model", + "/vitals", + "/status" + ], + "localizedBasePaths": ["/", "/case-studies", "/stellar", "/grants", "/blog"], + "smokeRoutes": [ + { + "path": "/", + "label": "homepage" + }, + { + "path": "/faq", + "label": "FAQ" + }, + { + "path": "/use-cases", + "label": "use cases" + }, + { + "path": "/stellar", + "label": "Stellar page" + } + ], + "a11yRoutes": [ + { + "path": "/", + "label": "homepage" + }, + { + "path": "/faq", + "label": "FAQ" + }, + { + "path": "/privacy", + "label": "privacy policy" + }, + { + "path": "/terms", + "label": "terms of service" + }, + { + "path": "/press", + "label": "press" + }, + { + "path": "/newsletter", + "label": "newsletter" + }, + { + "path": "/use-cases", + "label": "use cases" + }, + { + "path": "/use-cases/calculator", + "label": "payment cost calculator" + }, + { + "path": "/roadmap", + "label": "roadmap" + }, + { + "path": "/case-studies", + "label": "case studies list" + }, + { + "path": "/stellar", + "label": "Stellar page" + }, + { + "path": "/careers", + "label": "careers" + }, + { + "path": "/grants", + "label": "grants" + }, + { + "path": "/about", + "label": "about" + }, + { + "path": "/governance", + "label": "governance" + }, + { + "path": "/contributors", + "label": "contributors" + }, + { + "path": "/blog", + "label": "blog listing" + }, + { + "path": "/chains", + "label": "chains" + }, + { + "path": "/ecosystem", + "label": "ecosystem" + }, + { + "path": "/security", + "label": "security" + }, + { + "path": "/threat-model", + "label": "threat model" + }, + { + "path": "/vitals", + "label": "vitals" + }, + { + "path": "/status", + "label": "status" + }, + { + "path": "/blog/stealth-addresses-explained", + "label": "blog post detail" + }, + { + "path": "/case-studies/payroll-processor", + "label": "case study detail" + }, + { + "path": "/nonexistent-page", + "label": "404 not found" + } + ], + "lighthouseRoutes": [ + { + "path": "/", + "label": "homepage" + }, + { + "path": "/stellar", + "label": "Stellar page" + } + ], + "staticRoutes": [ + { + "path": "/", + "label": "homepage", + "localized": true, + "policy": { + "smoke": true, + "a11y": true, + "lighthouse": true + } + }, + { + "path": "/faq", + "label": "FAQ", + "localized": false, + "policy": { + "smoke": true, + "a11y": true, + "lighthouse": false + } + }, + { + "path": "/privacy", + "label": "privacy policy", + "localized": false, + "policy": { + "smoke": false, + "a11y": true, + "lighthouse": false + } + }, + { + "path": "/terms", + "label": "terms of service", + "localized": false, + "policy": { + "smoke": false, + "a11y": true, + "lighthouse": false + } + }, + { + "path": "/press", + "label": "press", + "localized": false, + "policy": { + "smoke": false, + "a11y": true, + "lighthouse": false + } + }, + { + "path": "/newsletter", + "label": "newsletter", + "localized": false, + "policy": { + "smoke": false, + "a11y": true, + "lighthouse": false + } + }, + { + "path": "/use-cases", + "label": "use cases", + "localized": false, + "policy": { + "smoke": true, + "a11y": true, + "lighthouse": false + } + }, + { + "path": "/use-cases/calculator", + "label": "payment cost calculator", + "localized": false, + "policy": { + "smoke": false, + "a11y": true, + "lighthouse": false + } + }, + { + "path": "/roadmap", + "label": "roadmap", + "localized": false, + "policy": { + "smoke": false, + "a11y": true, + "lighthouse": false + } + }, + { + "path": "/case-studies", + "label": "case studies list", + "localized": true, + "policy": { + "smoke": false, + "a11y": true, + "lighthouse": false + } + }, + { + "path": "/stellar", + "label": "Stellar page", + "localized": true, + "policy": { + "smoke": true, + "a11y": true, + "lighthouse": true + } + }, + { + "path": "/careers", + "label": "careers", + "localized": false, + "policy": { + "smoke": false, + "a11y": true, + "lighthouse": false + } + }, + { + "path": "/grants", + "label": "grants", + "localized": true, + "policy": { + "smoke": false, + "a11y": true, + "lighthouse": false + } + }, + { + "path": "/about", + "label": "about", + "localized": false, + "policy": { + "smoke": false, + "a11y": true, + "lighthouse": false + } + }, + { + "path": "/governance", + "label": "governance", + "localized": false, + "policy": { + "smoke": false, + "a11y": true, + "lighthouse": false + } + }, + { + "path": "/contributors", + "label": "contributors", + "localized": false, + "policy": { + "smoke": false, + "a11y": true, + "lighthouse": false + } + }, + { + "path": "/blog", + "label": "blog listing", + "localized": true, + "policy": { + "smoke": false, + "a11y": true, + "lighthouse": false + } + }, + { + "path": "/chains", + "label": "chains", + "localized": false, + "policy": { + "smoke": false, + "a11y": true, + "lighthouse": false + } + }, + { + "path": "/ecosystem", + "label": "ecosystem", + "localized": false, + "policy": { + "smoke": false, + "a11y": true, + "lighthouse": false + } + }, + { + "path": "/security", + "label": "security", + "localized": false, + "policy": { + "smoke": false, + "a11y": true, + "lighthouse": false + } + }, + { + "path": "/threat-model", + "label": "threat model", + "localized": false, + "policy": { + "smoke": false, + "a11y": true, + "lighthouse": false + } + }, + { + "path": "/vitals", + "label": "vitals", + "localized": false, + "policy": { + "smoke": false, + "a11y": true, + "lighthouse": false + } + }, + { + "path": "/status", + "label": "status", + "localized": false, + "policy": { + "smoke": false, + "a11y": true, + "lighthouse": false + } + } + ], + "dynamicRouteFamilies": [ + { + "pattern": "/blog/:slug", + "label": "blog post", + "localized": true, + "policy": { + "smoke": false, + "a11y": true, + "lighthouse": false + }, + "samples": [ + { + "path": "/blog/stealth-addresses-explained", + "label": "blog post detail" + } + ] + }, + { + "pattern": "/blog/tag/:tagSlug", + "label": "blog tag archive", + "localized": true, + "policy": { + "smoke": false, + "a11y": false, + "lighthouse": false + }, + "samples": [] + }, + { + "pattern": "/blog/author/:authorId", + "label": "blog author archive", + "localized": true, + "policy": { + "smoke": false, + "a11y": false, + "lighthouse": false + }, + "samples": [] + }, + { + "pattern": "/case-studies/:slug", + "label": "case study detail", + "localized": true, + "policy": { + "smoke": false, + "a11y": true, + "lighthouse": false + }, + "samples": [ + { + "path": "/case-studies/payroll-processor", + "label": "case study detail" + } + ] + } + ], + "specialRoutes": [ + { + "pattern": "*", + "label": "404 not found", + "policy": { + "smoke": false, + "a11y": true, + "lighthouse": false + } + } + ] +} From ec00a041d62c1c31a48f649feda543c81e2ca3b4 Mon Sep 17 00:00:00 2001 From: vicistar-star <ogazivictor17@gmail.com> Date: Thu, 22 Oct 2026 12:53:36 +0100 Subject: [PATCH 6/7] fix(ci): replace urlsFile with url in lhci configs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit treosh/lighthouse-ci-action@v12 does not accept urlsFile as an input. Move the URL list into collect.url in both lhci config files (lhci-mobile.json, lhci-desktop.json) β€” the same pattern that works on wave-10/lighthouse-budgets. Remove the 'Generate Lighthouse URL list' step and drop urlsFile from both action steps; configPath alone is sufficient since the configs now carry the full URL list. --- .github/lhci-desktop.json | 1 + .github/lhci-mobile.json | 3 ++- .github/workflows/ci.yml | 12 ------------ 3 files changed, 3 insertions(+), 13 deletions(-) diff --git a/.github/lhci-desktop.json b/.github/lhci-desktop.json index 8ae0ca2..1120c13 100644 --- a/.github/lhci-desktop.json +++ b/.github/lhci-desktop.json @@ -2,6 +2,7 @@ "ci": { "collect": { "numberOfRuns": 1, + "url": ["http://localhost:4173/", "http://localhost:4173/stellar"], "settings": { "preset": "desktop" } diff --git a/.github/lhci-mobile.json b/.github/lhci-mobile.json index 919df5e..ac540aa 100644 --- a/.github/lhci-mobile.json +++ b/.github/lhci-mobile.json @@ -1,7 +1,8 @@ { "ci": { "collect": { - "numberOfRuns": 1 + "numberOfRuns": 1, + "url": ["http://localhost:4173/", "http://localhost:4173/stellar"] }, "assert": { "assertions": { diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 65e6423..8cfe655 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -140,16 +140,6 @@ jobs: - run: pnpm install --frozen-lockfile - run: pnpm build - - name: Generate Lighthouse URL list from route inventory - run: | - node -e " - const snap = JSON.parse(require('fs').readFileSync('scripts/routes-snapshot.json','utf8')); - const base = 'http://localhost:4173'; - const urls = snap.lighthouseRoutes.map(r => base + r.path).join('\n'); - require('fs').writeFileSync('.github/lighthouse-urls.txt', urls + '\n'); - console.log('Lighthouse URLs:', urls); - " - - name: Start preview server run: pnpm preview --host & - name: Wait for preview server @@ -159,7 +149,6 @@ jobs: id: lighthouse_mobile uses: treosh/lighthouse-ci-action@v12 with: - urlsFile: './.github/lighthouse-urls.txt' configPath: './.github/lhci-mobile.json' - name: Save Mobile Reports @@ -170,7 +159,6 @@ jobs: id: lighthouse_desktop uses: treosh/lighthouse-ci-action@v12 with: - urlsFile: './.github/lighthouse-urls.txt' configPath: './.github/lhci-desktop.json' - name: Save Desktop Reports From b41d7332ba443d9fbf7b7b77e35fd46126f7cee7 Mon Sep 17 00:00:00 2001 From: vicistar-star <ogazivictor17@gmail.com> Date: Thu, 22 Oct 2026 13:31:54 +0100 Subject: [PATCH 7/7] fix(a11y): add scope to th elements and aria-label to explorer links MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit axe flags <th> without scope as a serious violation. Add scope="col" to all three header cells in StellarDeploymentTable. The 'View β†—' link text is not descriptive for screen readers. Add aria-label="View {name} on explorer (opens in new tab)" to each explorer link so assistive technology announces the contract name. --- src/components/StellarDeploymentTable.tsx | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/src/components/StellarDeploymentTable.tsx b/src/components/StellarDeploymentTable.tsx index 11e6a35..40e0af1 100644 --- a/src/components/StellarDeploymentTable.tsx +++ b/src/components/StellarDeploymentTable.tsx @@ -43,16 +43,28 @@ export default function StellarDeploymentTable() { </div> <div className="border border-outline-variant overflow-x-auto"> - <table className="w-full border-collapse font-body text-[13px]"> + <table + className="w-full border-collapse font-body text-[13px]" + aria-label="Testnet contract addresses" + > <thead> <tr className="border-b border-outline-variant bg-surface-container"> - <th className="py-3 px-4 text-left font-mono text-[10px] font-semibold tracking-[1.5px] text-outline uppercase"> + <th + scope="col" + className="py-3 px-4 text-left font-mono text-[10px] font-semibold tracking-[1.5px] text-outline uppercase" + > Contract </th> - <th className="py-3 px-4 text-left font-mono text-[10px] font-semibold tracking-[1.5px] text-outline uppercase"> + <th + scope="col" + className="py-3 px-4 text-left font-mono text-[10px] font-semibold tracking-[1.5px] text-outline uppercase" + > Address </th> - <th className="py-3 px-4 text-left font-mono text-[10px] font-semibold tracking-[1.5px] text-outline uppercase"> + <th + scope="col" + className="py-3 px-4 text-left font-mono text-[10px] font-semibold tracking-[1.5px] text-outline uppercase" + > Explorer </th> </tr> @@ -75,6 +87,7 @@ export default function StellarDeploymentTable() { target="_blank" rel="noopener noreferrer" onClick={trackOutbound('explorer')} + aria-label={`View ${c.name} on explorer (opens in new tab)`} className="font-mono text-[11px] font-semibold tracking-[1px] text-tertiary hover:brightness-110 transition-[filter]" > View β†—