From e2744efdbb1a2ee7c6da7095ce899ae5cba181ab Mon Sep 17 00:00:00 2001 From: Denys Date: Fri, 1 May 2026 10:00:10 +0200 Subject: [PATCH 01/19] Implement Allure family reports publishing to GitHub Pages --- .github/pages/allure-reports-index.html | 529 ++++++++++++++++++ .github/workflows/component-tests.yml | 152 +++++ .github/workflows/e2e-tests.yml | 235 ++++---- .github/workflows/unit-tests.yml | 158 ++++++ .gitignore | 2 + docs/ai/design/feature-allure-report-v3.md | 67 ++- .../feature-allure-report-v3.md | 16 + docs/ai/planning/feature-allure-report-v3.md | 53 +- .../requirements/feature-allure-report-v3.md | 14 +- docs/ai/testing/feature-allure-report-v3.md | 17 +- jest.config.cjs | 14 +- package.json | 2 + scripts/allure-pages-utils.js | 228 ++++++++ scripts/generate-allure-report-index.js | 102 ++++ scripts/prepare-allure-family-report.js | 249 +++++++++ scripts/prune-allure-pages.js | 98 ++++ 16 files changed, 1772 insertions(+), 164 deletions(-) create mode 100644 .github/pages/allure-reports-index.html create mode 100644 scripts/allure-pages-utils.js create mode 100644 scripts/generate-allure-report-index.js create mode 100644 scripts/prepare-allure-family-report.js create mode 100644 scripts/prune-allure-pages.js diff --git a/.github/pages/allure-reports-index.html b/.github/pages/allure-reports-index.html new file mode 100644 index 00000000000..6a31471c9dd --- /dev/null +++ b/.github/pages/allure-reports-index.html @@ -0,0 +1,529 @@ + + + + + + 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..a6cabbdf0b9 100644 --- a/.github/workflows/component-tests.yml +++ b/.github/workflows/component-tests.yml @@ -201,6 +201,158 @@ jobs: if-no-files-found: ignore retention-days: 30 + - name: Generate component Allure report + if: always() + 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: 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' + runs-on: ubuntu-latest + timeout-minutes: 20 + concurrency: + group: gh-pages-allure-publish + cancel-in-progress: false + permissions: + contents: 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" diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index f7d8c8ffd59..732802dc451 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -173,10 +173,8 @@ 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 }} env: BASE_URL: ${{ needs.wait-cloudflare-preview.outputs.preview_url || inputs.preview_url }} @@ -209,30 +207,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 +223,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 +232,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 +288,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 +327,153 @@ 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: 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 != '' + if: always() && needs.run-e2e.result != 'skipped' 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 + 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/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 - - - 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 + { + echo "## E2E Allure Report" + echo + echo "- Published report: ${REPORT_URL}" + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index 1939d1f92d8..00b59704e25 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -315,6 +315,15 @@ jobs: echo "No core unit Allure results found; skipping report generation." fi + - name: Generate core integration Allure report + if: always() + 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: Upload core test reports if: always() uses: actions/upload-artifact@v6 @@ -334,6 +343,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 @@ -499,3 +510,150 @@ 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() + runs-on: ubuntu-latest + timeout-minutes: 25 + concurrency: + group: gh-pages-allure-publish + cancel-in-progress: false + permissions: + contents: 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 + uses: actions/download-artifact@v7 + with: + name: unit-test-report-core-allure-${{ github.run_id }} + path: .artifacts/core + + - name: Download web Allure artifact + uses: actions/download-artifact@v7 + with: + name: unit-test-report-web-allure-${{ github.run_id }} + path: .artifacts/web + + - name: Download mobile Allure artifact + 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/ui/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" diff --git a/.gitignore b/.gitignore index d67a24e0458..a4e0d0e78eb 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 diff --git a/docs/ai/design/feature-allure-report-v3.md b/docs/ai/design/feature-allure-report-v3.md index 0a6ff0771d3..29f678fc055 100644 --- a/docs/ai/design/feature-allure-report-v3.md +++ b/docs/ai/design/feature-allure-report-v3.md @@ -14,13 +14,19 @@ 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 @@ -28,7 +34,9 @@ flowchart TD - `allure-cypress`: records Cypress component test execution into `allure-results/component`. - `allure-jest`: planned 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,52 @@ 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 +reports/ + index.json + index.html + e2e/pr-/run--attempt-/ + e2e/manual/run--attempt-/ + component/pr-/run--attempt-/ + unit/pr-/run--attempt-/ +_history/ + e2e/pr-.jsonl + e2e/branch-main.jsonl + e2e/branch-develop.jsonl + component/pr-.jsonl + unit/pr-.jsonl + unit/branch-main.jsonl +``` + +## 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` runs append to family-specific history files keyed by PR number. +- `main` and `develop` append to family-specific branch history files for long-lived trends. +- Manual runs publish under `manual` paths and may use their own manual history key or stay history-less if no stable scope exists. +- 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/implementation/feature-allure-report-v3.md b/docs/ai/implementation/feature-allure-report-v3.md index 209b36b293f..2ce62328563 100644 --- a/docs/ai/implementation/feature-allure-report-v3.md +++ b/docs/ai/implementation/feature-allure-report-v3.md @@ -18,11 +18,16 @@ 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 should join the published `unit` family through a dedicated suite label, even if they do not generate a separate local report script today. - 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//.jsonl`. ## Implementation Notes @@ -47,6 +52,15 @@ 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 + +- `component` stays a single-suite family report built from `allure-results/component`. +- `unit` is assembled in CI by downloading Allure result artifacts from: + `core-unit`, `core-integration`, `web-unit`, and `mobile-unit`. +- `e2e` is assembled from the external repository's `allure-results/e2e` artifact after the test run completes. +- Every family report gets injected `executor.json`, environment metadata, and a history path chosen from family plus scope. +- The shared Pages index reads a generated JSON catalog rather than crawling directories at runtime. + ## Integration Points - Component CI can upload `allure-results/component` immediately after the test step. @@ -54,3 +68,5 @@ description: Implementation notes for Allure reporting - `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 `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. +- Future Pages publication should consume raw Allure results rather than republishing the prebuilt per-suite HTML reports. +- The old `e2e-tests.yml` Playwright HTML Pages publication path should be removed once the `e2e` Allure family publish job is live. diff --git a/docs/ai/planning/feature-allure-report-v3.md b/docs/ai/planning/feature-allure-report-v3.md index 45db36a698f..61693704a7f 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. +- [ ] Milestone 2: Family-level GitHub Pages architecture and docs. +- [ ] Milestone 3: Family-level publication for component and unit workflows. +- [ ] 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. +- [ ] Task 2.1: Add a shared Pages catalog model for Allure family reports. +- [ ] Task 2.2: Define history-key rules for `PR`, `main`, `develop`, and manual scopes. +- [ ] 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. +- [ ] Task 3.1: Generate and upload component Allure artifacts in `.github/workflows/component-tests.yml`. +- [ ] Task 3.2: Merge core, integration, web, and mobile Allure results into a single `unit` family report. +- [ ] Task 3.3: Publish `component` and `unit` family reports plus the shared Pages index. +- [ ] 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. +- [ ] Task 4.2: Replace the old Playwright HTML Pages publish with `e2e` Allure family publication. +- [ ] 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/requirements/feature-allure-report-v3.md b/docs/ai/requirements/feature-allure-report-v3.md index 99892e7efca..725b46e8ea4 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,11 @@ 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 should be generated in the external E2E repo, then downloaded and republished by this repository's workflow so the Pages catalog stays centralized. +- 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/testing/feature-allure-report-v3.md b/docs/ai/testing/feature-allure-report-v3.md index 12e9a38d5c9..bd9f76f40c3 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 and scope, but not leaked across unrelated families or PRs. ## 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//.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,12 @@ 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. +- Shared GitHub Pages catalog for family reports is not implemented yet. +- Component and unit workflows do not publish family reports to Pages yet. +- E2E Allure Pages publication has not replaced the old Playwright HTML Pages flow yet. +- History retention logic per family and scope is still pending implementation. 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 d270d0beb8f..38f7a5a1f31 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 && npm run allure:generate:core-unit", "test:integration:core": "jest --config jest.config.cjs --selectProjects integration-core", + "test:integration:core:allure": "npm run test:integration:core && npm run allure:generate:core-integration", "test:unit:web": "jest --config jest.config.cjs --selectProjects unit-web", "test:unit:web:allure": "npm run test:unit:web && npm run allure:generate:web-unit", "type-check": "tsc --noEmit", @@ -41,6 +42,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..415db36b376 --- /dev/null +++ b/scripts/allure-pages-utils.js @@ -0,0 +1,228 @@ +#!/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 slugify = (value) => + String(value || "") + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") || "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 appendGithubOutput = (githubOutputPath, values) => { + if (!githubOutputPath) return; + const lines = Object.entries(values).map(([key, value]) => `${key}=${value ?? ""}`); + 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}`, + historyKey: `pr-${prNumber}`, + }; + } + + if (refName === "main" || refName === "develop") { + return { + scopeType: "branch", + scopeKey: `branch-${slugify(refName)}`, + scopeLabel: refName, + historyKey: `branch-${slugify(refName)}`, + }; + } + + if (eventName === "workflow_dispatch") { + return { + scopeType: "manual", + scopeKey: "manual", + scopeLabel: "Manual", + historyKey: null, + }; + } + + return { + scopeType: "manual", + scopeKey: "manual", + scopeLabel: "Manual", + historyKey: null, + }; +}; + +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 = (env.PAGES_BASE_URL || "").replace(/\/+$/, ""); + 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 = scope.historyKey + ? normalizeSlashes(path.join("_history", family, `${scope.historyKey}.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/generate-allure-report-index.js b/scripts/generate-allure-report-index.js new file mode 100644 index 00000000000..5028b545ce3 --- /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 && 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..127d325dca5 --- /dev/null +++ b/scripts/prepare-allure-family-report.js @@ -0,0 +1,249 @@ +#!/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"]); + +const addLabel = (labels, name, value) => { + if (!value) return; + if (labels.some((label) => label && label.name === name && label.value === value)) { + return; + } + labels.push({ name, value }); +}; + +const copyDirectory = (sourceDir, targetDir, suiteName, family) => { + if (!fs.existsSync(sourceDir)) { + return { copiedFiles: 0, resultFiles: 0 }; + } + + let copiedFiles = 0; + let resultFiles = 0; + + const visit = (currentSource, currentTarget) => { + ensureDir(currentTarget); + for (const entry of fs.readdirSync(currentSource, { withFileTypes: true })) { + const sourcePath = path.join(currentSource, entry.name); + const targetPath = path.join(currentTarget, entry.name); + + if (entry.isDirectory()) { + visit(sourcePath, targetPath); + continue; + } + + if (SKIPPED_FILENAMES.has(entry.name)) { + continue; + } + + if (fs.existsSync(targetPath)) { + throw new Error(`File collision while merging Allure results: ${targetPath}`); + } + + if (entry.name.endsWith("-result.json")) { + const payload = JSON.parse(fs.readFileSync(sourcePath, "utf8")); + 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; + fs.writeFileSync(targetPath, `${JSON.stringify(payload, null, 2)}\n`); + copiedFiles += 1; + resultFiles += 1; + continue; + } + + fs.copyFileSync(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: ["layer", "surface", "suite"] + } + } + } +}); +`; + + fs.writeFileSync(configPath, config); +}; + +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); + + 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 = []; + let copiedFiles = 0; + let resultFiles = 0; + + for (const item of inputArgs) { + const separatorIndex = item.indexOf("="); + if (separatorIndex === -1) { + throw new Error(`Invalid --input value '${item}', expected suite=path`); + } + const suite = item.slice(0, separatorIndex); + const sourceDir = path.resolve(item.slice(separatorIndex + 1)); + if (!fs.existsSync(sourceDir)) { + continue; + } + const copyStats = copyDirectory(sourceDir, resultsDir, suite, family); + if (copyStats.copiedFiles > 0) { + suites.push(suite); + copiedFiles += copyStats.copiedFiles; + resultFiles += copyStats.resultFiles; + } + } + + const metadata = { + 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)), + }; + + if (resultFiles > 0) { + writeExecutorFile(resultsDir, context, metadata.suiteLabels); + const absoluteHistoryPath = context.historyPath + ? path.join(historyRoot, context.historyPath) + : ""; + if (absoluteHistoryPath) { + ensureDir(path.dirname(absoluteHistoryPath)); + } + writeConfigFile(configPath, reportDir, absoluteHistoryPath, context, metadata.suiteLabels); + + const npxExecutable = process.platform === "win32" ? "npx.cmd" : "npx"; + execFileSync(npxExecutable, ["allure", "generate", resultsDir, "--config", configPath], { + stdio: "inherit", + }); + } + + 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..f935246182b --- /dev/null +++ b/scripts/prune-allure-pages.js @@ -0,0 +1,98 @@ +#!/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 removeEmptyParents = (root, currentPath) => { + let cursor = path.dirname(currentPath); + while (cursor.startsWith(root) && cursor !== root) { + if (fs.existsSync(cursor) && fs.readdirSync(cursor).length === 0) { + fs.rmSync(cursor, { recursive: true, force: true }); + } + cursor = path.dirname(cursor); + } +}; + +const pruneReportDirectories = (root, retainedPaths) => { + const reportsRoot = path.join(root, "reports"); + if (!fs.existsSync(reportsRoot)) { + return; + } + + const families = fs.readdirSync(reportsRoot, { withFileTypes: true }); + for (const family of families) { + if (!family.isDirectory()) continue; + const familyPath = path.join(reportsRoot, family.name); + for (const scope of fs.readdirSync(familyPath, { withFileTypes: true })) { + if (!scope.isDirectory()) continue; + const scopePath = path.join(familyPath, scope.name); + for (const run of fs.readdirSync(scopePath, { withFileTypes: true })) { + if (!run.isDirectory()) continue; + const runPath = path.join(scopePath, run.name); + const relativePath = path.relative(root, runPath).replaceAll(path.sep, "/"); + if (!retainedPaths.has(relativePath)) { + fs.rmSync(runPath, { recursive: true, force: true }); + removeEmptyParents(root, runPath); + } + } + } + } +}; + +const pruneHistoryFiles = (root, retainedHistoryPaths) => { + const historyRoot = path.join(root, "_history"); + if (!fs.existsSync(historyRoot)) { + return; + } + + const visit = (currentDir) => { + for (const entry of fs.readdirSync(currentDir, { withFileTypes: true })) { + 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); +} From 10f7926cf16e14b7fb84c616cb8dda6e4d25fd07 Mon Sep 17 00:00:00 2001 From: Denys Date: Fri, 1 May 2026 11:28:20 +0200 Subject: [PATCH 02/19] Fix Allure publish paths and harden report merge --- .github/workflows/component-tests.yml | 2 + .github/workflows/e2e-tests.yml | 2 +- .github/workflows/unit-tests.yml | 2 +- scripts/prepare-allure-family-report.js | 62 ++++++++++++++++++++----- 4 files changed, 54 insertions(+), 14 deletions(-) diff --git a/.github/workflows/component-tests.yml b/.github/workflows/component-tests.yml index a6cabbdf0b9..a2185fd724d 100644 --- a/.github/workflows/component-tests.yml +++ b/.github/workflows/component-tests.yml @@ -39,6 +39,8 @@ 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" diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index 732802dc451..273eeb210d5 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -422,7 +422,7 @@ jobs: --family e2e \ --work-dir .allure-publish/e2e \ --history-root .allure-history \ - --input e2e=.artifacts/e2e/e2e/allure-results/e2e \ + --input e2e=.artifacts/e2e/allure-results/e2e \ --github-output "$GITHUB_OUTPUT" - name: Update gh-pages content diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index 036d7423f1a..365d35e1436 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -605,7 +605,7 @@ jobs: --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/ui/mobile/allure-results/mobile-unit \ + --input mobile-unit=.artifacts/mobile/allure-results/mobile-unit \ --github-output "$GITHUB_OUTPUT" - name: Update gh-pages content diff --git a/scripts/prepare-allure-family-report.js b/scripts/prepare-allure-family-report.js index 127d325dca5..3f8ee9257bd 100644 --- a/scripts/prepare-allure-family-report.js +++ b/scripts/prepare-allure-family-report.js @@ -13,7 +13,19 @@ const { parseArgs, } = require("./allure-pages-utils"); -const SKIPPED_FILENAMES = new Set(["executor.json"]); +const SKIPPED_FILENAMES = new Set(["executor.json", "environment.properties", "categories.json"]); + +const isWithinDirectory = (baseDir, candidatePath) => { + const relativePath = path.relative(baseDir, candidatePath); + return relativePath !== ".." && !relativePath.startsWith(`..${path.sep}`) && !path.isAbsolute(relativePath); +}; + +const ensureWithinWorkspace = (targetPath, optionName) => { + const workspaceRoot = process.cwd(); + if (!isWithinDirectory(workspaceRoot, targetPath)) { + throw new Error(`${optionName} must be inside repository workspace: ${targetPath}`); + } +}; const addLabel = (labels, name, value) => { if (!value) return; @@ -24,16 +36,22 @@ const addLabel = (labels, name, value) => { }; const copyDirectory = (sourceDir, targetDir, suiteName, family) => { - if (!fs.existsSync(sourceDir)) { - return { copiedFiles: 0, resultFiles: 0 }; - } - let copiedFiles = 0; let resultFiles = 0; const visit = (currentSource, currentTarget) => { ensureDir(currentTarget); - for (const entry of fs.readdirSync(currentSource, { withFileTypes: true })) { + let entries = []; + try { + entries = fs.readdirSync(currentSource, { withFileTypes: true }); + } catch (error) { + if (error && (error.code === "ENOENT" || error.code === "ENOTDIR")) { + return; + } + throw error; + } + + for (const entry of entries) { const sourcePath = path.join(currentSource, entry.name); const targetPath = path.join(currentTarget, entry.name); @@ -46,10 +64,6 @@ const copyDirectory = (sourceDir, targetDir, suiteName, family) => { continue; } - if (fs.existsSync(targetPath)) { - throw new Error(`File collision while merging Allure results: ${targetPath}`); - } - if (entry.name.endsWith("-result.json")) { const payload = JSON.parse(fs.readFileSync(sourcePath, "utf8")); const labels = Array.isArray(payload.labels) ? payload.labels : []; @@ -60,13 +74,32 @@ const copyDirectory = (sourceDir, targetDir, suiteName, family) => { addLabel(labels, "layer", suiteMetadata.layer); addLabel(labels, "workflow", suiteMetadata.workflow); payload.labels = labels; - fs.writeFileSync(targetPath, `${JSON.stringify(payload, null, 2)}\n`); + try { + fs.writeFileSync(targetPath, `${JSON.stringify(payload, null, 2)}\n`, { + flag: "wx", + }); + } catch (error) { + if (error && error.code === "EEXIST") { + throw new Error(`File collision while merging Allure results: ${targetPath}`); + } + throw error; + } copiedFiles += 1; resultFiles += 1; continue; } - fs.copyFileSync(sourcePath, targetPath); + try { + fs.copyFileSync(sourcePath, targetPath, fs.constants.COPYFILE_EXCL); + } catch (error) { + if (error && error.code === "EEXIST") { + throw new Error(`File collision while merging Allure results: ${targetPath}`); + } + if (error && error.code === "ENOENT") { + continue; + } + throw error; + } copiedFiles += 1; } }; @@ -141,6 +174,8 @@ const main = () => { 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"); @@ -173,6 +208,9 @@ const main = () => { } const suite = item.slice(0, separatorIndex); const sourceDir = path.resolve(item.slice(separatorIndex + 1)); + if (!isWithinDirectory(process.cwd(), sourceDir)) { + throw new Error(`Input path must be inside repository workspace: ${sourceDir}`); + } if (!fs.existsSync(sourceDir)) { continue; } From 117385a04fae6299232894cd7e9139ba4cfdef45 Mon Sep 17 00:00:00 2001 From: Denys Date: Fri, 1 May 2026 12:02:24 +0200 Subject: [PATCH 03/19] git ignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index a4e0d0e78eb..bfc35e77c0c 100644 --- a/.gitignore +++ b/.gitignore @@ -131,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 From 9e9d13e830abf40f4612f61c53df77868bc7c52d Mon Sep 17 00:00:00 2001 From: Denys Date: Fri, 1 May 2026 12:11:10 +0200 Subject: [PATCH 04/19] Fix Allure CI publication and component crash capture --- .github/workflows/component-tests.yml | 38 ++- .github/workflows/e2e-tests.yml | 25 +- .github/workflows/unit-tests.yml | 66 ++++- docs/ai/design/feature-allure-report-v3.md | 18 +- .../feature-allure-report-v3.md | 16 +- docs/ai/planning/feature-allure-report-v3.md | 24 +- .../requirements/feature-allure-report-v3.md | 3 +- docs/ai/testing/feature-allure-report-v3.md | 8 +- package.json | 2 +- scripts/allure-pages-utils.js | 20 +- ...ackfill-cypress-spec-failures-to-allure.js | 225 ++++++++++++++++++ 11 files changed, 399 insertions(+), 46 deletions(-) create mode 100644 scripts/backfill-cypress-spec-failures-to-allure.js diff --git a/.github/workflows/component-tests.yml b/.github/workflows/component-tests.yml index a2185fd724d..b874eb26524 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 }} @@ -43,7 +45,9 @@ jobs: 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 -- --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() @@ -191,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 @@ -205,6 +216,7 @@ jobs: - 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 @@ -212,6 +224,18 @@ jobs: 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 @@ -230,7 +254,17 @@ jobs: publish-component-report: name: Publish Component Allure report needs: component-tests - if: always() && needs.component-tests.result != 'skipped' + 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: diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index 273eeb210d5..c77ae5df637 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -176,6 +176,7 @@ jobs: 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 }} @@ -340,6 +341,18 @@ jobs: 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 @@ -347,7 +360,17 @@ jobs: publish-e2e-report: name: Publish E2E Allure report needs: run-e2e - if: always() && needs.run-e2e.result != 'skipped' + if: | + always() && + needs.run-e2e.result != 'skipped' && + 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: 25 concurrency: diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index 365d35e1436..a394702ffa1 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 @@ -319,6 +335,7 @@ jobs: - 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 @@ -326,6 +343,23 @@ jobs: 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 @@ -356,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 @@ -490,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 @@ -517,7 +565,20 @@ jobs: publish-unit-report: name: Publish Unit Allure report needs: [unit-tests-mobile, unit-tests-core, unit-tests-web] - if: always() + 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: @@ -548,18 +609,21 @@ jobs: 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 }} diff --git a/docs/ai/design/feature-allure-report-v3.md b/docs/ai/design/feature-allure-report-v3.md index 29f678fc055..f087eeb9f00 100644 --- a/docs/ai/design/feature-allure-report-v3.md +++ b/docs/ai/design/feature-allure-report-v3.md @@ -32,7 +32,7 @@ flowchart TD ## 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 and family-level Pages publication. - `.github/pages/*`: static landing page template and report catalog assets for GitHub Pages. @@ -55,22 +55,24 @@ flowchart TD ## Pages Structure ```text +index.html reports/ index.json - index.html e2e/pr-/run--attempt-/ e2e/manual/run--attempt-/ component/pr-/run--attempt-/ unit/pr-/run--attempt-/ _history/ - e2e/pr-.jsonl - e2e/branch-main.jsonl - e2e/branch-develop.jsonl - component/pr-.jsonl - unit/pr-.jsonl - unit/branch-main.jsonl + e2e/pr-.json + e2e/branch-main.json + e2e/branch-develop.json + component/pr-.json + unit/pr-.json + unit/branch-main.json ``` +`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/`. + ## Report Identity Model - `family`: top-level Pages grouping and history namespace. diff --git a/docs/ai/implementation/feature-allure-report-v3.md b/docs/ai/implementation/feature-allure-report-v3.md index 2ce62328563..fcceaad5d7c 100644 --- a/docs/ai/implementation/feature-allure-report-v3.md +++ b/docs/ai/implementation/feature-allure-report-v3.md @@ -18,7 +18,8 @@ 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 should join the published `unit` family through a dedicated suite label, even if they do not generate a separate local report script today. +- 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`. @@ -27,7 +28,7 @@ description: Implementation notes for Allure reporting - GitHub Pages family reports: `reports/e2e/...`, `reports/component/...`, and `reports/unit/...`. - GitHub Pages history store: - `_history//.jsonl`. + `_history//.json`. ## Implementation Notes @@ -45,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. @@ -55,9 +59,10 @@ description: Implementation notes for Allure reporting ### Family Publication Model - `component` stays a single-suite family report built from `allure-results/component`. +- Component CI backfills a synthetic Allure failure when Cypress crashes a spec before `allure-cypress` can persist the failure result. - `unit` is assembled in CI by downloading Allure result artifacts from: `core-unit`, `core-integration`, `web-unit`, and `mobile-unit`. -- `e2e` is assembled from the external repository's `allure-results/e2e` artifact after the test run completes. +- `e2e` is assembled from the downloaded `EverFreeNote-e2e` workflow artifact inside `.github/workflows/e2e-tests.yml`. - Every family report gets injected `executor.json`, environment metadata, and a history path chosen from family plus scope. - The shared Pages index reads a generated JSON catalog rather than crawling directories at runtime. @@ -66,7 +71,8 @@ description: Implementation notes for Allure reporting - 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. -- Future Pages publication should consume raw Allure results rather than republishing the prebuilt per-suite HTML reports. -- The old `e2e-tests.yml` Playwright HTML Pages publication path should be removed once the `e2e` Allure family publish job is live. +- 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/planning/feature-allure-report-v3.md b/docs/ai/planning/feature-allure-report-v3.md index 61693704a7f..bdb3ec3a941 100644 --- a/docs/ai/planning/feature-allure-report-v3.md +++ b/docs/ai/planning/feature-allure-report-v3.md @@ -9,9 +9,9 @@ description: Incremental rollout plan for Allure reporting ## Milestones - [x] Milestone 1: Foundation and suite-level Allure generation. -- [ ] Milestone 2: Family-level GitHub Pages architecture and docs. -- [ ] Milestone 3: Family-level publication for component and unit workflows. -- [ ] Milestone 4: Replace the old E2E Pages publication with Allure family publication. +- [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 @@ -25,22 +25,22 @@ description: Incremental rollout plan for Allure reporting ### Phase 2: Web Component CI -- [ ] Task 2.1: Add a shared Pages catalog model for Allure family reports. -- [ ] Task 2.2: Define history-key rules for `PR`, `main`, `develop`, and manual scopes. -- [ ] Task 2.3: Add scripts/templates for a shared Allure Pages index. +- [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: Component and Unit Family Publication -- [ ] Task 3.1: Generate and upload component Allure artifacts in `.github/workflows/component-tests.yml`. -- [ ] Task 3.2: Merge core, integration, web, and mobile Allure results into a single `unit` family report. -- [ ] Task 3.3: Publish `component` and `unit` family reports plus the shared Pages index. -- [ ] Task 3.4: Add summary links to published family reports. +- [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: Web E2E Family Publication - [x] Task 4.1: Update `koreyba/EverFreeNote-e2e` Playwright config with an Allure reporter. -- [ ] Task 4.2: Replace the old Playwright HTML Pages publish with `e2e` Allure family publication. -- [ ] Task 4.3: Route E2E publication through the same shared Pages catalog and history logic. +- [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: Existing Suite Enablement diff --git a/docs/ai/requirements/feature-allure-report-v3.md b/docs/ai/requirements/feature-allure-report-v3.md index 725b46e8ea4..0d48f8ce5cb 100644 --- a/docs/ai/requirements/feature-allure-report-v3.md +++ b/docs/ai/requirements/feature-allure-report-v3.md @@ -52,5 +52,6 @@ The project has several independent test surfaces, but reporting is split betwee ## Questions & Open Items -- E2E Allure artifacts should be generated in the external E2E repo, then downloaded and republished by this repository's workflow so the Pages catalog stays centralized. +- 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/testing/feature-allure-report-v3.md b/docs/ai/testing/feature-allure-report-v3.md index bd9f76f40c3..5b18c306d63 100644 --- a/docs/ai/testing/feature-allure-report-v3.md +++ b/docs/ai/testing/feature-allure-report-v3.md @@ -28,7 +28,7 @@ description: Verification approach for Allure reporting - Pages family report directories: `reports/e2e/...`, `reports/component/...`, `reports/unit/...`. - Pages history files: - `_history//.jsonl`. + `_history//.json`. ## Verification Commands @@ -67,7 +67,5 @@ description: Verification approach for Allure reporting ## Outstanding Gaps -- Shared GitHub Pages catalog for family reports is not implemented yet. -- Component and unit workflows do not publish family reports to Pages yet. -- E2E Allure Pages publication has not replaced the old Playwright HTML Pages flow yet. -- History retention logic per family and scope is still pending implementation. +- 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/package.json b/package.json index 3cd5b4fdcc3..e424e89ae3d 100644 --- a/package.json +++ b/package.json @@ -10,7 +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 && npm run allure:generate:core-integration", + "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", diff --git a/scripts/allure-pages-utils.js b/scripts/allure-pages-utils.js index 415db36b376..c3e043a0980 100644 --- a/scripts/allure-pages-utils.js +++ b/scripts/allure-pages-utils.js @@ -147,15 +147,6 @@ const computeScope = ({ }; } - if (refName === "main" || refName === "develop") { - return { - scopeType: "branch", - scopeKey: `branch-${slugify(refName)}`, - scopeLabel: refName, - historyKey: `branch-${slugify(refName)}`, - }; - } - if (eventName === "workflow_dispatch") { return { scopeType: "manual", @@ -165,6 +156,15 @@ const computeScope = ({ }; } + if (refName === "main" || refName === "develop") { + return { + scopeType: "branch", + scopeKey: `branch-${slugify(refName)}`, + scopeLabel: refName, + historyKey: `branch-${slugify(refName)}`, + }; + } + return { scopeType: "manual", scopeKey: "manual", @@ -186,7 +186,7 @@ const computeReportContext = ({ family, env = process.env }) => { ); const reportUrl = pagesBaseUrl ? `${pagesBaseUrl}/${reportDir}/` : ""; const historyPath = scope.historyKey - ? normalizeSlashes(path.join("_history", family, `${scope.historyKey}.jsonl`)) + ? normalizeSlashes(path.join("_history", family, `${scope.historyKey}.json`)) : ""; return { 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..0e4f373ffcd --- /dev/null +++ b/scripts/backfill-cypress-spec-failures-to-allure.js @@ -0,0 +1,225 @@ +#!/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_PATTERN = /\u001b\[[0-9;]*m/g; +const SUMMARY_SPEC_PATTERN = + /([^\s]+\.cy\.(?:js|jsx|ts|tsx))\s+(?:\d{2}:\d{2}|\d+(?:ms|s))\s+(\d+)\s+(\d+)\s+(\d+)\s+(-|\d+)\s+(-|\d+)/; +const ERROR_HINT_PATTERN = /(OOM|heap|out of memory|failed the current spec|renderer|crash|mark-compacts)/i; + +const stripAnsi = (value) => value.replace(ANSI_PATTERN, ""); + +const normalizeSpecPath = (value) => value.replaceAll("\\", "/").replace(/^\/+/, ""); + +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); + const payload = JSON.parse(fs.readFileSync(filePath, "utf8")); + 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 match = line.match(SUMMARY_SPEC_PATTERN); + if (!match) { + continue; + } + + const spec = normalizeSpecPath(match[1]); + const failedCount = Number(match[4]); + if (!spec || failedCount <= 0) { + continue; + } + + if (!foundSpecs.has(spec)) { + foundSpecs.set(spec, { spec, summaryLine: line.trim() }); + } + } + + return [...foundSpecs.values()]; +}; + +const collectSegment = (logLines, spec) => { + const runningPattern = new RegExp(`Running:\\s+${spec.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`); + let startIndex = logLines.findIndex((line) => runningPattern.test(line)); + if (startIndex === -1) { + startIndex = 0; + } + + let endIndex = logLines.length; + for (let index = startIndex + 1; index < logLines.length; index += 1) { + if (/Running:\s+[^\s]+\.cy\.(?:js|jsx|ts|tsx)\b/.test(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 match = summaryLine.match(SUMMARY_SPEC_PATTERN); + if (!match) { + return null; + } + + const [, , total, passed, failed, pending, skipped] = match; + 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); + const resultsDir = path.resolve(args["results-dir"] || ""); + const logFile = path.resolve(args["log-file"] || ""); + + if (!resultsDir) { + throw new Error("--results-dir is required"); + } + + if (!logFile) { + throw new Error("--log-file is required"); + } + + 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); +} From 910e984ad8cb66a510949acb27cda390378b595d Mon Sep 17 00:00:00 2001 From: Denys Date: Fri, 1 May 2026 12:20:36 +0200 Subject: [PATCH 05/19] Harden Allure pages filtering and helpers --- .github/pages/allure-reports-index.html | 11 +++++- .../design/allure-reporting-architecture.md | 39 +++++++++++++++++++ .../feature-allure-report-v3.md | 12 +++--- scripts/allure-pages-utils.js | 14 ++++++- ...ackfill-cypress-spec-failures-to-allure.js | 18 ++++++--- scripts/prepare-allure-family-report.js | 8 +++- scripts/prune-allure-pages.js | 16 ++++++-- 7 files changed, 98 insertions(+), 20 deletions(-) create mode 100644 docs/ai/design/allure-reporting-architecture.md diff --git a/.github/pages/allure-reports-index.html b/.github/pages/allure-reports-index.html index 6a31471c9dd..a4ba4cad4d1 100644 --- a/.github/pages/allure-reports-index.html +++ b/.github/pages/allure-reports-index.html @@ -382,6 +382,12 @@

