diff --git a/.github/pages/allure-reports-index.html b/.github/pages/allure-reports-index.html new file mode 100644 index 00000000000..a4ba4cad4d1 --- /dev/null +++ b/.github/pages/allure-reports-index.html @@ -0,0 +1,536 @@ + + + + + + EverFreeNote Allure Reports + + + +
+
+
+

EverFreeNote Allure Reports

+

Unified GitHub Pages catalog for the latest Allure family reports. The page keeps the newest __REPORT_LIMIT__ runs per family.

+
+
Updated
+
+ +
+
Total0
+
Passed0
+
Failed0
+
PR0
+
Families0
+
+ +
+ + + + +
+ +
+ + + + + + + + + + + + + + +
FamilyReportOutcomeSuitesRefCommitPreviewGenerated
+
Loading reports...
+
+
+ + + + diff --git a/.github/workflows/component-tests.yml b/.github/workflows/component-tests.yml index 868c91c0919..a388151fecd 100644 --- a/.github/workflows/component-tests.yml +++ b/.github/workflows/component-tests.yml @@ -14,6 +14,8 @@ jobs: component-tests: runs-on: ubuntu-latest environment: Stage + outputs: + has_allure_results: ${{ steps.component-allure-check.outputs.has_allure_results }} env: NEXT_PUBLIC_SUPABASE_URL: ${{ secrets.NEXT_PUBLIC_SUPABASE_URL || secrets.SUPABASE_URL }} NEXT_PUBLIC_SUPABASE_ANON_KEY: ${{ secrets.NEXT_PUBLIC_SUPABASE_ANON_KEY || secrets.SUPABASE_ANON_KEY }} @@ -39,9 +41,13 @@ jobs: - name: Run component tests id: run-component-tests continue-on-error: true + env: + NODE_OPTIONS: --max-old-space-size=6144 run: | mkdir -p cypress/results - npm run test:component -- --reporter junit --reporter-options "mochaFile=cypress/results/component-tests-[hash].xml,toConsole=false" + set -o pipefail + npm run test:component:ci -- --reporter junit --reporter-options "mochaFile=cypress/results/component-tests-[hash].xml,toConsole=false" 2>&1 | tee cypress/results/component-tests.log + exit ${PIPESTATUS[0]} - name: Generate test summary if: always() @@ -189,6 +195,13 @@ jobs: fs.appendFileSync(summaryFile, md); NODE + - name: Backfill crashed spec failures into Allure results + if: always() + run: | + node scripts/backfill-cypress-spec-failures-to-allure.js \ + --results-dir allure-results/component \ + --log-file cypress/results/component-tests.log + - name: Upload test results if: always() uses: actions/upload-artifact@v6 @@ -201,6 +214,215 @@ jobs: if-no-files-found: ignore retention-days: 30 + - name: Generate component Allure report + if: always() + continue-on-error: true + run: | + if [ -d allure-results/component ] && [ "$(find allure-results/component -type f | wc -l)" -gt 0 ]; then + npm run allure:generate:component + else + echo "No component Allure results found; skipping report generation." + fi + + - name: Check component Allure artifacts + id: component-allure-check + if: always() + run: | + set -euo pipefail + + if [ -d allure-results/component ] && [ "$(find allure-results/component -type f | wc -l)" -gt 0 ]; then + echo "has_allure_results=true" >> "$GITHUB_OUTPUT" + else + echo "has_allure_results=false" >> "$GITHUB_OUTPUT" + fi + + - name: Upload component Allure artifacts + if: always() + uses: actions/upload-artifact@v6 + with: + name: component-test-allure-${{ github.run_id }} + path: | + allure-results/component + allure-report/component + if-no-files-found: ignore + retention-days: 30 + - name: Mark job as failed when component tests failed if: steps.run-component-tests.outcome != 'success' run: exit 1 + + publish-component-report: + name: Publish Component Allure report + needs: component-tests + if: | + always() && + needs.component-tests.result != 'skipped' && + needs.component-tests.outputs.has_allure_results == 'true' && + ( + github.event_name != 'pull_request' || + ( + github.event.pull_request.head.repo.full_name == github.repository && + github.actor != 'dependabot[bot]' + ) + ) + runs-on: ubuntu-latest + timeout-minutes: 20 + concurrency: + group: gh-pages-allure-publish + cancel-in-progress: false + permissions: + contents: write + issues: write + pull-requests: write + env: + PAGES_BASE_URL: https://koreyba.github.io/EverFreeNote + PR_NUMBER: ${{ github.event.pull_request.number }} + REF_NAME: ${{ github.head_ref || github.ref_name }} + COMMIT_SHA: ${{ github.event.pull_request.head.sha || github.sha }} + FAMILY_OUTCOME: ${{ needs.component-tests.result == 'success' && 'success' || 'failure' }} + WORKFLOW_NAME: Component Tests + + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: "24" + cache: "npm" + cache-dependency-path: package-lock.json + + - name: Install root dependencies + run: npm ci + + - name: Download component Allure artifact + uses: actions/download-artifact@v7 + with: + name: component-test-allure-${{ github.run_id }} + path: .artifacts/component + + - name: Prepare gh-pages branch + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + + rm -rf .pages-existing + git init --quiet .pages-existing + git -C .pages-existing config user.name "github-actions[bot]" + git -C .pages-existing config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git -C .pages-existing remote add origin "https://x-access-token:${GITHUB_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" + + if git -C .pages-existing fetch --quiet --depth=1 origin gh-pages; then + git -C .pages-existing checkout --quiet -B gh-pages FETCH_HEAD + else + git -C .pages-existing checkout --quiet --orphan gh-pages + find .pages-existing -mindepth 1 -maxdepth 1 ! -name .git -exec rm -rf {} + + fi + + - name: Restore component history + run: | + set -euo pipefail + + rm -rf .allure-history + mkdir -p .allure-history/_history + + if [ -d .pages-existing/_history/component ]; then + cp -R .pages-existing/_history/component .allure-history/_history/component + fi + + - name: Build component family report + id: prepare-component-report + run: | + node scripts/prepare-allure-family-report.js \ + --family component \ + --work-dir .allure-publish/component \ + --history-root .allure-history \ + --input component=.artifacts/component/allure-results/component \ + --github-output "$GITHUB_OUTPUT" + + - name: Update gh-pages content + if: steps.prepare-component-report.outputs.has_results == 'true' + run: | + set -euo pipefail + + report_dir="${{ steps.prepare-component-report.outputs.report_dir }}" + report_output_dir="${{ steps.prepare-component-report.outputs.report_output_dir }}" + history_path="${{ steps.prepare-component-report.outputs.history_path }}" + metadata_path="${{ steps.prepare-component-report.outputs.metadata_path }}" + + rm -rf ".pages-existing/${report_dir}" + parent_dir="$(dirname ".pages-existing/${report_dir}")" + mkdir -p "${parent_dir}" + cp -R "${report_output_dir}" ".pages-existing/${report_dir}" + + if [ -n "${history_path}" ] && [ -f ".allure-history/${history_path}" ]; then + mkdir -p ".pages-existing/$(dirname "${history_path}")" + cp ".allure-history/${history_path}" ".pages-existing/${history_path}" + fi + + node scripts/generate-allure-report-index.js \ + --existing .pages-existing/reports/index.json \ + --current "${metadata_path}" \ + --output .pages-existing \ + --template .github/pages/allure-reports-index.html \ + --limit-per-family 20 + + node scripts/prune-allure-pages.js \ + --root .pages-existing \ + --reports-list .pages-existing/reports/retained-paths.txt \ + --history-list .pages-existing/reports/retained-history-paths.txt + + - name: Push gh-pages updates + if: steps.prepare-component-report.outputs.has_results == 'true' + env: + REPORT_URL: ${{ steps.prepare-component-report.outputs.report_url }} + run: | + set -euo pipefail + + if [ -n "$(git -C .pages-existing status --porcelain)" ]; then + git -C .pages-existing add . + git -C .pages-existing commit -m "Publish component Allure report for run ${GITHUB_RUN_ID}" + git -C .pages-existing push origin HEAD:gh-pages + else + echo "No gh-pages changes to push." + fi + + { + echo "## Component Allure Report" + echo + echo "- Published report: ${REPORT_URL}" + } >> "$GITHUB_STEP_SUMMARY" + + - name: Update PR status comment + if: github.event_name == 'pull_request' && steps.prepare-component-report.outputs.has_results == 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + + mkdir -p .tmp-artifacts/pr-status-comment + comment_file=".tmp-artifacts/pr-status-comment/body.md" + payload_file=".tmp-artifacts/pr-status-comment/payload.json" + + node scripts/render-pr-status-comment.js \ + --reports-index .pages-existing/reports/index.json \ + --pr-number "${PR_NUMBER}" \ + --head-sha "${COMMIT_SHA}" \ + --catalog-url "${PAGES_BASE_URL}/" \ + --output "${comment_file}" + + jq -n --rawfile body "${comment_file}" '{ body: $body }' > "${payload_file}" + + comment_id="$( + gh api "repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments?per_page=100" \ + --jq '.[] | select(.user.login == "github-actions[bot]" and (.body | contains("everfreenote-pr-status-comment"))) | .id' \ + | head -n 1 + )" + + if [ -n "${comment_id}" ]; then + gh api --method PATCH "repos/${GITHUB_REPOSITORY}/issues/comments/${comment_id}" --input "${payload_file}" + else + gh api --method POST "repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" --input "${payload_file}" + fi diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index f7d8c8ffd59..8ebabaf8550 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -173,11 +173,10 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 45 outputs: - report_dir: ${{ steps.report-metadata.outputs.report_dir }} - report_url: ${{ steps.report-metadata.outputs.report_url }} - preview_url: ${{ steps.report-metadata.outputs.preview_url }} - e2e_ref: ${{ steps.report-metadata.outputs.e2e_ref }} + preview_url: ${{ steps.run-metadata.outputs.preview_url }} + e2e_ref: ${{ steps.run-metadata.outputs.e2e_ref }} e2e_outcome: ${{ steps.run-e2e-tests.outcome }} + has_allure_results: ${{ steps.e2e-allure-check.outputs.has_allure_results }} env: BASE_URL: ${{ needs.wait-cloudflare-preview.outputs.preview_url || inputs.preview_url }} SUPABASE_ANON_KEY: ${{ secrets.NEXT_PUBLIC_SUPABASE_ANON_KEY || secrets.SUPABASE_ANON_KEY }} @@ -209,30 +208,15 @@ jobs: - name: Run E2E tests id: run-e2e-tests continue-on-error: true - env: - PLAYWRIGHT_JSON_OUTPUT_NAME: results.json working-directory: e2e - run: npm run test -- --reporter=html,json + run: npm run test - - name: Compute Playwright report metadata - id: report-metadata + - name: Record run metadata + id: run-metadata if: always() - env: - PAGES_BASE_URL: https://koreyba.github.io/EverFreeNote - PR_NUMBER: ${{ github.event.pull_request.number }} run: | set -euo pipefail - if [ -n "${PR_NUMBER}" ]; then - report_dir="reports/pr-${PR_NUMBER}/run-${GITHUB_RUN_ID}-attempt-${GITHUB_RUN_ATTEMPT}" - else - report_dir="reports/manual/run-${GITHUB_RUN_ID}-attempt-${GITHUB_RUN_ATTEMPT}" - fi - - report_url="${PAGES_BASE_URL}/${report_dir}/" - - echo "report_dir=${report_dir}" >> "$GITHUB_OUTPUT" - echo "report_url=${report_url}" >> "$GITHUB_OUTPUT" echo "preview_url=${BASE_URL}" >> "$GITHUB_OUTPUT" echo "e2e_ref=${E2E_REF}" >> "$GITHUB_OUTPUT" @@ -240,7 +224,6 @@ jobs: if: always() env: E2E_STEP_OUTCOME: ${{ steps.run-e2e-tests.outcome }} - REPORT_URL: ${{ steps.report-metadata.outputs.report_url }} run: | node <<'NODE' const fs = require('fs'); @@ -250,7 +233,6 @@ jobs: const previewUrl = process.env.BASE_URL || 'n/a'; const repoRef = 'koreyba/EverFreeNote-e2e@' + (process.env.E2E_REF || 'master'); const overallOutcome = process.env.E2E_STEP_OUTCOME || 'unknown'; - const reportUrl = process.env.REPORT_URL || 'n/a'; const reportPath = path.resolve('e2e', 'results.json'); const counts = { passed: 0, failed: 0, flaky: 0, skipped: 0 }; @@ -307,7 +289,7 @@ jobs: let md = ''; md += `## ${headline}\n\n`; md += `- **Preview URL:** ${previewUrl}\n`; - md += `- **Full Playwright report:** ${reportUrl}\n`; + md += `- **Published Allure report:** created in the publish job for this run\n`; md += `- **Test repository:** ${repoRef}\n\n`; md += '| Metric | Count |\n'; md += '|---|---:|\n'; @@ -346,151 +328,209 @@ jobs: if-no-files-found: ignore retention-days: 14 + - name: Upload E2E Allure artifacts + if: always() + uses: actions/upload-artifact@v6 + with: + name: e2e-test-allure-${{ github.run_id }} + path: | + e2e/allure-results/e2e + e2e/results.json + e2e/playwright-report + e2e/test-results + if-no-files-found: ignore + retention-days: 14 + + - name: Check E2E Allure artifacts + id: e2e-allure-check + if: always() + run: | + set -euo pipefail + + if [ -d e2e/allure-results/e2e ] && [ "$(find e2e/allure-results/e2e -type f | wc -l)" -gt 0 ]; then + echo "has_allure_results=true" >> "$GITHUB_OUTPUT" + else + echo "has_allure_results=false" >> "$GITHUB_OUTPUT" + fi + - name: Mark job as failed when E2E failed if: steps.run-e2e-tests.outcome != 'success' run: exit 1 publish-e2e-report: - name: Publish Playwright E2E report + name: Publish E2E Allure report needs: run-e2e if: | always() && needs.run-e2e.result != 'skipped' && - needs.run-e2e.outputs.report_dir != '' + needs.run-e2e.outputs.has_allure_results == 'true' && + ( + github.event_name != 'pull_request' || + ( + github.event.pull_request.head.repo.full_name == github.repository && + github.actor != 'dependabot[bot]' + ) + ) runs-on: ubuntu-latest - timeout-minutes: 15 + timeout-minutes: 25 concurrency: - group: gh-pages-e2e-report-publish + group: gh-pages-allure-publish cancel-in-progress: false permissions: contents: write + issues: write + pull-requests: write + env: + PAGES_BASE_URL: https://koreyba.github.io/EverFreeNote + PR_NUMBER: ${{ github.event.pull_request.number }} + REF_NAME: ${{ github.head_ref || github.ref_name }} + COMMIT_SHA: ${{ github.event.pull_request.head.sha || github.sha }} + FAMILY_OUTCOME: ${{ needs.run-e2e.result == 'success' && 'success' || 'failure' }} + WORKFLOW_NAME: E2E Tests (PR Preview) + PREVIEW_URL: ${{ needs.run-e2e.outputs.preview_url }} + E2E_REF: ${{ needs.run-e2e.outputs.e2e_ref }} steps: - name: Checkout code uses: actions/checkout@v6 - - name: Download Playwright report artifact + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: "24" + cache: "npm" + cache-dependency-path: package-lock.json + + - name: Install root dependencies + run: npm ci + + - name: Download E2E Allure artifact uses: actions/download-artifact@v7 with: - name: playwright-report-${{ github.run_id }} - path: .e2e-artifacts + name: e2e-test-allure-${{ github.run_id }} + path: .artifacts/e2e - - name: Prepare Playwright reports index - if: always() + - name: Prepare gh-pages branch env: - E2E_STEP_OUTCOME: ${{ needs.run-e2e.outputs.e2e_outcome }} - REPORT_DIR: ${{ needs.run-e2e.outputs.report_dir }} - REPORT_URL: ${{ needs.run-e2e.outputs.report_url }} - PR_NUMBER: ${{ github.event.pull_request.number }} - REF_NAME: ${{ github.head_ref || github.ref_name }} - COMMIT_SHA: ${{ github.event.pull_request.head.sha || github.sha }} - BASE_URL: ${{ needs.run-e2e.outputs.preview_url }} - E2E_REF: ${{ needs.run-e2e.outputs.e2e_ref }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | set -euo pipefail - mkdir -p .pages-index/reports - existing_index="$(mktemp)" - + rm -rf .pages-existing git init --quiet .pages-existing + git -C .pages-existing config user.name "github-actions[bot]" + git -C .pages-existing config user.email "41898282+github-actions[bot]@users.noreply.github.com" git -C .pages-existing remote add origin "https://x-access-token:${GITHUB_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" if git -C .pages-existing fetch --quiet --depth=1 origin gh-pages; then - git -C .pages-existing show FETCH_HEAD:reports/index.json > "$existing_index" 2>/dev/null || printf '[]\n' > "$existing_index" + git -C .pages-existing checkout --quiet -B gh-pages FETCH_HEAD else - printf '[]\n' > "$existing_index" + git -C .pages-existing checkout --quiet --orphan gh-pages + find .pages-existing -mindepth 1 -maxdepth 1 ! -name .git -exec rm -rf {} + fi - node scripts/generate-e2e-report-index.js \ - --existing "$existing_index" \ - --output .pages-index \ - --template .github/pages/e2e-reports-index.html \ - --limit 20 - - - name: Prune stale Playwright report pages - if: always() + - name: Restore E2E history run: | set -euo pipefail - if ! git -C .pages-existing rev-parse --verify FETCH_HEAD >/dev/null 2>&1; then - echo "gh-pages was not fetched; nothing to prune." - exit 0 + rm -rf .allure-history + mkdir -p .allure-history/_history + + if [ -d .pages-existing/_history/e2e ]; then + cp -R .pages-existing/_history/e2e .allure-history/_history/e2e fi - git -C .pages-existing checkout --quiet FETCH_HEAD - mkdir -p .pages-existing/reports + - name: Build E2E family report + id: prepare-e2e-report + run: | + node scripts/prepare-allure-family-report.js \ + --family e2e \ + --work-dir .allure-publish/e2e \ + --history-root .allure-history \ + --input e2e=.artifacts/e2e/allure-results/e2e \ + --github-output "$GITHUB_OUTPUT" + + - name: Update gh-pages content + if: steps.prepare-e2e-report.outputs.has_results == 'true' + run: | + set -euo pipefail - node <<'NODE' - const fs = require('node:fs'); - const path = require('node:path'); - - const root = path.resolve('.pages-existing'); - const reportsRoot = path.join(root, 'reports'); - const retainedFile = path.resolve('.pages-index', 'reports', 'retained-paths.txt'); - const retained = new Set( - fs.readFileSync(retainedFile, 'utf8') - .split('\n') - .map((line) => line.trim()) - .filter(Boolean) - ); - - const removeIfStale = (entryPath) => { - const relativePath = path.relative(root, entryPath).replaceAll(path.sep, '/'); - if (!retained.has(relativePath)) { - fs.rmSync(entryPath, { recursive: true, force: true }); - } - }; + report_dir="${{ steps.prepare-e2e-report.outputs.report_dir }}" + report_output_dir="${{ steps.prepare-e2e-report.outputs.report_output_dir }}" + history_path="${{ steps.prepare-e2e-report.outputs.history_path }}" + metadata_path="${{ steps.prepare-e2e-report.outputs.metadata_path }}" - if (!fs.existsSync(reportsRoot)) { - process.exit(0); - } + rm -rf ".pages-existing/${report_dir}" + parent_dir="$(dirname ".pages-existing/${report_dir}")" + mkdir -p "${parent_dir}" + cp -R "${report_output_dir}" ".pages-existing/${report_dir}" - for (const scope of fs.readdirSync(reportsRoot, { withFileTypes: true })) { - if (!scope.isDirectory() || !['manual', 'pr-'].some((prefix) => scope.name.startsWith(prefix))) { - continue; - } + if [ -n "${history_path}" ] && [ -f ".allure-history/${history_path}" ]; then + mkdir -p ".pages-existing/$(dirname "${history_path}")" + cp ".allure-history/${history_path}" ".pages-existing/${history_path}" + fi - const scopePath = path.join(reportsRoot, scope.name); - if (scope.name === 'manual') { - for (const run of fs.readdirSync(scopePath, { withFileTypes: true })) { - if (run.isDirectory()) removeIfStale(path.join(scopePath, run.name)); - } - continue; - } + node scripts/generate-allure-report-index.js \ + --existing .pages-existing/reports/index.json \ + --current "${metadata_path}" \ + --output .pages-existing \ + --template .github/pages/allure-reports-index.html \ + --limit-per-family 20 - for (const run of fs.readdirSync(scopePath, { withFileTypes: true })) { - if (run.isDirectory()) removeIfStale(path.join(scopePath, run.name)); - } - if (fs.existsSync(scopePath) && fs.readdirSync(scopePath).length === 0) { - fs.rmSync(scopePath, { recursive: true, force: true }); - } - } - NODE + node scripts/prune-allure-pages.js \ + --root .pages-existing \ + --reports-list .pages-existing/reports/retained-paths.txt \ + --history-list .pages-existing/reports/retained-history-paths.txt + + - name: Push gh-pages updates + if: steps.prepare-e2e-report.outputs.has_results == 'true' + env: + REPORT_URL: ${{ steps.prepare-e2e-report.outputs.report_url }} + run: | + set -euo pipefail if [ -n "$(git -C .pages-existing status --porcelain)" ]; then - git -C .pages-existing config user.name "github-actions[bot]" - git -C .pages-existing config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git -C .pages-existing add reports - git -C .pages-existing commit -m "Prune stale E2E reports" + git -C .pages-existing add . + git -C .pages-existing commit -m "Publish e2e Allure report for run ${GITHUB_RUN_ID}" git -C .pages-existing push origin HEAD:gh-pages else - echo "No stale Playwright report pages to prune." + echo "No gh-pages changes to push." fi - - name: Publish Playwright report to gh-pages - if: always() - uses: peaceiris/actions-gh-pages@47f197a2200bb9de68ba5f48fad1c088eb1c4a32 # v4.0.0 - with: - github_token: ${{ secrets.GITHUB_TOKEN }} - publish_dir: .e2e-artifacts/playwright-report - destination_dir: ${{ needs.run-e2e.outputs.report_dir }} - keep_files: true + { + echo "## E2E Allure Report" + echo + echo "- Published report: ${REPORT_URL}" + } >> "$GITHUB_STEP_SUMMARY" - - name: Publish Playwright reports index to gh-pages - if: always() - uses: peaceiris/actions-gh-pages@47f197a2200bb9de68ba5f48fad1c088eb1c4a32 # v4.0.0 - with: - github_token: ${{ secrets.GITHUB_TOKEN }} - publish_dir: .pages-index - keep_files: true + - name: Update PR status comment + if: github.event_name == 'pull_request' && steps.prepare-e2e-report.outputs.has_results == 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + + mkdir -p .tmp-artifacts/pr-status-comment + comment_file=".tmp-artifacts/pr-status-comment/body.md" + payload_file=".tmp-artifacts/pr-status-comment/payload.json" + + node scripts/render-pr-status-comment.js \ + --reports-index .pages-existing/reports/index.json \ + --pr-number "${PR_NUMBER}" \ + --head-sha "${COMMIT_SHA}" \ + --catalog-url "${PAGES_BASE_URL}/" \ + --output "${comment_file}" + + jq -n --rawfile body "${comment_file}" '{ body: $body }' > "${payload_file}" + + comment_id="$( + gh api "repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments?per_page=100" \ + --jq '.[] | select(.user.login == "github-actions[bot]" and (.body | contains("everfreenote-pr-status-comment"))) | .id' \ + | head -n 1 + )" + + if [ -n "${comment_id}" ]; then + gh api --method PATCH "repos/${GITHUB_REPOSITORY}/issues/comments/${comment_id}" --input "${payload_file}" + else + gh api --method POST "repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" --input "${payload_file}" + fi diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index 89a146bfeaf..57b67cde1e4 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -13,6 +13,8 @@ permissions: jobs: unit-tests-mobile: runs-on: ubuntu-latest + outputs: + has_allure_results: ${{ steps.mobile-allure-check.outputs.has_allure_results }} steps: - name: Checkout code @@ -152,6 +154,18 @@ jobs: echo "No mobile unit Allure results found; skipping report generation." fi + - name: Check mobile unit Allure artifacts + id: mobile-allure-check + if: always() + run: | + set -euo pipefail + + if [ -d ui/mobile/allure-results/mobile-unit ] && [ "$(find ui/mobile/allure-results/mobile-unit -type f | wc -l)" -gt 0 ]; then + echo "has_allure_results=true" >> "$GITHUB_OUTPUT" + else + echo "has_allure_results=false" >> "$GITHUB_OUTPUT" + fi + - name: Upload mobile unit test report if: always() uses: actions/upload-artifact@v6 @@ -178,6 +192,8 @@ jobs: unit-tests-core: runs-on: ubuntu-latest + outputs: + has_allure_results: ${{ steps.core-allure-check.outputs.has_allure_results }} steps: - name: Checkout code @@ -317,6 +333,33 @@ jobs: echo "No core unit Allure results found; skipping report generation." fi + - name: Generate core integration Allure report + if: always() + continue-on-error: true + run: | + if [ -d allure-results/core-integration ] && [ "$(find allure-results/core-integration -type f | wc -l)" -gt 0 ]; then + npm run allure:generate:core-integration + else + echo "No core integration Allure results found; skipping report generation." + fi + + - name: Check core Allure artifacts + id: core-allure-check + if: always() + run: | + set -euo pipefail + + if [ -d allure-results/core-unit ] && [ "$(find allure-results/core-unit -type f | wc -l)" -gt 0 ]; then + echo "has_allure_results=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + + if [ -d allure-results/core-integration ] && [ "$(find allure-results/core-integration -type f | wc -l)" -gt 0 ]; then + echo "has_allure_results=true" >> "$GITHUB_OUTPUT" + else + echo "has_allure_results=false" >> "$GITHUB_OUTPUT" + fi + - name: Upload core test reports if: always() uses: actions/upload-artifact@v6 @@ -336,6 +379,8 @@ jobs: path: | allure-results/core-unit allure-report/core-unit + allure-results/core-integration + allure-report/core-integration if-no-files-found: ignore retention-days: 14 @@ -345,6 +390,8 @@ jobs: unit-tests-web: runs-on: ubuntu-latest + outputs: + has_allure_results: ${{ steps.web-allure-check.outputs.has_allure_results }} steps: - name: Checkout code @@ -479,6 +526,18 @@ jobs: echo "No web unit Allure results found; skipping report generation." fi + - name: Check web unit Allure artifacts + id: web-allure-check + if: always() + run: | + set -euo pipefail + + if [ -d allure-results/web-unit ] && [ "$(find allure-results/web-unit -type f | wc -l)" -gt 0 ]; then + echo "has_allure_results=true" >> "$GITHUB_OUTPUT" + else + echo "has_allure_results=false" >> "$GITHUB_OUTPUT" + fi + - name: Upload web unit test report if: always() uses: actions/upload-artifact@v6 @@ -502,3 +561,200 @@ jobs: - name: Mark job as failed when web unit tests failed if: steps.run-web-unit-tests.outcome != 'success' run: exit 1 + + publish-unit-report: + name: Publish Unit Allure report + needs: [unit-tests-mobile, unit-tests-core, unit-tests-web] + if: | + always() && + ( + needs.unit-tests-mobile.outputs.has_allure_results == 'true' || + needs.unit-tests-core.outputs.has_allure_results == 'true' || + needs.unit-tests-web.outputs.has_allure_results == 'true' + ) && + ( + github.event_name != 'pull_request' || + ( + github.event.pull_request.head.repo.full_name == github.repository && + github.actor != 'dependabot[bot]' + ) + ) + runs-on: ubuntu-latest + timeout-minutes: 25 + concurrency: + group: gh-pages-allure-publish + cancel-in-progress: false + permissions: + contents: write + issues: write + pull-requests: write + env: + PAGES_BASE_URL: https://koreyba.github.io/EverFreeNote + PR_NUMBER: ${{ github.event.pull_request.number }} + REF_NAME: ${{ github.head_ref || github.ref_name }} + COMMIT_SHA: ${{ github.event.pull_request.head.sha || github.sha }} + FAMILY_OUTCOME: ${{ needs.unit-tests-mobile.result == 'success' && needs.unit-tests-core.result == 'success' && needs.unit-tests-web.result == 'success' && 'success' || 'failure' }} + WORKFLOW_NAME: Unit Tests + + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: "24" + cache: "npm" + cache-dependency-path: package-lock.json + + - name: Install root dependencies + run: npm ci + + - name: Download core Allure artifact + continue-on-error: true + uses: actions/download-artifact@v7 + with: + name: unit-test-report-core-allure-${{ github.run_id }} + path: .artifacts/core + + - name: Download web Allure artifact + continue-on-error: true + uses: actions/download-artifact@v7 + with: + name: unit-test-report-web-allure-${{ github.run_id }} + path: .artifacts/web + + - name: Download mobile Allure artifact + continue-on-error: true + uses: actions/download-artifact@v7 + with: + name: unit-test-report-mobile-allure-${{ github.run_id }} + path: .artifacts/mobile + + - name: Prepare gh-pages branch + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + + rm -rf .pages-existing + git init --quiet .pages-existing + git -C .pages-existing config user.name "github-actions[bot]" + git -C .pages-existing config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git -C .pages-existing remote add origin "https://x-access-token:${GITHUB_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" + + if git -C .pages-existing fetch --quiet --depth=1 origin gh-pages; then + git -C .pages-existing checkout --quiet -B gh-pages FETCH_HEAD + else + git -C .pages-existing checkout --quiet --orphan gh-pages + find .pages-existing -mindepth 1 -maxdepth 1 ! -name .git -exec rm -rf {} + + fi + + - name: Restore unit history + run: | + set -euo pipefail + + rm -rf .allure-history + mkdir -p .allure-history/_history + + if [ -d .pages-existing/_history/unit ]; then + cp -R .pages-existing/_history/unit .allure-history/_history/unit + fi + + - name: Build unit family report + id: prepare-unit-report + run: | + node scripts/prepare-allure-family-report.js \ + --family unit \ + --work-dir .allure-publish/unit \ + --history-root .allure-history \ + --input core-unit=.artifacts/core/allure-results/core-unit \ + --input core-integration=.artifacts/core/allure-results/core-integration \ + --input web-unit=.artifacts/web/allure-results/web-unit \ + --input mobile-unit=.artifacts/mobile/allure-results/mobile-unit \ + --github-output "$GITHUB_OUTPUT" + + - name: Update gh-pages content + if: steps.prepare-unit-report.outputs.has_results == 'true' + run: | + set -euo pipefail + + report_dir="${{ steps.prepare-unit-report.outputs.report_dir }}" + report_output_dir="${{ steps.prepare-unit-report.outputs.report_output_dir }}" + history_path="${{ steps.prepare-unit-report.outputs.history_path }}" + metadata_path="${{ steps.prepare-unit-report.outputs.metadata_path }}" + + rm -rf ".pages-existing/${report_dir}" + parent_dir="$(dirname ".pages-existing/${report_dir}")" + mkdir -p "${parent_dir}" + cp -R "${report_output_dir}" ".pages-existing/${report_dir}" + + if [ -n "${history_path}" ] && [ -f ".allure-history/${history_path}" ]; then + mkdir -p ".pages-existing/$(dirname "${history_path}")" + cp ".allure-history/${history_path}" ".pages-existing/${history_path}" + fi + + node scripts/generate-allure-report-index.js \ + --existing .pages-existing/reports/index.json \ + --current "${metadata_path}" \ + --output .pages-existing \ + --template .github/pages/allure-reports-index.html \ + --limit-per-family 20 + + node scripts/prune-allure-pages.js \ + --root .pages-existing \ + --reports-list .pages-existing/reports/retained-paths.txt \ + --history-list .pages-existing/reports/retained-history-paths.txt + + - name: Push gh-pages updates + if: steps.prepare-unit-report.outputs.has_results == 'true' + env: + REPORT_URL: ${{ steps.prepare-unit-report.outputs.report_url }} + run: | + set -euo pipefail + + if [ -n "$(git -C .pages-existing status --porcelain)" ]; then + git -C .pages-existing add . + git -C .pages-existing commit -m "Publish unit Allure report for run ${GITHUB_RUN_ID}" + git -C .pages-existing push origin HEAD:gh-pages + else + echo "No gh-pages changes to push." + fi + + { + echo "## Unit Allure Report" + echo + echo "- Published report: ${REPORT_URL}" + } >> "$GITHUB_STEP_SUMMARY" + + - name: Update PR status comment + if: github.event_name == 'pull_request' && steps.prepare-unit-report.outputs.has_results == 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + + mkdir -p .tmp-artifacts/pr-status-comment + comment_file=".tmp-artifacts/pr-status-comment/body.md" + payload_file=".tmp-artifacts/pr-status-comment/payload.json" + + node scripts/render-pr-status-comment.js \ + --reports-index .pages-existing/reports/index.json \ + --pr-number "${PR_NUMBER}" \ + --head-sha "${COMMIT_SHA}" \ + --catalog-url "${PAGES_BASE_URL}/" \ + --output "${comment_file}" + + jq -n --rawfile body "${comment_file}" '{ body: $body }' > "${payload_file}" + + comment_id="$( + gh api "repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments?per_page=100" \ + --jq '.[] | select(.user.login == "github-actions[bot]" and (.body | contains("everfreenote-pr-status-comment"))) | .id' \ + | head -n 1 + )" + + if [ -n "${comment_id}" ]; then + gh api --method PATCH "repos/${GITHUB_REPOSITORY}/issues/comments/${comment_id}" --input "${payload_file}" + else + gh api --method POST "repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" --input "${payload_file}" + fi diff --git a/.gitignore b/.gitignore index d67a24e0458..bfc35e77c0c 100644 --- a/.gitignore +++ b/.gitignore @@ -27,6 +27,8 @@ node_modules/ /coverage /allure-results /allure-report +ui/mobile/allure-results/ +ui/mobile/allure-report/ # Test artifacts (keep test runners in build/, exclude temp results) TEST_SUMMARY.md @@ -129,3 +131,4 @@ ui/mobile/ios/EverFreeNote/WebEditor/ /.playwright-mcp/editor-webview-with-content.png /.playwright-mcp/editor-webview-working.png /.mcp.json +/.tmp-artifacts/ \ No newline at end of file diff --git a/GEMINI.md b/GEMINI.md index 09b99e6eebe..0746f4b4010 100644 --- a/GEMINI.md +++ b/GEMINI.md @@ -10,7 +10,7 @@ This project uses ai-devkit for structured AI-assisted development. Phase docume - `docs/ai/implementation/` - Implementation guides and notes - `docs/ai/testing/` - Testing strategy and test cases - `docs/ai/deployment/` - Deployment and infrastructure docs -- `docs/ai/monitoring/` - Monitoring and observability setup +- `docs/ai/monitoring/` - Monitoring and observability setup ## Code Style & Standards - Follow the project's established code style and conventions diff --git a/cypress.config.ts b/cypress.config.ts index dd47359b61b..5e34517cfcb 100644 --- a/cypress.config.ts +++ b/cypress.config.ts @@ -2,8 +2,23 @@ import { defineConfig } from "cypress" import { allureCypress } from "allure-cypress/reporter" import * as os from "node:os" +const componentCoverageOptions = { + exclude: [ + 'cypress/**/*.*', + '**/*.config.js', + 'node_modules/**/*', + 'coverage/**/*', + ], + include: [ + 'core/**/*.{js,jsx,ts,tsx}', + 'ui/**/*.{js,jsx,ts,tsx}', + ], +} + export default defineConfig({ projectId: '76trp2', + experimentalMemoryManagement: true, + numTestsKeptInMemory: 0, component: { // Required for CI stability. // With JIT enabled, Cypress CT can intermittently finish spec evaluation with an empty Mocha suite @@ -19,6 +34,14 @@ export default defineConfig({ specPattern: 'cypress/component/**/*.cy.{js,jsx,ts,tsx}', supportFile: 'cypress/support/component.ts', setupNodeEvents(on, config) { + on('before:browser:launch', (browser, launchOptions) => { + if (browser.family === 'chromium') { + launchOptions.args.push('--disable-dev-shm-usage') + launchOptions.args.push('--js-flags=--max-old-space-size=8192') + } + return launchOptions + }) + allureCypress(on, config, { resultsDir: "allure-results/component", environmentInfo: { @@ -30,8 +53,13 @@ export default defineConfig({ }, }) - // Add code coverage for component tests - require('@cypress/code-coverage/task')(on, config) + const coverageEnabled = config.env.codeCoverage === true || config.env.codeCoverage === 'true' + if (coverageEnabled) { + config.env.codeCoverage = componentCoverageOptions + require('@cypress/code-coverage/task')(on, config) + } else { + delete config.env.codeCoverage + } return config }, @@ -50,17 +78,6 @@ export default defineConfig({ responseTimeout: 60000, }, env: { - codeCoverage: { - exclude: [ - 'cypress/**/*.*', - '**/*.config.js', - 'node_modules/**/*', - 'coverage/**/*', - ], - include: [ - 'core/**/*.{js,jsx,ts,tsx}', - 'ui/**/*.{js,jsx,ts,tsx}', - ], - }, + codeCoverage: false, }, }) diff --git a/cypress/support/component.ts b/cypress/support/component.ts index c96e14f959c..f25c47228f8 100644 --- a/cypress/support/component.ts +++ b/cypress/support/component.ts @@ -3,9 +3,12 @@ import './commands' import 'allure-cypress' import '@testing-library/cypress/add-commands' -import '@cypress/code-coverage/support' import { registerGlobalErrorHandling } from './setup/error-handling' +if (Cypress.env('codeCoverage')) { + require('@cypress/code-coverage/support') +} + // Define a minimal Stub type based on usage to avoid 'any' type SinonStub = { returns: (value: unknown) => SinonStub diff --git a/deno.lock b/deno.lock index de13a4e189f..752035136ce 100644 --- a/deno.lock +++ b/deno.lock @@ -77,6 +77,10 @@ "npm:@types/jest@^29.5.14", "npm:@types/react-virtualized-auto-sizer@^1.0.4", "npm:ai-devkit@0.15", + "npm:allure-cypress@^3.7.1", + "npm:allure-jest@^3.7.1", + "npm:allure-js-commons@^3.7.1", + "npm:allure@^3.6.2", "npm:autoprefixer@^10.4.21", "npm:babel-jest@^29.7.0", "npm:babel-plugin-istanbul@^7.0.1", @@ -125,7 +129,6 @@ ], "overrides": { "picomatch": ">=2.3.2", - "brace-expansion": "1.1.13", "tar": ">=7.5.11", "flatted": ">=3.4.2", "serialize-javascript": ">=7.0.5", diff --git a/docs/ai/design/allure-reporting-architecture.md b/docs/ai/design/allure-reporting-architecture.md new file mode 100644 index 00000000000..73ae3316ca0 --- /dev/null +++ b/docs/ai/design/allure-reporting-architecture.md @@ -0,0 +1,39 @@ +--- +phase: design +title: Allure Reporting Architecture +description: Family-based publication architecture for Allure Pages reporting +--- + +# Allure Reporting Architecture + +## Architectural Decisions + +- GitHub Pages publishes report families instead of one global merged Allure report. +- The active families are `component`, `unit`, and `e2e`. +- Each family keeps its own history namespace and Pages path layout so unrelated runs do not pollute trend charts or retention behavior. + +## Family-Based Publication Model + +- `component` remains a single-suite family built from `allure-results/component`. +- `unit` is assembled from multiple artifact sources: + `core-unit`, `core-integration`, `web-unit`, and `mobile-unit`. +- `e2e` is assembled from the downloaded `EverFreeNote-e2e` workflow artifact inside this repository's publish workflow. + +## Rationale + +- Family-level publication keeps navigation simple without collapsing every suite into one noisy report. +- `unit` is grouped from multiple sources because readers usually want one place for core, web, mobile, and integration regressions while still preserving suite identity via labels. +- Keeping `component`, `unit`, and `e2e` separate preserves useful history boundaries and avoids misleading cross-surface aggregation. + +## History And Catalog Strategy + +- `scripts/allure-pages-utils.js` selects history paths from family plus scope: + PR-specific paths for pull requests, branch-specific paths for `main` and `develop`, and history-less manual runs for `workflow_dispatch`. +- `scripts/generate-allure-report-index.js` produces the shared Pages catalog as root `index.html` plus report metadata under `reports/index.json`. +- `scripts/prune-allure-pages.js` removes stale run directories and stale history files after catalog generation determines the retained set. + +## Synthetic Failure Backfill + +- Cypress component runs can crash at the spec level before `allure-cypress` writes a failing `*-result.json`. +- `scripts/backfill-cypress-spec-failures-to-allure.js` reads the captured Cypress runner log, detects crash-only failures that already-finished tests do not cover, and emits a synthetic `broken` Allure result. +- This backfill keeps published Allure output aligned with the failure signal that GitHub Actions and JUnit summaries already expose. diff --git a/docs/ai/design/feature-allure-report-v3.md b/docs/ai/design/feature-allure-report-v3.md index 0a6ff0771d3..5c1cedd49b7 100644 --- a/docs/ai/design/feature-allure-report-v3.md +++ b/docs/ai/design/feature-allure-report-v3.md @@ -14,21 +14,29 @@ flowchart TD RootJest["Root Jest projects"] --> AJ["allure-jest adapter"] MobileJest["Mobile Jest package"] --> MAJ["allure-jest adapter in ui/mobile"] ExternalE2E["EverFreeNote-e2e Playwright"] --> PW["Playwright Allure adapter"] - AC --> Results["allure-results/"] - AJ --> Results - MAJ --> Results - PW --> E2EResults["external e2e allure-results"] - Results --> CLI["Allure v3 CLI"] - E2EResults --> CLI - CLI --> Report["allure-report/"] + AC --> ComponentResults["allure-results/component"] + AJ --> UnitResults["allure-results/"] + MAJ --> MobileResults["ui/mobile/allure-results/mobile-unit"] + PW --> E2EResults["external allure-results/e2e"] + ComponentResults --> ComponentPublish["component family publish job"] + UnitResults --> UnitPublish["unit family publish job"] + MobileResults --> UnitPublish + E2EResults --> E2EPublish["e2e family publish job"] + ComponentPublish --> History["history store per family + scope"] + UnitPublish --> History + E2EPublish --> History + History --> AllureCLI["Allure v3 CLI with history + executor metadata"] + AllureCLI --> Pages["GitHub Pages catalog and family reports"] ``` ## Component Breakdown - `allure-cypress`: records Cypress component test execution into `allure-results/component`. -- `allure-jest`: planned adapter for root Jest projects and mobile Jest. +- `allure-jest`: adapter for root Jest projects and mobile Jest. - `allure`: Allure Report v3 CLI used by npm scripts to generate HTML reports. -- `.github/workflows/*`: CI upload points for Allure artifacts, added suite by suite. +- `.github/workflows/*`: CI upload points for Allure artifacts and family-level Pages publication. +- `.github/pages/*`: static landing page template and report catalog assets for GitHub Pages. +- `scripts/*`: report catalog generation and history-path preparation for report families. ## Design Decisions @@ -36,9 +44,51 @@ flowchart TD - Keep existing reporters and summaries in place to reduce migration risk. - Add Allure generation scripts separately from existing test scripts so local developers can opt in. - Treat web E2E as a cross-repository increment because the Playwright configuration is not stored in this repository. +- Publish three report families instead of one report per suite: + `e2e`, `component`, and `unit`. +- Merge `core-unit`, `core-integration`, `web-unit`, and `mobile-unit` raw Allure results into the `unit` family report. +- Preserve suite discoverability inside merged reports through stable labels such as `family`, `suite`, `surface`, `layer`, and `workflow`. +- Keep report history isolated by both family and scope so unrelated runs do not distort trend charts. +- Replace the current Playwright HTML Pages publish with an Allure-based `e2e` family report while keeping the same URL shape for run pages. +- Keep one shared Pages index that lists published family reports and links to individual run pages. + +## Pages Structure + +```text +index.html +reports/ + index.json + e2e/pr-/run--attempt-/ + e2e/manual/run--attempt-/ + component/pr-/run--attempt-/ + unit/pr-/run--attempt-/ +_history/ + e2e/history.jsonl + component/history.jsonl + unit/history.jsonl +``` + +`scripts/generate-allure-report-index.js` writes the landing page to the Pages root as `index.html`; only the report catalog JSON and manifests live under `reports/`. +The `_history/` tree stores one rolling Allure 3 JSONL history file per report family. PR, branch, and manual runs for the same family append to the same file. + +## Report Identity Model + +- `family`: top-level Pages grouping and history namespace. +- `suite`: concrete producer such as `core-unit`, `core-integration`, `web-unit`, `mobile-unit`, `component`, or `e2e`. +- `surface`: product surface such as `core`, `web`, or `mobile`. +- `layer`: testing layer such as `unit`, `integration`, `component`, or `e2e`. +- `workflow`: source workflow, used to keep traceability back to GitHub Actions. + +## History Strategy + +- `PR`, `main`/`develop`, and manual runs append to the same family-specific history file. +- Each family history file is trimmed to the latest 20 launches after report generation. +- Every published family report includes `executor.json` metadata pointing to the GitHub Actions run and the final Pages URL. ## Non-Functional Requirements - Reporting must not change test pass/fail semantics. - Generated reports and raw results must be ignored by git. - CI artifact retention should match existing suite retention unless a later decision changes it. +- Pages publication must remain resilient when one family has no results for a given run. +- Family publication must be concurrency-safe so one report family does not prune or overwrite another family's files. diff --git a/docs/ai/design/feature-pr-status-comment.md b/docs/ai/design/feature-pr-status-comment.md new file mode 100644 index 00000000000..a751f07a42e --- /dev/null +++ b/docs/ai/design/feature-pr-status-comment.md @@ -0,0 +1,45 @@ +--- +phase: design +title: PR Status Comment Design +description: Architecture for the reusable PR status comment +--- + +# PR Status Comment Design + +## Architecture Overview + +```mermaid +flowchart TD + A["Unit publish job"] --> P["Update gh-pages reports/index.json"] + B["Component publish job"] --> P + C["E2E publish job"] --> P + P --> S["scripts/render-pr-status-comment.js"] + S --> R["Read latest reports for PR number and head SHA"] + R --> M["Render generic PR Status markdown"] + M --> U["Workflow gh api step creates or updates one bot-owned PR comment"] +``` + +## Design Decisions + +- The comment is named and marked as generic PR status: + ``. +- The script is named `render-pr-status-comment.js`; Allure-specific wording stays inside the report data, not the script contract. +- The renderer does not call GitHub APIs. Workflows use `gh api` to create or update the marked comment, which keeps file-derived report metadata out of Node outbound requests. +- The workflow only updates comments authored by `github-actions[bot]`. Marker comments created manually are ignored so smoke tests from a developer token cannot make `GITHUB_TOKEN` hit a 403 update path. +- The update step runs at the end of each successful Pages publish job. The final comment becomes complete once the last family publish job finishes. +- The existing `gh-pages-allure-publish` concurrency group serializes report publication and comment updates, so the script can read the local `.pages-existing/reports/index.json` without cross-job comment races. +- The previous `workflow_run` aggregator design was rejected for this branch because GitHub only evaluates new `workflow_run` listeners after the workflow exists on the default branch. + +## Data Model + +- Source data: + `reports/index.json` from the local `gh-pages` checkout. +- Filter keys: + `prNumber` and `sha`. +- Output rows: + `unit`, `component`, and `e2e`, each with source workflow link and report link when available. + +## Future Expansion + +- Add sections below `Test Reports` for build, static analysis, deployment previews, and manual gates. +- Keep the same marker and updater script, adding data providers rather than creating separate PR comments. diff --git a/docs/ai/implementation/feature-allure-report-v3.md b/docs/ai/implementation/feature-allure-report-v3.md index 209b36b293f..4b3853a1341 100644 --- a/docs/ai/implementation/feature-allure-report-v3.md +++ b/docs/ai/implementation/feature-allure-report-v3.md @@ -18,11 +18,17 @@ description: Implementation notes for Allure reporting - Cypress component report: `allure-report/component`. - Core unit results: `allure-results/core-unit`. - Core unit report: `allure-report/core-unit`. +- Core integration results: `allure-results/core-integration`. +- Core integration report: `allure-report/core-integration`. - Mobile unit results: `ui/mobile/allure-results/mobile-unit`. - Mobile unit report: `ui/mobile/allure-report/mobile-unit`. - Web unit results: `allure-results/web-unit`. - Web unit report: `allure-report/web-unit`. - Aggregate local report: `allure-report`. +- GitHub Pages family reports: + `reports/e2e/...`, `reports/component/...`, and `reports/unit/...`. +- GitHub Pages history store: + `_history//history.jsonl`. ## Implementation Notes @@ -40,6 +46,9 @@ description: Implementation notes for Allure reporting - `npm run test:unit:core` now emits Allure results for the `unit-core` Jest project. - `npm run allure:generate:core-unit` generates the core unit HTML report from existing results. - `npm run test:unit:core:allure` runs the core unit suite, then generates the report. +- `npm run test:integration:core` now emits Allure results for the `integration-core` Jest project. +- `npm run allure:generate:core-integration` generates the core integration HTML report from existing results. +- `npm run test:integration:core:allure` preserves the original test exit code while still generating the report. - `npm --prefix ui/mobile test` now emits Allure results for mobile unit tests. - `npm --prefix ui/mobile run allure:generate` generates the mobile unit HTML report from existing results. - `npm run test:unit:web` now emits Allure results for the `unit-web` Jest project. @@ -47,10 +56,22 @@ description: Implementation notes for Allure reporting - `npm run test:unit:web:allure` runs the web unit suite, then generates the report. - `npm run allure:generate` generates an aggregate report from `allure-results`. +### Family Publication Model + +- Architecture and rationale for family-based publication live in + [allure-reporting-architecture.md](../design/allure-reporting-architecture.md). +- CI assembles `component`, `unit`, and `e2e` family reports through `scripts/prepare-allure-family-report.js`. +- The `unit` family publish flow downloads raw results from `core-unit`, `core-integration`, `web-unit`, and `mobile-unit` before generating the final Pages report. +- Component CI runs `scripts/backfill-cypress-spec-failures-to-allure.js` before report generation so spec-level Cypress crashes still surface in the published Allure data. +- All PR, branch, and manual runs for a family share one Allure 3 JSONL history file, trimmed to the latest 20 launches after generation. + ## Integration Points - Component CI can upload `allure-results/component` immediately after the test step. - CI report generation can run even when tests fail if the step uses `if: always()`. - `unit-tests.yml` now generates `allure-report/core-unit` and uploads both raw core unit results and the generated report as CI artifacts. +- `unit-tests.yml` now generates `allure-report/core-integration` and uploads both raw core integration results and the generated report as CI artifacts. - `unit-tests.yml` now generates `ui/mobile/allure-report/mobile-unit` and uploads both raw mobile unit results and the generated report as CI artifacts. - `unit-tests.yml` now generates `allure-report/web-unit` and uploads both raw web unit results and the generated report as CI artifacts. +- Component, unit, and E2E Pages publication consume raw Allure results rather than republishing the prebuilt per-suite HTML reports. +- The old `e2e-tests.yml` Playwright HTML Pages publication path has been replaced by the `e2e` Allure family publish job. diff --git a/docs/ai/implementation/feature-pr-status-comment.md b/docs/ai/implementation/feature-pr-status-comment.md new file mode 100644 index 00000000000..24386fed820 --- /dev/null +++ b/docs/ai/implementation/feature-pr-status-comment.md @@ -0,0 +1,27 @@ +--- +phase: implementation +title: PR Status Comment Implementation +description: Implementation notes for the reusable PR status comment +--- + +# PR Status Comment Implementation + +## Code Structure + +- `scripts/render-pr-status-comment.js` reads report metadata and renders the PR status body. +- `scripts/render-pr-status-comment.test.js` verifies report selection, generic comment rendering, and markdown/URL sanitization. +- The three Pages publish jobs render the body after `.pages-existing/reports/index.json` has been refreshed, then use `gh api` to update the marked bot-owned PR comment. + +## Implementation Notes + +- The renderer filters report metadata by PR number and current head SHA so stale reports do not appear in the comment. +- The comment uses `Not published yet` for missing report families. +- Existing publish jobs now request `issues: write` and `pull-requests: write` in addition to `contents: write`. +- The removed `workflow_run` workflow is intentionally not used because it cannot run from this PR until merged to the default branch. +- Update steps select an existing marker comment only when it is authored by `github-actions[bot]`; otherwise they create a new bot-owned comment. + +## Security Notes + +- The renderer requires `GITHUB_REPOSITORY`, PR number, and head SHA; it does not require a token. +- Network writes happen in workflow shell through `gh api` with `GH_TOKEN`. +- The workflow only writes issue comments and does not modify repository contents through the comment step. diff --git a/docs/ai/planning/feature-allure-report-v3.md b/docs/ai/planning/feature-allure-report-v3.md index 45db36a698f..bdb3ec3a941 100644 --- a/docs/ai/planning/feature-allure-report-v3.md +++ b/docs/ai/planning/feature-allure-report-v3.md @@ -8,9 +8,10 @@ description: Incremental rollout plan for Allure reporting ## Milestones -- [x] Milestone 1: Foundation and web component test reporting. -- [ ] Milestone 2: CI artifact publishing for web component reports. -- [ ] Milestone 3: Add remaining suites in priority order. +- [x] Milestone 1: Foundation and suite-level Allure generation. +- [x] Milestone 2: Family-level GitHub Pages architecture and docs. +- [x] Milestone 3: Family-level publication for component and unit workflows. +- [x] Milestone 4: Replace the old E2E Pages publication with Allure family publication. ## Task Breakdown @@ -24,33 +25,29 @@ description: Incremental rollout plan for Allure reporting ### Phase 2: Web Component CI -- [ ] Task 2.1: Generate Allure component report in `.github/workflows/component-tests.yml`. -- [ ] Task 2.2: Upload `allure-results/component` and `allure-report/component` artifacts. -- [ ] Task 2.3: Add CI summary link or artifact names to the workflow summary. +- [x] Task 2.1: Add a shared Pages catalog model for Allure family reports. +- [x] Task 2.2: Define history-key rules for `PR`, `main`, `develop`, and manual scopes. +- [x] Task 2.3: Add scripts/templates for a shared Allure Pages index. -### Phase 3: Web E2E Tests +### Phase 3: Component and Unit Family Publication -- [ ] Task 3.1: Update `koreyba/EverFreeNote-e2e` Playwright config with an Allure reporter. -- [ ] Task 3.2: Publish E2E Allure results alongside the existing Playwright HTML report. -- [ ] Task 3.3: Decide whether this repository should aggregate downloaded E2E Allure artifacts. +- [x] Task 3.1: Generate and upload component Allure artifacts in `.github/workflows/component-tests.yml`. +- [x] Task 3.2: Merge core, integration, web, and mobile Allure results into a single `unit` family report. +- [x] Task 3.3: Publish `component` and `unit` family reports plus the shared Pages index. +- [x] Task 3.4: Add summary links to published family reports. -### Phase 4: Core Unit Tests +### Phase 4: Web E2E Family Publication -- [x] Task 4.1: Add Allure Jest environment to the root `unit-core` project. -- [x] Task 4.2: Use `allure-results/core-unit` and keep JSON summaries unchanged. -- [x] Task 4.3: Add report generation and CI artifact upload. +- [x] Task 4.1: Update `koreyba/EverFreeNote-e2e` Playwright config with an Allure reporter. +- [x] Task 4.2: Replace the old Playwright HTML Pages publish with `e2e` Allure family publication. +- [x] Task 4.3: Route E2E publication through the same shared Pages catalog and history logic. -### Phase 5: Mobile Unit Tests +### Phase 5: Existing Suite Enablement -- [x] Task 5.1: Add Allure Jest dependencies to `ui/mobile`. -- [x] Task 5.2: Configure `ui/mobile/jest.config.js` for `allure-results/mobile-unit`. -- [x] Task 5.3: Update mobile unit CI artifacts. - -### Phase 6: Web Unit Tests - -- [x] Task 6.1: Add Allure Jest environment to the root `unit-web` project. -- [x] Task 6.2: Use `allure-results/web-unit` and keep JSON summaries unchanged. -- [x] Task 6.3: Add report generation and CI artifact upload. +- [x] Task 5.1: Add Allure Jest environment to the root `unit-core` project. +- [x] Task 5.2: Add Allure Jest environment to the root `unit-web` project. +- [x] Task 5.3: Add Allure Jest environment to the mobile Jest package. +- [x] Task 5.4: Preserve existing JSON and JUnit outputs across enabled suites. ## Dependencies @@ -58,15 +55,21 @@ description: Incremental rollout plan for Allure reporting - Root unit reporting depends on validating `allure-jest` with Jest multi-project configs. - Mobile reporting depends on the separate mobile package lock. - E2E reporting depends on changes outside this repository. +- Family-level Pages publication depends on shared scripts for catalog generation, history-path selection, and report metadata injection. +- The `unit` family publish step depends on downloading artifacts from multiple jobs before running Allure generation. ## Risks & Mitigation - Risk: Allure adapters change test environment behavior. Mitigation: adopt one suite at a time and run existing suite commands after each change. - Risk: CI reports become too large. - Mitigation: upload raw results and generated reports with retention aligned to existing artifacts. + Mitigation: publish only family reports to Pages and keep raw artifacts under Actions retention. - Risk: E2E ownership spans repositories. Mitigation: keep E2E as its own tracked phase and do not block component reporting on it. +- Risk: merged unit reports lose per-suite clarity. + Mitigation: standardize Allure labels and environment metadata before publication. +- Risk: family publish jobs overwrite each other on `gh-pages`. + Mitigation: use family-specific destination directories, family-specific retained-path manifests, and serialized publish concurrency. ## Resources Needed diff --git a/docs/ai/planning/feature-pr-status-comment.md b/docs/ai/planning/feature-pr-status-comment.md new file mode 100644 index 00000000000..1a67e50f3cd --- /dev/null +++ b/docs/ai/planning/feature-pr-status-comment.md @@ -0,0 +1,46 @@ +--- +phase: planning +title: PR Status Comment Plan +description: Plan for the reusable PR status comment +--- + +# PR Status Comment Plan + +## Milestones + +- [x] Milestone 1: Define the generic PR status comment architecture. +- [x] Milestone 2: Replace the Allure-specific comment workflow with a generic renderer plus workflow-owned update step. +- [x] Milestone 3: Wire the updater into serialized Pages publish jobs. +- [x] Milestone 4: Verify rendering, validation, and branch-safe behavior. + +## Task Breakdown + +### Phase 1: Architecture + +- [x] Task 1.1: Document why `workflow_run` is not sufficient for this PR branch. +- [x] Task 1.2: Select publish-job updates as the working model. +- [x] Task 1.3: Rename the feature from Allure-specific comment publishing to generic PR status comment publishing. + +### Phase 2: Implementation + +- [x] Task 2.1: Add `scripts/render-pr-status-comment.js`. +- [x] Task 2.2: Add local renderer tests. +- [x] Task 2.3: Remove the branch-local `workflow_run` aggregator. +- [x] Task 2.4: Add PR comment update steps to `unit`, `component`, and `e2e` publish jobs. + +### Phase 3: Verification + +- [x] Task 3.1: Run local unit tests for comment rendering. +- [x] Task 3.2: Run repository validation. +- [x] Task 3.3: Confirm the workflow can create/update the PR comment through GitHub API after publish jobs. + +## Risks & Mitigation + +- Risk: comment updates happen before every family publishes. + Mitigation: each update renders all known reports and the last serialized publish job produces the complete final state. +- Risk: future non-report checks need different data sources. + Mitigation: keep the renderer generic and add data-provider sections incrementally. +- Risk: a developer-created smoke comment with the marker cannot be updated by `GITHUB_TOKEN`. + Mitigation: only update bot-owned marker comments and create a new bot-owned comment when needed. +- Risk: fork PRs lack write permissions. + Mitigation: trusted publish guards already skip Pages/comment updates for those runs. diff --git a/docs/ai/requirements/feature-allure-report-v3.md b/docs/ai/requirements/feature-allure-report-v3.md index 99892e7efca..0d48f8ce5cb 100644 --- a/docs/ai/requirements/feature-allure-report-v3.md +++ b/docs/ai/requirements/feature-allure-report-v3.md @@ -16,6 +16,8 @@ The project has several independent test surfaces, but reporting is split betwee - Roll out the integration incrementally by test-suite priority. - Preserve existing test commands, JSON/JUnit outputs, coverage outputs, and CI summaries. - Keep generated Allure result and report directories out of git. +- Replace the existing GitHub Pages publication of standalone Playwright HTML reports with a Pages catalog of Allure reports. +- Keep GitHub Pages navigation convenient without collapsing every suite into one global report. ## Priority Order @@ -31,6 +33,11 @@ The project has several independent test surfaces, but reporting is split betwee - Allure Report v3 can generate HTML reports from those results with npm scripts. - CI can upload Allure result/report artifacts without replacing existing summary artifacts. - The plan documents pending suites and ownership boundaries. +- GitHub Pages exposes one shared landing page for published Allure report families. +- Published reports are grouped into the report families `e2e`, `component`, and `unit`. +- The `unit` family combines core unit, core integration, web unit, and mobile unit results into one Allure report while preserving suite identity via labels. +- Report history is retained separately per report family and per scope (`PR`, `main`, `develop`, manual runs) instead of one global trend. +- The existing Playwright HTML Pages publication flow is removed after the Allure replacement is in place. ## Constraints & Assumptions @@ -39,8 +46,12 @@ The project has several independent test surfaces, but reporting is split betwee - Core and web unit tests share the root Jest multi-project config. - Mobile tests use a separate `ui/mobile` package and package lock. - Allure docs were checked through Context7 on 2026-04-30. +- GitHub Pages should preserve the current run URL pattern model of `PR/manual + run id + attempt`, even after the underlying report format changes. +- Allure history storage should be Git-friendly and workflow-safe for concurrent report families. +- Mainline history should be long-lived for `main` and `develop`, while PR history should stay isolated to each PR. ## Questions & Open Items -- Decide whether E2E Allure artifacts should be generated in the external E2E repo only, or downloaded and republished by this repo's workflow. -- Decide whether mobile component/integration Jest tests should be grouped with mobile unit reporting or handled as separate Allure suites. +- E2E Allure artifacts are generated during `.github/workflows/e2e-tests.yml` in the `run-e2e` job, uploaded as a workflow artifact, then downloaded and republished by `publish-e2e-report` so the Pages catalog stays centralized in this repository. +- No cross-repository publish handoff is required beyond checking out the `koreyba/EverFreeNote-e2e` test repository for execution. +- Mobile component or future mobile integration suites are out of scope unless they join the `unit` family under the same labeling strategy. diff --git a/docs/ai/requirements/feature-pr-status-comment.md b/docs/ai/requirements/feature-pr-status-comment.md new file mode 100644 index 00000000000..2c201a8f440 --- /dev/null +++ b/docs/ai/requirements/feature-pr-status-comment.md @@ -0,0 +1,32 @@ +--- +phase: requirements +title: PR Status Comment Requirements +description: Requirements for a single durable PR status comment +--- + +# PR Status Comment Requirements + +## Problem Statement + +- Reviewers need one stable PR location that links to the latest generated test reports. +- GitHub Pages already has `unit`, `component`, and `e2e` Allure reports, but those links are scattered across Actions summaries and the Pages catalog. +- The first increment should show report links only, while leaving room for build, analysis, and deployment readiness later. + +## Goals & Objectives + +- Maintain one bot-authored PR comment marked with ``. +- Populate the comment with links to the latest available test reports for the active PR head SHA. +- Keep the implementation generic: Allure is only the first report source, not the name or ownership boundary of the comment. + +## Success Criteria + +- Publishing any family report updates or creates the same PR status comment. +- After `unit`, `component`, and `e2e` publish jobs finish, the comment lists all available report links for the latest PR SHA. +- Missing reports appear as `Not published yet` instead of breaking the workflow. +- The comment can later grow into a broader PR readiness panel without replacing the marker or script. + +## Constraints & Assumptions + +- The implementation must work before merge, so it cannot depend on a newly added `workflow_run` file being present on the default branch. +- Existing report publish jobs are serialized by `gh-pages-allure-publish`, so comment updates can safely happen at the end of those jobs. +- Publication is already guarded to trusted PRs; fork PRs may keep artifacts without Pages or comment updates. diff --git a/docs/ai/testing/feature-allure-report-v3.md b/docs/ai/testing/feature-allure-report-v3.md index 12e9a38d5c9..17cdfd8b058 100644 --- a/docs/ai/testing/feature-allure-report-v3.md +++ b/docs/ai/testing/feature-allure-report-v3.md @@ -11,6 +11,8 @@ description: Verification approach for Allure reporting - Verify that adopted suites still execute through their existing commands. - Verify that adopted suites produce Allure result files. - Verify that Allure v3 can generate an HTML report from the result files. +- Verify that family publication jobs can generate Pages-ready Allure reports from raw uploaded results. +- Verify that history is preserved within one family across PR, branch, and manual runs, but not leaked across unrelated families. ## Test Reporting & Coverage @@ -23,6 +25,10 @@ description: Verification approach for Allure reporting - Web unit results directory: `allure-results/web-unit`. - Web unit report directory: `allure-report/web-unit`. - Existing Cypress coverage and JUnit outputs remain separate from Allure outputs. +- Pages family report directories: + `reports/e2e/...`, `reports/component/...`, `reports/unit/...`. +- Pages history files: + `_history//history.jsonl`. ## Verification Commands @@ -35,6 +41,9 @@ description: Verification approach for Allure reporting - `npm --prefix ui/mobile run allure:generate` - `npm run test:unit:web -- --runTestsByPath ui/web/tests/unit/lib/aiIndexNavigationState.test.ts` - `npm run allure:generate:web-unit` +- `act -W .github/workflows/component-tests.yml` +- `act -W .github/workflows/unit-tests.yml` +- `act -W .github/workflows/e2e-tests.yml` ## Current Status @@ -53,8 +62,10 @@ description: Verification approach for Allure reporting - [x] Web unit Allure Jest environment configured. - [x] Smoke web unit test with Allure result generation. - [x] Web unit Allure report generation from smoke results. +- [x] Family-report architecture selected: + separate `e2e`, `component`, and merged `unit`. ## Outstanding Gaps -- Web E2E Allure requires changes in the external E2E repository. -- Component test CI and any optional cross-repository E2E aggregation remain planned follow-up work. +- Component spec-level crashes can bypass `allure-cypress`, so CI now backfills a synthetic Allure failure from the captured Cypress log; this path still deserves a focused regression check on future Cypress upgrades. +- Trusted-event guards intentionally skip `gh-pages` publication for fork PRs and read-only bot PRs, so those runs keep artifacts in Actions without publishing Pages output. diff --git a/docs/ai/testing/feature-pr-status-comment.md b/docs/ai/testing/feature-pr-status-comment.md new file mode 100644 index 00000000000..ba714082faa --- /dev/null +++ b/docs/ai/testing/feature-pr-status-comment.md @@ -0,0 +1,32 @@ +--- +phase: testing +title: PR Status Comment Testing +description: Verification notes for the reusable PR status comment +--- + +# PR Status Comment Testing + +## Test Coverage Goals + +- Verify latest report selection by PR number and head SHA. +- Verify the comment marker and headings are generic. +- Verify missing reports render as `Not published yet`. +- Verify markdown table cells are escaped and unsafe report URLs are not emitted as links. + +## Verification Commands + +- `node --test scripts/render-pr-status-comment.test.js` +- `npm run validate` + +## Current Status + +- [x] Added renderer tests for latest-report selection. +- [x] Added renderer tests for generic PR status shape and missing report fallback. +- [x] Added renderer tests for table escaping and unsafe URL fallback. +- [x] Ran local renderer tests. +- [x] Ran repository validation. + +## Manual Testing + +- After the next PR publish run, confirm the PR has one bot-owned comment containing `PR Status` and `Test Reports`. +- Confirm subsequent family publish jobs update that same bot-owned comment instead of creating duplicates. diff --git a/eslint.config.mjs b/eslint.config.mjs index aa202e7a1b5..ffad535e6e5 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -26,6 +26,7 @@ export default defineConfig([ 'coverage/**', 'allure-results/**', 'allure-report/**', + '.tmp-artifacts/**', '.worktrees/**', 'act-artifacts/**', 'out/**', diff --git a/jest.config.cjs b/jest.config.cjs index cbbf76181ae..4b25de0f374 100644 --- a/jest.config.cjs +++ b/jest.config.cjs @@ -36,6 +36,17 @@ const webUnitAllureOptions = { }, } +const coreIntegrationAllureOptions = { + resultsDir: 'allure-results/core-integration', + environmentInfo: { + os_platform: os.platform(), + os_release: os.release(), + os_version: os.version(), + node_version: process.version, + test_type: 'core-integration', + }, +} + module.exports = { projects: [ { @@ -52,7 +63,8 @@ module.exports = { { displayName: 'integration-core', rootDir: __dirname, - testEnvironment: 'node', + testEnvironment: 'allure-jest/node', + testEnvironmentOptions: coreIntegrationAllureOptions, testRegex: ['core/tests/integration/.*\\.test\\.(ts|tsx)$'], setupFilesAfterEnv: ['/tests/jest/core.setup.cjs'], transform, diff --git a/package.json b/package.json index 04be9a597e6..c93da0544ea 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "test:unit:core": "jest --config jest.config.cjs --selectProjects unit-core", "test:unit:core:allure": "npm run test:unit:core; test_exit=$?; npm run allure:generate:core-unit; exit $test_exit", "test:integration:core": "jest --config jest.config.cjs --selectProjects integration-core", + "test:integration:core:allure": "npm run test:integration:core; test_exit=$?; npm run allure:generate:core-integration; exit $test_exit", "test:unit:web": "jest --config jest.config.cjs --selectProjects unit-web", "test:unit:web:allure": "npm run test:unit:web; test_exit=$?; npm run allure:generate:web-unit; exit $test_exit", "type-check": "tsc --noEmit", @@ -34,6 +35,7 @@ "perf:test": "node scripts/test-migrations.js", "cypress": "cypress open", "test:component": "cypress run --component --browser electron --spec 'cypress/component/**/*.cy.{js,jsx,ts,tsx}'", + "test:component:ci": "cypress run --component --browser chrome --spec 'cypress/component/**/*.cy.{js,jsx,ts,tsx}'", "test:component:allure": "npm run test:component; test_exit=$?; npm run allure:generate:component; exit $test_exit", "test:component:coverage": "cypress run --component --browser electron --spec 'cypress/component/**/*.cy.{js,jsx,ts,tsx}' --env codeCoverage=true", "test:component:watch": "cypress open --component", @@ -41,6 +43,7 @@ "allure:generate": "allure generate allure-results --output allure-report", "allure:generate:component": "allure generate allure-results/component --output allure-report/component --report-name \"Web Component Tests\"", "allure:generate:core-unit": "allure generate allure-results/core-unit --output allure-report/core-unit --report-name \"Core Unit Tests\"", + "allure:generate:core-integration": "allure generate allure-results/core-integration --output allure-report/core-integration --report-name \"Core Integration Tests\"", "allure:generate:web-unit": "allure generate allure-results/web-unit --output allure-report/web-unit --report-name \"Web Unit Tests\"", "act:list": "act -l", "act:build": "act -W .github/workflows/build.yml", diff --git a/scripts/allure-pages-utils.js b/scripts/allure-pages-utils.js new file mode 100644 index 00000000000..e38dafe0246 --- /dev/null +++ b/scripts/allure-pages-utils.js @@ -0,0 +1,284 @@ +#!/usr/bin/env node + +const fs = require("node:fs"); +const path = require("node:path"); + +const FAMILY_LABELS = { + component: "Component", + e2e: "E2E", + unit: "Unit", +}; + +const SUITE_METADATA = { + component: { + suite: "component", + surface: "web", + layer: "component", + workflow: "component-tests", + label: "Web Component", + }, + "core-unit": { + suite: "core-unit", + surface: "core", + layer: "unit", + workflow: "unit-tests", + label: "Core Unit", + }, + "core-integration": { + suite: "core-integration", + surface: "core", + layer: "integration", + workflow: "unit-tests", + label: "Core Integration", + }, + "web-unit": { + suite: "web-unit", + surface: "web", + layer: "unit", + workflow: "unit-tests", + label: "Web Unit", + }, + "mobile-unit": { + suite: "mobile-unit", + surface: "mobile", + layer: "unit", + workflow: "unit-tests", + label: "Mobile Unit", + }, + e2e: { + suite: "e2e", + surface: "web", + layer: "e2e", + workflow: "e2e-tests", + label: "Web E2E", + }, +}; + +const DEFAULT_PER_FAMILY_LIMIT = 20; + +const parseArgs = (argv) => { + const args = {}; + for (let index = 2; index < argv.length; index += 1) { + const key = argv[index]; + if (!key.startsWith("--")) { + throw new Error(`Unexpected argument: ${key}`); + } + + const normalizedKey = key.slice(2); + const next = argv[index + 1]; + + if (next === undefined || next.startsWith("--")) { + if (!args[normalizedKey]) { + args[normalizedKey] = true; + } else if (Array.isArray(args[normalizedKey])) { + args[normalizedKey].push(true); + } else { + args[normalizedKey] = [args[normalizedKey], true]; + } + continue; + } + + if (!args[normalizedKey]) { + args[normalizedKey] = next; + } else if (Array.isArray(args[normalizedKey])) { + args[normalizedKey].push(next); + } else { + args[normalizedKey] = [args[normalizedKey], next]; + } + + index += 1; + } + return args; +}; + +const normalizeSlashes = (value) => value.replaceAll(path.sep, "/"); + +const trimTrailingSlashes = (value) => { + let endIndex = value.length; + while (endIndex > 0 && value[endIndex - 1] === "/") { + endIndex -= 1; + } + return value.slice(0, endIndex); +}; + +const trimBoundaryCharacter = (value, boundaryCharacter) => { + let startIndex = 0; + let endIndex = value.length; + + while (startIndex < endIndex && value[startIndex] === boundaryCharacter) { + startIndex += 1; + } + + while (endIndex > startIndex && value[endIndex - 1] === boundaryCharacter) { + endIndex -= 1; + } + + return value.slice(startIndex, endIndex); +}; + +const slugify = (value) => + { + const normalizedValue = String(value || "").trim().toLowerCase(); + let slug = ""; + let previousWasDash = false; + + for (const character of normalizedValue) { + const isAlphaNumeric = + (character >= "a" && character <= "z") || + (character >= "0" && character <= "9"); + if (isAlphaNumeric) { + slug += character; + previousWasDash = false; + continue; + } + + if (!previousWasDash) { + slug += "-"; + previousWasDash = true; + } + } + + return trimBoundaryCharacter(slug, "-") || "unknown"; + }; + +const ensureDir = (dirPath) => { + fs.mkdirSync(dirPath, { recursive: true }); +}; + +const readJson = (filePath, fallback = null) => { + try { + return JSON.parse(fs.readFileSync(filePath, "utf8")); + } catch { + return fallback; + } +}; + +const listify = (value) => { + if (value === undefined) return []; + return Array.isArray(value) ? value : [value]; +}; + +const createGithubOutputDelimiter = (key, value) => { + const safeKey = slugify(key).replaceAll("-", "_").toUpperCase(); + let counter = 0; + let delimiter = ""; + do { + delimiter = `EOF_${safeKey}_${counter}`; + counter += 1; + } while (value.includes(delimiter)); + return delimiter; +}; + +const appendGithubOutput = (githubOutputPath, values) => { + if (!githubOutputPath) return; + const lines = []; + for (const [key, value] of Object.entries(values)) { + const normalizedValue = `${value ?? ""}`; + if (!normalizedValue.includes("\n")) { + lines.push(`${key}=${normalizedValue}`); + continue; + } + + const delimiter = createGithubOutputDelimiter(key, normalizedValue); + lines.push(`${key}<<${delimiter}`, normalizedValue, delimiter); + } + fs.appendFileSync(githubOutputPath, `${lines.join("\n")}\n`); +}; + +const getFamilyLabel = (family) => FAMILY_LABELS[family] || family; + +const getSuiteMetadata = (suite) => { + const metadata = SUITE_METADATA[suite]; + if (!metadata) { + throw new Error(`Unknown suite metadata for '${suite}'`); + } + return metadata; +}; + +const computeScope = ({ + prNumber, + refName, + eventName, +}) => { + if (prNumber) { + return { + scopeType: "pr", + scopeKey: `pr-${prNumber}`, + scopeLabel: `PR #${prNumber}`, + }; + } + + if (eventName === "workflow_dispatch") { + return { + scopeType: "manual", + scopeKey: "manual", + scopeLabel: "Manual", + }; + } + + if (refName === "main" || refName === "develop") { + return { + scopeType: "branch", + scopeKey: `branch-${slugify(refName)}`, + scopeLabel: refName, + }; + } + + return { + scopeType: "manual", + scopeKey: "manual", + scopeLabel: "Manual", + }; +}; + +const computeReportContext = ({ family, env = process.env }) => { + const runId = env.GITHUB_RUN_ID || "0"; + const runAttempt = env.GITHUB_RUN_ATTEMPT || "1"; + const prNumber = env.PR_NUMBER || ""; + const refName = env.REF_NAME || env.GITHUB_REF_NAME || "unknown"; + const eventName = env.GITHUB_EVENT_NAME || ""; + const pagesBaseUrl = trimTrailingSlashes(env.PAGES_BASE_URL || ""); + const scope = computeScope({ prNumber, refName, eventName }); + const reportDir = normalizeSlashes( + path.join("reports", family, scope.scopeKey, `run-${runId}-attempt-${runAttempt}`) + ); + const reportUrl = pagesBaseUrl ? `${pagesBaseUrl}/${reportDir}/` : ""; + const historyPath = normalizeSlashes(path.join("_history", family, "history.jsonl")); + + return { + family, + familyLabel: getFamilyLabel(family), + runId, + runAttempt, + prNumber: prNumber || null, + refName, + eventName, + scopeType: scope.scopeType, + scopeKey: scope.scopeKey, + scopeLabel: scope.scopeLabel, + reportDir, + reportUrl, + historyPath, + pagesBaseUrl, + sha: env.COMMIT_SHA || env.GITHUB_SHA || "unknown", + previewUrl: env.PREVIEW_URL || env.BASE_URL || "", + e2eRef: env.E2E_REF || "", + workflow: env.WORKFLOW_NAME || env.GITHUB_WORKFLOW || "", + outcome: env.FAMILY_OUTCOME || "unknown", + generatedAt: new Date().toISOString(), + }; +}; + +module.exports = { + DEFAULT_PER_FAMILY_LIMIT, + appendGithubOutput, + computeReportContext, + ensureDir, + getFamilyLabel, + getSuiteMetadata, + listify, + normalizeSlashes, + parseArgs, + readJson, + slugify, +}; diff --git a/scripts/backfill-cypress-spec-failures-to-allure.js b/scripts/backfill-cypress-spec-failures-to-allure.js new file mode 100644 index 00000000000..82718b3e0f9 --- /dev/null +++ b/scripts/backfill-cypress-spec-failures-to-allure.js @@ -0,0 +1,286 @@ +#!/usr/bin/env node + +const fs = require("node:fs"); +const path = require("node:path"); +const { randomUUID } = require("node:crypto"); +const { parseArgs, ensureDir } = require("./allure-pages-utils"); + +const ANSI_ESCAPE = String.fromCodePoint(27); +const ERROR_HINT_PATTERN = /(OOM|heap|out of memory|failed the current spec|renderer|crash|mark-compacts)/i; + +const stripAnsiCodePrefix = (segment) => { + if (!segment.startsWith("[")) { + return segment; + } + + let index = 1; + while (index < segment.length && (segment[index] === ";" || /\d/.test(segment[index]))) { + index += 1; + } + + return segment[index] === "m" ? segment.slice(index + 1) : segment; +}; + +const stripAnsi = (value) => + value + .split(ANSI_ESCAPE) + .map((segment, index) => (index === 0 ? segment : stripAnsiCodePrefix(segment))) + .join(""); + +const normalizeSpecPath = (value) => { + const normalized = value.replaceAll("\\", "/"); + let firstNonSlashIndex = 0; + while (normalized[firstNonSlashIndex] === "/") { + firstNonSlashIndex += 1; + } + return normalized.slice(firstNonSlashIndex); +}; + +const isSpecToken = (value) => /\.cy\.(?:js|jsx|ts|tsx)$/.test(value); + +const parseSummarySpecLine = (line) => { + const tokens = line.trim().split(/\s+/); + const specIndex = tokens.findIndex(isSpecToken); + if (specIndex === -1 || tokens.length < specIndex + 7) { + return null; + } + + const [, total, passed, failed, pending, skipped] = tokens.slice(specIndex + 1, specIndex + 7); + if (!/^\d+$/.test(total) || !/^\d+$/.test(passed) || !/^\d+$/.test(failed)) { + return null; + } + + return { + spec: normalizeSpecPath(tokens[specIndex]), + total, + passed, + failed, + pending, + skipped, + }; +}; + +const parseRunningSpecLine = (line) => { + const markerIndex = line.indexOf("Running:"); + if (markerIndex === -1) { + return ""; + } + + const candidate = line.slice(markerIndex + "Running:".length).trim().split(/\s+/)[0] || ""; + return isSpecToken(candidate) ? normalizeSpecPath(candidate) : ""; +}; + +const readTextFile = (filePath) => { + const buffer = fs.readFileSync(filePath); + if (buffer.length >= 2) { + const bom16le = buffer[0] === 0xff && buffer[1] === 0xfe; + const bom16be = buffer[0] === 0xfe && buffer[1] === 0xff; + if (bom16le) { + return buffer.slice(2).toString("utf16le"); + } + if (bom16be) { + const swapped = Buffer.from(buffer.slice(2)); + swapped.swap16(); + return swapped.toString("utf16le"); + } + } + + const utf8 = buffer.toString("utf8"); + if (utf8.includes("\u0000")) { + return buffer.toString("utf16le"); + } + + return utf8; +}; + +const collectExistingFailures = (resultsDir) => { + const failedPackages = new Set(); + if (!fs.existsSync(resultsDir)) { + return failedPackages; + } + + for (const entry of fs.readdirSync(resultsDir)) { + if (!entry.endsWith("-result.json")) { + continue; + } + + const filePath = path.join(resultsDir, entry); + let payload; + try { + payload = JSON.parse(fs.readFileSync(filePath, "utf8")); + } catch (error) { + console.warn(`Skipping malformed Allure result file ${filePath}: ${error instanceof Error ? error.message : error}`); + continue; + } + if (payload.status === "passed" || payload.status === "skipped") { + continue; + } + + const packageLabel = (payload.labels || []).find((label) => label.name === "package")?.value; + if (packageLabel) { + failedPackages.add(packageLabel); + } + } + + return failedPackages; +}; + +const extractFailingSpecs = (logLines) => { + const foundSpecs = new Map(); + + for (const line of logLines) { + const summary = parseSummarySpecLine(line); + if (!summary) { + continue; + } + + const failedCount = Number(summary.failed); + const spec = summary.spec; + if (!spec || failedCount <= 0) { + continue; + } + + if (!foundSpecs.has(spec)) { + foundSpecs.set(spec, { spec, summaryLine: line.trim() }); + } + } + + return [...foundSpecs.values()]; +}; + +const collectSegment = (logLines, spec) => { + let startIndex = logLines.findIndex((line) => parseRunningSpecLine(line) === spec); + if (startIndex === -1) { + startIndex = 0; + } + + let endIndex = logLines.length; + for (let index = startIndex + 1; index < logLines.length; index += 1) { + if (parseRunningSpecLine(logLines[index])) { + endIndex = index; + break; + } + } + + return logLines.slice(startIndex, endIndex); +}; + +const buildMessage = (segmentLines, summaryLine) => { + const messageLines = segmentLines.filter((line) => ERROR_HINT_PATTERN.test(line)); + const trimmedLines = messageLines.slice(0, 8); + if (trimmedLines.length > 0) { + return trimmedLines.join("\n"); + } + + return summaryLine || "Cypress failed this spec before Allure could persist a failing test result."; +}; + +const extractCounts = (summaryLine) => { + const summary = parseSummarySpecLine(summaryLine); + if (!summary) { + return null; + } + + const { total, passed, failed, pending, skipped } = summary; + return { total, passed, failed, pending, skipped }; +}; + +const writeSyntheticFailure = (resultsDir, spec, message, summaryLine) => { + const normalizedSpec = normalizeSpecPath(spec); + const packageName = `cypress.component.${normalizedSpec.replaceAll("/", ".")}`; + const specFilePath = `cypress/component/${normalizedSpec}`; + const summaryCounts = extractCounts(summaryLine); + const now = Date.now(); + const outputPath = path.join(resultsDir, `${randomUUID()}-result.json`); + + const payload = { + uuid: randomUUID(), + name: `spec crash: ${normalizedSpec}`, + fullName: `${specFilePath}#spec crash`, + historyId: `${packageName}:spec-crash`, + testCaseId: `${packageName}:spec-crash`, + status: "broken", + statusDetails: { + message, + trace: summaryLine || message, + }, + stage: "finished", + steps: [], + attachments: [], + parameters: [ + { + name: "Synthetic", + value: "Generated from Cypress component runner log because Allure adapter emitted no failing result.", + }, + ...(summaryCounts + ? [ + { name: "Spec tests", value: summaryCounts.total }, + { name: "Spec passed", value: summaryCounts.passed }, + { name: "Spec failed", value: summaryCounts.failed }, + { name: "Spec pending", value: summaryCounts.pending }, + { name: "Spec skipped", value: summaryCounts.skipped }, + ] + : []), + ], + labels: [ + { name: "language", value: "javascript" }, + { name: "framework", value: "cypress" }, + { name: "parentSuite", value: "Component Spec Crash" }, + { name: "suite", value: normalizedSpec }, + { name: "package", value: packageName }, + ], + links: [], + start: now, + stop: now, + }; + + fs.writeFileSync(outputPath, `${JSON.stringify(payload, null, 2)}\n`); + return outputPath; +}; + +const main = () => { + const args = parseArgs(process.argv); + if (typeof args["results-dir"] !== "string" || args["results-dir"].trim() === "") { + throw new Error("--results-dir is required"); + } + + if (typeof args["log-file"] !== "string" || args["log-file"].trim() === "") { + throw new Error("--log-file is required"); + } + + const resultsDir = path.resolve(args["results-dir"]); + const logFile = path.resolve(args["log-file"]); + + if (!fs.existsSync(logFile)) { + console.log(`No Cypress log file found at ${logFile}; skipping Allure backfill.`); + return; + } + + ensureDir(resultsDir); + + const existingFailures = collectExistingFailures(resultsDir); + const logLines = stripAnsi(readTextFile(logFile)).split(/\r?\n/); + const failingSpecs = extractFailingSpecs(logLines); + let created = 0; + + for (const failingSpec of failingSpecs) { + const packageName = `cypress.component.${failingSpec.spec.replaceAll("/", ".")}`; + if (existingFailures.has(packageName)) { + continue; + } + + const segment = collectSegment(logLines, failingSpec.spec); + const message = buildMessage(segment, failingSpec.summaryLine); + writeSyntheticFailure(resultsDir, failingSpec.spec, message, failingSpec.summaryLine); + created += 1; + } + + console.log(`Synthetic Allure failures created: ${created}`); +}; + +try { + main(); +} catch (error) { + console.error(error instanceof Error ? error.message : error); + process.exit(1); +} diff --git a/scripts/generate-allure-report-index.js b/scripts/generate-allure-report-index.js new file mode 100644 index 00000000000..93839aaa286 --- /dev/null +++ b/scripts/generate-allure-report-index.js @@ -0,0 +1,102 @@ +#!/usr/bin/env node + +const fs = require("node:fs"); +const path = require("node:path"); +const { + DEFAULT_PER_FAMILY_LIMIT, + ensureDir, + parseArgs, + readJson, +} = require("./allure-pages-utils"); + +const readExistingReports = (filePath) => { + if (!filePath || !fs.existsSync(filePath)) { + return []; + } + const parsed = readJson(filePath, []); + return Array.isArray(parsed) ? parsed : []; +}; + +const parseLimit = (value) => { + const parsed = Number.parseInt(value || `${DEFAULT_PER_FAMILY_LIMIT}`, 10); + if (!Number.isFinite(parsed) || parsed < 1) { + return DEFAULT_PER_FAMILY_LIMIT; + } + return parsed; +}; + +const readCurrentReports = (currentArgs) => { + const files = Array.isArray(currentArgs) ? currentArgs : [currentArgs]; + return files + .filter(Boolean) + .map((filePath) => readJson(path.resolve(filePath), null)) + .filter((payload) => payload?.path); +}; + +const main = () => { + const args = parseArgs(process.argv); + const current = readCurrentReports(args.current); + const existing = readExistingReports(args.existing ? path.resolve(args.existing) : ""); + const outputDir = path.resolve(args.output || ".pages-index"); + const templatePath = path.resolve(args.template || ".github/pages/allure-reports-index.html"); + const limitPerFamily = parseLimit(args["limit-per-family"]); + const generatedAt = new Date().toISOString(); + + const reportsByPath = new Map(); + for (const report of [...existing, ...current]) { + if (report && typeof report.path === "string") { + reportsByPath.set(report.path, report); + } + } + + const familyBuckets = new Map(); + for (const report of reportsByPath.values()) { + const family = report.family || "unknown"; + if (!familyBuckets.has(family)) { + familyBuckets.set(family, []); + } + familyBuckets.get(family).push(report); + } + + const reports = []; + for (const bucket of familyBuckets.values()) { + const limited = bucket + .sort((left, right) => { + const leftDate = Date.parse(left.generatedAt || "") || 0; + const rightDate = Date.parse(right.generatedAt || "") || 0; + return rightDate - leftDate; + }) + .slice(0, limitPerFamily); + reports.push(...limited); + } + + reports.sort((left, right) => { + const leftDate = Date.parse(left.generatedAt || "") || 0; + const rightDate = Date.parse(right.generatedAt || "") || 0; + return rightDate - leftDate; + }); + + ensureDir(path.join(outputDir, "reports")); + fs.writeFileSync(path.join(outputDir, "reports", "index.json"), `${JSON.stringify(reports, null, 2)}\n`); + fs.writeFileSync( + path.join(outputDir, "reports", "retained-paths.txt"), + `${reports.map((report) => report.path).join("\n")}\n` + ); + fs.writeFileSync( + path.join(outputDir, "reports", "retained-history-paths.txt"), + `${reports.map((report) => report.historyPath).filter(Boolean).join("\n")}\n` + ); + + const template = fs.readFileSync(templatePath, "utf8"); + const html = template + .replaceAll("__GENERATED_AT__", generatedAt) + .replaceAll("__REPORT_LIMIT__", `${limitPerFamily}`); + fs.writeFileSync(path.join(outputDir, "index.html"), html); +}; + +try { + main(); +} catch (error) { + console.error(error instanceof Error ? error.message : error); + process.exit(1); +} diff --git a/scripts/prepare-allure-family-report.js b/scripts/prepare-allure-family-report.js new file mode 100644 index 00000000000..be414ec9cb7 --- /dev/null +++ b/scripts/prepare-allure-family-report.js @@ -0,0 +1,416 @@ +#!/usr/bin/env node + +const fs = require("node:fs"); +const path = require("node:path"); +const { execFileSync } = require("node:child_process"); +const { + appendGithubOutput, + computeReportContext, + ensureDir, + getSuiteMetadata, + listify, + normalizeSlashes, + parseArgs, +} = require("./allure-pages-utils"); + +const SKIPPED_FILENAMES = new Set(["executor.json", "environment.properties", "categories.json"]); +const HISTORY_LIMIT = 20; +const realpathSyncNative = fs.realpathSync.native || fs.realpathSync; + +const isWithinDirectory = (baseDir, candidatePath) => { + const relativePath = path.relative(baseDir, candidatePath); + return relativePath !== ".." && !relativePath.startsWith(`..${path.sep}`) && !path.isAbsolute(relativePath); +}; + +const resolvePathForWorkspaceCheck = (targetPath) => { + let currentPath = path.resolve(targetPath); + const trailingSegments = []; + + while (!fs.existsSync(currentPath)) { + const parentPath = path.dirname(currentPath); + if (parentPath === currentPath) { + throw new Error(`Unable to resolve workspace ancestor for path: ${targetPath}`); + } + trailingSegments.unshift(path.basename(currentPath)); + currentPath = parentPath; + } + + let resolvedPath = realpathSyncNative(currentPath); + for (const segment of trailingSegments) { + resolvedPath = path.join(resolvedPath, segment); + } + return resolvedPath; +}; + +const ensureWithinWorkspace = (targetPath, optionName) => { + const workspaceRoot = realpathSyncNative(process.cwd()); + const resolvedTargetPath = resolvePathForWorkspaceCheck(targetPath); + if (!isWithinDirectory(workspaceRoot, resolvedTargetPath)) { + throw new Error(`${optionName} must be inside repository workspace: ${targetPath}`); + } +}; + +const GROUPING_LABELS = new Set(["suite", "surface", "layer", "workflow"]); + +const addLabel = (labels, name, value) => { + if (!value) return; + const existingLabel = labels.find((label) => label && label.name === name); + if (existingLabel && GROUPING_LABELS.has(name)) { + existingLabel.value = value; + return; + } + if (existingLabel?.value === value) { + return; + } + labels.push({ name, value }); +}; + +const readDirectoryEntries = (dirPath) => { + try { + return fs.readdirSync(dirPath, { withFileTypes: true }); + } catch (error) { + if (error?.code === "ENOENT") { + return []; + } + throw error; + } +}; + +const writeFileExclusive = (targetPath, writeOperation) => { + try { + writeOperation(); + } catch (error) { + if (error?.code === "EEXIST") { + throw new Error(`File collision while merging Allure results: ${targetPath}`); + } + throw error; + } +}; + +const readAllureResultPayload = (sourcePath) => { + try { + return JSON.parse(fs.readFileSync(sourcePath, "utf8")); + } catch (error) { + console.warn(`Skipping malformed Allure result file ${sourcePath}: ${error instanceof Error ? error.message : error}`); + return null; + } +}; + +const writeMergedResultFile = (sourcePath, targetPath, suiteName, family) => { + const payload = readAllureResultPayload(sourcePath); + if (!payload) { + return false; + } + + const labels = Array.isArray(payload.labels) ? payload.labels : []; + const suiteMetadata = getSuiteMetadata(suiteName); + addLabel(labels, "family", family); + addLabel(labels, "suite", suiteMetadata.suite); + addLabel(labels, "surface", suiteMetadata.surface); + addLabel(labels, "layer", suiteMetadata.layer); + addLabel(labels, "workflow", suiteMetadata.workflow); + payload.labels = labels; + + writeFileExclusive(targetPath, () => { + fs.writeFileSync(targetPath, `${JSON.stringify(payload, null, 2)}\n`, { + flag: "wx", + }); + }); + return true; +}; + +const copyAllureAsset = (sourcePath, targetPath) => { + try { + fs.copyFileSync(sourcePath, targetPath, fs.constants.COPYFILE_EXCL); + return true; + } catch (error) { + if (error?.code === "EEXIST") { + throw new Error(`File collision while merging Allure results: ${targetPath}`); + } + if (error?.code === "ENOENT") { + return false; + } + throw error; + } +}; + +const copyDirectory = (sourceDir, targetDir, suiteName, family) => { + let copiedFiles = 0; + let resultFiles = 0; + + const visit = (currentSource, currentTarget) => { + ensureDir(currentTarget); + for (const entry of readDirectoryEntries(currentSource)) { + const sourcePath = path.join(currentSource, entry.name); + const targetPath = path.join(currentTarget, entry.name); + + if (entry.isSymbolicLink()) { + throw new Error(`Symlinks are not allowed in Allure inputs: ${sourcePath}`); + } + + if (entry.isDirectory()) { + visit(sourcePath, targetPath); + continue; + } + + if (SKIPPED_FILENAMES.has(entry.name)) { + continue; + } + + if (entry.name.endsWith("-result.json")) { + if (writeMergedResultFile(sourcePath, targetPath, suiteName, family)) { + copiedFiles += 1; + resultFiles += 1; + } + continue; + } + + if (copyAllureAsset(sourcePath, targetPath)) { + copiedFiles += 1; + } + } + }; + + visit(sourceDir, targetDir); + return { copiedFiles, resultFiles }; +}; + +const writeExecutorFile = (resultsDir, context, suites) => { + const executor = { + name: "GitHub Actions", + type: "github", + buildName: `${context.workflow || "workflow"} #${context.runId}`, + buildOrder: Number.parseInt(context.runId, 10) || 0, + buildUrl: `https://github.com/${process.env.GITHUB_REPOSITORY}/actions/runs/${context.runId}`, + reportName: `${context.familyLabel} Allure Report`, + reportUrl: context.reportUrl, + }; + + const payload = { + ...executor, + description: suites.join(", "), + }; + + fs.writeFileSync(path.join(resultsDir, "executor.json"), `${JSON.stringify(payload, null, 2)}\n`); +}; + +const writeConfigFile = (configPath, reportDir, historyPath, context, suites) => { + const variables = { + Family: context.familyLabel, + Suites: suites.join(", "), + Scope: context.scopeLabel, + Ref: context.refName, + Commit: context.sha.slice(0, 7), + Workflow: context.workflow || "unknown", + }; + + if (context.previewUrl) { + variables["Preview URL"] = context.previewUrl; + } + + if (context.e2eRef) { + variables["E2E Ref"] = context.e2eRef; + } + + const config = `const { defineConfig } = require("allure"); + +module.exports = defineConfig({ + name: ${JSON.stringify(`${context.familyLabel} Allure Report`)}, + output: ${JSON.stringify(reportDir)}, + ${historyPath ? `historyPath: ${JSON.stringify(historyPath)},` : ""} + variables: ${JSON.stringify(variables, null, 2)}, + plugins: { + awesome: { + options: { + reportName: ${JSON.stringify(`${context.familyLabel} Allure Report`)}, + singleFile: false, + reportLanguage: "en", + groupBy: ["surface", "suite"] + } + } + } +}); +`; + + fs.writeFileSync(configPath, config); +}; + +const parseInput = (item) => { + const separatorIndex = item.indexOf("="); + if (separatorIndex === -1) { + throw new Error(`Invalid --input value '${item}', expected suite=path`); + } + + const suite = item.slice(0, separatorIndex); + getSuiteMetadata(suite); + const sourceDir = path.resolve(item.slice(separatorIndex + 1)); + ensureWithinWorkspace(sourceDir, "--input"); + + return { suite, sourceDir }; +}; + +const copyInputResults = (inputArgs, resultsDir, family) => { + const suites = []; + let copiedFiles = 0; + let resultFiles = 0; + + for (const item of inputArgs) { + const { suite, sourceDir } = parseInput(item); + if (!fs.existsSync(sourceDir)) { + continue; + } + + const copyStats = copyDirectory(sourceDir, resultsDir, suite, family); + if (copyStats.copiedFiles === 0) { + continue; + } + + suites.push(suite); + copiedFiles += copyStats.copiedFiles; + resultFiles += copyStats.resultFiles; + } + + return { suites, copiedFiles, resultFiles }; +}; + +const buildMetadata = ({ + family, + context, + suites, + resultFiles, + reportDir, + resultsDir, +}) => ({ + family, + familyLabel: context.familyLabel, + suites, + suiteLabels: suites.map((suite) => getSuiteMetadata(suite).label), + path: context.reportDir, + url: context.reportUrl, + runId: context.runId, + runAttempt: context.runAttempt, + prNumber: context.prNumber, + ref: context.refName, + sha: context.sha, + outcome: context.outcome, + previewUrl: context.previewUrl || null, + e2eRef: context.e2eRef || null, + workflow: context.workflow || null, + scopeType: context.scopeType, + scopeLabel: context.scopeLabel, + historyPath: context.historyPath || null, + generatedAt: context.generatedAt, + hasResults: resultFiles > 0, + reportOutputDir: normalizeSlashes(path.relative(process.cwd(), reportDir)), + combinedResultsDir: normalizeSlashes(path.relative(process.cwd(), resultsDir)), +}); + +const generateAllureReport = ({ resultFiles, resultsDir, reportDir, configPath, historyRoot, context, suiteLabels }) => { + if (resultFiles === 0) { + return; + } + + writeExecutorFile(resultsDir, context, suiteLabels); + const absoluteHistoryPath = context.historyPath ? path.join(historyRoot, context.historyPath) : ""; + if (absoluteHistoryPath) { + ensureDir(path.dirname(absoluteHistoryPath)); + } + writeConfigFile(configPath, reportDir, absoluteHistoryPath, context, suiteLabels); + + const npxExecutable = process.platform === "win32" ? "npx.cmd" : "npx"; + execFileSync(npxExecutable, ["allure", "generate", resultsDir, "--config", configPath], { + stdio: "inherit", + }); + + trimHistoryFile(absoluteHistoryPath, HISTORY_LIMIT); +}; + +const trimHistoryFile = (historyPath, limit) => { + if (!historyPath) { + return; + } + + let fileDescriptor; + try { + fileDescriptor = fs.openSync(historyPath, "r+"); + } catch (error) { + if (error?.code === "ENOENT") { + return; + } + throw error; + } + + try { + const historyContents = fs.readFileSync(fileDescriptor, "utf8"); + const lines = historyContents.split(/\r?\n/).filter(Boolean); + const retainedLines = lines.slice(-limit); + fs.ftruncateSync(fileDescriptor, 0); + fs.writeSync(fileDescriptor, `${retainedLines.join("\n")}\n`, 0, "utf8"); + } finally { + fs.closeSync(fileDescriptor); + } +}; + +const main = () => { + const args = parseArgs(process.argv); + const family = args.family; + const inputArgs = listify(args.input); + const workDir = path.resolve(args["work-dir"] || path.join(".allure-publish", family || "report")); + const historyRoot = path.resolve(args["history-root"] || workDir); + ensureWithinWorkspace(workDir, "--work-dir"); + ensureWithinWorkspace(historyRoot, "--history-root"); + + if (!family) { + throw new Error("--family is required"); + } + + if (inputArgs.length === 0) { + throw new Error("At least one --input suite=path is required"); + } + + ensureDir(workDir); + + const resultsDir = path.join(workDir, "results"); + const reportDir = path.join(workDir, "report"); + const metadataPath = path.join(workDir, "metadata.json"); + const configPath = path.join(workDir, "allurerc.cjs"); + + fs.rmSync(resultsDir, { recursive: true, force: true }); + fs.rmSync(reportDir, { recursive: true, force: true }); + ensureDir(resultsDir); + + const context = computeReportContext({ family }); + const { suites, copiedFiles, resultFiles } = copyInputResults(inputArgs, resultsDir, family); + const metadata = buildMetadata({ + family, + context, + suites, + resultFiles, + reportDir, + resultsDir, + }); + + generateAllureReport({ resultFiles, resultsDir, reportDir, configPath, historyRoot, context, suiteLabels: metadata.suiteLabels }); + + fs.writeFileSync(metadataPath, `${JSON.stringify(metadata, null, 2)}\n`); + + appendGithubOutput(args["github-output"], { + has_results: metadata.hasResults ? "true" : "false", + report_dir: metadata.path, + report_url: metadata.url, + history_path: metadata.historyPath || "", + metadata_path: metadataPath, + report_output_dir: reportDir, + combined_results_dir: resultsDir, + suites: metadata.suites.join(","), + copied_files: copiedFiles, + result_files: resultFiles, + }); +}; + +try { + main(); +} catch (error) { + console.error(error instanceof Error ? error.message : error); + process.exit(1); +} diff --git a/scripts/prune-allure-pages.js b/scripts/prune-allure-pages.js new file mode 100644 index 00000000000..0acf7740c3c --- /dev/null +++ b/scripts/prune-allure-pages.js @@ -0,0 +1,130 @@ +#!/usr/bin/env node + +const fs = require("node:fs"); +const path = require("node:path"); +const { parseArgs } = require("./allure-pages-utils"); + +const readRetained = (filePath) => { + if (!filePath || !fs.existsSync(filePath)) { + return new Set(); + } + return new Set( + fs + .readFileSync(filePath, "utf8") + .split("\n") + .map((line) => line.trim()) + .filter(Boolean) + ); +}; + +const listDirectories = (dirPath) => + fs + .readdirSync(dirPath, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()); + +const listEntries = (dirPath) => fs.readdirSync(dirPath, { withFileTypes: true }); + +const isDescendant = (root, candidatePath) => { + const relativePath = path.relative(path.resolve(root), path.resolve(candidatePath)); + return relativePath !== "" && !relativePath.startsWith("..") && !path.isAbsolute(relativePath); +}; + +const removeReportDirectory = (root, reportPath) => { + fs.rmSync(reportPath, { recursive: true, force: true }); + removeEmptyParents(root, reportPath); +}; + +const removeEmptyParents = (root, currentPath) => { + const resolvedRoot = path.resolve(root); + let cursor = path.resolve(path.dirname(currentPath)); + while (cursor !== resolvedRoot) { + if (!isDescendant(resolvedRoot, cursor)) { + break; + } + + if (fs.existsSync(cursor) && fs.readdirSync(cursor).length !== 0) { + break; + } + + if (fs.existsSync(cursor)) { + fs.rmSync(cursor, { recursive: true, force: true }); + } + cursor = path.dirname(cursor); + } +}; + +const collectReportRunPaths = (root) => { + const reportsRoot = path.join(root, "reports"); + if (!fs.existsSync(reportsRoot)) { + return []; + } + + const runPaths = []; + for (const family of listDirectories(reportsRoot)) { + const familyPath = path.join(reportsRoot, family.name); + for (const scope of listDirectories(familyPath)) { + const scopePath = path.join(familyPath, scope.name); + for (const run of listDirectories(scopePath)) { + runPaths.push(path.join(scopePath, run.name)); + } + } + } + return runPaths; +}; + +const isRetainedReportPath = (root, runPath, retainedPaths) => { + const relativePath = path.relative(root, runPath).replaceAll(path.sep, "/"); + return retainedPaths.has(relativePath); +}; + +const pruneReportDirectories = (root, retainedPaths) => { + for (const runPath of collectReportRunPaths(root)) { + if (!isRetainedReportPath(root, runPath, retainedPaths)) { + removeReportDirectory(root, runPath); + } + } +}; + +const pruneHistoryFiles = (root, retainedHistoryPaths) => { + const historyRoot = path.join(root, "_history"); + if (!fs.existsSync(historyRoot)) { + return; + } + + const visit = (currentDir) => { + for (const entry of listEntries(currentDir)) { + const entryPath = path.join(currentDir, entry.name); + if (entry.isDirectory()) { + visit(entryPath); + if (fs.existsSync(entryPath) && fs.readdirSync(entryPath).length === 0) { + fs.rmSync(entryPath, { recursive: true, force: true }); + } + continue; + } + + const relativePath = path.relative(root, entryPath).replaceAll(path.sep, "/"); + if (!retainedHistoryPaths.has(relativePath)) { + fs.rmSync(entryPath, { force: true }); + } + } + }; + + visit(historyRoot); +}; + +const main = () => { + const args = parseArgs(process.argv); + const root = path.resolve(args.root || "."); + const retainedPaths = readRetained(args["reports-list"] ? path.resolve(args["reports-list"]) : ""); + const retainedHistoryPaths = readRetained(args["history-list"] ? path.resolve(args["history-list"]) : ""); + + pruneReportDirectories(root, retainedPaths); + pruneHistoryFiles(root, retainedHistoryPaths); +}; + +try { + main(); +} catch (error) { + console.error(error instanceof Error ? error.message : error); + process.exit(1); +} diff --git a/scripts/render-pr-status-comment.js b/scripts/render-pr-status-comment.js new file mode 100644 index 00000000000..a440a471d21 --- /dev/null +++ b/scripts/render-pr-status-comment.js @@ -0,0 +1,204 @@ +#!/usr/bin/env node + +const fs = require("node:fs"); +const path = require("node:path"); +const { parseArgs } = require("./allure-pages-utils"); + +const COMMENT_MARKER = ""; +const REPORT_FAMILIES = ["unit", "component", "e2e"]; +const FAMILY_LABELS = { + component: "Component", + e2e: "E2E", + unit: "Unit", +}; + +const normalizePrNumber = (value) => `${value ?? ""}`.trim(); +const normalizeSha = (value) => `${value ?? ""}`.trim().toLowerCase(); + +const formatDateTime = (value) => { + const timestamp = Date.parse(value || ""); + if (!Number.isFinite(timestamp)) { + return "unknown"; + } + return new Date(timestamp).toISOString().replace(".000Z", "Z"); +}; + +const compareReports = (left, right) => { + const leftDate = Date.parse(left?.generatedAt || "") || 0; + const rightDate = Date.parse(right?.generatedAt || "") || 0; + if (rightDate !== leftDate) { + return rightDate - leftDate; + } + + const leftRunId = Number.parseInt(left?.runId || "0", 10) || 0; + const rightRunId = Number.parseInt(right?.runId || "0", 10) || 0; + if (rightRunId !== leftRunId) { + return rightRunId - leftRunId; + } + + const leftAttempt = Number.parseInt(left?.runAttempt || "0", 10) || 0; + const rightAttempt = Number.parseInt(right?.runAttempt || "0", 10) || 0; + return rightAttempt - leftAttempt; +}; + +const readReportsIndex = (filePath) => { + if (!filePath || !fs.existsSync(filePath)) { + return []; + } + + let rawContents = fs.readFileSync(path.resolve(filePath), "utf8"); + if (rawContents.charCodeAt(0) === 0xfeff) { + rawContents = rawContents.slice(1); + } + + const payload = JSON.parse(rawContents); + return Array.isArray(payload) ? payload : []; +}; + +const selectLatestReports = (reports, prNumber, headSha) => { + const normalizedPrNumber = normalizePrNumber(prNumber); + const normalizedHeadSha = normalizeSha(headSha); + const reportsByFamily = new Map(); + + for (const family of REPORT_FAMILIES) { + const match = reports + .filter((report) => + report?.family === family && + normalizePrNumber(report?.prNumber) === normalizedPrNumber && + normalizeSha(report?.sha) === normalizedHeadSha + ) + .sort(compareReports)[0]; + reportsByFamily.set(family, match || null); + } + + return reportsByFamily; +}; + +const isSafeHttpUrl = (value) => { + try { + const url = new URL(`${value ?? ""}`); + return url.protocol === "https:" || url.protocol === "http:"; + } catch { + return false; + } +}; + +const escapeMarkdownCell = (value) => + `${value ?? ""}` + .replaceAll("\r", " ") + .replaceAll("\n", " ") + .replaceAll("|", "\\|") + .trim(); + +const buildMarkdownLink = (label, url) => { + const safeLabel = escapeMarkdownCell(label); + return isSafeHttpUrl(url) ? `[${safeLabel}](${url})` : safeLabel; +}; + +const buildRunUrl = (repository, report) => { + if (!repository || !report?.runId) { + return ""; + } + return `https://github.com/${repository}/actions/runs/${report.runId}`; +}; + +const buildReportCell = (report) => { + if (!report?.url) { + return "Not published yet"; + } + return buildMarkdownLink("Open report", report.url); +}; + +const buildWorkflowCell = (repository, report) => { + if (!report) { + return "Waiting for publish"; + } + + const label = report.workflow || "Workflow run"; + const runUrl = buildRunUrl(repository, report); + return runUrl ? buildMarkdownLink(label, runUrl) : escapeMarkdownCell(label); +}; + +const renderComment = ({ + catalogUrl, + headSha, + prNumber, + reportsByFamily, + repository, + updatedAt = new Date().toISOString(), +}) => { + const lines = [ + COMMENT_MARKER, + "## PR Status", + "", + `Updated for PR #${escapeMarkdownCell(prNumber)} at \`${normalizeSha(headSha).slice(0, 7) || "unknown"}\` on ${formatDateTime(updatedAt)}.`, + "", + "### Test Reports", + "", + "| Family | Source | Report |", + "|---|---|---|", + ]; + + for (const family of REPORT_FAMILIES) { + const report = reportsByFamily.get(family) || null; + lines.push( + `| ${FAMILY_LABELS[family] || family} | ${buildWorkflowCell(repository, report)} | ${buildReportCell(report)} |` + ); + } + + if (catalogUrl) { + lines.push("", `Catalog: ${buildMarkdownLink("All reports", catalogUrl)}`); + } + + return `${lines.join("\n")}\n`; +}; + +const main = () => { + const args = parseArgs(process.argv); + const repository = args.repository || process.env.GITHUB_REPOSITORY || ""; + const prNumber = normalizePrNumber(args["pr-number"] || process.env.PR_NUMBER); + const headSha = normalizeSha(args["head-sha"] || process.env.COMMIT_SHA || process.env.GITHUB_SHA); + + if (!repository) { + throw new Error("--repository or GITHUB_REPOSITORY is required"); + } + if (!prNumber) { + throw new Error("--pr-number or PR_NUMBER is required"); + } + if (!headSha) { + throw new Error("--head-sha, COMMIT_SHA, or GITHUB_SHA is required"); + } + + const reports = readReportsIndex(args["reports-index"]); + const reportsByFamily = selectLatestReports(reports, prNumber, headSha); + const body = renderComment({ + catalogUrl: args["catalog-url"] || "", + headSha, + prNumber, + reportsByFamily, + repository, + }); + + if (args.output) { + fs.writeFileSync(path.resolve(args.output), body); + return; + } + + process.stdout.write(body); +}; + +if (require.main === module) { + try { + main(); + } catch (error) { + console.error(error instanceof Error ? error.message : error); + process.exit(1); + } +} + +module.exports = { + COMMENT_MARKER, + REPORT_FAMILIES, + renderComment, + selectLatestReports, +}; diff --git a/scripts/render-pr-status-comment.test.js b/scripts/render-pr-status-comment.test.js new file mode 100644 index 00000000000..f03208441a8 --- /dev/null +++ b/scripts/render-pr-status-comment.test.js @@ -0,0 +1,105 @@ +const test = require("node:test"); +const assert = require("node:assert/strict"); + +const { + COMMENT_MARKER, + renderComment, + selectLatestReports, +} = require("./render-pr-status-comment"); + +test("selectLatestReports keeps the newest report per family for the active PR head sha", () => { + const reports = [ + { + family: "unit", + prNumber: 112, + sha: "abcdef123456", + url: "https://example.test/unit-old", + generatedAt: "2026-05-04T10:00:00Z", + runId: "100", + runAttempt: "1", + }, + { + family: "unit", + prNumber: 112, + sha: "abcdef123456", + url: "https://example.test/unit-new", + generatedAt: "2026-05-04T10:05:00Z", + runId: "101", + runAttempt: "1", + }, + { + family: "component", + prNumber: 112, + sha: "different", + url: "https://example.test/component-stale", + generatedAt: "2026-05-04T10:06:00Z", + runId: "102", + runAttempt: "1", + }, + ]; + + const reportsByFamily = selectLatestReports(reports, "112", "abcdef123456"); + + assert.equal(reportsByFamily.get("unit")?.url, "https://example.test/unit-new"); + assert.equal(reportsByFamily.get("component"), null); + assert.equal(reportsByFamily.get("e2e"), null); +}); + +test("renderComment uses a generic PR status marker and report section", () => { + const reportsByFamily = new Map([ + [ + "unit", + { + runId: "1", + url: "https://example.test/unit", + workflow: "Unit Tests", + }, + ], + ["component", null], + ["e2e", null], + ]); + + const body = renderComment({ + catalogUrl: "https://example.test/reports", + headSha: "abcdef123456", + prNumber: "112", + reportsByFamily, + repository: "koreyba/EverFreeNote", + updatedAt: "2026-05-04T11:00:00Z", + }); + + assert.match(body, new RegExp(COMMENT_MARKER.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))); + assert.match(body, /## PR Status/); + assert.match(body, /### Test Reports/); + assert.match(body, /\| Unit \| \[Unit Tests\]\(https:\/\/github\.com\/koreyba\/EverFreeNote\/actions\/runs\/1\) \| \[Open report\]\(https:\/\/example\.test\/unit\) \|/); + assert.match(body, /\| Component \| Waiting for publish \| Not published yet \|/); + assert.match(body, /Catalog: \[All reports\]\(https:\/\/example\.test\/reports\)/); +}); + +test("renderComment escapes table cells and refuses unsafe URLs", () => { + const reportsByFamily = new Map([ + [ + "unit", + { + runId: "1", + url: "javascript:alert(1)", + workflow: "Unit | Tests\nInjected", + }, + ], + ["component", null], + ["e2e", null], + ]); + + const body = renderComment({ + catalogUrl: "javascript:alert(2)", + headSha: "abcdef123456", + prNumber: "112", + reportsByFamily, + repository: "koreyba/EverFreeNote", + updatedAt: "2026-05-04T11:00:00Z", + }); + + assert.match(body, /Unit \\\| Tests Injected/); + assert.doesNotMatch(body, /\]\(javascript:/); + assert.match(body, /Catalog: All reports/); +});