diff --git a/.github/workflows/README.md b/.github/workflows/README.md index 1b8b6af..bf8de7a 100644 --- a/.github/workflows/README.md +++ b/.github/workflows/README.md @@ -10,12 +10,12 @@ Workflows are split by responsibility. **Node.js 24** is used in CI to match [`S | Job | Purpose | |-----|---------| -| **Formatting** | Prettier (`pnpm run format:check`) | -| **Lint** | ESLint | +| **Format and lint** | Prettier (`pnpm run format:check`) then ESLint (one setup; runs **in parallel** with **Build**). | | **Build** | Single `pnpm run build`; uploads `SortVision/.next` as artifact `next-build` (no duplicate builds downstream). | | **Test** | After **Build**: `pnpm install` + restore `next-build` tarball, **`next start`**, `pnpm test`, PR QA comment + `qa-pr-comment` artifact. Runs **in parallel** with **Lighthouse**. | -| **Lighthouse** | After **Build**: install + download `next-build`, **`next start`**, mobile + desktop Lighthouse ([`lighthouserc.json`](../../SortVision/lighthouserc.json), [`lighthouserc.desktop.json`](../../SortVision/lighthouserc.desktop.json)); job summary via [`lighthouse-ci-summary.cjs`](../../SortVision/scripts/lighthouse-ci-summary.cjs). | -| **Production validation** | On `main` / `master` only, after **Test** and **Lighthouse**: production smoke tests and HTTP checks | +| **Lighthouse** | After **Build**: install + download `next-build`, **`next start`**, mobile + desktop Lighthouse ([`lighthouserc.json`](../../SortVision/lighthouserc.json), [`lighthouserc.desktop.json`](../../SortVision/lighthouserc.desktop.json)); uploads `lighthouse-manifest-{mobile,desktop}` (manifest after the treosh action) for the summary job. | +| **Lighthouse summary** | Merges mobile/desktop manifests, **job summary** + **PR comment** (same pattern as QA), gates Lighthouse. | +| **Production validation** | On `main` / `master` only, after **Test** and **Lighthouse** (+ summary): production smoke tests and HTTP checks | Shared setup: [`setup-sortvision`](../actions/setup-sortvision/action.yml) (pnpm, Node, `pnpm install`). Consumer jobs use [`restore-next-build`](../actions/restore-next-build/action.yml) after **Build** to unpack `next-build.tar.gz` into `SortVision/.next`. diff --git a/.github/workflows/continuous-integration.yml b/.github/workflows/continuous-integration.yml index b2b7298..4533c0f 100644 --- a/.github/workflows/continuous-integration.yml +++ b/.github/workflows/continuous-integration.yml @@ -37,8 +37,9 @@ defaults: working-directory: ./SortVision jobs: - formatting: - name: Formatting + # One checkout + install for both checks (faster than separate Formatting + Lint jobs). + format-lint: + name: Format and lint runs-on: ubuntu-latest timeout-minutes: 15 steps: @@ -53,26 +54,12 @@ jobs: - name: Prettier (check) run: pnpm run format:check - lint: - name: Lint - runs-on: ubuntu-latest - timeout-minutes: 15 - steps: - - name: Checkout repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 (node24 runtime; fewer forced-runtime warnings) - - - name: Setup SortVision - uses: ./.github/actions/setup-sortvision - with: - node-version: ${{ env.NODE_VERSION }} - - name: ESLint run: pnpm run lint - # Single `next build`; consumers run in parallel with `next start` + tests or Lighthouse. + # Runs in parallel with format-lint so wall time is max(static, build), not sum (update branch rules if you required separate "Formatting" / "Lint" checks). build: name: Build - needs: [formatting, lint] runs-on: ubuntu-latest timeout-minutes: 20 steps: @@ -190,11 +177,22 @@ jobs: path: SortVision/.qa-pr-comment/ include-hidden-files: true + # Mobile + desktop run in parallel (matrix); combined table + gate in lighthouse-summary. lighthouse: - name: Lighthouse + name: Lighthouse (${{ matrix.variant }}) needs: [build] runs-on: ubuntu-latest timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + include: + - variant: mobile + config: lighthouserc.json + artifact: lighthouse-results-mobile + - variant: desktop + config: lighthouserc.desktop.json + artifact: lighthouse-results-desktop steps: - name: Checkout repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 (node24 runtime; fewer forced-runtime warnings) @@ -214,12 +212,10 @@ jobs: npx wait-on http://localhost:3000 --timeout 120000 sleep 2 - - name: Lighthouse (mobile) - id: lighthouse-mobile - continue-on-error: true + - name: Lighthouse (${{ matrix.variant }}) uses: treosh/lighthouse-ci-action@3e7e23fb74242897f95c0ba9cabad3d0227b9b18 # v12 with: - configPath: ./SortVision/lighthouserc.json + configPath: ./SortVision/${{ matrix.config }} runs: 1 urls: | http://localhost:3000 @@ -227,38 +223,91 @@ jobs: http://localhost:3000/es http://localhost:3000/contributions/overview uploadArtifacts: true + artifactName: ${{ matrix.artifact }} temporaryPublicStorage: true - - name: Lighthouse (desktop) - id: lighthouse-desktop - continue-on-error: true - uses: treosh/lighthouse-ci-action@3e7e23fb74242897f95c0ba9cabad3d0227b9b18 # v12 + # treosh uploads GitHub artifacts before `lhci upload --target=filesystem` writes manifest.json, so the zip + # from uploadArtifacts does not include it. Upload manifest after the action for lighthouse-summary / PR comment. + - name: Upload Lighthouse manifest (for summary) + uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3 with: - configPath: ./SortVision/lighthouserc.desktop.json - runs: 1 - urls: | - http://localhost:3000 - http://localhost:3000/algorithms/config/bubble - http://localhost:3000/es - http://localhost:3000/contributions/overview - uploadArtifacts: true - temporaryPublicStorage: true + name: lighthouse-manifest-${{ matrix.variant }} + path: .lighthouseci/manifest.json + if-no-files-found: error + retention-days: 7 - - name: Print Lighthouse scores (job summary) - if: always() + lighthouse-summary: + name: Lighthouse summary + needs: [lighthouse] + # always(): run after matrix success or failure (still need manifest download + gate); skip if matrix never ran. + if: ${{ always() && !cancelled() && needs.lighthouse.result != 'skipped' }} + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 (node24 runtime; fewer forced-runtime warnings) + + - name: Download Lighthouse manifests (mobile) + uses: actions/download-artifact@95815c38cf2ff2164869cbab79da8d1f422bc89e # v4.2.1 + with: + name: lighthouse-manifest-mobile + path: SortVision/lighthouse-mobile + if-no-files-found: ignore + + - name: Download Lighthouse manifests (desktop) + uses: actions/download-artifact@95815c38cf2ff2164869cbab79da8d1f422bc89e # v4.2.1 + with: + name: lighthouse-manifest-desktop + path: SortVision/lighthouse-desktop + if-no-files-found: ignore + + - name: Print Lighthouse scores (job summary + PR comment file) + working-directory: SortVision env: - MANIFEST_MOBILE: ${{ steps.lighthouse-mobile.outputs.manifest }} - MANIFEST_DESKTOP: ${{ steps.lighthouse-desktop.outputs.manifest }} + MANIFEST_MOBILE_PATH: ${{ github.workspace }}/SortVision/lighthouse-mobile/manifest.json + MANIFEST_DESKTOP_PATH: ${{ github.workspace }}/SortVision/lighthouse-desktop/manifest.json + LIGHTHOUSE_COMMENT_DIR: ${{ github.event.pull_request && format('{0}/SortVision/.lighthouse-pr-comment', github.workspace) || '' }} run: node scripts/lighthouse-ci-summary.cjs + - name: Prepare Lighthouse PR comment (fallback) + if: github.event_name == 'pull_request' && always() + run: | + DIR="${GITHUB_WORKSPACE}/SortVision/.lighthouse-pr-comment" + mkdir -p "$DIR" + if [ ! -s "$DIR/comment.md" ]; then + { + echo '' + echo '### Lighthouse (CI)' + echo '' + echo '**Result:** Score table was not written (early failure). See **Lighthouse summary** job logs.' + echo '' + echo "[View run](${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID})" + } > "$DIR/comment.md" + fi + + - name: Find previous Lighthouse comment + id: find_lighthouse_comment + if: github.event_name == 'pull_request' && always() + uses: peter-evans/find-comment@b30e6a3c0ed37e7c023ccd3f1db5c6c0b0c23aad # v4 + with: + issue-number: ${{ github.event.pull_request.number }} + body-includes: '' + + - name: Post Lighthouse report to pull request + if: github.event_name == 'pull_request' && always() + uses: peter-evans/create-or-update-comment@e8674b075228eee787fea43ef493e45ece1004c9 # v5 + with: + comment-id: ${{ steps.find_lighthouse_comment.outputs.comment-id }} + issue-number: ${{ github.event.pull_request.number }} + body-path: SortVision/.lighthouse-pr-comment/comment.md + edit-mode: replace + - name: Require Lighthouse audits to pass if: always() run: | - echo "Lighthouse (mobile): ${{ steps.lighthouse-mobile.outcome }}" - echo "Lighthouse (desktop): ${{ steps.lighthouse-desktop.outcome }}" - if [ "${{ steps.lighthouse-mobile.outcome }}" != "success" ] || \ - [ "${{ steps.lighthouse-desktop.outcome }}" != "success" ]; then - echo "::error::One or more Lighthouse runs failed (see steps above and job summary)." + echo "Lighthouse matrix: ${{ needs.lighthouse.result }}" + if [ "${{ needs.lighthouse.result }}" != "success" ]; then + echo "::error::One or more Lighthouse runs failed (see Lighthouse (mobile) / (desktop) logs and job summary)." exit 1 fi @@ -266,7 +315,7 @@ jobs: name: Production validation runs-on: ubuntu-latest if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/master' - needs: [test, lighthouse] + needs: [test, lighthouse, lighthouse-summary] timeout-minutes: 10 defaults: run: diff --git a/SortVision/lighthouserc.desktop.json b/SortVision/lighthouserc.desktop.json index 6782414..3d0626b 100644 --- a/SortVision/lighthouserc.desktop.json +++ b/SortVision/lighthouserc.desktop.json @@ -5,14 +5,6 @@ "settings": { "preset": "desktop" } - }, - "assert": { - "assertions": { - "categories:accessibility": ["warn", { "minScore": 0.85 }], - "categories:seo": ["warn", { "minScore": 0.85 }], - "categories:best-practices": ["warn", { "minScore": 0.8 }], - "categories:performance": ["warn", { "minScore": 0.75 }] - } } } } diff --git a/SortVision/lighthouserc.json b/SortVision/lighthouserc.json index 10dfc79..437be75 100644 --- a/SortVision/lighthouserc.json +++ b/SortVision/lighthouserc.json @@ -2,14 +2,6 @@ "ci": { "collect": { "numberOfRuns": 1 - }, - "assert": { - "assertions": { - "categories:accessibility": ["warn", { "minScore": 0.85 }], - "categories:seo": ["warn", { "minScore": 0.85 }], - "categories:best-practices": ["warn", { "minScore": 0.8 }], - "categories:performance": ["warn", { "minScore": 0.75 }] - } } } } diff --git a/SortVision/scripts/lighthouse-ci-summary.cjs b/SortVision/scripts/lighthouse-ci-summary.cjs index 2abaa5c..90f0b87 100644 --- a/SortVision/scripts/lighthouse-ci-summary.cjs +++ b/SortVision/scripts/lighthouse-ci-summary.cjs @@ -1,37 +1,54 @@ #!/usr/bin/env node /** - * Appends Lighthouse category scores to GITHUB_STEP_SUMMARY (mobile + desktop manifests from LHCI action). + * Lighthouse category scores: optional GITHUB_STEP_SUMMARY, optional PR comment file (CI). */ const fs = require('fs'); +const path = require('path'); -const out = process.env.GITHUB_STEP_SUMMARY; -if (!out) { +const summaryPath = process.env.GITHUB_STEP_SUMMARY; +const commentDir = process.env.LIGHTHOUSE_COMMENT_DIR; + +if (!summaryPath && !commentDir) { process.exit(0); } +/** Prefer file path (parallel CI jobs upload manifests); fall back to inline JSON from the same job. */ +function loadManifest(envJsonKey, envPathKey) { + const p = process.env[envPathKey]; + if (p) { + try { + if (fs.existsSync(p)) { + return fs.readFileSync(p, 'utf8'); + } + } catch { + /* ignore */ + } + } + return process.env[envJsonKey] || ''; +} + function pct(n) { if (n == null || Number.isNaN(n)) return '—'; return `${Math.round(Number(n) * 100)}`; } -function table(title, manifestJson) { +/** Markdown table cell: escape backslashes first, then pipes (predictable table parsing). */ +function escapeMarkdownTableCell(s) { + return String(s).replace(/\\/g, '\\\\').replace(/\|/g, '\\|'); +} + +function tableMarkdown(title, manifestJson) { const lines = [`### ${title}`, '']; let rows; try { rows = JSON.parse(manifestJson || '[]'); } catch { lines.push('_Could not parse Lighthouse manifest._', ''); - const text = lines.join('\n'); - console.log(text); - fs.appendFileSync(out, text + '\n', 'utf8'); - return; + return lines.join('\n'); } if (!Array.isArray(rows) || rows.length === 0) { lines.push('_No manifest rows (run may have failed before collection)._', ''); - const text = lines.join('\n'); - console.log(text); - fs.appendFileSync(out, text + '\n', 'utf8'); - return; + return lines.join('\n'); } lines.push( @@ -41,17 +58,41 @@ function table(title, manifestJson) { for (const r of rows) { const s = r.summary || {}; - const url = String(r.url || '—').replace(/\|/g, '\\|'); + const url = escapeMarkdownTableCell(r.url || '—'); const bp = s['best-practices'] ?? s.bestPractices; lines.push( `| ${url} | ${pct(s.performance)} | ${pct(s.accessibility)} | ${pct(bp)} | ${pct(s.seo)} |` ); } lines.push(''); - const text = lines.join('\n'); - console.log(text); - fs.appendFileSync(out, text + '\n', 'utf8'); + return lines.join('\n'); +} + +function buildReportBody() { + const mobile = loadManifest('MANIFEST_MOBILE', 'MANIFEST_MOBILE_PATH'); + const desktop = loadManifest('MANIFEST_DESKTOP', 'MANIFEST_DESKTOP_PATH'); + return [tableMarkdown('Lighthouse (mobile)', mobile), tableMarkdown('Lighthouse (desktop)', desktop)].join( + '\n' + ); } -table('Lighthouse (mobile)', process.env.MANIFEST_MOBILE); -table('Lighthouse (desktop)', process.env.MANIFEST_DESKTOP); +const body = buildReportBody(); +console.log(body); + +if (summaryPath) { + fs.appendFileSync(summaryPath, `${body}\n`, 'utf8'); +} + +if (commentDir) { + fs.mkdirSync(commentDir, { recursive: true }); + const runUrl = + process.env.GITHUB_SERVER_URL && process.env.GITHUB_REPOSITORY && process.env.GITHUB_RUN_ID + ? `${process.env.GITHUB_SERVER_URL}/${process.env.GITHUB_REPOSITORY}/actions/runs/${process.env.GITHUB_RUN_ID}` + : ''; + const headerLines = ['', '### Lighthouse (CI)', '']; + if (runUrl) { + headerLines.push(`[View workflow run](${runUrl})`, ''); + } + const text = `${headerLines.join('\n')}${body}\n`; + fs.writeFileSync(path.join(commentDir, 'comment.md'), text, 'utf8'); +} diff --git a/SortVision/tests/quality-assurance.mjs b/SortVision/tests/quality-assurance.mjs index 56976c4..5333ed4 100644 --- a/SortVision/tests/quality-assurance.mjs +++ b/SortVision/tests/quality-assurance.mjs @@ -73,7 +73,8 @@ const PERF_THRESHOLDS = { // Test configuration const ALGORITHMS = ['bubble', 'insertion', 'selection', 'merge', 'quick', 'heap', 'radix', 'bucket']; -const LANGUAGES = ['en', 'es', 'hi', 'fr', 'de', 'zh', 'ja', 'pt']; +// Must match `middleware.js` supportedLanguages and the in-app language selector (no Portuguese). +const LANGUAGES = ['en', 'zh', 'hi', 'es', 'bn', 'fr', 'de', 'ja']; const TABS = ['config', 'details', 'metrics']; // Results tracking @@ -158,8 +159,13 @@ async function writePrCommentReport({ duration, grade }) { if (failedTestDetails.length > 0) { lines.push('
Failed tests (first 15)', '', '| Test | Details |', '|------|---------|'); for (const { name, details } of failedTestDetails.slice(0, 15)) { - const safeName = String(name).replace(/\|/g, '\\|'); - const safeDetails = String(details || '').replace(/\|/g, '\\|').slice(0, 200); + const safeName = String(name) + .replace(/\\/g, '\\\\') + .replace(/\|/g, '\\|'); + const safeDetails = String(details || '') + .replace(/\\/g, '\\\\') + .replace(/\|/g, '\\|') + .slice(0, 200); lines.push(`| ${safeName} | ${safeDetails} |`); } lines.push('', '
', ''); @@ -518,16 +524,20 @@ async function testURL(url, expectedStatus, options = {}) { // Quick validation (30 tests) async function runQuickValidation() { - logSection('Quick Validation Suite (30 Tests)'); + logSection('Quick Validation Suite (~32 tests)'); const tests = []; // Core pages tests.push(testURL(`${BASE_URL}/`, 200, { name: 'Homepage', checkSEO: true, checkContent: true })); tests.push(testURL(`${BASE_URL}/en`, 200, { name: 'English homepage', checkSEO: true })); + tests.push(testURL(`${BASE_URL}/zh`, 200, { name: 'Chinese homepage', checkSEO: true })); + tests.push(testURL(`${BASE_URL}/hi`, 200, { name: 'Hindi homepage', checkSEO: true })); tests.push(testURL(`${BASE_URL}/es`, 200, { name: 'Spanish homepage', checkSEO: true })); + tests.push(testURL(`${BASE_URL}/bn`, 200, { name: 'Bengali homepage', checkSEO: true })); tests.push(testURL(`${BASE_URL}/fr`, 200, { name: 'French homepage', checkSEO: true })); tests.push(testURL(`${BASE_URL}/de`, 200, { name: 'German homepage', checkSEO: true })); + tests.push(testURL(`${BASE_URL}/ja`, 200, { name: 'Japanese homepage', checkSEO: true })); // Algorithm pages with canonicals tests.push(testURL(`${BASE_URL}/algorithms/config/bubble`, 200, { @@ -558,11 +568,10 @@ async function runQuickValidation() { const canonical = lang === 'en' ? `${CANONICAL_BASE}/algorithms/config/${algo}` : `${CANONICAL_BASE}/${lang}/algorithms/config/${algo}`; - const skipCanonical = lang === 'pt'; // PT not fully supported - + tests.push(testURL(`${BASE_URL}/${lang}/algorithms/${tab}/${algo}`, 200, { name: `${lang.toUpperCase()}/${algo}/${tab}`, - checkCanonical: !skipCanonical, + checkCanonical: true, canonicalUrl: canonical })); } @@ -601,15 +610,13 @@ async function runComprehensiveValidation() { for (const algo of ALGORITHMS) { for (const tab of TABS) { // English uses /algorithms/config/algo (no /en prefix as it's default) - // Portuguese not fully supported yet, skip canonical check - const canonicalUrl = lang === 'en' ? + const canonicalUrl = lang === 'en' ? `${CANONICAL_BASE}/algorithms/config/${algo}` : `${CANONICAL_BASE}/${lang}/algorithms/config/${algo}`; - const skipCanonical = lang === 'pt'; // PT not fully supported - + tests.push(testURL(`${BASE_URL}/${lang}/algorithms/${tab}/${algo}`, 200, { name: `${lang}/${algo}/${tab}`, - checkCanonical: !skipCanonical, + checkCanonical: true, canonicalUrl: canonicalUrl })); } @@ -657,11 +664,10 @@ async function runIntegrationSuite() { const canonicalUrl = lang === 'en' ? `${CANONICAL_BASE}/algorithms/config/${algo}` : `${CANONICAL_BASE}/${lang}/algorithms/config/${algo}`; - const skipCanonical = lang === 'pt'; - + tests.push(testURL(`${BASE_URL}/${lang}/algorithms/config/${algo}`, 200, { name: `SEO: ${lang}/${algo}`, - checkCanonical: !skipCanonical, + checkCanonical: true, canonicalUrl: canonicalUrl, checkSEO: true, checkContent: true @@ -771,18 +777,6 @@ async function runProductionTests() { tests.push(testURL(`${BASE_URL}/manifest.json`, 200, { name: 'Prod: Manifest' })); for (const lang of LANGUAGES) { - // PT is not fully supported in production yet; it may redirect. - if (lang === 'pt') { - tests.push( - testURL(`${BASE_URL}/${lang}`, [200, 301, 308], { - name: 'Prod: pt (redirect ok)', - checkSEO: false, - checkContent: false, - }) - ); - continue; - } - tests.push( testURL(`${BASE_URL}/${lang}`, 200, { name: `Prod: ${lang}`, @@ -803,7 +797,7 @@ async function runProductionTests() { } } - for (const lang of ['es', 'fr', 'de', 'zh']) { + for (const lang of LANGUAGES.filter((l) => l !== 'en')) { for (const algo of ALGORITHMS) { tests.push(testURL(`${BASE_URL}/${lang}/algorithms/config/${algo}`, 200, { name: `Prod: ${lang}/${algo}`,