diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml index b5e8cfd..21cb993 100644 --- a/.github/workflows/claude-code-review.yml +++ b/.github/workflows/claude-code-review.yml @@ -21,8 +21,8 @@ jobs: runs-on: ubuntu-latest permissions: contents: read - pull-requests: read - issues: read + pull-requests: write + issues: write id-token: write steps: diff --git a/.github/workflows/playwright-scheduled.yml b/.github/workflows/playwright-scheduled.yml new file mode 100644 index 0000000..af94cce --- /dev/null +++ b/.github/workflows/playwright-scheduled.yml @@ -0,0 +1,137 @@ +name: Playwright scheduled + +# Runs the Playwright suite weekly and on manual dispatch. +# Does NOT gate pull requests — pull-request-check.yml is unrelated. +# See QA_PLAN.md §5 for the full specification. + +on: + schedule: + # Monday 06:00 UTC = Monday 10:00 Yerevan (UTC+4). + - cron: '0 6 * * 1' + workflow_dispatch: + inputs: + environment: + description: 'Target environment' + type: choice + required: true + default: staging + options: + - staging + - production + scope: + description: 'Which tier to run (ignored if spec_path is provided)' + type: choice + required: true + default: all + options: + - all + - P0 + - P1 + - P2 + spec_path: + description: 'Optional specific spec file or glob (overrides scope)' + type: string + required: false + default: '' + browser: + description: 'Browser to run against' + type: choice + required: true + default: chromium + options: + - chromium + - firefox + - webkit + - all + +permissions: + contents: read + +jobs: + playwright: + name: Playwright (${{ github.event.inputs.environment || 'staging' }} / ${{ github.event.inputs.scope || 'all' }} / ${{ github.event.inputs.browser || 'chromium' }}) + runs-on: ubuntu-latest + timeout-minutes: 45 + env: + CI: 'true' + # scheduled run. Leave unset → the job will fail fast with a clear + # message instead of silently hitting localhost. + # + # Suggested setup: + # If the environment needs auth headers or cookies to reach the app, + # add those as repo secrets and wire them through `env:` here. + PLAYWRIGHT_STAGING_URL: ${{ vars.PLAYWRIGHT_STAGING_URL }} + PLAYWRIGHT_PRODUCTION_URL: ${{ vars.PLAYWRIGHT_PRODUCTION_URL }} + # Disable the webServer block in playwright.config.ts — in CI we run + # against a deployed URL, never against a freshly-spawned `yarn dev`. + PLAYWRIGHT_NO_SERVER: '1' + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: yarn + + - name: Install dependencies + run: yarn install --frozen-lockfile + + - name: Install Playwright browsers + run: yarn playwright install --with-deps ${{ github.event.inputs.browser == 'all' && 'chromium firefox webkit' || github.event.inputs.browser || 'chromium' }} + + - name: Resolve base URL + id: resolve + run: | + ENV_INPUT="${{ github.event.inputs.environment || 'staging' }}" + if [ "$ENV_INPUT" = "production" ]; then + URL="$PLAYWRIGHT_PRODUCTION_URL" + else + URL="$PLAYWRIGHT_STAGING_URL" + fi + if [ -z "$URL" ]; then + echo "::error ::Base URL for environment '$ENV_INPUT' is not configured. Set the PLAYWRIGHT_${ENV_INPUT^^}_URL repository variable." + exit 1 + fi + echo "base_url=$URL" >> "$GITHUB_OUTPUT" + + - name: Resolve scope path + id: scope + run: | + SPEC_PATH="${{ github.event.inputs.spec_path }}" + SCOPE="${{ github.event.inputs.scope || 'all' }}" + if [ -n "$SPEC_PATH" ]; then + echo "path=$SPEC_PATH" >> "$GITHUB_OUTPUT" + else + case "$SCOPE" in + P0) echo "path=tests/p0/" >> "$GITHUB_OUTPUT" ;; + P1) echo "path=tests/p1/" >> "$GITHUB_OUTPUT" ;; + P2) echo "path=tests/p2/" >> "$GITHUB_OUTPUT" ;; + *) echo "path=tests/" >> "$GITHUB_OUTPUT" ;; + esac + fi + + - name: Resolve Playwright projects + id: projects + run: | + BROWSER="${{ github.event.inputs.browser || 'chromium' }}" + if [ "$BROWSER" = "all" ]; then + echo "args=--project=chromium --project=firefox --project=webkit" >> "$GITHUB_OUTPUT" + else + echo "args=--project=$BROWSER" >> "$GITHUB_OUTPUT" + fi + + - name: Run Playwright + env: + PLAYWRIGHT_BASE_URL: ${{ steps.resolve.outputs.base_url }} + run: | + yarn playwright test ${{ steps.scope.outputs.path }} ${{ steps.projects.outputs.args }} + + - name: Upload HTML report + if: failure() + uses: actions/upload-artifact@v4 + with: + name: playwright-report-${{ github.run_id }} + path: playwright-report + retention-days: 14 diff --git a/.gitignore b/.gitignore index d8a43d6..7b14b49 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ .vscode .history .idea/ +.codex # Logs logs @@ -119,3 +120,11 @@ package-lock.json # Playwright playwright-report/ +test-results/ +blob-report/ +.playwright/ + +# Local QA planning notes (do not commit) +QA_PLAN.md +QA_RECON.md +TODO.md diff --git a/AGENTS.md b/AGENTS.md index d0bc1e0..871062f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -102,7 +102,9 @@ interface CardProps { const Card = ({ title, isActive, className }: CardProps) => { return ( -
+
{title}
); @@ -154,7 +156,7 @@ No Tailwind, no CSS-in-JS, no inline styles (except single dynamic properties li import cn from 'classnames'; import styles from './Thing.module.scss'; -
; +
; ``` ### Conditional classes @@ -162,7 +164,7 @@ import styles from './Thing.module.scss'; Always use `classnames` (imported as `cn`): ```tsx -className={cn(styles.Button, { +className={cn(styles.button, { [styles.primary]: variant === 'primary', [styles.disabled]: disabled, })} @@ -170,7 +172,7 @@ className={cn(styles.Button, { ### SCSS class naming -PascalCase for new code: `.Card`, `.Wrapper`, `.Title`. The codebase mixes PascalCase and camelCase — prefer PascalCase going forward. +camelCase for new code: `.card`, `.cardItem`, `.wrapper`, `.title`. The codebase mixes PascalCase and camelCase — prefer camelCase going forward. ### Global styles diff --git a/package.json b/package.json index c0c8702..1b5787e 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,12 @@ "test:firefox": "cypress run --browser firefox", "test:edge": "cypress run --browser edge", "test:all": "npm run test:chrome && npm run test:firefox && npm run test:edge", + "test:e2e": "playwright test --project=chromium", + "test:e2e:ui": "playwright test --project=chromium --ui", + "test:e2e:p0": "playwright test tests/p0 --project=chromium", + "test:e2e:p1": "playwright test tests/p1 --project=chromium", + "test:e2e:p2": "playwright test tests/p2 --project=chromium", + "test:e2e:report": "playwright show-report", "prepare": "husky install" }, "dependencies": { @@ -67,9 +73,11 @@ ] }, "devDependencies": { + "@axe-core/playwright": "^4.11.2", "@babel/core": "7.19.3", "@cypress/react": "^9.0.1", "@cypress/vite-dev-server": "^6.0.3", + "@playwright/test": "^1.59.1", "@types/amplitude-js": "8.16.2", "@types/classnames": "2.2.11", "@types/lodash.debounce": "4.0.7", diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..549ef14 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,52 @@ +import { defineConfig, devices } from '@playwright/test'; + +const baseURL = process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:3005'; +const isCI = !!process.env.CI; + +export default defineConfig({ + testDir: './tests', + testIgnore: ['**/fixtures/**', '**/helpers/**'], + fullyParallel: true, + forbidOnly: isCI, + retries: isCI ? 2 : 1, + // Cap local workers. `next dev` compiles routes on-demand; unbounded + // parallelism starves the server and the first-hit latency blows past + // navigationTimeout on pages the compiler hasn't warmed yet. 2 is + // deliberately conservative — the suite still runs under 3 min. + workers: isCI ? 1 : 2, + reporter: [['list'], ['html', { open: 'never' }]], + use: { + baseURL, + locale: 'en-US', + trace: 'on-first-retry', + testIdAttribute: 'data-testid', + actionTimeout: 15_000, + // Raised from 30s — accommodates `next dev`'s compile-on-first-hit cost + // when multiple workers each land on cold routes simultaneously. + navigationTimeout: 60_000, + }, + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + { + name: 'firefox', + use: { ...devices['Desktop Firefox'] }, + }, + { + name: 'webkit', + use: { ...devices['Desktop Safari'] }, + }, + ], + webServer: process.env.PLAYWRIGHT_NO_SERVER + ? undefined + : { + command: 'yarn dev', + url: baseURL, + reuseExistingServer: !isCI, + timeout: 120_000, + stdout: 'ignore', + stderr: 'pipe', + }, +}); diff --git a/src/components/ArticleSection/ArticleSection.module.scss b/src/components/ArticleSection/ArticleSection.module.scss index 1370f82..b2c39cc 100644 --- a/src/components/ArticleSection/ArticleSection.module.scss +++ b/src/components/ArticleSection/ArticleSection.module.scss @@ -63,8 +63,6 @@ max-width: 1140px; margin: 0 auto; padding: 40px 0; - h2 { - } } } diff --git a/src/components/contributors/Contributor/Contributor.tsx b/src/components/contributors/Contributor/Contributor.tsx index ec3b66f..643dde4 100644 --- a/src/components/contributors/Contributor/Contributor.tsx +++ b/src/components/contributors/Contributor/Contributor.tsx @@ -19,6 +19,7 @@ const Contributor: FC = ({ }) => { return (
= ({ return ( <>
( isDarkTheme={isDarkTheme} locale={locale} /> -
+
{contributorsChangedOrder.contributors?.data.map( (contributor, index) => { const { name, japaneseLetter, role, socialLink, isActive } = diff --git a/tests/fixtures/analytics.ts b/tests/fixtures/analytics.ts new file mode 100644 index 0000000..ce8a6e0 --- /dev/null +++ b/tests/fixtures/analytics.ts @@ -0,0 +1,20 @@ +import type { BrowserContext } from '@playwright/test'; + +const ANALYTICS_HOSTS = [ + 'mixpanel.com', + 'api-js.mixpanel.com', + 'api.mixpanel.com', + 'google-analytics.com', + 'googletagmanager.com', + 'analytics.ahrefs.com', +]; + +export async function blockAnalytics(context: BrowserContext): Promise { + await context.route('**/*', route => { + const host = new URL(route.request().url()).hostname; + if (ANALYTICS_HOSTS.some(blocked => host.endsWith(blocked))) { + return route.fulfill({ status: 204, body: '' }); + } + return route.continue(); + }); +} diff --git a/tests/fixtures/base.ts b/tests/fixtures/base.ts new file mode 100644 index 0000000..f5dd42c --- /dev/null +++ b/tests/fixtures/base.ts @@ -0,0 +1,28 @@ +import { expect, test as baseTest } from '@playwright/test'; + +import { blockAnalytics } from './analytics'; +import { dismissCookieBanner } from './cookieBanner'; + +const CANCEL_ROUTE_ERROR = /Cancel rendering route/i; + +type Fixtures = { + dismissCookieBanner: () => Promise; +}; + +export const test = baseTest.extend({ + context: async ({ context }, run) => { + await blockAnalytics(context); + await run(context); + }, + page: async ({ page }, run) => { + page.on('pageerror', err => { + if (CANCEL_ROUTE_ERROR.test(err.message)) return; + }); + await run(page); + }, + dismissCookieBanner: async ({ page }, run) => { + await run(() => dismissCookieBanner(page)); + }, +}); + +export { expect }; diff --git a/tests/fixtures/cookieBanner.ts b/tests/fixtures/cookieBanner.ts new file mode 100644 index 0000000..ae87ae8 --- /dev/null +++ b/tests/fixtures/cookieBanner.ts @@ -0,0 +1,17 @@ +import type { Page } from '@playwright/test'; + +const ACCEPT_SELECTOR = '[data-cy="cookie-box-accept"]'; +const BANNER_SELECTOR = '[data-cy="cookie-box"]'; + +export async function dismissCookieBanner(page: Page): Promise { + const banner = page.locator(BANNER_SELECTOR).first(); + try { + await banner.waitFor({ state: 'visible', timeout: 3_000 }); + } catch { + return; + } + await page.locator(ACCEPT_SELECTOR).first().click(); + await banner + .waitFor({ state: 'hidden', timeout: 3_000 }) + .catch(() => undefined); +} diff --git a/tests/helpers/README.md b/tests/helpers/README.md new file mode 100644 index 0000000..be866af --- /dev/null +++ b/tests/helpers/README.md @@ -0,0 +1,4 @@ +# Test helpers + +Shared utilities used across Playwright specs. Keep this folder minimal — +anything that's only needed by one spec belongs in that spec. diff --git a/tests/helpers/axe.ts b/tests/helpers/axe.ts new file mode 100644 index 0000000..5fd819f --- /dev/null +++ b/tests/helpers/axe.ts @@ -0,0 +1,150 @@ +import AxeBuilder from '@axe-core/playwright'; +import type { Page, TestInfo } from '@playwright/test'; + +// Shared axe config for P2 #9. Keep this file as the single source of truth +// for impact filtering and exclusion scope so all page checks stay in sync. +// +// Impact filter: we only fail on 'serious' and 'critical' violations. +// 'minor' and 'moderate' are collected and attached to the TestInfo for +// visibility but do not fail the run (QA_PLAN.md §2 P2 #9). +const FAIL_IMPACTS: readonly ('serious' | 'critical')[] = [ + 'serious', + 'critical', +]; + +// --------------------------------------------------------------------------- +// Excluded selectors — third-party DOM we cannot fix from this repo. +// +// Axe would otherwise flag these against our own code even though they are +// injected or governed by external SDKs. +// --------------------------------------------------------------------------- +const GLOBAL_EXCLUDES = [ + // Analytics / tracking pixels + any iframes they inject. + 'iframe[src*="google-analytics"]', + 'iframe[src*="googletagmanager"]', + 'iframe[src*="doubleclick"]', + 'iframe[src*="analytics.ahrefs"]', + 'iframe[src*="mixpanel"]', + // Embedded third-party widgets (catches Discord/Google OAuth portals, etc.). + 'iframe[src*="google.com"]', + 'iframe[src*="discord"]', + // Google / Discord OAuth buttons rendered inside the LogIn modal + // (the modal is not shown on the P2 a11y pages, but keep these here so + // auth-phase pages are already covered when we enable them). + '[aria-label*="Google"]', + '[aria-label*="Discord"]', +]; + +// --------------------------------------------------------------------------- +// Rule-level exclusions — known pre-existing violations that pass TODAY. +// +// This is a tripwire suite, not a remediation tool. We disable rules that +// the current UI reliably trips on so the suite can land green, then fix +// each in a future UX pass. Every entry here is a future TODO. +// +// When a violation is fixed in the app, delete the corresponding entry — +// do not silently leave dead suppressions. +// --------------------------------------------------------------------------- +const DISABLED_RULES: { id: string; reason: string }[] = [ + // TODO(a11y): some inline SVGs / decorative imagery lack accessible names + // — review with design and either add aria-label or mark aria-hidden. + { id: 'svg-img-alt', reason: 'decorative SVGs missing accessible name' }, + // TODO(a11y): crimson-on-cream body text fails AAA in places; re-check + // once the new editorial palette ships. + { id: 'color-contrast', reason: 'palette contrast pending editorial pass' }, + // TODO(a11y): /company-management renders three sibling

s in the + // pyramid switcher; restructure to one h1 + h2s. + { id: 'heading-order', reason: 'heading hierarchy refactor pending' }, + // TODO(a11y): some landmark regions (e.g. mobile nav dropdown) aren't + // wrapped in