diff --git a/.github/workflows/_reusable-axe.yml b/.github/workflows/_reusable-axe.yml deleted file mode 100644 index a68845e92..000000000 --- a/.github/workflows/_reusable-axe.yml +++ /dev/null @@ -1,102 +0,0 @@ -# axe-core CI — reusable workflow (local copy) -# Sprint B9 DR-B9-A11Y-01 | Ticket TB9-17 -# Source: /Volumes/UG/Sites/ummeco/.github/workflows/axe-ci-reusable.yml -# Keep in sync with the monorepo-root version. - -name: axe-core CI (reusable) - -on: - workflow_call: - inputs: - project_path: - description: 'Workspace-relative path to the Next.js project (e.g. islamwiki/web)' - required: true - type: string - port: - description: 'Port the dev server listens on' - required: false - type: number - default: 3000 - extra_urls: - description: 'Space-separated list of additional paths to scan (e.g. /about /donate)' - required: false - type: string - default: '' - -jobs: - axe: - name: axe-core WCAG 2.1 AA - runs-on: ubuntu-latest - timeout-minutes: 15 - - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Setup pnpm - uses: pnpm/action-setup@v4 - - - name: Setup Node - uses: actions/setup-node@v4 - with: - node-version: 22 - cache: 'pnpm' - cache-dependency-path: ${{ inputs.project_path }}/pnpm-lock.yaml - - - name: Install dependencies - working-directory: ${{ inputs.project_path }} - run: pnpm install --frozen-lockfile - - - name: Build - working-directory: ${{ inputs.project_path }} - run: pnpm build - - - name: Start dev server - working-directory: ${{ inputs.project_path }} - run: pnpm start & - - - name: Wait for server - run: npx wait-on http://localhost:${{ inputs.port }} -t 60000 - - # @axe-core/cli bundles a `chromedriver` pinned to the latest Chrome stable, - # which drifts out of sync with the runner's installed Chrome. When they - # disagree the session comes up broken and axe fails with - # "Cannot read properties of undefined (reading 'runPartial')" - axe-core - # never finishes injecting. `which chromedriver` finds the bundled - # mismatched binary, so pointing at it does not help. - # nanasess/setup-chromedriver drops the driver matching THIS runner's - # Chrome at /usr/local/bin/chromedriver. Same fix chatislam/praycalc use. - - name: Setup matching ChromeDriver - uses: nanasess/setup-chromedriver@v3 - - - name: Install axe-core CLI - run: npm install -g @axe-core/cli - # Make the bundled chromedriver npm package reuse the matched binary - # rather than downloading its own (dequelabs/axe-core-npm#370). - env: - CHROMEDRIVER_FILEPATH: /usr/local/bin/chromedriver - - - name: Run axe — root route - working-directory: ${{ inputs.project_path }} - run: | - axe \ - http://localhost:${{ inputs.port }} \ - --exit \ - --tags wcag2a,wcag2aa,wcag21a,wcag21aa \ - --chromedriver-path /usr/local/bin/chromedriver \ - --chrome-options="no-sandbox,disable-setuid-sandbox,disable-dev-shm-usage" - - - name: Run axe — extra routes - if: ${{ inputs.extra_urls != '' }} - working-directory: ${{ inputs.project_path }} - run: | - CHROMEDRIVER=/usr/local/bin/chromedriver - for path in ${{ inputs.extra_urls }}; do - echo "--- axe: http://localhost:${{ inputs.port }}${path} ---" - axe \ - "http://localhost:${{ inputs.port }}${path}" \ - --exit \ - --tags wcag2a,wcag2aa,wcag21a,wcag21aa \ - --chromedriver-path "$CHROMEDRIVER" \ - --chrome-options="no-sandbox,disable-setuid-sandbox,disable-dev-shm-usage" - done diff --git a/.github/workflows/_reusable-lighthouse.yml b/.github/workflows/_reusable-lighthouse.yml deleted file mode 100644 index af50ab5f8..000000000 --- a/.github/workflows/_reusable-lighthouse.yml +++ /dev/null @@ -1,49 +0,0 @@ -# Lighthouse CI — reusable workflow (local copy) -# Sprint B9 DR-B9-PERF-01 | Ticket TB9-17 -# Source: /Volumes/UG/Sites/ummeco/.github/workflows/lighthouse-ci-reusable.yml -# Keep in sync with the monorepo-root version. - -name: Lighthouse CI (reusable) - -on: - workflow_call: - inputs: - project_path: - required: true - type: string - description: 'Path to the Next.js project (relative to repo root)' - build_command: - required: false - type: string - default: 'pnpm install && pnpm build' - -jobs: - lighthouse: - runs-on: ubuntu-latest - timeout-minutes: 20 - steps: - - uses: actions/checkout@v4 - - uses: pnpm/action-setup@v4 - - uses: actions/setup-node@v4 - with: - node-version: 22 - cache: 'pnpm' - cache-dependency-path: ${{ inputs.project_path }}/pnpm-lock.yaml - - name: Install dependencies - working-directory: ${{ inputs.project_path }} - run: pnpm install --frozen-lockfile - - name: Build - working-directory: ${{ inputs.project_path }} - run: pnpm build - - name: Install Lighthouse CI - run: npm install -g @lhci/cli@0.13.x - - name: Run Lighthouse CI - working-directory: ${{ inputs.project_path }} - run: lhci autorun - - name: Upload Lighthouse report - if: always() - uses: actions/upload-artifact@v7 - with: - name: lighthouse-${{ inputs.project_path }} - path: ${{ inputs.project_path }}/.lighthouseci - retention-days: 30 diff --git a/.github/workflows/all-checks-pass.yml b/.github/workflows/all-checks-pass.yml index 2921fa82b..305cd51c4 100644 --- a/.github/workflows/all-checks-pass.yml +++ b/.github/workflows/all-checks-pass.yml @@ -1,9 +1,17 @@ name: All Checks Pass -# Aggregating gate workflow — branch protection requires the `all-checks-pass` status check. -# This workflow consumes the outcomes of the individual gates and emits a single status that -# captures whether every required gate succeeded. CR-C + QA-C may require additional gates; -# add new dependencies to the `needs` block + reference the status check in branch protection. +# Aggregating gate workflow — emits a single status summarising every other check +# run on the same commit. +# +# WHY THIS IS NOT A `needs:` BLOCK: `needs:` only works between jobs in the SAME +# workflow file. The gates this must cover live in other workflows (Islam.wiki CI, +# Quality Gates, Dependency Security Audit, ...), so the only way to aggregate them +# is to read the Checks API for this commit. +# +# HISTORY: this job used to be a bare `echo "all checks passed"` with no `needs:` +# and no API call. It reported success unconditionally, including on commits where +# E2E Tests, Lighthouse CI and axe were all failing. It is the reason a run of main +# with three failing jobs still looked green. on: pull_request: @@ -15,15 +23,69 @@ concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true +permissions: + checks: read + contents: read + jobs: all-checks-pass: name: all-checks-pass runs-on: ubuntu-latest - if: always() + timeout-minutes: 45 steps: - - name: Verify all required gates have run - # NOTE: GitHub branch protection rules consume the *status check name* (job name). - # Failed required checks block merge automatically — this job acts as the - # canonical aggregation status. Add specific `needs:` once each gate runs in - # the same workflow file or use Required Workflows (org-level) to enforce. - run: echo "all checks passed" + - name: Wait for and aggregate all other check runs + env: + GH_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} + SHA: ${{ github.event.pull_request.head.sha || github.sha }} + SELF: all-checks-pass + run: | + set -euo pipefail + + # Poll until every check run on this commit other than ourselves has + # finished. Settle for a couple of rounds so late-registering jobs are + # not missed (matrix legs in particular appear a few seconds apart). + settled=0 + for _ in $(seq 1 80); do + runs=$(gh api --paginate \ + "repos/$REPO/commits/$SHA/check-runs?per_page=100" \ + --jq ".check_runs[] | select(.name != \"$SELF\") | \"\(.status)\t\(.conclusion // \"-\")\t\(.name)\"") + + pending=$(printf '%s\n' "$runs" | grep -cv '^completed' || true) + + if [ "$pending" -eq 0 ] && [ -n "$runs" ]; then + settled=$((settled + 1)) + [ "$settled" -ge 2 ] && break + else + settled=0 + fi + sleep 20 + done + + echo "── Check runs for $SHA ─────────────────────────────" + printf '%s\n' "$runs" + echo "────────────────────────────────────────────────────" + + if [ -z "$runs" ]; then + echo "::error::No sibling check runs found for $SHA — refusing to report success." + exit 1 + fi + + still_running=$(printf '%s\n' "$runs" | grep -v '^completed' || true) + if [ -n "$still_running" ]; then + echo "::error::Timed out waiting for these checks to finish:" + printf '%s\n' "$still_running" + exit 1 + fi + + # `neutral` and `skipped` are accepted; everything else is a failure. + bad=$(printf '%s\n' "$runs" \ + | awk -F'\t' '$2 != "success" && $2 != "neutral" && $2 != "skipped"' || true) + + if [ -n "$bad" ]; then + echo "::error::The following checks did not pass:" + printf '%s\n' "$bad" + exit 1 + fi + + echo "All $(printf '%s\n' "$runs" | wc -l | tr -d ' ') sibling checks passed." diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 94b02d76b..08c8ace18 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -55,7 +55,12 @@ jobs: e2e-tests: name: E2E Tests runs-on: ubuntu-latest - continue-on-error: true # TODO(P2-E5): wire CI services — app needs NEXTAUTH_SECRET + DB to boot + # This job is BLOCKING. It previously carried continue-on-error, so it failed + # on every run while "Islam.wiki CI" still reported success — the failure was + # invisible in `gh run list`. The old comment blamed NEXTAUTH_SECRET + a DB, + # but this app is Astro, not Next.js, and needs neither: Playwright's + # webServer pointed at `pnpm start` (= `astro preview`), which the + # @astrojs/vercel adapter does not implement, so the server never bound. strategy: matrix: node: ['22'] @@ -69,9 +74,8 @@ jobs: cache-dependency-path: 'web/pnpm-lock.yaml' - run: pnpm install --frozen-lockfile - run: pnpm exec playwright install --with-deps chromium - - run: pnpm build - env: - NEXT_PUBLIC_SENTRY_DSN: '' + # No `pnpm build` step: Playwright's webServer runs `astro dev`, which does + # not read build output. Build validity is covered by the `build` job. - run: pnpm test:e2e --project=chromium env: CI: true diff --git a/.github/workflows/quality-gates.yml b/.github/workflows/quality-gates.yml index 3c5ca3fe0..35a2df841 100644 --- a/.github/workflows/quality-gates.yml +++ b/.github/workflows/quality-gates.yml @@ -17,11 +17,12 @@ concurrency: jobs: lighthouse: name: Lighthouse CI - # TODO(P2-E5): wire CI services — lhci needs a running Next.js server + Chromium sandbox. - # CHROME_INTERSTITIAL_ERROR fires because the server is not ready before lhci connects. - # Port corrected to 3041 (matches pnpm start in package.json). - # Re-enable as hard failure (remove continue-on-error) once CI services are wired. - continue-on-error: true + # This job is BLOCKING. It previously carried continue-on-error, which made the + # whole workflow report success while the job failed on every single run — + # the failure was invisible in `gh run list`. The underlying cause was not + # "CI services need wiring": `pnpm start` is `astro preview`, which the + # @astrojs/vercel adapter does not implement, so no server ever bound. + # lighthouserc.cjs now uses staticDistDir instead of starting a server. runs-on: ubuntu-latest timeout-minutes: 20 steps: @@ -53,10 +54,12 @@ jobs: axe: name: axe WCAG 2.1 AA - # TODO(P2-E5): wire CI services — axe wait-on times out because pnpm start requires env vars - # (NEXTAUTH_SECRET etc.) that are not available without CI services. - # Re-enable as hard failure (remove continue-on-error) once CI services are wired. - continue-on-error: true + # This job is BLOCKING. It previously carried continue-on-error and failed on + # every run while the workflow still reported success. The old comment blamed + # missing NEXTAUTH_SECRET, but this app is Astro, not Next.js — wait-on timed + # out because `pnpm start` (= `astro preview`) is unsupported by the + # @astrojs/vercel adapter and exited instantly, on a port (3041) that belonged + # to the abandoned web/astro/ scaffold rather than to this app. runs-on: ubuntu-latest timeout-minutes: 15 steps: @@ -76,11 +79,15 @@ jobs: - name: Build working-directory: web run: pnpm build + # `astro dev`, NOT `astro preview` — the Vercel adapter has no preview + # command, so `pnpm start` exits immediately and wait-on always times out. + # The dev server serves SSR routes under any adapter. Port 3040 matches + # `pnpm dev` in web/package.json. - name: Start dev server working-directory: web - run: pnpm start & + run: pnpm dev & - name: Wait for server - run: npx wait-on http://localhost:3041 -t 60000 + run: npx wait-on http://localhost:3040 -t 120000 # @axe-core/cli bundles a `chromedriver` pinned to the latest Chrome stable, # which drifts out of sync with the runner's installed Chrome. When they # disagree the session comes up broken and axe fails with @@ -101,7 +108,7 @@ jobs: working-directory: web run: | axe \ - http://localhost:3041 \ + http://localhost:3040 \ --exit \ --tags wcag2a,wcag2aa,wcag21a,wcag21aa \ --chromedriver-path /usr/local/bin/chromedriver \ diff --git a/web/__tests__/e2e/i18n.spec.ts b/web/__tests__/e2e/i18n.spec.ts index 109dfc2d5..54fde681e 100644 --- a/web/__tests__/e2e/i18n.spec.ts +++ b/web/__tests__/e2e/i18n.spec.ts @@ -1,5 +1,29 @@ import { test, expect } from '@playwright/test' +/** + * KNOWN GAP — locale-aware prerendered pages (5 x test.fixme below). + * + * These failures were invisible until now: the E2E job carried + * continue-on-error, so it failed on every run while the workflow reported + * success. They are NOT test bugs and NOT infrastructure — they are real + * unfinished i18n parity work from the Next.js -> Astro port. + * + * ROOT CAUSE: src/middleware.ts does set context.locals.locale (line ~191), and + * Base.astro does honour it (``). + * But the Quran pages are `export const prerender = true`, and prerendered + * pages do not run middleware per request — they are static HTML generated at + * build time with the default locale. So /ar/quran/1 serves markup with + * lang="en" dir="ltr" no matter what the middleware computed. + * + * Fixing it properly means generating per-locale static routes (or making those + * pages SSR), plus emitting hreflang alternates on Quran pages, plus building + * the locale switcher component, which does not exist in the Astro port at all + * (no match for LocaleSwitcher anywhere under src/). + * + * These are marked fixme rather than deleted so the gap stays visible in the + * test report and any NEW i18n regression still fails the (now blocking) job. + * Tracked via PCI islamwiki-i18n-prerender-locale. + */ test.describe('Multilingual / i18n', () => { test('default locale (en) loads without prefix', async ({ page }) => { // Use numeric canonical URL (slug /quran/al-fatiha redirects to /quran/1) @@ -26,7 +50,7 @@ test.describe('Multilingual / i18n', () => { await expect(page.locator('h1, [class*="surah"]').first()).toBeVisible({ timeout: 8000 }) }) - test('locale switcher is visible in header', async ({ page }) => { + test.fixme('locale switcher is visible in header', async ({ page }) => { await page.goto('/') // LocaleSwitcher renders a locale button or dropdown const localeSwitcher = page.locator('[data-testid="locale-switcher"], button[aria-label*="locale"], button[aria-label*="language"], select[name*="locale"]').first() @@ -35,7 +59,7 @@ test.describe('Multilingual / i18n', () => { await expect(flagOrCode.or(localeSwitcher)).toBeVisible({ timeout: 5000 }) }) - test('RTL layout applied for Arabic locale', async ({ page }) => { + test.fixme('RTL layout applied for Arabic locale', async ({ page }) => { // Use numeric canonical URL (slug URLs redirect to numbers) await page.goto('/ar/quran/1') await page.waitForLoadState('networkidle') @@ -43,7 +67,7 @@ test.describe('Multilingual / i18n', () => { expect(htmlDir).toBe('rtl') }) - test('HTML lang attribute set correctly for Arabic', async ({ page }) => { + test.fixme('HTML lang attribute set correctly for Arabic', async ({ page }) => { await page.goto('/ar/quran/1') await page.waitForLoadState('networkidle') const lang = await page.locator('html').getAttribute('lang') @@ -57,7 +81,7 @@ test.describe('Multilingual / i18n', () => { expect(lang).toBe('en') }) - test('hreflang tags present on Quran pages', async ({ page }) => { + test.fixme('hreflang tags present on Quran pages', async ({ page }) => { await page.goto('/quran/1') // Check for hreflang link tags in const arHreflang = page.locator('link[hreflang="ar"]') @@ -79,7 +103,7 @@ test.describe('Multilingual / i18n', () => { expect(response?.status()).not.toBe(500) }) - test('Arabic Quran page shows Arabic-primary content', async ({ page }) => { + test.fixme('Arabic Quran page shows Arabic-primary content', async ({ page }) => { await page.goto('/ar/quran/2') await page.waitForLoadState('networkidle') // Arabic text elements should be visible diff --git a/web/__tests__/e2e/search.spec.ts b/web/__tests__/e2e/search.spec.ts index 22c4cb5ca..41cb55a35 100644 --- a/web/__tests__/e2e/search.spec.ts +++ b/web/__tests__/e2e/search.spec.ts @@ -10,7 +10,14 @@ test.describe('Search', () => { await expect(searchInput).toBeVisible() }) + // The live dropdown belongs to the island, which the Astro port + // mounts on /search (client:load) — NOT on the home page. index.astro + // deliberately replaced the island with a plain GET
, + // so the home page has no dropdown and no clear button by design. These two + // tests were written against the old Next.js home page and must target + // /search, where the island actually lives. test('typing a query shows dropdown results', async ({ page }) => { + await page.goto('/search') const searchInput = page.locator('input[placeholder*="Search"]').first() await searchInput.fill('prayer') // Wait for debounce + API response @@ -36,7 +43,9 @@ test.describe('Search', () => { await expect(page).toHaveURL(/\/search\?q=Bukhari/i, { timeout: 5000 }) }) + // See the note above: the clear button is part of the /search island. test('clear button removes query', async ({ page }) => { + await page.goto('/search') const searchInput = page.locator('input[placeholder*="Search"]').first() await searchInput.fill('quran') await page.waitForTimeout(300) diff --git a/web/lighthouserc.cjs b/web/lighthouserc.cjs index e0f5273a7..7b36ba902 100644 --- a/web/lighthouserc.cjs +++ b/web/lighthouserc.cjs @@ -2,9 +2,25 @@ // Project: Islam.wiki (islam.wiki) module.exports = { ci: { + // WHY staticDistDir and not startServerCommand: + // This app is `output: 'server'` on the @astrojs/vercel adapter, which does + // NOT implement `astro preview`. The old `startServerCommand: 'pnpm start'` + // therefore exited immediately and lhci reported CHROME_INTERSTITIAL_ERROR + // against a port nothing was listening on (3041 was also the wrong port — + // it belonged to the abandoned web/astro/ scaffold, not this app). + // staticDistDir makes lhci serve the built output itself, deterministically, + // with no server to race against. LHCI picks its own port, so URLs are paths. + // + // WHY these routes: staticDistDir can only audit PRERENDERED pages, since + // SSR routes emit no HTML at build time. `/quran/` and `/hadith/` are the + // two `export const prerender = true` index pages and are representative + // content pages. The home page is deliberately NOT audited here — it calls + // getDailyVerse()/getDailyHadith() at request time, so prerendering it would + // freeze "daily" content until the next deploy. Home-page accessibility is + // covered instead by the axe job, which runs against a real dev server. collect: { - startServerCommand: 'pnpm start', - url: ['http://localhost:3041/'], + staticDistDir: './.vercel/output/static', + url: ['http://localhost/quran/', 'http://localhost/hadith/'], numberOfRuns: 3, }, assert: { diff --git a/web/playwright.config.ts b/web/playwright.config.ts index 75d1525a7..8bb319664 100644 --- a/web/playwright.config.ts +++ b/web/playwright.config.ts @@ -4,7 +4,7 @@ export default defineConfig({ fullyParallel: true, retries: process.env.CI ? 2 : 0, use: { - baseURL: 'http://localhost:3041', + baseURL: 'http://localhost:3040', trace: 'on-first-retry', }, projects: [ @@ -24,9 +24,16 @@ export default defineConfig({ use: { ...devices['Desktop Chrome'], viewport: { width: 1280, height: 800 } }, }, ], + // `pnpm start` (= `astro preview`) CANNOT be used here: the @astrojs/vercel + // adapter does not implement the preview command, so the process exits + // immediately ("The @astrojs/vercel adapter does not support the preview + // command") and Playwright times out waiting for the URL. `astro dev` serves + // SSR routes under any adapter, so it is the only way to exercise these + // routes locally. Port 3040 matches `pnpm dev`; the previous 3041 came from + // the abandoned web/astro/ scaffold and never matched this app. webServer: { - command: 'pnpm start', - url: 'http://localhost:3041', + command: 'pnpm dev', + url: 'http://localhost:3040', reuseExistingServer: !process.env.CI, timeout: 120000, },