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