diff --git a/frontend/.prettierignore b/frontend/.prettierignore new file mode 100644 index 0000000..baba72e --- /dev/null +++ b/frontend/.prettierignore @@ -0,0 +1,8 @@ +.next/ +coverage/ +playwright-report/ +test-results/ +node_modules/ +public/ +src/lib/api/schema.d.ts +next-env.d.ts diff --git a/frontend/.prettierrc b/frontend/.prettierrc new file mode 100644 index 0000000..a95cb05 --- /dev/null +++ b/frontend/.prettierrc @@ -0,0 +1,7 @@ +{ + "semi": true, + "singleQuote": true, + "trailingComma": "es5", + "printWidth": 100, + "tabWidth": 2 +} diff --git a/frontend/eslint.config.mjs b/frontend/eslint.config.mjs new file mode 100644 index 0000000..eb0c573 --- /dev/null +++ b/frontend/eslint.config.mjs @@ -0,0 +1,19 @@ +import { FlatCompat } from '@eslint/eslintrc'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const compat = new FlatCompat({ + baseDirectory: __dirname, +}); + +const eslintConfig = [ + ...compat.extends('next/core-web-vitals', 'next/typescript', 'prettier'), + { + ignores: ['.next/**', 'coverage/**', 'playwright-report/**', 'test-results/**'], + }, +]; + +export default eslintConfig; diff --git a/frontend/package.json b/frontend/package.json index f11a63d..38c98d5 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -9,6 +9,8 @@ "build": "npm run generate-client && next build", "start": "next start", "lint": "next lint", + "format": "prettier --write .", + "format:check": "prettier --check .", "test": "jest", "test:watch": "jest --watch", "test:coverage": "jest --coverage", @@ -41,6 +43,7 @@ }, "devDependencies": { "@axe-core/react": "^4.12.1", + "@eslint/eslintrc": "^3.2.0", "@next/bundle-analyzer": "16.2.11", "@playwright/test": "^1.61.1", "@testing-library/dom": "^10.4.1", @@ -53,12 +56,16 @@ "@types/react-dom": "^19.2.3", "axe-core": "^4.12.1", "bundlewatch": "^0.4.2", + "eslint": "^9.20.0", + "eslint-config-next": "16.2.11", + "eslint-config-prettier": "^9.1.0", "jest": "^30.4.2", "jest-axe": "^10.0.0", "jest-environment-jsdom": "^30.4.1", "lighthouse": "^13.4.1", "openapi-typescript": "^7.0.0", "pa11y": "^9.1.1", + "prettier": "^3.4.2", "typescript": "^7.0.2" }, "engines": { diff --git a/frontend/scripts/manual-testing-checklist.js b/frontend/scripts/manual-testing-checklist.js new file mode 100644 index 0000000..f67c6eb --- /dev/null +++ b/frontend/scripts/manual-testing-checklist.js @@ -0,0 +1,67 @@ +/** + * Manual Accessibility Testing Checklist + * + * Automated tools (axe-core, Lighthouse, jest-axe) catch most WCAG 2.1 AA + * violations, but several categories require a human to actually try the + * page. This prints that checklist so `npm run a11y:manual` has something + * to run — it previously pointed at a script that didn't exist in this repo. + */ + +const CHECKLIST = [ + { + category: 'Keyboard navigation', + items: [ + 'Tab through the entire page — every interactive element is reachable and in a logical order', + 'Focus is always visible (no invisible focus outlines)', + 'No keyboard trap: focus can always move forward and backward out of any widget', + 'Modals/dialogs trap focus while open and return it to the trigger on close', + 'Skip-to-content link is the first focusable element and actually works', + ], + }, + { + category: 'Screen reader (VoiceOver / NVDA / JAWS)', + items: [ + 'Page landmarks (banner, main, navigation, contentinfo) are announced correctly', + 'Headings form a logical, non-skipping hierarchy', + 'Form fields announce their label, required state, and any validation error', + 'Live regions (toasts, status updates) are announced without moving focus', + 'Images convey their alt text (or are correctly marked decorative)', + ], + }, + { + category: 'Color & contrast', + items: [ + 'Text meets 4.5:1 contrast (3:1 for large text) in both light and dark mode', + 'Information is never conveyed by color alone (e.g. status badges also use text/icons)', + 'Focus indicators meet 3:1 contrast against their background', + ], + }, + { + category: 'Zoom & reflow', + items: [ + 'Page is usable at 200% browser zoom with no horizontal scroll or clipped content', + 'Page is usable at 400% zoom in a 1280px viewport (WCAG 1.4.10 reflow)', + ], + }, + { + category: 'Motion & animation', + items: [ + 'prefers-reduced-motion is respected for any animated transitions', + 'No content flashes more than 3 times per second', + ], + }, +]; + +function printChecklist() { + console.log('Manual Accessibility Testing Checklist (WCAG 2.1 AA)\n'); + for (const { category, items } of CHECKLIST) { + console.log(category); + for (const item of items) { + console.log(` [ ] ${item}`); + } + console.log(''); + } + console.log('Run alongside `npm run test:a11y`, `npm run lighthouse`, and `npm run axe`.'); +} + +printChecklist(); diff --git a/frontend/src/lib/__tests__/env.test.ts b/frontend/src/lib/__tests__/env.test.ts index 3eb2f3e..c8ee5e8 100644 --- a/frontend/src/lib/__tests__/env.test.ts +++ b/frontend/src/lib/__tests__/env.test.ts @@ -1,8 +1,10 @@ describe('env validation', () => { const original = process.env.NEXT_PUBLIC_API_URL; + const originalTts = process.env.NEXT_PUBLIC_TTS_API_URL; afterEach(() => { process.env.NEXT_PUBLIC_API_URL = original; + process.env.NEXT_PUBLIC_TTS_API_URL = originalTts; jest.resetModules(); }); @@ -33,4 +35,48 @@ describe('env validation', () => { }); }).toThrow(/must be a valid URL/); }); + + // Issue #1307: TTS integration (#116) is now in scope, but no test + // exercised NEXT_PUBLIC_TTS_API_URL's validation branch at all. + it('parses a valid NEXT_PUBLIC_TTS_API_URL', () => { + process.env.NEXT_PUBLIC_API_URL = 'http://localhost:3001'; + process.env.NEXT_PUBLIC_TTS_API_URL = 'http://localhost:3002'; + let mod: typeof import('../env'); + jest.isolateModules(() => { + mod = require('../env'); + }); + expect(mod!.getEnvConfig().NEXT_PUBLIC_TTS_API_URL).toBe('http://localhost:3002'); + }); + + it('allows NEXT_PUBLIC_TTS_API_URL to be unset — TTS features are optional', () => { + process.env.NEXT_PUBLIC_API_URL = 'http://localhost:3001'; + delete process.env.NEXT_PUBLIC_TTS_API_URL; + let mod: typeof import('../env'); + expect(() => { + jest.isolateModules(() => { + mod = require('../env'); + }); + }).not.toThrow(); + expect(mod!.getEnvConfig().NEXT_PUBLIC_TTS_API_URL).toBeUndefined(); + }); + + it('allows NEXT_PUBLIC_TTS_API_URL to be an empty string', () => { + process.env.NEXT_PUBLIC_API_URL = 'http://localhost:3001'; + process.env.NEXT_PUBLIC_TTS_API_URL = ''; + expect(() => { + jest.isolateModules(() => { + require('../env'); + }); + }).not.toThrow(); + }); + + it('throws when NEXT_PUBLIC_TTS_API_URL is set but not a valid URL', () => { + process.env.NEXT_PUBLIC_API_URL = 'http://localhost:3001'; + process.env.NEXT_PUBLIC_TTS_API_URL = 'not-a-url'; + expect(() => { + jest.isolateModules(() => { + require('../env'); + }); + }).toThrow(/NEXT_PUBLIC_TTS_API_URL.*must be a valid URL/s); + }); });