diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 5fd687cde..5bd560fd4 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -1,21 +1,28 @@ name: Tests on: - push: - branches: ['**'] + pull_request: + branches: + - main + - dev + - develop workflow_dispatch: -# Cancel in-progress runs on the same branch when a newer commit lands. -# Rapid-iteration sessions burn CI minutes on stale runs without this. +# Cancel in-progress runs on the same PR 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: - test: + # Lint + format. Fast, no node_modules needed beyond what setup-node + # cache restores. + lint: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -23,19 +30,46 @@ jobs: submodules: true token: ${{ secrets.SUBMODULE_TOKEN }} + - uses: pnpm/action-setup@v4 + with: + version: 10 + - uses: actions/setup-node@v4 with: - node-version: '21.1.0' + node-version: '20' + cache: 'pnpm' + + - run: pnpm install --frozen-lockfile + + - name: Check formatting + run: pnpm prettier --check . + + # Validates content-submodule integrity. Currently advisory + # because of unrelated content-baseline drift. + - name: Validate internal links + run: pnpm validate-links + continue-on-error: true + + # 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 with: version: 10 - - name: Install Dependencies - run: pnpm install + - uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'pnpm' - - name: Check formatting - run: pnpm prettier --check . + - run: pnpm install --frozen-lockfile # Generate Next.js route + asset type declarations without a full # build. tsconfig includes .next/types/**/*.ts; without these, @@ -47,37 +81,282 @@ jobs: - name: Typecheck run: pnpm typecheck - # Validates content-submodule integrity (broken links, MDX - # component coverage, page-count baseline). Owned by the content - # team — currently failing on src/content drift unrelated to - # code PRs. Advisory until the content baseline is rebased. - - name: Validate internal links - run: pnpm validate-links - continue-on-error: true + unit: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + submodules: true + token: ${{ secrets.SUBMODULE_TOKEN }} - - name: Run Unit Tests - run: pnpm test + - uses: pnpm/action-setup@v4 + with: + version: 10 - - name: Install Playwright Browsers - run: npx playwright install --with-deps chromium + - uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'pnpm' - # Playwright e2e tests need TEST_HARNESS_SECRET to match the API - # side. Until that secret is wired into repo settings + the - # harness API is reachable from CI, this is advisory. Local devs - # still run via `pnpm test:e2e` against the dev stack. - - name: Run E2E Tests - run: pnpm test:e2e - continue-on-error: true + - run: pnpm install --frozen-lockfile + + - name: Generate Next types + run: pnpm next typegen + + - 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 Coverage + - name: Upload unit JUnit XML + if: always() uses: actions/upload-artifact@v4 with: - name: coverage - path: coverage/ + 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 + with: + version: 10 - - name: Upload Playwright Report + - 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 lint + 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: [lint, 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(/]*tests=\"(\d+)\"/) || [])[1] || '0', 10); + const failures = parseInt((xml.match(/]*failures=\"(\d+)\"/) || [])[1] || '0', 10); + const skipped = parseInt((xml.match(/]*skipped=\"(\d+)\"/) || [])[1] || '0', 10); + const time = parseFloat((xml.match(/]*time=\"([\d.]+)\"/) || [])[1] || '0'); + const cases = []; + for (const m of xml.matchAll(/]*>([\\s\\S]*?)<\\/testsuite>/g)) { + const file = decode(m[1]); + for (const tc of m[2].matchAll(/]*classname=\"([^\"]*)\"[^>]*name=\"([^\"]+)\"[^>]*time=\"([\d.]+)\"[^>]*(\\/>|>([\\s\\S]*?)<\\/testcase>)/g)) { + const failed = tc[5] && / 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 ? '' : [ + '
⏱ 10 slowest test cases', + '', + '| time | test |', + '| ---: | --- |', + ...p.slowest.map(c => `| ${c.time >= 5 ? '🐢 ' : ''}${dur(c.time)} | \`${c.file}\` › ${c.name} |`), + '', + '
', + ].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 — ${status}`, + '', + '### Suites', + suiteLine('unit', p.unit), + '', + failedBlock, + covBlock, + '', + slowBlock, + '', + '📍 Inline annotations are in the **Unit test report** check above. Coverage artifact: `coverage-unit`. Generated by `.github/workflows/tests.yml`.', + ].filter(Boolean).join('\n'); + + const tag = ''; + // 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: [lint, 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 " lint: ${{ needs.lint.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" diff --git a/.gitignore b/.gitignore index 81cfc719d..bb7e711a9 100644 --- a/.gitignore +++ b/.gitignore @@ -98,3 +98,5 @@ keystore.properties # capgo ota signing key .capgo_key_v2 +test-results/ +coverage/ diff --git a/package.json b/package.json index c026e5417..3b2454eeb 100644 --- a/package.json +++ b/package.json @@ -36,7 +36,8 @@ "native:build": "node scripts/native-build.js", "native:sync": "npx cap sync android", "capgo:upload:dev": "node scripts/native-build.js && npx @capgo/cli@latest bundle upload --channel development --path ./out", - "capgo:upload:staging": "node scripts/native-build.js && npx @capgo/cli@latest bundle upload --channel staging --path ./out" + "capgo:upload:staging": "node scripts/native-build.js && npx @capgo/cli@latest bundle upload --channel staging --path ./out", + "test:unit:ci": "jest --coverage --reporters=default --reporters=jest-junit" }, "dependencies": { "@capacitor/android": "8.2.0", @@ -141,7 +142,8 @@ "tailwindcss": "^3.4.15", "ts-jest": "^29.1.2", "tsx": "^4.19.3", - "typescript": "^5.6.3" + "typescript": "^5.6.3", + "jest-junit": "^16.0.0" }, "jest": { "preset": "ts-jest", @@ -209,5 +211,14 @@ "path": ".next/static/chunks/*.js", "limit": "500 KB" } - ] + ], + "jest-junit": { + "outputDirectory": "test-results", + "outputName": "junit.xml", + "ancestorSeparator": " › ", + "uniqueOutputName": "false", + "suiteNameTemplate": "{filepath}", + "classNameTemplate": "{classname}", + "titleTemplate": "{title}" + } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8e5951838..2c9814e30 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -290,6 +290,9 @@ importers: jest-environment-jsdom: specifier: ^29.7.0 version: 29.7.0(bufferutil@4.1.0)(utf-8-validate@5.0.10) + jest-junit: + specifier: ^16.0.0 + version: 16.0.0 jest-transform-stub: specifier: ^2.0.0 version: 2.0.0 @@ -4747,6 +4750,10 @@ packages: resolution: {integrity: sha512-fP8u2pyfqx0K1rGn1R9pyE0/KTn+G7PxktWidOBTqFPLYX0b9ksaMFkhK5vrS3DVun09pckLdlx90QthlW7AmA==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + jest-junit@16.0.0: + resolution: {integrity: sha512-A94mmw6NfJab4Fg/BlvVOUXzXgF0XIH6EmTgJ5NDPp4xoKq0Kr7sErb+4Xs9nZvu58pJojz5RFGpqnZYJTrRfQ==} + engines: {node: '>=10.12.0'} + jest-leak-detector@29.7.0: resolution: {integrity: sha512-kYA8IJcSYtST2BY9I+SMC32nDpBT3J2NvWJx8+JCuCdl/CR1I4EKUJROiP8XtCcxqgTTBGJNdbB1A8XRKbTetw==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -5267,6 +5274,11 @@ packages: mitt@3.0.1: resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==} + mkdirp@1.0.4: + resolution: {integrity: sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==} + engines: {node: '>=10'} + hasBin: true + module-details-from-path@1.0.4: resolution: {integrity: sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w==} @@ -7084,6 +7096,9 @@ packages: resolution: {integrity: sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==} engines: {node: '>=4.0.0'} + xml@1.0.1: + resolution: {integrity: sha512-huCv9IH9Tcf95zuYCsQraZtWnJvBtLVE0QHMOs8bWyZAFZNDcYjsPq1nEx8jKA9y+Beo9v+7OBPRisQTjinQMw==} + xmlbuilder@11.0.1: resolution: {integrity: sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==} engines: {node: '>=4.0'} @@ -13114,6 +13129,13 @@ snapshots: optionalDependencies: fsevents: 2.3.3 + jest-junit@16.0.0: + dependencies: + mkdirp: 1.0.4 + strip-ansi: 6.0.1 + uuid: 8.3.2 + xml: 1.0.1 + jest-leak-detector@29.7.0: dependencies: jest-get-type: 29.6.3 @@ -13995,6 +14017,8 @@ snapshots: mitt@3.0.1: {} + mkdirp@1.0.4: {} + module-details-from-path@1.0.4: {} motion-dom@11.18.1: @@ -15959,6 +15983,8 @@ snapshots: sax: 1.6.0 xmlbuilder: 11.0.1 + xml@1.0.1: {} + xmlbuilder@11.0.1: {} xmlbuilder@15.1.1: {}