Tests #9251
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Tests | |
| on: | |
| push: | |
| branches: ['**'] | |
| pull_request: | |
| branches: [main, dev, develop] | |
| workflow_dispatch: | |
| # Cancel in-progress runs on the same PR / branch when a newer commit lands. | |
| concurrency: | |
| group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} | |
| cancel-in-progress: true | |
| permissions: | |
| contents: read | |
| # `checks: write` lets dorny/test-reporter publish per-PR check | |
| # annotations from the JUnit XML below. | |
| checks: write | |
| jobs: | |
| # Prettier formatting + content-link validation. Fast, no node_modules | |
| # needed beyond what setup-node cache restores. | |
| # (Renamed from "lint" — the job never actually ran a linter; ESLint now | |
| # lives in its own advisory job below.) | |
| format: | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v4 | |
| with: | |
| submodules: true | |
| token: ${{ secrets.SUBMODULE_TOKEN }} | |
| - uses: pnpm/action-setup@v4 | |
| - uses: actions/setup-node@v4 | |
| with: | |
| node-version: '20' | |
| cache: 'pnpm' | |
| - run: pnpm install --frozen-lockfile | |
| - name: Check formatting | |
| run: pnpm prettier --check . | |
| # Validates content-submodule integrity. BLOCKING — runs the | |
| # 11-pass verify-content suite, including: | |
| # • Pass 1: every /...link in published blog/country/etc. markdown | |
| # resolves to a static route | |
| # • Pass 3: every href in the homepage footer-manifest resolves | |
| # • Pass 11: every URL `sitemap.ts` would emit has a matching | |
| # static route AND its data source is non-empty (catches the | |
| # May 2026 SEO-loader regression where `generateStaticParams` | |
| # returned [] and silently 404-ed ~625 SEO pages). | |
| - name: Validate sitemap, footer, and blog links | |
| run: pnpm validate-links | |
| # Advisory ESLint pass. Allowed to fail without blocking merge while the | |
| # codebase is brought up to baseline. Not in ci-success.needs so a red | |
| # eslint job doesn't gate the PR. Promote to blocking once errors are at | |
| # zero (or after team decides a warn floor). | |
| eslint: | |
| runs-on: ubuntu-latest | |
| continue-on-error: true | |
| steps: | |
| - uses: actions/checkout@v4 | |
| with: | |
| submodules: true | |
| token: ${{ secrets.SUBMODULE_TOKEN }} | |
| - uses: pnpm/action-setup@v4 | |
| - uses: actions/setup-node@v4 | |
| with: | |
| node-version: '20' | |
| cache: 'pnpm' | |
| - run: pnpm install --frozen-lockfile | |
| - name: Run ESLint | |
| run: pnpm lint | |
| # Typecheck job. Independent of unit tests so type errors surface in | |
| # parallel. | |
| typecheck: | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v4 | |
| with: | |
| submodules: true | |
| token: ${{ secrets.SUBMODULE_TOKEN }} | |
| - uses: pnpm/action-setup@v4 | |
| - uses: actions/setup-node@v4 | |
| with: | |
| node-version: '20' | |
| cache: 'pnpm' | |
| - run: pnpm install --frozen-lockfile | |
| # Generate Next.js route + asset type declarations without a full | |
| # build. tsconfig includes .next/types/**/*.ts; without these, | |
| # next-env.d.ts's image-types references can't resolve and every | |
| # static-asset import (.svg/.gif/.webp) cascades into TS2307. | |
| - name: Generate Next types | |
| run: pnpm next typegen | |
| - name: Typecheck | |
| run: pnpm typecheck | |
| unit: | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v4 | |
| with: | |
| submodules: true | |
| token: ${{ secrets.SUBMODULE_TOKEN }} | |
| - uses: pnpm/action-setup@v4 | |
| - uses: actions/setup-node@v4 | |
| with: | |
| node-version: '20' | |
| cache: 'pnpm' | |
| - run: pnpm install --frozen-lockfile | |
| - name: Generate Next types | |
| run: pnpm next typegen | |
| # Fetch the canonical 49-entry HistoryEntry baseline from the BE | |
| # repo. Living source-of-truth = peanut-api-ts/test/integration/ | |
| # __fixtures__/history-real-staging.jsonl. We don't vendor a copy | |
| # in this repo because it bloats every FE diff by ~3.7K lines. | |
| # PR base branch tracks the BE branch the FE is coordinated with | |
| # (dev when targeting dev; the paired BE PR's HEAD when in flight). | |
| - name: Fetch BE render-snapshot baseline | |
| env: | |
| GH_TOKEN: ${{ secrets.PEANUT_ALL_READ_TOKEN }} | |
| run: | | |
| BE_REF="${{ github.base_ref || 'dev' }}" | |
| gh api "repos/peanutprotocol/peanut-api-ts/contents/test/integration/__fixtures__/history-real-staging.jsonl?ref=${BE_REF}" \ | |
| --jq '.content' | base64 -d > /tmp/be-baseline.jsonl | |
| node scripts/import-be-baseline.mjs /tmp/be-baseline.jsonl \ | |
| src/components/TransactionDetails/__tests__/fixtures/be-entries.json | |
| - name: Run unit tests | |
| run: pnpm test:unit:ci | |
| - name: Publish unit test report | |
| uses: dorny/test-reporter@v1 | |
| if: always() | |
| with: | |
| name: Unit test report | |
| path: test-results/junit.xml | |
| reporter: jest-junit | |
| fail-on-error: false | |
| - name: Upload unit coverage | |
| if: always() | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: coverage-unit | |
| path: coverage/coverage-final.json | |
| retention-days: 7 | |
| - name: Upload unit JUnit XML | |
| if: always() | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: junit-unit | |
| path: test-results/junit.xml | |
| retention-days: 7 | |
| # Playwright e2e — advisory until TEST_HARNESS_SECRET + harness API | |
| # reachability are wired in repo settings (per the prior comment). | |
| e2e: | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v4 | |
| with: | |
| submodules: true | |
| token: ${{ secrets.SUBMODULE_TOKEN }} | |
| - uses: pnpm/action-setup@v4 | |
| - uses: actions/setup-node@v4 | |
| with: | |
| node-version: '20' | |
| cache: 'pnpm' | |
| - run: pnpm install --frozen-lockfile | |
| - name: Install Playwright browsers | |
| run: npx playwright install --with-deps chromium | |
| - name: Run E2E tests | |
| run: pnpm test:e2e | |
| continue-on-error: true | |
| - name: Upload Playwright report | |
| if: always() | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: playwright-report | |
| path: playwright-report/ | |
| retention-days: 7 | |
| # Coverage + test-summary aggregator. Mirrors the pattern from | |
| # peanut-api-ts/.github/workflows/tests.yaml `report` job. | |
| report: | |
| runs-on: ubuntu-latest | |
| # Wait for format + typecheck + e2e too — the report header says | |
| # "all green", which would lie if only unit results were | |
| # considered (per CR feedback on PR #1908). | |
| needs: [format, typecheck, unit, e2e] | |
| if: always() | |
| permissions: | |
| contents: read | |
| pull-requests: write | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - uses: actions/setup-node@v4 | |
| with: | |
| node-version: '20' | |
| - name: Download unit coverage | |
| uses: actions/download-artifact@v4 | |
| continue-on-error: true | |
| with: | |
| name: coverage-unit | |
| path: coverage-raw/unit | |
| - name: Download unit JUnit | |
| uses: actions/download-artifact@v4 | |
| continue-on-error: true | |
| with: | |
| name: junit-unit | |
| path: junit-raw/unit | |
| - name: Summarize | |
| id: report | |
| run: | | |
| set -euo pipefail | |
| mkdir -p coverage-merged coverage-input | |
| [[ -f coverage-raw/unit/coverage-final.json ]] && cp coverage-raw/unit/coverage-final.json coverage-input/unit.json || echo "no unit coverage" | |
| if ls coverage-input/*.json >/dev/null 2>&1; then | |
| npx --yes nyc@15 merge coverage-input coverage-merged/coverage-final.json | |
| npx --yes nyc@15 report --reporter=json-summary --temp-dir=coverage-merged --report-dir=coverage-merged | |
| else | |
| echo '{"total":{"statements":{"pct":0},"branches":{"pct":0},"functions":{"pct":0},"lines":{"pct":0}}}' > coverage-merged/coverage-summary.json | |
| fi | |
| node -e " | |
| const fs = require('fs'); | |
| let cov = { total: { statements:{pct:0}, branches:{pct:0}, functions:{pct:0}, lines:{pct:0} } }; | |
| try { cov = JSON.parse(fs.readFileSync('coverage-merged/coverage-summary.json','utf8')); } catch {} | |
| function decode(s) { | |
| return s.replaceAll('&','&').replaceAll('<','<').replaceAll('>','>').replaceAll('"','\"').replaceAll(''','\\''); | |
| } | |
| function parseJunit(p, label) { | |
| try { | |
| const xml = fs.readFileSync(p,'utf8'); | |
| const tests = parseInt((xml.match(/<testsuites [^>]*tests=\"(\d+)\"/) || [])[1] || '0', 10); | |
| const failures = parseInt((xml.match(/<testsuites [^>]*failures=\"(\d+)\"/) || [])[1] || '0', 10); | |
| const skipped = parseInt((xml.match(/<testsuites [^>]*skipped=\"(\d+)\"/) || [])[1] || '0', 10); | |
| const time = parseFloat((xml.match(/<testsuites [^>]*time=\"([\d.]+)\"/) || [])[1] || '0'); | |
| const cases = []; | |
| for (const m of xml.matchAll(/<testsuite name=\"([^\"]+)\"[^>]*>([\\s\\S]*?)<\\/testsuite>/g)) { | |
| const file = decode(m[1]); | |
| for (const tc of m[2].matchAll(/<testcase[^>]*classname=\"([^\"]*)\"[^>]*name=\"([^\"]+)\"[^>]*time=\"([\d.]+)\"[^>]*(\\/>|>([\\s\\S]*?)<\\/testcase>)/g)) { | |
| const failed = tc[5] && /<failure/.test(tc[5]); | |
| cases.push({ file, project: label, suite: decode(tc[1]), name: decode(tc[2]), time: parseFloat(tc[3]), failed: !!failed }); | |
| } | |
| } | |
| return { tests, failures, skipped, time, cases }; | |
| } catch { return null; } | |
| } | |
| const unit = parseJunit('junit-raw/unit/junit.xml','unit'); | |
| const allCases = unit?.cases || []; | |
| const failed = allCases.filter(c => c.failed); | |
| const slowest = [...allCases].sort((a,b) => b.time - a.time).slice(0, 10); | |
| fs.writeFileSync('report-payload.json', JSON.stringify({ cov, unit, failed, slowest }, null, 2)); | |
| " | |
| - name: Comment on PR | |
| if: github.event_name == 'pull_request' | |
| uses: actions/github-script@v7 | |
| with: | |
| script: | | |
| const fs = require('fs'); | |
| const p = JSON.parse(fs.readFileSync('report-payload.json','utf8')); | |
| const pct = (n) => (n == null ? '—' : `${n.toFixed(1)}%`); | |
| const dur = (n) => (n == null ? '—' : n >= 60 ? `${(n/60).toFixed(1)}m` : `${n.toFixed(1)}s`); | |
| const status = p.failed.length === 0 ? '✅ all green' : `🔴 ${p.failed.length} failing`; | |
| const suiteLine = (label, s) => { | |
| if (!s) return `- **${label}**: skipped / unavailable`; | |
| const icon = s.failures > 0 ? '🔴' : '✅'; | |
| return `- ${icon} **${label}**: ${s.tests - s.skipped} ran, ${s.failures} failed, ${s.skipped} skipped, ${dur(s.time)}`; | |
| }; | |
| const failedBlock = p.failed.length === 0 ? '' : [ | |
| '### 🔴 Failing tests', | |
| '', | |
| ...p.failed.map(c => `- \`${c.file}\` › ${c.suite ? c.suite + ' › ' : ''}**${c.name}** — ${c.time.toFixed(2)}s`), | |
| '', | |
| ].join('\n'); | |
| const slowBlock = p.slowest.length === 0 ? '' : [ | |
| '<details><summary>⏱ 10 slowest test cases</summary>', | |
| '', | |
| '| time | test |', | |
| '| ---: | --- |', | |
| ...p.slowest.map(c => `| ${c.time >= 5 ? '🐢 ' : ''}${dur(c.time)} | \`${c.file}\` › ${c.name} |`), | |
| '', | |
| '</details>', | |
| ].join('\n'); | |
| const covBlock = [ | |
| '### 📊 Coverage (unit)', | |
| '', | |
| '| metric | % |', | |
| '| --- | ---: |', | |
| `| statements | ${pct(p.cov.total.statements?.pct)} |`, | |
| `| branches | ${pct(p.cov.total.branches?.pct)} |`, | |
| `| functions | ${pct(p.cov.total.functions?.pct)} |`, | |
| `| lines | ${pct(p.cov.total.lines?.pct)} |`, | |
| ].join('\n'); | |
| const body = [ | |
| '<!-- ui-test-report -->', | |
| `## 🧪 UI test report — ${status}`, | |
| '', | |
| '### Suites', | |
| suiteLine('unit', p.unit), | |
| '', | |
| failedBlock, | |
| covBlock, | |
| '', | |
| slowBlock, | |
| '', | |
| '<sub>📍 Inline annotations are in the **Unit test report** check above. Coverage artifact: `coverage-unit`. Generated by `.github/workflows/tests.yml`.</sub>', | |
| ].filter(Boolean).join('\n'); | |
| const tag = '<!-- ui-test-report -->'; | |
| // Paginate so the idempotent-update lookup doesn't break once | |
| // a PR accumulates >30 comments (CR feedback on #1908). | |
| const comments = await github.paginate(github.rest.issues.listComments, { | |
| issue_number: context.issue.number, | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| per_page: 100, | |
| }); | |
| const existing = comments.find(c => c.body && c.body.includes(tag)); | |
| if (existing) { | |
| await github.rest.issues.updateComment({ | |
| comment_id: existing.id, | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| body, | |
| }); | |
| } else { | |
| await github.rest.issues.createComment({ | |
| issue_number: context.issue.number, | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| body, | |
| }); | |
| } | |
| # Single umbrella check that aggregates all required test/quality jobs. | |
| # Branch protection on `dev`/`main` requires only `ci-success` instead of | |
| # listing every individual job — keeps the ruleset stable as jobs are | |
| # added/renamed. To gate a new job: add it to `needs:` here. | |
| # | |
| # `if: always()` so this runs even when an upstream fails. Explicit | |
| # `failure || cancelled` check because GitHub treats `skipped` as neutral | |
| # (an upstream skip would silently let this pass without the explicit gate). | |
| ci-success: | |
| name: ci-success | |
| if: always() | |
| needs: [format, typecheck, unit, e2e, report] | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Verify all required jobs passed | |
| run: | | |
| if [[ "${{ contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') }}" == "true" ]]; then | |
| echo "::error::One or more required jobs failed or were cancelled" | |
| echo "Job results:" | |
| echo " format: ${{ needs.format.result }}" | |
| echo " typecheck: ${{ needs.typecheck.result }}" | |
| echo " unit: ${{ needs.unit.result }}" | |
| echo " e2e: ${{ needs.e2e.result }}" | |
| echo " report: ${{ needs.report.result }}" | |
| exit 1 | |
| fi | |
| echo "✅ All required jobs passed" |