EverFreeNote Allure Reports

const text = (value) => String(value ?? ''); const shortSha = (value) => text(value).slice(0, 7) || 'unknown'; const normalizedOutcome = (item) => text(item.outcome || 'unknown').toLowerCase(); + const groupedOutcome = (value) => { + const normalized = text(value || 'unknown').toLowerCase(); + if (normalized === 'success') return 'success'; + if (['failure', 'timed_out', 'cancelled'].includes(normalized)) return 'failure'; + return 'unknown'; + }; const suitesText = (item) => (Array.isArray(item.suiteLabels) ? item.suiteLabels.join(', ') : ''); const reportTitle = (item) => `${item.scopeLabel || item.familyLabel} / run ${item.runId} / attempt ${item.runAttempt}`; @@ -403,7 +409,7 @@

EverFreeNote Allure Reports

const updateStats = () => { document.getElementById('stat-total').textContent = reports.length; document.getElementById('stat-passed').textContent = reports.filter((item) => normalizedOutcome(item) === 'success').length; - document.getElementById('stat-failed').textContent = reports.filter((item) => ['failure', 'timed_out', 'cancelled'].includes(normalizedOutcome(item))).length; + document.getElementById('stat-failed').textContent = reports.filter((item) => groupedOutcome(item.outcome) === 'failure').length; document.getElementById('stat-pr').textContent = reports.filter((item) => item.prNumber).length; document.getElementById('stat-families').textContent = new Set(reports.map((item) => item.family)).size; generatedAt.textContent = formatDate(generatedAt.dateTime); @@ -416,6 +422,7 @@

EverFreeNote Allure Reports

const matches = reports.filter((item) => { const itemOutcome = normalizedOutcome(item); + const itemOutcomeGroup = groupedOutcome(itemOutcome); const searchable = [ item.family, item.familyLabel, @@ -429,7 +436,7 @@

EverFreeNote Allure Reports

].map(text).join(' ').toLowerCase(); return (!query || searchable.includes(query)) && - (selectedOutcome === 'all' || itemOutcome === selectedOutcome) && + (selectedOutcome === 'all' || itemOutcome === selectedOutcome || itemOutcomeGroup === selectedOutcome) && (selectedFamily === 'all' || item.family === selectedFamily); }); 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/implementation/feature-allure-report-v3.md b/docs/ai/implementation/feature-allure-report-v3.md index fcceaad5d7c..948402f9097 100644 --- a/docs/ai/implementation/feature-allure-report-v3.md +++ b/docs/ai/implementation/feature-allure-report-v3.md @@ -58,13 +58,11 @@ description: Implementation notes for Allure reporting ### Family Publication Model -- `component` stays a single-suite family report built from `allure-results/component`. -- Component CI backfills a synthetic Allure failure when Cypress crashes a spec before `allure-cypress` can persist the failure result. -- `unit` is assembled in CI by downloading Allure result artifacts from: - `core-unit`, `core-integration`, `web-unit`, and `mobile-unit`. -- `e2e` is assembled from the downloaded `EverFreeNote-e2e` workflow artifact inside `.github/workflows/e2e-tests.yml`. -- Every family report gets injected `executor.json`, environment metadata, and a history path chosen from family plus scope. -- The shared Pages index reads a generated JSON catalog rather than crawling directories at runtime. +- Architecture and rationale for family-based publication live in + [allure-reporting-architecture.md](/C:/Projects/EverFreeNote/docs/ai/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. ## Integration Points diff --git a/scripts/allure-pages-utils.js b/scripts/allure-pages-utils.js index c3e043a0980..6cd6b02ef6b 100644 --- a/scripts/allure-pages-utils.js +++ b/scripts/allure-pages-utils.js @@ -119,7 +119,19 @@ const listify = (value) => { const appendGithubOutput = (githubOutputPath, values) => { if (!githubOutputPath) return; - const lines = Object.entries(values).map(([key, value]) => `${key}=${value ?? ""}`); + const lines = Object.entries(values).flatMap(([key, value]) => { + const normalizedValue = `${value ?? ""}`; + if (!normalizedValue.includes("\n")) { + return `${key}=${normalizedValue}`; + } + + const delimiter = `EOF_${key.toUpperCase()}_${Math.random().toString(16).slice(2)}`; + return [ + `${key}<<${delimiter}`, + normalizedValue, + delimiter, + ]; + }); fs.appendFileSync(githubOutputPath, `${lines.join("\n")}\n`); }; diff --git a/scripts/backfill-cypress-spec-failures-to-allure.js b/scripts/backfill-cypress-spec-failures-to-allure.js index 0e4f373ffcd..72f4fd520cc 100644 --- a/scripts/backfill-cypress-spec-failures-to-allure.js +++ b/scripts/backfill-cypress-spec-failures-to-allure.js @@ -49,7 +49,13 @@ const collectExistingFailures = (resultsDir) => { } const filePath = path.join(resultsDir, entry); - const payload = JSON.parse(fs.readFileSync(filePath, "utf8")); + 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; } @@ -179,17 +185,17 @@ const writeSyntheticFailure = (resultsDir, spec, message, summaryLine) => { const main = () => { const args = parseArgs(process.argv); - const resultsDir = path.resolve(args["results-dir"] || ""); - const logFile = path.resolve(args["log-file"] || ""); - - if (!resultsDir) { + if (typeof args["results-dir"] !== "string" || args["results-dir"].trim() === "") { throw new Error("--results-dir is required"); } - if (!logFile) { + 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; diff --git a/scripts/prepare-allure-family-report.js b/scripts/prepare-allure-family-report.js index 3f8ee9257bd..1cd4cfb5b7f 100644 --- a/scripts/prepare-allure-family-report.js +++ b/scripts/prepare-allure-family-report.js @@ -65,7 +65,13 @@ const copyDirectory = (sourceDir, targetDir, suiteName, family) => { } if (entry.name.endsWith("-result.json")) { - const payload = JSON.parse(fs.readFileSync(sourcePath, "utf8")); + let payload; + try { + payload = JSON.parse(fs.readFileSync(sourcePath, "utf8")); + } catch (error) { + console.warn(`Skipping malformed Allure result file ${sourcePath}: ${error instanceof Error ? error.message : error}`); + continue; + } const labels = Array.isArray(payload.labels) ? payload.labels : []; const suiteMetadata = getSuiteMetadata(suiteName); addLabel(labels, "family", family); diff --git a/scripts/prune-allure-pages.js b/scripts/prune-allure-pages.js index f935246182b..5c0b4ce4db0 100644 --- a/scripts/prune-allure-pages.js +++ b/scripts/prune-allure-pages.js @@ -18,9 +18,19 @@ const readRetained = (filePath) => { }; const removeEmptyParents = (root, currentPath) => { - let cursor = path.dirname(currentPath); - while (cursor.startsWith(root) && cursor !== root) { - if (fs.existsSync(cursor) && fs.readdirSync(cursor).length === 0) { + const resolvedRoot = path.resolve(root); + let cursor = path.resolve(path.dirname(currentPath)); + while (cursor !== resolvedRoot) { + const relativePath = path.relative(resolvedRoot, cursor); + if (relativePath.startsWith("..") || path.isAbsolute(relativePath)) { + 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); From 7716934c825120b5ddbf090db60865039568698a Mon Sep 17 00:00:00 2001 From: Denys Date: Fri, 1 May 2026 12:26:17 +0200 Subject: [PATCH 06/19] =?UTF-8?q?=D1=80=D0=BF=D1=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From b566e4c2dd50d929a8996f5e441d45cedd3188dc Mon Sep 17 00:00:00 2001 From: Denys Date: Fri, 1 May 2026 12:33:22 +0200 Subject: [PATCH 07/19] Trigger PR run --- GEMINI.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 From 0bbc8b673b76cdc57a8fef41a0202a76191ff236 Mon Sep 17 00:00:00 2001 From: Denys Date: Fri, 1 May 2026 16:12:52 +0200 Subject: [PATCH 08/19] Fix Sonar issues in Allure reporting scripts --- deno.lock | 5 +- docs/ai/design/feature-allure-report-v3.md | 1 + .../feature-allure-report-v3.md | 2 +- eslint.config.mjs | 1 + scripts/allure-pages-utils.js | 31 +- ...ackfill-cypress-spec-failures-to-allure.js | 85 +++++- scripts/generate-allure-report-index.js | 2 +- scripts/prepare-allure-family-report.js | 282 +++++++++++------- scripts/prune-allure-pages.js | 35 ++- 9 files changed, 293 insertions(+), 151 deletions(-) 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/feature-allure-report-v3.md b/docs/ai/design/feature-allure-report-v3.md index f087eeb9f00..ac8ae3c98b6 100644 --- a/docs/ai/design/feature-allure-report-v3.md +++ b/docs/ai/design/feature-allure-report-v3.md @@ -72,6 +72,7 @@ _history/ ``` `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 above is non-exhaustive: every family can have `pr-.json`, `branch-main.json`, and `branch-develop.json` files when those scopes publish results. ## Report Identity Model diff --git a/docs/ai/implementation/feature-allure-report-v3.md b/docs/ai/implementation/feature-allure-report-v3.md index 948402f9097..4f8a9c9c728 100644 --- a/docs/ai/implementation/feature-allure-report-v3.md +++ b/docs/ai/implementation/feature-allure-report-v3.md @@ -59,7 +59,7 @@ description: Implementation notes for Allure reporting ### Family Publication Model - Architecture and rationale for family-based publication live in - [allure-reporting-architecture.md](/C:/Projects/EverFreeNote/docs/ai/design/allure-reporting-architecture.md). + [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. 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/scripts/allure-pages-utils.js b/scripts/allure-pages-utils.js index 6cd6b02ef6b..2340dc0c2e8 100644 --- a/scripts/allure-pages-utils.js +++ b/scripts/allure-pages-utils.js @@ -97,8 +97,8 @@ const slugify = (value) => String(value || "") .trim() .toLowerCase() - .replace(/[^a-z0-9]+/g, "-") - .replace(/^-+|-+$/g, "") || "unknown"; + .replaceAll(/[^a-z0-9]+/g, "-") + .replaceAll(/^-+|-+$/g, "") || "unknown"; const ensureDir = (dirPath) => { fs.mkdirSync(dirPath, { recursive: true }); @@ -117,21 +117,30 @@ const listify = (value) => { 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 = Object.entries(values).flatMap(([key, value]) => { + const lines = []; + for (const [key, value] of Object.entries(values)) { const normalizedValue = `${value ?? ""}`; if (!normalizedValue.includes("\n")) { - return `${key}=${normalizedValue}`; + lines.push(`${key}=${normalizedValue}`); + continue; } - const delimiter = `EOF_${key.toUpperCase()}_${Math.random().toString(16).slice(2)}`; - return [ - `${key}<<${delimiter}`, - normalizedValue, - delimiter, - ]; - }); + const delimiter = createGithubOutputDelimiter(key, normalizedValue); + lines.push(`${key}<<${delimiter}`, normalizedValue, delimiter); + } fs.appendFileSync(githubOutputPath, `${lines.join("\n")}\n`); }; diff --git a/scripts/backfill-cypress-spec-failures-to-allure.js b/scripts/backfill-cypress-spec-failures-to-allure.js index 72f4fd520cc..2c766f96bbb 100644 --- a/scripts/backfill-cypress-spec-failures-to-allure.js +++ b/scripts/backfill-cypress-spec-failures-to-allure.js @@ -5,14 +5,70 @@ const path = require("node:path"); const { randomUUID } = require("node:crypto"); const { parseArgs, ensureDir } = require("./allure-pages-utils"); -const ANSI_PATTERN = /\u001b\[[0-9;]*m/g; -const SUMMARY_SPEC_PATTERN = - /([^\s]+\.cy\.(?:js|jsx|ts|tsx))\s+(?:\d{2}:\d{2}|\d+(?:ms|s))\s+(\d+)\s+(\d+)\s+(\d+)\s+(-|\d+)\s+(-|\d+)/; +const ANSI_ESCAPE = String.fromCharCode(27); const ERROR_HINT_PATTERN = /(OOM|heap|out of memory|failed the current spec|renderer|crash|mark-compacts)/i; -const stripAnsi = (value) => value.replace(ANSI_PATTERN, ""); +const stripAnsiCodePrefix = (segment) => { + if (!segment.startsWith("[")) { + return segment; + } + + let index = 1; + while (index < segment.length && (segment[index] === ";" || /\d/.test(segment[index]))) { + index += 1; + } -const normalizeSpecPath = (value) => value.replaceAll("\\", "/").replace(/^\/+/, ""); + 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); @@ -73,13 +129,13 @@ const extractFailingSpecs = (logLines) => { const foundSpecs = new Map(); for (const line of logLines) { - const match = line.match(SUMMARY_SPEC_PATTERN); - if (!match) { + const summary = parseSummarySpecLine(line); + if (!summary) { continue; } - const spec = normalizeSpecPath(match[1]); - const failedCount = Number(match[4]); + const failedCount = Number(summary.failed); + const spec = summary.spec; if (!spec || failedCount <= 0) { continue; } @@ -93,15 +149,14 @@ const extractFailingSpecs = (logLines) => { }; const collectSegment = (logLines, spec) => { - const runningPattern = new RegExp(`Running:\\s+${spec.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`); - let startIndex = logLines.findIndex((line) => runningPattern.test(line)); + 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 (/Running:\s+[^\s]+\.cy\.(?:js|jsx|ts|tsx)\b/.test(logLines[index])) { + if (parseRunningSpecLine(logLines[index])) { endIndex = index; break; } @@ -121,12 +176,12 @@ const buildMessage = (segmentLines, summaryLine) => { }; const extractCounts = (summaryLine) => { - const match = summaryLine.match(SUMMARY_SPEC_PATTERN); - if (!match) { + const summary = parseSummarySpecLine(summaryLine); + if (!summary) { return null; } - const [, , total, passed, failed, pending, skipped] = match; + const { total, passed, failed, pending, skipped } = summary; return { total, passed, failed, pending, skipped }; }; diff --git a/scripts/generate-allure-report-index.js b/scripts/generate-allure-report-index.js index 5028b545ce3..93839aaa286 100644 --- a/scripts/generate-allure-report-index.js +++ b/scripts/generate-allure-report-index.js @@ -30,7 +30,7 @@ const readCurrentReports = (currentArgs) => { return files .filter(Boolean) .map((filePath) => readJson(path.resolve(filePath), null)) - .filter((payload) => payload && payload.path); + .filter((payload) => payload?.path); }; const main = () => { diff --git a/scripts/prepare-allure-family-report.js b/scripts/prepare-allure-family-report.js index 1cd4cfb5b7f..d645d2ef921 100644 --- a/scripts/prepare-allure-family-report.js +++ b/scripts/prepare-allure-family-report.js @@ -35,23 +35,82 @@ const addLabel = (labels, name, value) => { labels.push({ name, value }); }; +const readDirectoryEntries = (dirPath) => { + try { + return fs.readdirSync(dirPath, { withFileTypes: true }); + } catch (error) { + if (error?.code === "ENOENT" || error?.code === "ENOTDIR") { + 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); - let entries = []; - try { - entries = fs.readdirSync(currentSource, { withFileTypes: true }); - } catch (error) { - if (error && (error.code === "ENOENT" || error.code === "ENOTDIR")) { - return; - } - throw error; - } - - for (const entry of entries) { + for (const entry of readDirectoryEntries(currentSource)) { const sourcePath = path.join(currentSource, entry.name); const targetPath = path.join(currentTarget, entry.name); @@ -65,48 +124,16 @@ const copyDirectory = (sourceDir, targetDir, suiteName, family) => { } if (entry.name.endsWith("-result.json")) { - let payload; - try { - payload = JSON.parse(fs.readFileSync(sourcePath, "utf8")); - } catch (error) { - console.warn(`Skipping malformed Allure result file ${sourcePath}: ${error instanceof Error ? error.message : error}`); - continue; - } - 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; - try { - fs.writeFileSync(targetPath, `${JSON.stringify(payload, null, 2)}\n`, { - flag: "wx", - }); - } catch (error) { - if (error && error.code === "EEXIST") { - throw new Error(`File collision while merging Allure results: ${targetPath}`); - } - throw error; + if (writeMergedResultFile(sourcePath, targetPath, suiteName, family)) { + copiedFiles += 1; + resultFiles += 1; } - copiedFiles += 1; - resultFiles += 1; continue; } - try { - fs.copyFileSync(sourcePath, targetPath, fs.constants.COPYFILE_EXCL); - } catch (error) { - if (error && error.code === "EEXIST") { - throw new Error(`File collision while merging Allure results: ${targetPath}`); - } - if (error && error.code === "ENOENT") { - continue; - } - throw error; + if (copyAllureAsset(sourcePath, targetPath)) { + copiedFiles += 1; } - copiedFiles += 1; } }; @@ -174,6 +201,95 @@ module.exports = defineConfig({ 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); + const sourceDir = path.resolve(item.slice(separatorIndex + 1)); + if (!isWithinDirectory(process.cwd(), sourceDir)) { + throw new Error(`Input path must be inside repository workspace: ${sourceDir}`); + } + + 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", + }); +}; + const main = () => { const args = parseArgs(process.argv); const family = args.family; @@ -203,71 +319,17 @@ const main = () => { ensureDir(resultsDir); const context = computeReportContext({ family }); - const suites = []; - let copiedFiles = 0; - let resultFiles = 0; - - for (const item of inputArgs) { - const separatorIndex = item.indexOf("="); - if (separatorIndex === -1) { - throw new Error(`Invalid --input value '${item}', expected suite=path`); - } - const suite = item.slice(0, separatorIndex); - const sourceDir = path.resolve(item.slice(separatorIndex + 1)); - if (!isWithinDirectory(process.cwd(), sourceDir)) { - throw new Error(`Input path must be inside repository workspace: ${sourceDir}`); - } - if (!fs.existsSync(sourceDir)) { - continue; - } - const copyStats = copyDirectory(sourceDir, resultsDir, suite, family); - if (copyStats.copiedFiles > 0) { - suites.push(suite); - copiedFiles += copyStats.copiedFiles; - resultFiles += copyStats.resultFiles; - } - } - - const metadata = { + const { suites, copiedFiles, resultFiles } = copyInputResults(inputArgs, resultsDir, family); + const metadata = buildMetadata({ family, - familyLabel: context.familyLabel, + context, 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)), - }; - - if (resultFiles > 0) { - writeExecutorFile(resultsDir, context, metadata.suiteLabels); - const absoluteHistoryPath = context.historyPath - ? path.join(historyRoot, context.historyPath) - : ""; - if (absoluteHistoryPath) { - ensureDir(path.dirname(absoluteHistoryPath)); - } - writeConfigFile(configPath, reportDir, absoluteHistoryPath, context, metadata.suiteLabels); + resultFiles, + reportDir, + resultsDir, + }); - const npxExecutable = process.platform === "win32" ? "npx.cmd" : "npx"; - execFileSync(npxExecutable, ["allure", "generate", resultsDir, "--config", configPath], { - stdio: "inherit", - }); - } + generateAllureReport({ resultFiles, resultsDir, reportDir, configPath, historyRoot, context, suiteLabels: metadata.suiteLabels }); fs.writeFileSync(metadataPath, `${JSON.stringify(metadata, null, 2)}\n`); diff --git a/scripts/prune-allure-pages.js b/scripts/prune-allure-pages.js index 5c0b4ce4db0..3abf73b1256 100644 --- a/scripts/prune-allure-pages.js +++ b/scripts/prune-allure-pages.js @@ -17,12 +17,28 @@ const readRetained = (filePath) => { ); }; +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) { - const relativePath = path.relative(resolvedRoot, cursor); - if (relativePath.startsWith("..") || path.isAbsolute(relativePath)) { + if (!isDescendant(resolvedRoot, cursor)) { break; } @@ -43,20 +59,15 @@ const pruneReportDirectories = (root, retainedPaths) => { return; } - const families = fs.readdirSync(reportsRoot, { withFileTypes: true }); - for (const family of families) { - if (!family.isDirectory()) continue; + for (const family of listDirectories(reportsRoot)) { const familyPath = path.join(reportsRoot, family.name); - for (const scope of fs.readdirSync(familyPath, { withFileTypes: true })) { - if (!scope.isDirectory()) continue; + for (const scope of listDirectories(familyPath)) { const scopePath = path.join(familyPath, scope.name); - for (const run of fs.readdirSync(scopePath, { withFileTypes: true })) { - if (!run.isDirectory()) continue; + for (const run of listDirectories(scopePath)) { const runPath = path.join(scopePath, run.name); const relativePath = path.relative(root, runPath).replaceAll(path.sep, "/"); if (!retainedPaths.has(relativePath)) { - fs.rmSync(runPath, { recursive: true, force: true }); - removeEmptyParents(root, runPath); + removeReportDirectory(root, runPath); } } } @@ -70,7 +81,7 @@ const pruneHistoryFiles = (root, retainedHistoryPaths) => { } const visit = (currentDir) => { - for (const entry of fs.readdirSync(currentDir, { withFileTypes: true })) { + for (const entry of listEntries(currentDir)) { const entryPath = path.join(currentDir, entry.name); if (entry.isDirectory()) { visit(entryPath); From ea8f465fd6676aa88d674d7399f89ff332f1748d Mon Sep 17 00:00:00 2001 From: Denys Date: Fri, 1 May 2026 16:41:57 +0200 Subject: [PATCH 09/19] Use shared Allure history per report family --- docs/ai/design/feature-allure-report-v3.md | 16 ++++++---------- .../implementation/feature-allure-report-v3.md | 3 ++- docs/ai/testing/feature-allure-report-v3.md | 4 ++-- scripts/allure-pages-utils.js | 8 +------- scripts/prepare-allure-family-report.js | 16 ++++++++++++++++ 5 files changed, 27 insertions(+), 20 deletions(-) diff --git a/docs/ai/design/feature-allure-report-v3.md b/docs/ai/design/feature-allure-report-v3.md index ac8ae3c98b6..5c1cedd49b7 100644 --- a/docs/ai/design/feature-allure-report-v3.md +++ b/docs/ai/design/feature-allure-report-v3.md @@ -63,16 +63,13 @@ reports/ component/pr-/run--attempt-/ unit/pr-/run--attempt-/ _history/ - e2e/pr-.json - e2e/branch-main.json - e2e/branch-develop.json - component/pr-.json - unit/pr-.json - unit/branch-main.json + 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 above is non-exhaustive: every family can have `pr-.json`, `branch-main.json`, and `branch-develop.json` files when those scopes publish results. +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 @@ -84,9 +81,8 @@ The `_history/` tree above is non-exhaustive: every family can have `pr- ## History Strategy -- `PR` runs append to family-specific history files keyed by PR number. -- `main` and `develop` append to family-specific branch history files for long-lived trends. -- Manual runs publish under `manual` paths and may use their own manual history key or stay history-less if no stable scope exists. +- `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 diff --git a/docs/ai/implementation/feature-allure-report-v3.md b/docs/ai/implementation/feature-allure-report-v3.md index 4f8a9c9c728..4b3853a1341 100644 --- a/docs/ai/implementation/feature-allure-report-v3.md +++ b/docs/ai/implementation/feature-allure-report-v3.md @@ -28,7 +28,7 @@ description: Implementation notes for Allure reporting - GitHub Pages family reports: `reports/e2e/...`, `reports/component/...`, and `reports/unit/...`. - GitHub Pages history store: - `_history//.json`. + `_history//history.jsonl`. ## Implementation Notes @@ -63,6 +63,7 @@ description: Implementation notes for Allure reporting - 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 diff --git a/docs/ai/testing/feature-allure-report-v3.md b/docs/ai/testing/feature-allure-report-v3.md index 5b18c306d63..17cdfd8b058 100644 --- a/docs/ai/testing/feature-allure-report-v3.md +++ b/docs/ai/testing/feature-allure-report-v3.md @@ -12,7 +12,7 @@ description: Verification approach for Allure reporting - 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 and scope, but not leaked across unrelated families or PRs. +- Verify that history is preserved within one family across PR, branch, and manual runs, but not leaked across unrelated families. ## Test Reporting & Coverage @@ -28,7 +28,7 @@ description: Verification approach for Allure reporting - Pages family report directories: `reports/e2e/...`, `reports/component/...`, `reports/unit/...`. - Pages history files: - `_history//.json`. + `_history//history.jsonl`. ## Verification Commands diff --git a/scripts/allure-pages-utils.js b/scripts/allure-pages-utils.js index 2340dc0c2e8..087064fd892 100644 --- a/scripts/allure-pages-utils.js +++ b/scripts/allure-pages-utils.js @@ -164,7 +164,6 @@ const computeScope = ({ scopeType: "pr", scopeKey: `pr-${prNumber}`, scopeLabel: `PR #${prNumber}`, - historyKey: `pr-${prNumber}`, }; } @@ -173,7 +172,6 @@ const computeScope = ({ scopeType: "manual", scopeKey: "manual", scopeLabel: "Manual", - historyKey: null, }; } @@ -182,7 +180,6 @@ const computeScope = ({ scopeType: "branch", scopeKey: `branch-${slugify(refName)}`, scopeLabel: refName, - historyKey: `branch-${slugify(refName)}`, }; } @@ -190,7 +187,6 @@ const computeScope = ({ scopeType: "manual", scopeKey: "manual", scopeLabel: "Manual", - historyKey: null, }; }; @@ -206,9 +202,7 @@ const computeReportContext = ({ family, env = process.env }) => { path.join("reports", family, scope.scopeKey, `run-${runId}-attempt-${runAttempt}`) ); const reportUrl = pagesBaseUrl ? `${pagesBaseUrl}/${reportDir}/` : ""; - const historyPath = scope.historyKey - ? normalizeSlashes(path.join("_history", family, `${scope.historyKey}.json`)) - : ""; + const historyPath = normalizeSlashes(path.join("_history", family, "history.jsonl")); return { family, diff --git a/scripts/prepare-allure-family-report.js b/scripts/prepare-allure-family-report.js index d645d2ef921..e960f3317a0 100644 --- a/scripts/prepare-allure-family-report.js +++ b/scripts/prepare-allure-family-report.js @@ -14,6 +14,7 @@ const { } = require("./allure-pages-utils"); const SKIPPED_FILENAMES = new Set(["executor.json", "environment.properties", "categories.json"]); +const HISTORY_LIMIT = 20; const isWithinDirectory = (baseDir, candidatePath) => { const relativePath = path.relative(baseDir, candidatePath); @@ -288,6 +289,21 @@ const generateAllureReport = ({ resultFiles, resultsDir, reportDir, configPath, execFileSync(npxExecutable, ["allure", "generate", resultsDir, "--config", configPath], { stdio: "inherit", }); + + trimHistoryFile(absoluteHistoryPath, HISTORY_LIMIT); +}; + +const trimHistoryFile = (historyPath, limit) => { + if (!historyPath || !fs.existsSync(historyPath)) { + return; + } + + const lines = fs + .readFileSync(historyPath, "utf8") + .split(/\r?\n/) + .filter(Boolean); + const retainedLines = lines.slice(-limit); + fs.writeFileSync(historyPath, `${retainedLines.join("\n")}\n`); }; const main = () => { From a31423a960879c1db70559540edafadfda5169e8 Mon Sep 17 00:00:00 2001 From: Denys Date: Fri, 1 May 2026 17:03:25 +0200 Subject: [PATCH 10/19] Fix CodeQL and Sonar report script warnings --- .../backfill-cypress-spec-failures-to-allure.js | 2 +- scripts/prepare-allure-family-report.js | 17 ++++++++++++----- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/scripts/backfill-cypress-spec-failures-to-allure.js b/scripts/backfill-cypress-spec-failures-to-allure.js index 2c766f96bbb..82718b3e0f9 100644 --- a/scripts/backfill-cypress-spec-failures-to-allure.js +++ b/scripts/backfill-cypress-spec-failures-to-allure.js @@ -5,7 +5,7 @@ const path = require("node:path"); const { randomUUID } = require("node:crypto"); const { parseArgs, ensureDir } = require("./allure-pages-utils"); -const ANSI_ESCAPE = String.fromCharCode(27); +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) => { diff --git a/scripts/prepare-allure-family-report.js b/scripts/prepare-allure-family-report.js index e960f3317a0..31260a817d9 100644 --- a/scripts/prepare-allure-family-report.js +++ b/scripts/prepare-allure-family-report.js @@ -294,14 +294,21 @@ const generateAllureReport = ({ resultFiles, resultsDir, reportDir, configPath, }; const trimHistoryFile = (historyPath, limit) => { - if (!historyPath || !fs.existsSync(historyPath)) { + if (!historyPath) { return; } - const lines = fs - .readFileSync(historyPath, "utf8") - .split(/\r?\n/) - .filter(Boolean); + let historyContents = ""; + try { + historyContents = fs.readFileSync(historyPath, "utf8"); + } catch (error) { + if (error?.code === "ENOENT") { + return; + } + throw error; + } + + const lines = historyContents.split(/\r?\n/).filter(Boolean); const retainedLines = lines.slice(-limit); fs.writeFileSync(historyPath, `${retainedLines.join("\n")}\n`); }; From 72c00bfb107954fb19fec8eac1c177291c3d6b58 Mon Sep 17 00:00:00 2001 From: Denys Date: Fri, 1 May 2026 17:21:57 +0200 Subject: [PATCH 11/19] Harden Allure report metadata handling --- scripts/allure-pages-utils.js | 53 ++++++++++++++++++++++--- scripts/prepare-allure-family-report.js | 11 ++++- 2 files changed, 56 insertions(+), 8 deletions(-) diff --git a/scripts/allure-pages-utils.js b/scripts/allure-pages-utils.js index 087064fd892..e38dafe0246 100644 --- a/scripts/allure-pages-utils.js +++ b/scripts/allure-pages-utils.js @@ -93,12 +93,53 @@ const parseArgs = (argv) => { 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) => - String(value || "") - .trim() - .toLowerCase() - .replaceAll(/[^a-z0-9]+/g, "-") - .replaceAll(/^-+|-+$/g, "") || "unknown"; + { + 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 }); @@ -196,7 +237,7 @@ const computeReportContext = ({ family, env = process.env }) => { const prNumber = env.PR_NUMBER || ""; const refName = env.REF_NAME || env.GITHUB_REF_NAME || "unknown"; const eventName = env.GITHUB_EVENT_NAME || ""; - const pagesBaseUrl = (env.PAGES_BASE_URL || "").replace(/\/+$/, ""); + 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}`) diff --git a/scripts/prepare-allure-family-report.js b/scripts/prepare-allure-family-report.js index 31260a817d9..47cfd9849af 100644 --- a/scripts/prepare-allure-family-report.js +++ b/scripts/prepare-allure-family-report.js @@ -28,9 +28,16 @@ const ensureWithinWorkspace = (targetPath, optionName) => { } }; +const GROUPING_LABELS = new Set(["suite", "surface", "layer", "workflow"]); + const addLabel = (labels, name, value) => { if (!value) return; - if (labels.some((label) => label && label.name === name && label.value === value)) { + 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 }); @@ -40,7 +47,7 @@ const readDirectoryEntries = (dirPath) => { try { return fs.readdirSync(dirPath, { withFileTypes: true }); } catch (error) { - if (error?.code === "ENOENT" || error?.code === "ENOTDIR") { + if (error?.code === "ENOENT") { return []; } throw error; From 4ff4052e749ead822b8d02782b6b7e935a73e5c5 Mon Sep 17 00:00:00 2001 From: Denys Date: Fri, 1 May 2026 17:24:54 +0200 Subject: [PATCH 12/19] Simplify Allure report tree grouping --- scripts/prepare-allure-family-report.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/prepare-allure-family-report.js b/scripts/prepare-allure-family-report.js index 47cfd9849af..cb978b02c68 100644 --- a/scripts/prepare-allure-family-report.js +++ b/scripts/prepare-allure-family-report.js @@ -199,7 +199,7 @@ module.exports = defineConfig({ reportName: ${JSON.stringify(`${context.familyLabel} Allure Report`)}, singleFile: false, reportLanguage: "en", - groupBy: ["layer", "surface", "suite"] + groupBy: ["surface", "suite"] } } } From 96704793f02398def34e2b807fcc6fc03c264d31 Mon Sep 17 00:00:00 2001 From: Denys Date: Mon, 4 May 2026 17:57:40 +0200 Subject: [PATCH 13/19] Add final-only Allure PR comment workflow --- .github/workflows/allure-pr-comment.yml | 277 ++++++++++++++++++ docs/ai/design/feature-allure-pr-comment.md | 67 +++++ .../feature-allure-pr-comment.md | 48 +++ docs/ai/planning/feature-allure-pr-comment.md | 67 +++++ .../requirements/feature-allure-pr-comment.md | 43 +++ docs/ai/testing/feature-allure-pr-comment.md | 46 +++ scripts/render-allure-pr-comment.js | 193 ++++++++++++ scripts/render-allure-pr-comment.test.js | 85 ++++++ 8 files changed, 826 insertions(+) create mode 100644 .github/workflows/allure-pr-comment.yml create mode 100644 docs/ai/design/feature-allure-pr-comment.md create mode 100644 docs/ai/implementation/feature-allure-pr-comment.md create mode 100644 docs/ai/planning/feature-allure-pr-comment.md create mode 100644 docs/ai/requirements/feature-allure-pr-comment.md create mode 100644 docs/ai/testing/feature-allure-pr-comment.md create mode 100644 scripts/render-allure-pr-comment.js create mode 100644 scripts/render-allure-pr-comment.test.js diff --git a/.github/workflows/allure-pr-comment.yml b/.github/workflows/allure-pr-comment.yml new file mode 100644 index 00000000000..0ab77feac8a --- /dev/null +++ b/.github/workflows/allure-pr-comment.yml @@ -0,0 +1,277 @@ +name: Allure PR Comment + +on: + workflow_run: + workflows: + - Component Tests + - Unit Tests + - E2E Tests (PR Preview) + types: [completed] + workflow_dispatch: + inputs: + pr_number: + description: "Pull request number to refresh" + required: true + type: string + head_sha: + description: "Optional PR head SHA guard" + required: false + default: "" + type: string + +permissions: + actions: read + contents: read + issues: write + pull-requests: read + +env: + PAGES_BASE_URL: https://koreyba.github.io/EverFreeNote + +jobs: + update-pr-comment: + name: Update Allure PR Comment + if: github.event_name == 'workflow_dispatch' || github.event.workflow_run.event == 'pull_request' + runs-on: ubuntu-latest + timeout-minutes: 10 + + steps: + - name: Resolve PR comment context + id: context + uses: actions/github-script@v8 + env: + INPUT_PR_NUMBER: ${{ inputs.pr_number }} + INPUT_HEAD_SHA: ${{ inputs.head_sha }} + with: + script: | + const workflowNames = [ + "Component Tests", + "Unit Tests", + "E2E Tests (PR Preview)", + ]; + const familyByWorkflow = { + "Component Tests": "component", + "Unit Tests": "unit", + "E2E Tests (PR Preview)": "e2e", + }; + + const parsePositiveInteger = (value) => { + const parsed = Number.parseInt(`${value || ""}`.trim(), 10); + return Number.isFinite(parsed) && parsed > 0 ? parsed : null; + }; + + const matchesPullRequest = (run, pullNumber) => + Array.isArray(run.pull_requests) && + run.pull_requests.some((pullRequest) => pullRequest.number === pullNumber); + + const compareRuns = (left, right) => { + if ((right.run_number || 0) !== (left.run_number || 0)) { + return (right.run_number || 0) - (left.run_number || 0); + } + return (right.run_attempt || 0) - (left.run_attempt || 0); + }; + + let pullNumber = null; + let requestedHeadSha = ""; + + if (context.eventName === "workflow_run") { + const workflowRun = context.payload.workflow_run; + pullNumber = workflowRun.pull_requests?.[0]?.number || null; + requestedHeadSha = `${workflowRun.head_sha || ""}`.trim(); + } else { + pullNumber = parsePositiveInteger(process.env.INPUT_PR_NUMBER); + requestedHeadSha = `${process.env.INPUT_HEAD_SHA || ""}`.trim(); + } + + if (!pullNumber) { + core.setOutput("should_update", "false"); + core.setOutput("reason", "No pull request number was available for comment update."); + return; + } + + const { owner, repo } = context.repo; + const { data: pullRequest } = await github.rest.pulls.get({ + owner, + repo, + pull_number: pullNumber, + }); + + const currentHeadSha = `${pullRequest.head.sha || ""}`.trim(); + + if (requestedHeadSha && requestedHeadSha !== currentHeadSha) { + core.setOutput("should_update", "false"); + core.setOutput("reason", `Skipping stale workflow completion for ${requestedHeadSha.slice(0, 7)}; PR head is ${currentHeadSha.slice(0, 7)}.`); + return; + } + + const workflowRuns = await github.paginate( + github.rest.actions.listWorkflowRunsForRepo, + { + owner, + repo, + event: "pull_request", + head_sha: currentHeadSha, + per_page: 100, + }, + (response) => response.data.workflow_runs + ); + + const selectedRuns = []; + const missingWorkflowNames = []; + const incompleteWorkflowNames = []; + + for (const workflowName of workflowNames) { + const matches = workflowRuns + .filter((run) => run.name === workflowName && matchesPullRequest(run, pullNumber)) + .sort(compareRuns); + + if (matches.length === 0) { + missingWorkflowNames.push(workflowName); + continue; + } + + const latestRun = matches[0]; + if (latestRun.status !== "completed") { + incompleteWorkflowNames.push(workflowName); + } + + selectedRuns.push({ + family: familyByWorkflow[workflowName], + workflow_name: workflowName, + status: latestRun.status, + conclusion: latestRun.conclusion, + html_url: latestRun.html_url, + run_id: latestRun.id, + run_number: latestRun.run_number, + run_attempt: latestRun.run_attempt, + }); + } + + if (missingWorkflowNames.length > 0 || incompleteWorkflowNames.length > 0) { + core.setOutput("should_update", "false"); + core.setOutput( + "reason", + JSON.stringify({ + missingWorkflowNames, + incompleteWorkflowNames, + }) + ); + return; + } + + core.setOutput("should_update", "true"); + core.setOutput("reason", "All relevant workflows completed for the current PR head SHA."); + core.setOutput("pr_number", `${pullNumber}`); + core.setOutput("head_sha", currentHeadSha); + core.setOutput("workflow_runs_json", JSON.stringify(selectedRuns)); + + - name: Record skip reason + if: steps.context.outputs.should_update != 'true' + run: | + { + echo "## Allure PR Comment" + echo + echo "${{ steps.context.outputs.reason }}" + } >> "$GITHUB_STEP_SUMMARY" + + - name: Checkout code + if: steps.context.outputs.should_update == 'true' + uses: actions/checkout@v6 + + - name: Write workflow context file + if: steps.context.outputs.should_update == 'true' + env: + WORKFLOW_RUNS_JSON: ${{ steps.context.outputs.workflow_runs_json }} + run: | + set -euo pipefail + mkdir -p .tmp-artifacts/allure-comment + node <<'NODE' + const fs = require("node:fs"); + fs.writeFileSync(".tmp-artifacts/allure-comment/workflow-runs.json", process.env.WORKFLOW_RUNS_JSON || "[]"); + NODE + + - name: Prepare gh-pages branch + if: steps.context.outputs.should_update == 'true' + 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 + mkdir -p .pages-existing/reports + fi + + - name: Render PR comment body + if: steps.context.outputs.should_update == 'true' + run: | + set -euo pipefail + + node scripts/render-allure-pr-comment.js \ + --reports-index .pages-existing/reports/index.json \ + --workflow-runs-file .tmp-artifacts/allure-comment/workflow-runs.json \ + --pr-number "${{ steps.context.outputs.pr_number }}" \ + --head-sha "${{ steps.context.outputs.head_sha }}" \ + --catalog-url "${PAGES_BASE_URL}/" \ + --output .tmp-artifacts/allure-comment/comment.md + + - name: Update PR comment + if: steps.context.outputs.should_update == 'true' + uses: actions/github-script@v8 + env: + PR_NUMBER: ${{ steps.context.outputs.pr_number }} + with: + script: | + const fs = require("node:fs"); + const marker = ""; + const body = fs.readFileSync(".tmp-artifacts/allure-comment/comment.md", "utf8"); + const issueNumber = Number.parseInt(process.env.PR_NUMBER, 10); + const { owner, repo } = context.repo; + + const comments = await github.paginate( + github.rest.issues.listComments, + { + owner, + repo, + issue_number: issueNumber, + per_page: 100, + }, + (response) => response.data + ); + + const existingComment = comments.find((comment) => + typeof comment.body === "string" && comment.body.includes(marker) + ); + + if (existingComment) { + await github.rest.issues.updateComment({ + owner, + repo, + comment_id: existingComment.id, + body, + }); + await core.summary + .addHeading("Allure PR Comment") + .addRaw(`Updated existing comment ${existingComment.id}.`) + .write(); + return; + } + + const createdComment = await github.rest.issues.createComment({ + owner, + repo, + issue_number: issueNumber, + body, + }); + + await core.summary + .addHeading("Allure PR Comment") + .addRaw(`Created comment ${createdComment.data.id}.`) + .write(); diff --git a/docs/ai/design/feature-allure-pr-comment.md b/docs/ai/design/feature-allure-pr-comment.md new file mode 100644 index 00000000000..8be73118a5f --- /dev/null +++ b/docs/ai/design/feature-allure-pr-comment.md @@ -0,0 +1,67 @@ +--- +phase: design +title: Allure PR Comment Design +description: Final-only PR comment architecture for Allure report links +--- + +# Allure PR Comment Design + +## Architecture Overview + +```mermaid +flowchart TD + A["Component Tests workflow"] --> P["Publish family report to gh-pages"] + B["Unit Tests workflow"] --> P + C["E2E Tests workflow"] --> P + P --> I["reports/index.json on gh-pages"] + A --> W["workflow_run trigger"] + B --> W + C --> W + W --> G["PR comment aggregator workflow"] + G --> S["Read current PR head SHA and workflow completion state"] + S --> R["Render markdown from gh-pages report metadata"] + R --> U["Create or update one PR comment"] +``` + +## Data Models + +- PR comment marker: + `` +- Comment payload fields: + `prNumber`, `headSha`, `updatedAt`, `catalogUrl`, and one row per family. +- Family row fields: + `family`, `workflowName`, `workflowConclusion`, `workflowRunUrl`, `reportUrl`, `publishedState`. + +## API Design + +- Trigger source: + `workflow_run` on `Component Tests`, `Unit Tests`, and `E2E Tests (PR Preview)`. +- GitHub API usage: + fetch current PR head SHA, list workflow runs for that SHA, and upsert one issue comment on the PR. +- Report metadata source: + `.pages-existing/reports/index.json` from the `gh-pages` branch. + +## Component Breakdown + +- New renderer/update script: + loads PR workflow context, reads Pages metadata, decides whether all workflows are complete, and renders markdown. +- New aggregator workflow: + runs on `workflow_run`, exits early for stale or incomplete states, and updates the PR comment only when the full set is ready. +- Existing family publish workflows: + stay responsible only for publishing reports and shared catalog metadata. + +## Design Decisions + +- Use `final-only` publication instead of progressive updates to avoid race conditions between parallel workflows. +- Read report URLs from `gh-pages` metadata instead of recomputing them in the comment workflow. +- Keep one durable comment and update it by marker rather than posting a new comment per run. +- Match reports to the active PR by `prNumber` plus current `headSha`, so stale runs cannot overwrite the latest state. + +## Non-Functional Requirements + +- Reliability: + stale workflow completions must no-op. +- Maintainability: + comment rendering logic should live in a local script, not inline YAML, so future expansion to broader PR status stays manageable. +- Permissions: + only the aggregator workflow needs `issues: write`; existing publish workflows should not gain extra PR-comment permissions. diff --git a/docs/ai/implementation/feature-allure-pr-comment.md b/docs/ai/implementation/feature-allure-pr-comment.md new file mode 100644 index 00000000000..33314666b74 --- /dev/null +++ b/docs/ai/implementation/feature-allure-pr-comment.md @@ -0,0 +1,48 @@ +--- +phase: implementation +title: Allure PR Comment Implementation +description: Implementation notes for the final-only Allure PR comment workflow +--- + +# Allure PR Comment Implementation + +## Development Setup + +- No new package dependencies are required. +- The workflow uses the repository Node runtime already available on `ubuntu-latest`. + +## Code Structure + +- Comment rendering and GitHub API interaction live in a dedicated script under `scripts/`. +- Workflow orchestration lives in a new `.github/workflows` file and reuses the existing `gh-pages` fetch pattern. + +## Implementation Notes + +### Core Features + +- The aggregator workflow listens to completed test workflow runs and only proceeds for pull request runs. +- The script reads `gh-pages/reports/index.json`, filters entries by `prNumber` and current PR head SHA, and selects the latest available report for each family. +- The script renders one durable markdown comment and upserts it by HTML marker. + +### Patterns & Best Practices + +- Keep workflow-level branching minimal and let the script own readiness checks and rendering. +- Treat missing reports as expected states in the comment body rather than workflow errors. +- Keep the comment shape future-friendly so a later PR readiness panel can add more sections without replacing the marker strategy. + +## Integration Points + +- GitHub REST API for pull request lookup, workflow-run lookup, issue comment list/create/update. +- `gh-pages` branch contents for `reports/index.json`. +- Existing family publish workflows remain untouched except as data producers. + +## Error Handling + +- The updater exits cleanly when the triggering run is stale or the workflow set is incomplete. +- Missing `reports/index.json` or missing family entries render fallback states instead of crashing when possible. +- GitHub API failures should fail the aggregator workflow so comment publication problems remain visible. + +## Security Notes + +- The new workflow needs `issues: write` to update PR comments and `actions: read` to inspect workflow completion state. +- Existing publish workflows do not gain new comment-write permissions. diff --git a/docs/ai/planning/feature-allure-pr-comment.md b/docs/ai/planning/feature-allure-pr-comment.md new file mode 100644 index 00000000000..eab4d325636 --- /dev/null +++ b/docs/ai/planning/feature-allure-pr-comment.md @@ -0,0 +1,67 @@ +--- +phase: planning +title: Allure PR Comment Plan +description: Plan for publishing final-only Allure report links into one PR comment +--- + +# Allure PR Comment Plan + +## Milestones + +- [x] Milestone 1: Document the final-only PR comment architecture and rollout plan. +- [x] Milestone 2: Implement a reusable renderer/updater script for the PR comment body. +- [x] Milestone 3: Add a `workflow_run` aggregator workflow that updates one PR comment after all relevant test workflows complete. +- [x] Milestone 4: Verify local script behavior and document workflow-run testing limits plus follow-up validation steps. + +## Task Breakdown + +### Phase 1: Documentation + +- [x] Task 1.1: Capture requirements for a single durable PR comment that lists all Allure report families. +- [x] Task 1.2: Document the final-only architecture, trigger model, and data sources. +- [x] Task 1.3: Record rollout risks around stale SHAs, fork PR publication gaps, and `workflow_run` testing limits. + +### Phase 2: Comment Rendering + +- [x] Task 2.1: Add a local script that loads `gh-pages` report metadata and selects the latest report per family for the active PR head SHA. +- [x] Task 2.2: Render one markdown comment body with stable marker, report rows, catalog link, and workflow links. +- [x] Task 2.3: Add upsert logic so the bot updates one existing PR comment instead of posting duplicates. + +### Phase 3: Workflow Integration + +- [x] Task 3.1: Add a new aggregator workflow triggered by completed `Component Tests`, `Unit Tests`, and `E2E Tests (PR Preview)` runs. +- [x] Task 3.2: Gate the workflow so it exits for non-PR runs, stale SHAs, or incomplete workflow sets. +- [x] Task 3.3: Read `gh-pages` metadata, render the comment, and update the PR only once the full workflow set is complete. + +### Phase 4: Verification & Reconciliation + +- [x] Task 4.1: Run local validation for the new script and repository checks affected by the change. +- [x] Task 4.2: Update implementation and testing docs with the delivered behavior and known limitations. +- [x] Task 4.3: Reconcile this planning doc with completed work and note any remaining follow-up validation after merge. + +## Dependencies + +- The PR comment workflow depends on existing `gh-pages` publication and `reports/index.json` staying current. +- The aggregator workflow depends on the three test workflows keeping stable workflow names. +- End-to-end live validation of the `workflow_run` trigger depends on the workflow file existing on the default branch. + +## Risks & Mitigation + +- Risk: a stale workflow completion updates the comment for an older commit. + Mitigation: compare the triggering workflow SHA against the current PR head SHA and exit on mismatch. +- Risk: one family does not publish a report even though its workflow completed. + Mitigation: render explicit fallback states such as `Not published` instead of failing comment generation. +- Risk: comment logic becomes hard to extend later. + Mitigation: keep rendering and upsert logic in a local Node script with a stable marker and simple data model. +- Risk: pre-merge verification of `workflow_run` is limited. + Mitigation: keep the script locally testable and document the need for post-merge live validation. + +## Resources Needed + +- Existing Pages catalog metadata under `gh-pages/reports/index.json`. +- GitHub Actions `workflow_run` trigger and PR comment write permission. +- Local Node runtime already present in repository workflows. + +## Progress Summary + +The final-only PR comment flow is implemented with dedicated requirements, design, implementation, and testing docs; a reusable Node renderer; and a new `workflow_run` aggregator workflow that upserts one durable PR comment from `gh-pages` metadata once all three test workflows complete for the current PR head SHA. Local validation passed, while live end-to-end confirmation of the `workflow_run` trigger remains a post-merge check because GitHub only evaluates that trigger from workflows present on the default branch. diff --git a/docs/ai/requirements/feature-allure-pr-comment.md b/docs/ai/requirements/feature-allure-pr-comment.md new file mode 100644 index 00000000000..328571db1e4 --- /dev/null +++ b/docs/ai/requirements/feature-allure-pr-comment.md @@ -0,0 +1,43 @@ +--- +phase: requirements +title: Allure PR Comment Requirements +description: Requirements for publishing Allure report links into a single PR comment +--- + +# Allure PR Comment Requirements + +## Problem Statement + +- GitHub Pages already publishes separate Allure reports for the `unit`, `component`, and `e2e` families, but reviewers must manually open the shared catalog or Actions summaries to find them. +- Pull request discussion currently has no stable place that surfaces test-report links for the latest PR commit. +- The team wants one durable PR comment that can later expand into a broader CI readiness panel, but the first increment should only show Allure report links. + +## Goals & Objectives + +- Publish one stable PR comment that lists the latest available Allure report links for `unit`, `component`, and `e2e`. +- Update that comment only after all relevant test workflows for the current PR head SHA have finished. +- Reuse existing GitHub Pages metadata instead of inventing a second source of truth for report URLs. + +## User Stories & Use Cases + +- As a reviewer, I want one PR comment with links to all test reports so I can inspect failures without hunting through workflow tabs. +- As an author, I want the comment to stay in one place and refresh for the latest commit so the PR thread stays tidy. +- As a future maintainer, I want this comment format to be easy to extend into a broader PR status summary. + +## Success Criteria + +- A pull request run produces or updates exactly one bot-authored comment marked as the Allure report comment. +- The comment contains entries for `unit`, `component`, and `e2e`, with a published link when available and a clear fallback state when not published. +- Older workflow completions for stale PR SHAs do not overwrite the comment for the latest PR head commit. + +## Constraints & Assumptions + +- Initial rollout uses a `final-only` update model: the comment renders after all three relevant workflows have completed for the same PR head SHA. +- Existing Pages publication remains the source of truth for report metadata. +- Fork PRs and read-only bot runs may not publish Pages reports; the comment must reflect missing publications without failing the overall workflow. +- GitHub `workflow_run` workflows only run when the workflow file exists on the default branch, so branch-only testing of that trigger is limited before merge. + +## Questions & Open Items + +- Future expansion of the same comment into a general PR readiness panel is intentionally out of scope for this increment. +- The first version will show one latest report per family, not a history list. diff --git a/docs/ai/testing/feature-allure-pr-comment.md b/docs/ai/testing/feature-allure-pr-comment.md new file mode 100644 index 00000000000..46d53e82b64 --- /dev/null +++ b/docs/ai/testing/feature-allure-pr-comment.md @@ -0,0 +1,46 @@ +--- +phase: testing +title: Allure PR Comment Testing +description: Verification strategy for final-only Allure PR comment publication +--- + +# Allure PR Comment Testing + +## Test Coverage Goals + +- Verify the comment renderer selects the latest report per family for the active PR head SHA. +- Verify stale workflow SHAs do not update the PR comment. +- Verify missing family reports render as explicit fallback states instead of breaking the workflow. + +## Integration Tests + +- [ ] Render comment from synthetic `reports/index.json` data with all three families present. +- [ ] Render comment from synthetic `reports/index.json` data with one or more families missing. +- [ ] Verify readiness gating rejects incomplete workflow sets. +- [ ] Verify readiness gating rejects stale workflow completions for older SHAs. + +## Test Reporting & Coverage + +- Repository validation: + `npm run validate` +- Script smoke verification: + run the new PR comment updater script against fixture data or local synthetic metadata. +- Workflow validation: + YAML review plus post-merge live validation, because `workflow_run` only executes when the workflow file exists on the default branch. + +## Current Status + +- [x] Added local renderer tests for latest-report selection and missing-report fallback rendering. +- [x] Ran `node --test scripts/render-allure-pr-comment.test.js`. +- [x] Ran `npm run validate`. +- [ ] Live `workflow_run` validation after merge to the default branch. + +## Manual Testing + +- After merge, open a PR from the main repository and confirm that the comment appears only after `Component Tests`, `Unit Tests`, and `E2E Tests (PR Preview)` all complete for the same head SHA. +- Confirm rerunning a single workflow on the same SHA updates the existing comment rather than posting a duplicate. +- Confirm a suite without a published report shows a readable fallback state. + +## Outstanding Gaps + +- Full end-to-end verification of the `workflow_run` trigger cannot be completed entirely from a feature branch before merge. diff --git a/scripts/render-allure-pr-comment.js b/scripts/render-allure-pr-comment.js new file mode 100644 index 00000000000..14e98a0af73 --- /dev/null +++ b/scripts/render-allure-pr-comment.js @@ -0,0 +1,193 @@ +#!/usr/bin/env node + +const fs = require("node:fs"); +const path = require("node:path"); +const { parseArgs, readJson } = require("./allure-pages-utils"); + +const COMMENT_MARKER = ""; +const FAMILY_ORDER = ["unit", "component", "e2e"]; +const FAMILY_LABELS = { + component: "Component", + e2e: "E2E", + unit: "Unit", +}; + +const normalizePrNumber = (value) => { + if (value === undefined || value === null || value === "") { + return ""; + } + return `${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 formatConclusion = (value) => { + switch (`${value || ""}`.toLowerCase()) { + case "success": + return "Passed"; + case "failure": + return "Failed"; + case "timed_out": + return "Timed out"; + case "cancelled": + return "Cancelled"; + case "skipped": + return "Skipped"; + case "action_required": + return "Action required"; + case "neutral": + return "Completed"; + default: + return "Completed"; + } +}; + +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 []; + } + const payload = readJson(path.resolve(filePath), []); + return Array.isArray(payload) ? payload : []; +}; + +const readWorkflowRuns = (filePath) => { + const payload = readJson(path.resolve(filePath), []); + 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 FAMILY_ORDER) { + 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 buildReportCell = (report) => { + if (!report?.url) { + return "Not published"; + } + return `[Open report](${report.url})`; +}; + +const buildWorkflowCell = (workflowRun) => { + const label = formatConclusion(workflowRun?.conclusion); + if (!workflowRun?.html_url) { + return label; + } + return `[${label}](${workflowRun.html_url})`; +}; + +const renderComment = ({ + catalogUrl, + headSha, + prNumber, + reportsByFamily, + workflowRuns, + updatedAt = new Date().toISOString(), +}) => { + const runMap = new Map( + workflowRuns.map((workflowRun) => [workflowRun.family, workflowRun]) + ); + + const lines = [ + COMMENT_MARKER, + "## PR Status", + "", + "### Allure Reports", + "", + `Updated for PR #${prNumber} at \`${normalizeSha(headSha).slice(0, 7) || "unknown"}\` on ${formatDateTime(updatedAt)}.`, + "", + "| Family | Workflow | Report |", + "|---|---|---|", + ]; + + for (const family of FAMILY_ORDER) { + const workflowRun = runMap.get(family) || null; + const report = reportsByFamily.get(family) || null; + lines.push( + `| ${FAMILY_LABELS[family] || family} | ${buildWorkflowCell(workflowRun)} | ${buildReportCell(report)} |` + ); + } + + if (catalogUrl) { + lines.push("", `Catalog: [All reports](${catalogUrl})`); + } + + return `${lines.join("\n")}\n`; +}; + +const main = () => { + const args = parseArgs(process.argv); + const reportsIndex = readReportsIndex(args["reports-index"]); + const workflowRuns = readWorkflowRuns(args["workflow-runs-file"]); + const reportsByFamily = selectLatestReports(reportsIndex, args["pr-number"], args["head-sha"]); + const body = renderComment({ + catalogUrl: args["catalog-url"] || "", + headSha: args["head-sha"], + prNumber: args["pr-number"], + reportsByFamily, + workflowRuns, + updatedAt: args["updated-at"], + }); + + if (args.output) { + fs.writeFileSync(path.resolve(args.output), body); + } else { + 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, + FAMILY_ORDER, + formatConclusion, + renderComment, + selectLatestReports, +}; diff --git a/scripts/render-allure-pr-comment.test.js b/scripts/render-allure-pr-comment.test.js new file mode 100644 index 00000000000..d1bb6089053 --- /dev/null +++ b/scripts/render-allure-pr-comment.test.js @@ -0,0 +1,85 @@ +const test = require("node:test"); +const assert = require("node:assert/strict"); + +const { + COMMENT_MARKER, + renderComment, + selectLatestReports, +} = require("./render-allure-pr-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 includes fallback states for families without published reports", () => { + const reportsByFamily = new Map([ + ["unit", { url: "https://example.test/unit" }], + ["component", null], + ["e2e", null], + ]); + const workflowRuns = [ + { + family: "unit", + conclusion: "success", + html_url: "https://github.com/example/actions/runs/1", + }, + { + family: "component", + conclusion: "failure", + html_url: "https://github.com/example/actions/runs/2", + }, + { + family: "e2e", + conclusion: "success", + html_url: "https://github.com/example/actions/runs/3", + }, + ]; + + const body = renderComment({ + catalogUrl: "https://example.test/reports", + headSha: "abcdef123456", + prNumber: "112", + reportsByFamily, + workflowRuns, + updatedAt: "2026-05-04T11:00:00Z", + }); + + assert.match(body, new RegExp(COMMENT_MARKER.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))); + assert.match(body, /\| Unit \| \[Passed\]\(https:\/\/github\.com\/example\/actions\/runs\/1\) \| \[Open report\]\(https:\/\/example\.test\/unit\) \|/); + assert.match(body, /\| Component \| \[Failed\]\(https:\/\/github\.com\/example\/actions\/runs\/2\) \| Not published \|/); + assert.match(body, /Catalog: \[All reports\]\(https:\/\/example\.test\/reports\)/); +}); From b05b1de05700c214e3676ec48fd302bc43c176fe Mon Sep 17 00:00:00 2001 From: Denys Date: Mon, 4 May 2026 17:57:50 +0200 Subject: [PATCH 14/19] Harden Allure family report input validation --- scripts/prepare-allure-family-report.js | 35 +++++++++++++++++++++---- 1 file changed, 30 insertions(+), 5 deletions(-) diff --git a/scripts/prepare-allure-family-report.js b/scripts/prepare-allure-family-report.js index cb978b02c68..cccf7f293cd 100644 --- a/scripts/prepare-allure-family-report.js +++ b/scripts/prepare-allure-family-report.js @@ -15,15 +15,37 @@ const { 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 = process.cwd(); - if (!isWithinDirectory(workspaceRoot, targetPath)) { + const workspaceRoot = realpathSyncNative(process.cwd()); + const resolvedTargetPath = resolvePathForWorkspaceCheck(targetPath); + if (!isWithinDirectory(workspaceRoot, resolvedTargetPath)) { throw new Error(`${optionName} must be inside repository workspace: ${targetPath}`); } }; @@ -122,6 +144,10 @@ const copyDirectory = (sourceDir, targetDir, suiteName, family) => { 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; @@ -216,10 +242,9 @@ const parseInput = (item) => { } const suite = item.slice(0, separatorIndex); + getSuiteMetadata(suite); const sourceDir = path.resolve(item.slice(separatorIndex + 1)); - if (!isWithinDirectory(process.cwd(), sourceDir)) { - throw new Error(`Input path must be inside repository workspace: ${sourceDir}`); - } + ensureWithinWorkspace(sourceDir, "--input"); return { suite, sourceDir }; }; From 089ea8c9bb695699dfc0eb4f0edff9ae9b81a36d Mon Sep 17 00:00:00 2001 From: Denys Date: Mon, 4 May 2026 18:22:26 +0200 Subject: [PATCH 15/19] Rework report links into generic PR status comment --- .github/workflows/allure-pr-comment.yml | 277 ------------------ .github/workflows/component-tests.yml | 12 + .github/workflows/e2e-tests.yml | 12 + .github/workflows/unit-tests.yml | 12 + docs/ai/design/feature-allure-pr-comment.md | 67 ----- docs/ai/design/feature-pr-status-comment.md | 43 +++ .../feature-allure-pr-comment.md | 48 --- .../feature-pr-status-comment.md | 25 ++ docs/ai/planning/feature-allure-pr-comment.md | 67 ----- docs/ai/planning/feature-pr-status-comment.md | 44 +++ .../requirements/feature-allure-pr-comment.md | 43 --- .../requirements/feature-pr-status-comment.md | 32 ++ docs/ai/testing/feature-allure-pr-comment.md | 46 --- docs/ai/testing/feature-pr-status-comment.md | 30 ++ scripts/render-allure-pr-comment.js | 193 ------------ scripts/update-pr-status-comment.js | 249 ++++++++++++++++ ...st.js => update-pr-status-comment.test.js} | 38 +-- 17 files changed, 474 insertions(+), 764 deletions(-) delete mode 100644 .github/workflows/allure-pr-comment.yml delete mode 100644 docs/ai/design/feature-allure-pr-comment.md create mode 100644 docs/ai/design/feature-pr-status-comment.md delete mode 100644 docs/ai/implementation/feature-allure-pr-comment.md create mode 100644 docs/ai/implementation/feature-pr-status-comment.md delete mode 100644 docs/ai/planning/feature-allure-pr-comment.md create mode 100644 docs/ai/planning/feature-pr-status-comment.md delete mode 100644 docs/ai/requirements/feature-allure-pr-comment.md create mode 100644 docs/ai/requirements/feature-pr-status-comment.md delete mode 100644 docs/ai/testing/feature-allure-pr-comment.md create mode 100644 docs/ai/testing/feature-pr-status-comment.md delete mode 100644 scripts/render-allure-pr-comment.js create mode 100644 scripts/update-pr-status-comment.js rename scripts/{render-allure-pr-comment.test.js => update-pr-status-comment.test.js} (64%) diff --git a/.github/workflows/allure-pr-comment.yml b/.github/workflows/allure-pr-comment.yml deleted file mode 100644 index 0ab77feac8a..00000000000 --- a/.github/workflows/allure-pr-comment.yml +++ /dev/null @@ -1,277 +0,0 @@ -name: Allure PR Comment - -on: - workflow_run: - workflows: - - Component Tests - - Unit Tests - - E2E Tests (PR Preview) - types: [completed] - workflow_dispatch: - inputs: - pr_number: - description: "Pull request number to refresh" - required: true - type: string - head_sha: - description: "Optional PR head SHA guard" - required: false - default: "" - type: string - -permissions: - actions: read - contents: read - issues: write - pull-requests: read - -env: - PAGES_BASE_URL: https://koreyba.github.io/EverFreeNote - -jobs: - update-pr-comment: - name: Update Allure PR Comment - if: github.event_name == 'workflow_dispatch' || github.event.workflow_run.event == 'pull_request' - runs-on: ubuntu-latest - timeout-minutes: 10 - - steps: - - name: Resolve PR comment context - id: context - uses: actions/github-script@v8 - env: - INPUT_PR_NUMBER: ${{ inputs.pr_number }} - INPUT_HEAD_SHA: ${{ inputs.head_sha }} - with: - script: | - const workflowNames = [ - "Component Tests", - "Unit Tests", - "E2E Tests (PR Preview)", - ]; - const familyByWorkflow = { - "Component Tests": "component", - "Unit Tests": "unit", - "E2E Tests (PR Preview)": "e2e", - }; - - const parsePositiveInteger = (value) => { - const parsed = Number.parseInt(`${value || ""}`.trim(), 10); - return Number.isFinite(parsed) && parsed > 0 ? parsed : null; - }; - - const matchesPullRequest = (run, pullNumber) => - Array.isArray(run.pull_requests) && - run.pull_requests.some((pullRequest) => pullRequest.number === pullNumber); - - const compareRuns = (left, right) => { - if ((right.run_number || 0) !== (left.run_number || 0)) { - return (right.run_number || 0) - (left.run_number || 0); - } - return (right.run_attempt || 0) - (left.run_attempt || 0); - }; - - let pullNumber = null; - let requestedHeadSha = ""; - - if (context.eventName === "workflow_run") { - const workflowRun = context.payload.workflow_run; - pullNumber = workflowRun.pull_requests?.[0]?.number || null; - requestedHeadSha = `${workflowRun.head_sha || ""}`.trim(); - } else { - pullNumber = parsePositiveInteger(process.env.INPUT_PR_NUMBER); - requestedHeadSha = `${process.env.INPUT_HEAD_SHA || ""}`.trim(); - } - - if (!pullNumber) { - core.setOutput("should_update", "false"); - core.setOutput("reason", "No pull request number was available for comment update."); - return; - } - - const { owner, repo } = context.repo; - const { data: pullRequest } = await github.rest.pulls.get({ - owner, - repo, - pull_number: pullNumber, - }); - - const currentHeadSha = `${pullRequest.head.sha || ""}`.trim(); - - if (requestedHeadSha && requestedHeadSha !== currentHeadSha) { - core.setOutput("should_update", "false"); - core.setOutput("reason", `Skipping stale workflow completion for ${requestedHeadSha.slice(0, 7)}; PR head is ${currentHeadSha.slice(0, 7)}.`); - return; - } - - const workflowRuns = await github.paginate( - github.rest.actions.listWorkflowRunsForRepo, - { - owner, - repo, - event: "pull_request", - head_sha: currentHeadSha, - per_page: 100, - }, - (response) => response.data.workflow_runs - ); - - const selectedRuns = []; - const missingWorkflowNames = []; - const incompleteWorkflowNames = []; - - for (const workflowName of workflowNames) { - const matches = workflowRuns - .filter((run) => run.name === workflowName && matchesPullRequest(run, pullNumber)) - .sort(compareRuns); - - if (matches.length === 0) { - missingWorkflowNames.push(workflowName); - continue; - } - - const latestRun = matches[0]; - if (latestRun.status !== "completed") { - incompleteWorkflowNames.push(workflowName); - } - - selectedRuns.push({ - family: familyByWorkflow[workflowName], - workflow_name: workflowName, - status: latestRun.status, - conclusion: latestRun.conclusion, - html_url: latestRun.html_url, - run_id: latestRun.id, - run_number: latestRun.run_number, - run_attempt: latestRun.run_attempt, - }); - } - - if (missingWorkflowNames.length > 0 || incompleteWorkflowNames.length > 0) { - core.setOutput("should_update", "false"); - core.setOutput( - "reason", - JSON.stringify({ - missingWorkflowNames, - incompleteWorkflowNames, - }) - ); - return; - } - - core.setOutput("should_update", "true"); - core.setOutput("reason", "All relevant workflows completed for the current PR head SHA."); - core.setOutput("pr_number", `${pullNumber}`); - core.setOutput("head_sha", currentHeadSha); - core.setOutput("workflow_runs_json", JSON.stringify(selectedRuns)); - - - name: Record skip reason - if: steps.context.outputs.should_update != 'true' - run: | - { - echo "## Allure PR Comment" - echo - echo "${{ steps.context.outputs.reason }}" - } >> "$GITHUB_STEP_SUMMARY" - - - name: Checkout code - if: steps.context.outputs.should_update == 'true' - uses: actions/checkout@v6 - - - name: Write workflow context file - if: steps.context.outputs.should_update == 'true' - env: - WORKFLOW_RUNS_JSON: ${{ steps.context.outputs.workflow_runs_json }} - run: | - set -euo pipefail - mkdir -p .tmp-artifacts/allure-comment - node <<'NODE' - const fs = require("node:fs"); - fs.writeFileSync(".tmp-artifacts/allure-comment/workflow-runs.json", process.env.WORKFLOW_RUNS_JSON || "[]"); - NODE - - - name: Prepare gh-pages branch - if: steps.context.outputs.should_update == 'true' - 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 - mkdir -p .pages-existing/reports - fi - - - name: Render PR comment body - if: steps.context.outputs.should_update == 'true' - run: | - set -euo pipefail - - node scripts/render-allure-pr-comment.js \ - --reports-index .pages-existing/reports/index.json \ - --workflow-runs-file .tmp-artifacts/allure-comment/workflow-runs.json \ - --pr-number "${{ steps.context.outputs.pr_number }}" \ - --head-sha "${{ steps.context.outputs.head_sha }}" \ - --catalog-url "${PAGES_BASE_URL}/" \ - --output .tmp-artifacts/allure-comment/comment.md - - - name: Update PR comment - if: steps.context.outputs.should_update == 'true' - uses: actions/github-script@v8 - env: - PR_NUMBER: ${{ steps.context.outputs.pr_number }} - with: - script: | - const fs = require("node:fs"); - const marker = ""; - const body = fs.readFileSync(".tmp-artifacts/allure-comment/comment.md", "utf8"); - const issueNumber = Number.parseInt(process.env.PR_NUMBER, 10); - const { owner, repo } = context.repo; - - const comments = await github.paginate( - github.rest.issues.listComments, - { - owner, - repo, - issue_number: issueNumber, - per_page: 100, - }, - (response) => response.data - ); - - const existingComment = comments.find((comment) => - typeof comment.body === "string" && comment.body.includes(marker) - ); - - if (existingComment) { - await github.rest.issues.updateComment({ - owner, - repo, - comment_id: existingComment.id, - body, - }); - await core.summary - .addHeading("Allure PR Comment") - .addRaw(`Updated existing comment ${existingComment.id}.`) - .write(); - return; - } - - const createdComment = await github.rest.issues.createComment({ - owner, - repo, - issue_number: issueNumber, - body, - }); - - await core.summary - .addHeading("Allure PR Comment") - .addRaw(`Created comment ${createdComment.data.id}.`) - .write(); diff --git a/.github/workflows/component-tests.yml b/.github/workflows/component-tests.yml index b874eb26524..ae6cb0014f1 100644 --- a/.github/workflows/component-tests.yml +++ b/.github/workflows/component-tests.yml @@ -272,6 +272,7 @@ jobs: cancel-in-progress: false permissions: contents: write + issues: write env: PAGES_BASE_URL: https://koreyba.github.io/EverFreeNote PR_NUMBER: ${{ github.event.pull_request.number }} @@ -392,3 +393,14 @@ jobs: 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: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + node scripts/update-pr-status-comment.js \ + --reports-index .pages-existing/reports/index.json \ + --pr-number "${PR_NUMBER}" \ + --head-sha "${COMMIT_SHA}" \ + --catalog-url "${PAGES_BASE_URL}/" diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index c77ae5df637..0d480242a3d 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -378,6 +378,7 @@ jobs: cancel-in-progress: false permissions: contents: write + issues: write env: PAGES_BASE_URL: https://koreyba.github.io/EverFreeNote PR_NUMBER: ${{ github.event.pull_request.number }} @@ -500,3 +501,14 @@ jobs: echo echo "- Published report: ${REPORT_URL}" } >> "$GITHUB_STEP_SUMMARY" + + - name: Update PR status comment + if: github.event_name == 'pull_request' && steps.prepare-e2e-report.outputs.has_results == 'true' + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + node scripts/update-pr-status-comment.js \ + --reports-index .pages-existing/reports/index.json \ + --pr-number "${PR_NUMBER}" \ + --head-sha "${COMMIT_SHA}" \ + --catalog-url "${PAGES_BASE_URL}/" diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index a394702ffa1..e6ac450b64a 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -586,6 +586,7 @@ jobs: cancel-in-progress: false permissions: contents: write + issues: write env: PAGES_BASE_URL: https://koreyba.github.io/EverFreeNote PR_NUMBER: ${{ github.event.pull_request.number }} @@ -724,3 +725,14 @@ jobs: 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: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + node scripts/update-pr-status-comment.js \ + --reports-index .pages-existing/reports/index.json \ + --pr-number "${PR_NUMBER}" \ + --head-sha "${COMMIT_SHA}" \ + --catalog-url "${PAGES_BASE_URL}/" diff --git a/docs/ai/design/feature-allure-pr-comment.md b/docs/ai/design/feature-allure-pr-comment.md deleted file mode 100644 index 8be73118a5f..00000000000 --- a/docs/ai/design/feature-allure-pr-comment.md +++ /dev/null @@ -1,67 +0,0 @@ ---- -phase: design -title: Allure PR Comment Design -description: Final-only PR comment architecture for Allure report links ---- - -# Allure PR Comment Design - -## Architecture Overview - -```mermaid -flowchart TD - A["Component Tests workflow"] --> P["Publish family report to gh-pages"] - B["Unit Tests workflow"] --> P - C["E2E Tests workflow"] --> P - P --> I["reports/index.json on gh-pages"] - A --> W["workflow_run trigger"] - B --> W - C --> W - W --> G["PR comment aggregator workflow"] - G --> S["Read current PR head SHA and workflow completion state"] - S --> R["Render markdown from gh-pages report metadata"] - R --> U["Create or update one PR comment"] -``` - -## Data Models - -- PR comment marker: - `` -- Comment payload fields: - `prNumber`, `headSha`, `updatedAt`, `catalogUrl`, and one row per family. -- Family row fields: - `family`, `workflowName`, `workflowConclusion`, `workflowRunUrl`, `reportUrl`, `publishedState`. - -## API Design - -- Trigger source: - `workflow_run` on `Component Tests`, `Unit Tests`, and `E2E Tests (PR Preview)`. -- GitHub API usage: - fetch current PR head SHA, list workflow runs for that SHA, and upsert one issue comment on the PR. -- Report metadata source: - `.pages-existing/reports/index.json` from the `gh-pages` branch. - -## Component Breakdown - -- New renderer/update script: - loads PR workflow context, reads Pages metadata, decides whether all workflows are complete, and renders markdown. -- New aggregator workflow: - runs on `workflow_run`, exits early for stale or incomplete states, and updates the PR comment only when the full set is ready. -- Existing family publish workflows: - stay responsible only for publishing reports and shared catalog metadata. - -## Design Decisions - -- Use `final-only` publication instead of progressive updates to avoid race conditions between parallel workflows. -- Read report URLs from `gh-pages` metadata instead of recomputing them in the comment workflow. -- Keep one durable comment and update it by marker rather than posting a new comment per run. -- Match reports to the active PR by `prNumber` plus current `headSha`, so stale runs cannot overwrite the latest state. - -## Non-Functional Requirements - -- Reliability: - stale workflow completions must no-op. -- Maintainability: - comment rendering logic should live in a local script, not inline YAML, so future expansion to broader PR status stays manageable. -- Permissions: - only the aggregator workflow needs `issues: write`; existing publish workflows should not gain extra PR-comment permissions. 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..785819d5a3d --- /dev/null +++ b/docs/ai/design/feature-pr-status-comment.md @@ -0,0 +1,43 @@ +--- +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/update-pr-status-comment.js"] + S --> R["Read latest reports for PR number and head SHA"] + R --> M["Render generic PR Status markdown"] + M --> U["Create or update one PR comment"] +``` + +## Design Decisions + +- The comment is named and marked as generic PR status: + ``. +- The script is named `update-pr-status-comment.js`; Allure-specific wording stays inside the report data, not the script contract. +- The updater 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-pr-comment.md b/docs/ai/implementation/feature-allure-pr-comment.md deleted file mode 100644 index 33314666b74..00000000000 --- a/docs/ai/implementation/feature-allure-pr-comment.md +++ /dev/null @@ -1,48 +0,0 @@ ---- -phase: implementation -title: Allure PR Comment Implementation -description: Implementation notes for the final-only Allure PR comment workflow ---- - -# Allure PR Comment Implementation - -## Development Setup - -- No new package dependencies are required. -- The workflow uses the repository Node runtime already available on `ubuntu-latest`. - -## Code Structure - -- Comment rendering and GitHub API interaction live in a dedicated script under `scripts/`. -- Workflow orchestration lives in a new `.github/workflows` file and reuses the existing `gh-pages` fetch pattern. - -## Implementation Notes - -### Core Features - -- The aggregator workflow listens to completed test workflow runs and only proceeds for pull request runs. -- The script reads `gh-pages/reports/index.json`, filters entries by `prNumber` and current PR head SHA, and selects the latest available report for each family. -- The script renders one durable markdown comment and upserts it by HTML marker. - -### Patterns & Best Practices - -- Keep workflow-level branching minimal and let the script own readiness checks and rendering. -- Treat missing reports as expected states in the comment body rather than workflow errors. -- Keep the comment shape future-friendly so a later PR readiness panel can add more sections without replacing the marker strategy. - -## Integration Points - -- GitHub REST API for pull request lookup, workflow-run lookup, issue comment list/create/update. -- `gh-pages` branch contents for `reports/index.json`. -- Existing family publish workflows remain untouched except as data producers. - -## Error Handling - -- The updater exits cleanly when the triggering run is stale or the workflow set is incomplete. -- Missing `reports/index.json` or missing family entries render fallback states instead of crashing when possible. -- GitHub API failures should fail the aggregator workflow so comment publication problems remain visible. - -## Security Notes - -- The new workflow needs `issues: write` to update PR comments and `actions: read` to inspect workflow completion state. -- Existing publish workflows do not gain new comment-write permissions. 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..7354a1f9b3d --- /dev/null +++ b/docs/ai/implementation/feature-pr-status-comment.md @@ -0,0 +1,25 @@ +--- +phase: implementation +title: PR Status Comment Implementation +description: Implementation notes for the reusable PR status comment +--- + +# PR Status Comment Implementation + +## Code Structure + +- `scripts/update-pr-status-comment.js` reads report metadata, renders the PR status body, and upserts the marked PR comment through GitHub REST API. +- `scripts/update-pr-status-comment.test.js` verifies report selection and generic comment rendering. +- The three Pages publish jobs call the updater after `.pages-existing/reports/index.json` has been refreshed. + +## Implementation Notes + +- The updater 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` 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. + +## Security Notes + +- The script requires `GITHUB_TOKEN`, `GITHUB_REPOSITORY`, PR number, and head SHA. +- It only writes issue comments and does not modify repository contents. diff --git a/docs/ai/planning/feature-allure-pr-comment.md b/docs/ai/planning/feature-allure-pr-comment.md deleted file mode 100644 index eab4d325636..00000000000 --- a/docs/ai/planning/feature-allure-pr-comment.md +++ /dev/null @@ -1,67 +0,0 @@ ---- -phase: planning -title: Allure PR Comment Plan -description: Plan for publishing final-only Allure report links into one PR comment ---- - -# Allure PR Comment Plan - -## Milestones - -- [x] Milestone 1: Document the final-only PR comment architecture and rollout plan. -- [x] Milestone 2: Implement a reusable renderer/updater script for the PR comment body. -- [x] Milestone 3: Add a `workflow_run` aggregator workflow that updates one PR comment after all relevant test workflows complete. -- [x] Milestone 4: Verify local script behavior and document workflow-run testing limits plus follow-up validation steps. - -## Task Breakdown - -### Phase 1: Documentation - -- [x] Task 1.1: Capture requirements for a single durable PR comment that lists all Allure report families. -- [x] Task 1.2: Document the final-only architecture, trigger model, and data sources. -- [x] Task 1.3: Record rollout risks around stale SHAs, fork PR publication gaps, and `workflow_run` testing limits. - -### Phase 2: Comment Rendering - -- [x] Task 2.1: Add a local script that loads `gh-pages` report metadata and selects the latest report per family for the active PR head SHA. -- [x] Task 2.2: Render one markdown comment body with stable marker, report rows, catalog link, and workflow links. -- [x] Task 2.3: Add upsert logic so the bot updates one existing PR comment instead of posting duplicates. - -### Phase 3: Workflow Integration - -- [x] Task 3.1: Add a new aggregator workflow triggered by completed `Component Tests`, `Unit Tests`, and `E2E Tests (PR Preview)` runs. -- [x] Task 3.2: Gate the workflow so it exits for non-PR runs, stale SHAs, or incomplete workflow sets. -- [x] Task 3.3: Read `gh-pages` metadata, render the comment, and update the PR only once the full workflow set is complete. - -### Phase 4: Verification & Reconciliation - -- [x] Task 4.1: Run local validation for the new script and repository checks affected by the change. -- [x] Task 4.2: Update implementation and testing docs with the delivered behavior and known limitations. -- [x] Task 4.3: Reconcile this planning doc with completed work and note any remaining follow-up validation after merge. - -## Dependencies - -- The PR comment workflow depends on existing `gh-pages` publication and `reports/index.json` staying current. -- The aggregator workflow depends on the three test workflows keeping stable workflow names. -- End-to-end live validation of the `workflow_run` trigger depends on the workflow file existing on the default branch. - -## Risks & Mitigation - -- Risk: a stale workflow completion updates the comment for an older commit. - Mitigation: compare the triggering workflow SHA against the current PR head SHA and exit on mismatch. -- Risk: one family does not publish a report even though its workflow completed. - Mitigation: render explicit fallback states such as `Not published` instead of failing comment generation. -- Risk: comment logic becomes hard to extend later. - Mitigation: keep rendering and upsert logic in a local Node script with a stable marker and simple data model. -- Risk: pre-merge verification of `workflow_run` is limited. - Mitigation: keep the script locally testable and document the need for post-merge live validation. - -## Resources Needed - -- Existing Pages catalog metadata under `gh-pages/reports/index.json`. -- GitHub Actions `workflow_run` trigger and PR comment write permission. -- Local Node runtime already present in repository workflows. - -## Progress Summary - -The final-only PR comment flow is implemented with dedicated requirements, design, implementation, and testing docs; a reusable Node renderer; and a new `workflow_run` aggregator workflow that upserts one durable PR comment from `gh-pages` metadata once all three test workflows complete for the current PR head SHA. Local validation passed, while live end-to-end confirmation of the `workflow_run` trigger remains a post-merge check because GitHub only evaluates that trigger from workflows present on the default branch. 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..514ba742348 --- /dev/null +++ b/docs/ai/planning/feature-pr-status-comment.md @@ -0,0 +1,44 @@ +--- +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 updater script. +- [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/update-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 updater 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 script generic and add data-provider sections incrementally. +- 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-pr-comment.md b/docs/ai/requirements/feature-allure-pr-comment.md deleted file mode 100644 index 328571db1e4..00000000000 --- a/docs/ai/requirements/feature-allure-pr-comment.md +++ /dev/null @@ -1,43 +0,0 @@ ---- -phase: requirements -title: Allure PR Comment Requirements -description: Requirements for publishing Allure report links into a single PR comment ---- - -# Allure PR Comment Requirements - -## Problem Statement - -- GitHub Pages already publishes separate Allure reports for the `unit`, `component`, and `e2e` families, but reviewers must manually open the shared catalog or Actions summaries to find them. -- Pull request discussion currently has no stable place that surfaces test-report links for the latest PR commit. -- The team wants one durable PR comment that can later expand into a broader CI readiness panel, but the first increment should only show Allure report links. - -## Goals & Objectives - -- Publish one stable PR comment that lists the latest available Allure report links for `unit`, `component`, and `e2e`. -- Update that comment only after all relevant test workflows for the current PR head SHA have finished. -- Reuse existing GitHub Pages metadata instead of inventing a second source of truth for report URLs. - -## User Stories & Use Cases - -- As a reviewer, I want one PR comment with links to all test reports so I can inspect failures without hunting through workflow tabs. -- As an author, I want the comment to stay in one place and refresh for the latest commit so the PR thread stays tidy. -- As a future maintainer, I want this comment format to be easy to extend into a broader PR status summary. - -## Success Criteria - -- A pull request run produces or updates exactly one bot-authored comment marked as the Allure report comment. -- The comment contains entries for `unit`, `component`, and `e2e`, with a published link when available and a clear fallback state when not published. -- Older workflow completions for stale PR SHAs do not overwrite the comment for the latest PR head commit. - -## Constraints & Assumptions - -- Initial rollout uses a `final-only` update model: the comment renders after all three relevant workflows have completed for the same PR head SHA. -- Existing Pages publication remains the source of truth for report metadata. -- Fork PRs and read-only bot runs may not publish Pages reports; the comment must reflect missing publications without failing the overall workflow. -- GitHub `workflow_run` workflows only run when the workflow file exists on the default branch, so branch-only testing of that trigger is limited before merge. - -## Questions & Open Items - -- Future expansion of the same comment into a general PR readiness panel is intentionally out of scope for this increment. -- The first version will show one latest report per family, not a history list. 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-pr-comment.md b/docs/ai/testing/feature-allure-pr-comment.md deleted file mode 100644 index 46d53e82b64..00000000000 --- a/docs/ai/testing/feature-allure-pr-comment.md +++ /dev/null @@ -1,46 +0,0 @@ ---- -phase: testing -title: Allure PR Comment Testing -description: Verification strategy for final-only Allure PR comment publication ---- - -# Allure PR Comment Testing - -## Test Coverage Goals - -- Verify the comment renderer selects the latest report per family for the active PR head SHA. -- Verify stale workflow SHAs do not update the PR comment. -- Verify missing family reports render as explicit fallback states instead of breaking the workflow. - -## Integration Tests - -- [ ] Render comment from synthetic `reports/index.json` data with all three families present. -- [ ] Render comment from synthetic `reports/index.json` data with one or more families missing. -- [ ] Verify readiness gating rejects incomplete workflow sets. -- [ ] Verify readiness gating rejects stale workflow completions for older SHAs. - -## Test Reporting & Coverage - -- Repository validation: - `npm run validate` -- Script smoke verification: - run the new PR comment updater script against fixture data or local synthetic metadata. -- Workflow validation: - YAML review plus post-merge live validation, because `workflow_run` only executes when the workflow file exists on the default branch. - -## Current Status - -- [x] Added local renderer tests for latest-report selection and missing-report fallback rendering. -- [x] Ran `node --test scripts/render-allure-pr-comment.test.js`. -- [x] Ran `npm run validate`. -- [ ] Live `workflow_run` validation after merge to the default branch. - -## Manual Testing - -- After merge, open a PR from the main repository and confirm that the comment appears only after `Component Tests`, `Unit Tests`, and `E2E Tests (PR Preview)` all complete for the same head SHA. -- Confirm rerunning a single workflow on the same SHA updates the existing comment rather than posting a duplicate. -- Confirm a suite without a published report shows a readable fallback state. - -## Outstanding Gaps - -- Full end-to-end verification of the `workflow_run` trigger cannot be completed entirely from a feature branch before merge. 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..f1e3a988ed2 --- /dev/null +++ b/docs/ai/testing/feature-pr-status-comment.md @@ -0,0 +1,30 @@ +--- +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`. + +## Verification Commands + +- `node --test scripts/update-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] Ran local renderer tests. +- [x] Ran repository validation. + +## Manual Testing + +- After the next PR publish run, confirm the PR has one comment containing `PR Status` and `Test Reports`. +- Confirm subsequent family publish jobs update that same comment instead of creating duplicates. diff --git a/scripts/render-allure-pr-comment.js b/scripts/render-allure-pr-comment.js deleted file mode 100644 index 14e98a0af73..00000000000 --- a/scripts/render-allure-pr-comment.js +++ /dev/null @@ -1,193 +0,0 @@ -#!/usr/bin/env node - -const fs = require("node:fs"); -const path = require("node:path"); -const { parseArgs, readJson } = require("./allure-pages-utils"); - -const COMMENT_MARKER = ""; -const FAMILY_ORDER = ["unit", "component", "e2e"]; -const FAMILY_LABELS = { - component: "Component", - e2e: "E2E", - unit: "Unit", -}; - -const normalizePrNumber = (value) => { - if (value === undefined || value === null || value === "") { - return ""; - } - return `${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 formatConclusion = (value) => { - switch (`${value || ""}`.toLowerCase()) { - case "success": - return "Passed"; - case "failure": - return "Failed"; - case "timed_out": - return "Timed out"; - case "cancelled": - return "Cancelled"; - case "skipped": - return "Skipped"; - case "action_required": - return "Action required"; - case "neutral": - return "Completed"; - default: - return "Completed"; - } -}; - -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 []; - } - const payload = readJson(path.resolve(filePath), []); - return Array.isArray(payload) ? payload : []; -}; - -const readWorkflowRuns = (filePath) => { - const payload = readJson(path.resolve(filePath), []); - 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 FAMILY_ORDER) { - 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 buildReportCell = (report) => { - if (!report?.url) { - return "Not published"; - } - return `[Open report](${report.url})`; -}; - -const buildWorkflowCell = (workflowRun) => { - const label = formatConclusion(workflowRun?.conclusion); - if (!workflowRun?.html_url) { - return label; - } - return `[${label}](${workflowRun.html_url})`; -}; - -const renderComment = ({ - catalogUrl, - headSha, - prNumber, - reportsByFamily, - workflowRuns, - updatedAt = new Date().toISOString(), -}) => { - const runMap = new Map( - workflowRuns.map((workflowRun) => [workflowRun.family, workflowRun]) - ); - - const lines = [ - COMMENT_MARKER, - "## PR Status", - "", - "### Allure Reports", - "", - `Updated for PR #${prNumber} at \`${normalizeSha(headSha).slice(0, 7) || "unknown"}\` on ${formatDateTime(updatedAt)}.`, - "", - "| Family | Workflow | Report |", - "|---|---|---|", - ]; - - for (const family of FAMILY_ORDER) { - const workflowRun = runMap.get(family) || null; - const report = reportsByFamily.get(family) || null; - lines.push( - `| ${FAMILY_LABELS[family] || family} | ${buildWorkflowCell(workflowRun)} | ${buildReportCell(report)} |` - ); - } - - if (catalogUrl) { - lines.push("", `Catalog: [All reports](${catalogUrl})`); - } - - return `${lines.join("\n")}\n`; -}; - -const main = () => { - const args = parseArgs(process.argv); - const reportsIndex = readReportsIndex(args["reports-index"]); - const workflowRuns = readWorkflowRuns(args["workflow-runs-file"]); - const reportsByFamily = selectLatestReports(reportsIndex, args["pr-number"], args["head-sha"]); - const body = renderComment({ - catalogUrl: args["catalog-url"] || "", - headSha: args["head-sha"], - prNumber: args["pr-number"], - reportsByFamily, - workflowRuns, - updatedAt: args["updated-at"], - }); - - if (args.output) { - fs.writeFileSync(path.resolve(args.output), body); - } else { - 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, - FAMILY_ORDER, - formatConclusion, - renderComment, - selectLatestReports, -}; diff --git a/scripts/update-pr-status-comment.js b/scripts/update-pr-status-comment.js new file mode 100644 index 00000000000..61a877bb772 --- /dev/null +++ b/scripts/update-pr-status-comment.js @@ -0,0 +1,249 @@ +#!/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 []; + } + const rawContents = fs.readFileSync(path.resolve(filePath), "utf8"); + const payload = JSON.parse(rawContents.replace(/^\uFEFF/, "")); + 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 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 `[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 ? `[${label}](${runUrl})` : label; +}; + +const renderComment = ({ + catalogUrl, + headSha, + prNumber, + reportsByFamily, + repository, + updatedAt = new Date().toISOString(), +}) => { + const lines = [ + COMMENT_MARKER, + "## PR Status", + "", + `Updated for PR #${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: [All reports](${catalogUrl})`); + } + + return `${lines.join("\n")}\n`; +}; + +const requestJson = async ({ body, method = "GET", path: requestPath, token, repository }) => { + const response = await fetch(`https://api.github.com/repos/${repository}${requestPath}`, { + method, + headers: { + accept: "application/vnd.github+json", + authorization: `Bearer ${token}`, + "content-type": "application/json", + "x-github-api-version": "2022-11-28", + }, + body: body ? JSON.stringify(body) : undefined, + }); + + if (!response.ok) { + const text = await response.text(); + throw new Error(`GitHub API ${method} ${requestPath} failed with ${response.status}: ${text}`); + } + + if (response.status === 204) { + return null; + } + return response.json(); +}; + +const listIssueComments = async ({ issueNumber, repository, token }) => { + const comments = []; + let page = 1; + + while (true) { + const pageComments = await requestJson({ + path: `/issues/${issueNumber}/comments?per_page=100&page=${page}`, + repository, + token, + }); + comments.push(...pageComments); + + if (pageComments.length < 100) { + return comments; + } + page += 1; + } +}; + +const upsertPrStatusComment = async ({ body, prNumber, repository, token }) => { + const comments = await listIssueComments({ issueNumber: prNumber, repository, token }); + const existingComment = comments.find((comment) => + typeof comment.body === "string" && comment.body.includes(COMMENT_MARKER) + ); + + if (existingComment) { + await requestJson({ + body: { body }, + method: "PATCH", + path: `/issues/comments/${existingComment.id}`, + repository, + token, + }); + return { action: "updated", commentId: existingComment.id }; + } + + const createdComment = await requestJson({ + body: { body }, + method: "POST", + path: `/issues/${prNumber}/comments`, + repository, + token, + }); + return { action: "created", commentId: createdComment.id }; +}; + +const main = async () => { + const args = parseArgs(process.argv); + const repository = args.repository || process.env.GITHUB_REPOSITORY || ""; + const token = args.token || process.env.GITHUB_TOKEN || ""; + 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 (!token) { + throw new Error("--token or GITHUB_TOKEN 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); + } + + const result = await upsertPrStatusComment({ body, prNumber, repository, token }); + console.log(`PR status comment ${result.action}: ${result.commentId}`); +}; + +if (require.main === module) { + 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-allure-pr-comment.test.js b/scripts/update-pr-status-comment.test.js similarity index 64% rename from scripts/render-allure-pr-comment.test.js rename to scripts/update-pr-status-comment.test.js index d1bb6089053..79a96ab6e81 100644 --- a/scripts/render-allure-pr-comment.test.js +++ b/scripts/update-pr-status-comment.test.js @@ -5,7 +5,7 @@ const { COMMENT_MARKER, renderComment, selectLatestReports, -} = require("./render-allure-pr-comment"); +} = require("./update-pr-status-comment"); test("selectLatestReports keeps the newest report per family for the active PR head sha", () => { const reports = [ @@ -45,41 +45,33 @@ test("selectLatestReports keeps the newest report per family for the active PR h assert.equal(reportsByFamily.get("e2e"), null); }); -test("renderComment includes fallback states for families without published reports", () => { +test("renderComment uses a generic PR status marker and report section", () => { const reportsByFamily = new Map([ - ["unit", { url: "https://example.test/unit" }], + [ + "unit", + { + runId: "1", + url: "https://example.test/unit", + workflow: "Unit Tests", + }, + ], ["component", null], ["e2e", null], ]); - const workflowRuns = [ - { - family: "unit", - conclusion: "success", - html_url: "https://github.com/example/actions/runs/1", - }, - { - family: "component", - conclusion: "failure", - html_url: "https://github.com/example/actions/runs/2", - }, - { - family: "e2e", - conclusion: "success", - html_url: "https://github.com/example/actions/runs/3", - }, - ]; const body = renderComment({ catalogUrl: "https://example.test/reports", headSha: "abcdef123456", prNumber: "112", reportsByFamily, - workflowRuns, + repository: "koreyba/EverFreeNote", updatedAt: "2026-05-04T11:00:00Z", }); assert.match(body, new RegExp(COMMENT_MARKER.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))); - assert.match(body, /\| Unit \| \[Passed\]\(https:\/\/github\.com\/example\/actions\/runs\/1\) \| \[Open report\]\(https:\/\/example\.test\/unit\) \|/); - assert.match(body, /\| Component \| \[Failed\]\(https:\/\/github\.com\/example\/actions\/runs\/2\) \| Not published \|/); + 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\)/); }); From 5654e1319df772371514c436ba6ba1c380f0b907 Mon Sep 17 00:00:00 2001 From: Denys Date: Tue, 5 May 2026 09:18:15 +0200 Subject: [PATCH 16/19] Fix PR status publishing checks --- .github/workflows/component-tests.yml | 29 ++++- .github/workflows/e2e-tests.yml | 27 +++- .github/workflows/unit-tests.yml | 27 +++- cypress.config.ts | 2 + docs/ai/design/feature-pr-status-comment.md | 10 +- .../feature-pr-status-comment.md | 14 +- docs/ai/planning/feature-pr-status-comment.md | 10 +- docs/ai/testing/feature-pr-status-comment.md | 8 +- package.json | 1 + scripts/prepare-allure-family-report.js | 16 ++- scripts/prune-allure-pages.js | 25 +++- ...comment.js => render-pr-status-comment.js} | 123 ++++++------------ ...st.js => render-pr-status-comment.test.js} | 30 ++++- 13 files changed, 198 insertions(+), 124 deletions(-) rename scripts/{update-pr-status-comment.js => render-pr-status-comment.js} (62%) rename scripts/{update-pr-status-comment.test.js => render-pr-status-comment.test.js} (75%) diff --git a/.github/workflows/component-tests.yml b/.github/workflows/component-tests.yml index ae6cb0014f1..de77e9f7954 100644 --- a/.github/workflows/component-tests.yml +++ b/.github/workflows/component-tests.yml @@ -46,7 +46,7 @@ jobs: run: | mkdir -p cypress/results set -o pipefail - npm run test:component -- --reporter junit --reporter-options "mochaFile=cypress/results/component-tests-[hash].xml,toConsole=false" 2>&1 | tee cypress/results/component-tests.log + 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 @@ -397,10 +397,31 @@ jobs: - name: Update PR status comment if: github.event_name == 'pull_request' && steps.prepare-component-report.outputs.has_results == 'true' env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - node scripts/update-pr-status-comment.js \ + 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}/" + --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 0d480242a3d..d92245065b4 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -505,10 +505,31 @@ jobs: - name: Update PR status comment if: github.event_name == 'pull_request' && steps.prepare-e2e-report.outputs.has_results == 'true' env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - node scripts/update-pr-status-comment.js \ + 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}/" + --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 e6ac450b64a..7045e0f6f0c 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -729,10 +729,31 @@ jobs: - name: Update PR status comment if: github.event_name == 'pull_request' && steps.prepare-unit-report.outputs.has_results == 'true' env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - node scripts/update-pr-status-comment.js \ + 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}/" + --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/cypress.config.ts b/cypress.config.ts index dd47359b61b..415c42fe112 100644 --- a/cypress.config.ts +++ b/cypress.config.ts @@ -4,6 +4,8 @@ import * as os from "node:os" 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 diff --git a/docs/ai/design/feature-pr-status-comment.md b/docs/ai/design/feature-pr-status-comment.md index 785819d5a3d..a751f07a42e 100644 --- a/docs/ai/design/feature-pr-status-comment.md +++ b/docs/ai/design/feature-pr-status-comment.md @@ -13,18 +13,20 @@ 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/update-pr-status-comment.js"] + 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["Create or update one PR comment"] + 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 `update-pr-status-comment.js`; Allure-specific wording stays inside the report data, not the script contract. -- The updater runs at the end of each successful Pages publish job. The final comment becomes complete once the last family publish job finishes. +- 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. diff --git a/docs/ai/implementation/feature-pr-status-comment.md b/docs/ai/implementation/feature-pr-status-comment.md index 7354a1f9b3d..881018a3523 100644 --- a/docs/ai/implementation/feature-pr-status-comment.md +++ b/docs/ai/implementation/feature-pr-status-comment.md @@ -8,18 +8,20 @@ description: Implementation notes for the reusable PR status comment ## Code Structure -- `scripts/update-pr-status-comment.js` reads report metadata, renders the PR status body, and upserts the marked PR comment through GitHub REST API. -- `scripts/update-pr-status-comment.test.js` verifies report selection and generic comment rendering. -- The three Pages publish jobs call the updater after `.pages-existing/reports/index.json` has been refreshed. +- `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 updater filters report metadata by PR number and current head SHA so stale reports do not appear in the comment. +- 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` 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 script requires `GITHUB_TOKEN`, `GITHUB_REPOSITORY`, PR number, and head SHA. -- It only writes issue comments and does not modify repository contents. +- 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-pr-status-comment.md b/docs/ai/planning/feature-pr-status-comment.md index 514ba742348..1a67e50f3cd 100644 --- a/docs/ai/planning/feature-pr-status-comment.md +++ b/docs/ai/planning/feature-pr-status-comment.md @@ -9,7 +9,7 @@ description: Plan for the reusable PR status comment ## Milestones - [x] Milestone 1: Define the generic PR status comment architecture. -- [x] Milestone 2: Replace the Allure-specific comment workflow with a generic updater script. +- [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. @@ -23,7 +23,7 @@ description: Plan for the reusable PR status comment ### Phase 2: Implementation -- [x] Task 2.1: Add `scripts/update-pr-status-comment.js`. +- [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. @@ -32,13 +32,15 @@ description: Plan for the reusable PR status comment - [x] Task 3.1: Run local unit tests for comment rendering. - [x] Task 3.2: Run repository validation. -- [x] Task 3.3: Confirm the updater can create/update the PR comment through GitHub API after publish jobs. +- [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 script generic and add data-provider sections incrementally. + 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/testing/feature-pr-status-comment.md b/docs/ai/testing/feature-pr-status-comment.md index f1e3a988ed2..ba714082faa 100644 --- a/docs/ai/testing/feature-pr-status-comment.md +++ b/docs/ai/testing/feature-pr-status-comment.md @@ -11,20 +11,22 @@ description: Verification notes for the reusable PR status comment - 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/update-pr-status-comment.test.js` +- `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 comment containing `PR Status` and `Test Reports`. -- Confirm subsequent family publish jobs update that same comment instead of creating duplicates. +- 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/package.json b/package.json index e424e89ae3d..c93da0544ea 100644 --- a/package.json +++ b/package.json @@ -35,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", diff --git a/scripts/prepare-allure-family-report.js b/scripts/prepare-allure-family-report.js index cccf7f293cd..be414ec9cb7 100644 --- a/scripts/prepare-allure-family-report.js +++ b/scripts/prepare-allure-family-report.js @@ -330,9 +330,9 @@ const trimHistoryFile = (historyPath, limit) => { return; } - let historyContents = ""; + let fileDescriptor; try { - historyContents = fs.readFileSync(historyPath, "utf8"); + fileDescriptor = fs.openSync(historyPath, "r+"); } catch (error) { if (error?.code === "ENOENT") { return; @@ -340,9 +340,15 @@ const trimHistoryFile = (historyPath, limit) => { throw error; } - const lines = historyContents.split(/\r?\n/).filter(Boolean); - const retainedLines = lines.slice(-limit); - fs.writeFileSync(historyPath, `${retainedLines.join("\n")}\n`); + 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 = () => { diff --git a/scripts/prune-allure-pages.js b/scripts/prune-allure-pages.js index 3abf73b1256..0acf7740c3c 100644 --- a/scripts/prune-allure-pages.js +++ b/scripts/prune-allure-pages.js @@ -53,25 +53,36 @@ const removeEmptyParents = (root, currentPath) => { } }; -const pruneReportDirectories = (root, retainedPaths) => { +const collectReportRunPaths = (root) => { const reportsRoot = path.join(root, "reports"); if (!fs.existsSync(reportsRoot)) { - return; + 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)) { - const runPath = path.join(scopePath, run.name); - const relativePath = path.relative(root, runPath).replaceAll(path.sep, "/"); - if (!retainedPaths.has(relativePath)) { - removeReportDirectory(root, runPath); - } + 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) => { diff --git a/scripts/update-pr-status-comment.js b/scripts/render-pr-status-comment.js similarity index 62% rename from scripts/update-pr-status-comment.js rename to scripts/render-pr-status-comment.js index 61a877bb772..a440a471d21 100644 --- a/scripts/update-pr-status-comment.js +++ b/scripts/render-pr-status-comment.js @@ -45,8 +45,13 @@ const readReportsIndex = (filePath) => { if (!filePath || !fs.existsSync(filePath)) { return []; } - const rawContents = fs.readFileSync(path.resolve(filePath), "utf8"); - const payload = JSON.parse(rawContents.replace(/^\uFEFF/, "")); + + 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 : []; }; @@ -69,6 +74,27 @@ const selectLatestReports = (reports, prNumber, headSha) => { 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 ""; @@ -80,7 +106,7 @@ const buildReportCell = (report) => { if (!report?.url) { return "Not published yet"; } - return `[Open report](${report.url})`; + return buildMarkdownLink("Open report", report.url); }; const buildWorkflowCell = (repository, report) => { @@ -90,7 +116,7 @@ const buildWorkflowCell = (repository, report) => { const label = report.workflow || "Workflow run"; const runUrl = buildRunUrl(repository, report); - return runUrl ? `[${label}](${runUrl})` : label; + return runUrl ? buildMarkdownLink(label, runUrl) : escapeMarkdownCell(label); }; const renderComment = ({ @@ -105,7 +131,7 @@ const renderComment = ({ COMMENT_MARKER, "## PR Status", "", - `Updated for PR #${prNumber} at \`${normalizeSha(headSha).slice(0, 7) || "unknown"}\` on ${formatDateTime(updatedAt)}.`, + `Updated for PR #${escapeMarkdownCell(prNumber)} at \`${normalizeSha(headSha).slice(0, 7) || "unknown"}\` on ${formatDateTime(updatedAt)}.`, "", "### Test Reports", "", @@ -121,94 +147,21 @@ const renderComment = ({ } if (catalogUrl) { - lines.push("", `Catalog: [All reports](${catalogUrl})`); + lines.push("", `Catalog: ${buildMarkdownLink("All reports", catalogUrl)}`); } return `${lines.join("\n")}\n`; }; -const requestJson = async ({ body, method = "GET", path: requestPath, token, repository }) => { - const response = await fetch(`https://api.github.com/repos/${repository}${requestPath}`, { - method, - headers: { - accept: "application/vnd.github+json", - authorization: `Bearer ${token}`, - "content-type": "application/json", - "x-github-api-version": "2022-11-28", - }, - body: body ? JSON.stringify(body) : undefined, - }); - - if (!response.ok) { - const text = await response.text(); - throw new Error(`GitHub API ${method} ${requestPath} failed with ${response.status}: ${text}`); - } - - if (response.status === 204) { - return null; - } - return response.json(); -}; - -const listIssueComments = async ({ issueNumber, repository, token }) => { - const comments = []; - let page = 1; - - while (true) { - const pageComments = await requestJson({ - path: `/issues/${issueNumber}/comments?per_page=100&page=${page}`, - repository, - token, - }); - comments.push(...pageComments); - - if (pageComments.length < 100) { - return comments; - } - page += 1; - } -}; - -const upsertPrStatusComment = async ({ body, prNumber, repository, token }) => { - const comments = await listIssueComments({ issueNumber: prNumber, repository, token }); - const existingComment = comments.find((comment) => - typeof comment.body === "string" && comment.body.includes(COMMENT_MARKER) - ); - - if (existingComment) { - await requestJson({ - body: { body }, - method: "PATCH", - path: `/issues/comments/${existingComment.id}`, - repository, - token, - }); - return { action: "updated", commentId: existingComment.id }; - } - - const createdComment = await requestJson({ - body: { body }, - method: "POST", - path: `/issues/${prNumber}/comments`, - repository, - token, - }); - return { action: "created", commentId: createdComment.id }; -}; - -const main = async () => { +const main = () => { const args = parseArgs(process.argv); const repository = args.repository || process.env.GITHUB_REPOSITORY || ""; - const token = args.token || process.env.GITHUB_TOKEN || ""; 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 (!token) { - throw new Error("--token or GITHUB_TOKEN is required"); - } if (!prNumber) { throw new Error("--pr-number or PR_NUMBER is required"); } @@ -228,17 +181,19 @@ const main = async () => { if (args.output) { fs.writeFileSync(path.resolve(args.output), body); + return; } - const result = await upsertPrStatusComment({ body, prNumber, repository, token }); - console.log(`PR status comment ${result.action}: ${result.commentId}`); + process.stdout.write(body); }; if (require.main === module) { - main().catch((error) => { + try { + main(); + } catch (error) { console.error(error instanceof Error ? error.message : error); process.exit(1); - }); + } } module.exports = { diff --git a/scripts/update-pr-status-comment.test.js b/scripts/render-pr-status-comment.test.js similarity index 75% rename from scripts/update-pr-status-comment.test.js rename to scripts/render-pr-status-comment.test.js index 79a96ab6e81..f03208441a8 100644 --- a/scripts/update-pr-status-comment.test.js +++ b/scripts/render-pr-status-comment.test.js @@ -5,7 +5,7 @@ const { COMMENT_MARKER, renderComment, selectLatestReports, -} = require("./update-pr-status-comment"); +} = require("./render-pr-status-comment"); test("selectLatestReports keeps the newest report per family for the active PR head sha", () => { const reports = [ @@ -75,3 +75,31 @@ test("renderComment uses a generic PR status marker and report section", () => { 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/); +}); From ac527f1494eaf5b067a45cbf185eeb2f9df12066 Mon Sep 17 00:00:00 2001 From: Denys Date: Tue, 5 May 2026 09:23:35 +0200 Subject: [PATCH 17/19] Allow PR status comment writes --- .github/workflows/component-tests.yml | 1 + .github/workflows/e2e-tests.yml | 1 + .github/workflows/unit-tests.yml | 1 + docs/ai/implementation/feature-pr-status-comment.md | 2 +- 4 files changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/component-tests.yml b/.github/workflows/component-tests.yml index de77e9f7954..a388151fecd 100644 --- a/.github/workflows/component-tests.yml +++ b/.github/workflows/component-tests.yml @@ -273,6 +273,7 @@ jobs: permissions: contents: write issues: write + pull-requests: write env: PAGES_BASE_URL: https://koreyba.github.io/EverFreeNote PR_NUMBER: ${{ github.event.pull_request.number }} diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index d92245065b4..8ebabaf8550 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -379,6 +379,7 @@ jobs: permissions: contents: write issues: write + pull-requests: write env: PAGES_BASE_URL: https://koreyba.github.io/EverFreeNote PR_NUMBER: ${{ github.event.pull_request.number }} diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index 7045e0f6f0c..57b67cde1e4 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -587,6 +587,7 @@ jobs: permissions: contents: write issues: write + pull-requests: write env: PAGES_BASE_URL: https://koreyba.github.io/EverFreeNote PR_NUMBER: ${{ github.event.pull_request.number }} diff --git a/docs/ai/implementation/feature-pr-status-comment.md b/docs/ai/implementation/feature-pr-status-comment.md index 881018a3523..24386fed820 100644 --- a/docs/ai/implementation/feature-pr-status-comment.md +++ b/docs/ai/implementation/feature-pr-status-comment.md @@ -16,7 +16,7 @@ description: Implementation notes for the reusable PR status comment - 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` in addition to `contents: write`. +- 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. From 147825fcb18431bab8680516c3f7c7f5ff921fe0 Mon Sep 17 00:00:00 2001 From: Denys Date: Tue, 5 May 2026 09:44:43 +0200 Subject: [PATCH 18/19] Stabilize Cypress component browser memory --- cypress.config.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/cypress.config.ts b/cypress.config.ts index 415c42fe112..5d4e85a95fa 100644 --- a/cypress.config.ts +++ b/cypress.config.ts @@ -21,6 +21,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: { From 1d670abc270c34c259e7e94e025af628888c8425 Mon Sep 17 00:00:00 2001 From: Denys Date: Tue, 5 May 2026 09:58:28 +0200 Subject: [PATCH 19/19] Silence component coverage warnings outside coverage runs --- cypress.config.ts | 35 +++++++++++++++++++++-------------- cypress/support/component.ts | 5 ++++- 2 files changed, 25 insertions(+), 15 deletions(-) diff --git a/cypress.config.ts b/cypress.config.ts index 5d4e85a95fa..5e34517cfcb 100644 --- a/cypress.config.ts +++ b/cypress.config.ts @@ -2,6 +2,19 @@ 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, @@ -40,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 }, @@ -60,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