diff --git a/.github/pages/e2e-reports-index.html b/.github/pages/e2e-reports-index.html
new file mode 100644
index 00000000000..a11e3264edb
--- /dev/null
+++ b/.github/pages/e2e-reports-index.html
@@ -0,0 +1,472 @@
+
+
+
+
+
+ EverFreeNote E2E Reports
+
+
+
+
+
+
+
+ Total0
+ Passed0
+ Failed0
+ Manual0
+
+
+
+
+
+
+
+
+ | Report |
+ Outcome |
+ Ref |
+ Commit |
+ Preview |
+ E2E Ref |
+ Generated |
+
+
+
+
+ Loading reports...
+
+
+
+
+
+
diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml
index 826c210cdea..f7d8c8ffd59 100644
--- a/.github/workflows/e2e-tests.yml
+++ b/.github/workflows/e2e-tests.yml
@@ -21,6 +21,9 @@ permissions:
deployments: read
checks: read
+env:
+ FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
+
concurrency:
group: e2e-${{ github.event.pull_request.number || github.run_id }}
cancel-in-progress: true
@@ -169,6 +172,12 @@ jobs:
(needs.wait-cloudflare-preview.result == 'success' || (github.event_name == 'workflow_dispatch' && needs.wait-cloudflare-preview.result == 'skipped'))
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 }}
+ e2e_outcome: ${{ steps.run-e2e-tests.outcome }}
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 }}
@@ -205,10 +214,33 @@ jobs:
working-directory: e2e
run: npm run test -- --reporter=html,json
+ - name: Compute Playwright report metadata
+ id: report-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"
+
- name: Add visual summary
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');
@@ -218,6 +250,7 @@ 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 };
@@ -274,6 +307,7 @@ jobs:
let md = '';
md += `## ${headline}\n\n`;
md += `- **Preview URL:** ${previewUrl}\n`;
+ md += `- **Full Playwright report:** ${reportUrl}\n`;
md += `- **Test repository:** ${repoRef}\n\n`;
md += '| Metric | Count |\n';
md += '|---|---:|\n';
@@ -300,7 +334,7 @@ jobs:
fs.appendFileSync(summaryFile, md);
NODE
- - name: Upload Playwright report
+ - name: Upload Playwright report artifact
if: always()
uses: actions/upload-artifact@v6
with:
@@ -315,3 +349,148 @@ jobs:
- 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
+ needs: run-e2e
+ if: |
+ always() &&
+ needs.run-e2e.result != 'skipped' &&
+ needs.run-e2e.outputs.report_dir != ''
+ runs-on: ubuntu-latest
+ timeout-minutes: 15
+ concurrency:
+ group: gh-pages-e2e-report-publish
+ cancel-in-progress: false
+ permissions:
+ contents: write
+
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v6
+
+ - name: Download Playwright report artifact
+ uses: actions/download-artifact@v7
+ with:
+ name: playwright-report-${{ github.run_id }}
+ path: .e2e-artifacts
+
+ - name: Prepare Playwright reports index
+ if: always()
+ 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)"
+
+ git init --quiet .pages-existing
+ 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"
+ else
+ printf '[]\n' > "$existing_index"
+ 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()
+ 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
+ fi
+
+ git -C .pages-existing checkout --quiet FETCH_HEAD
+ mkdir -p .pages-existing/reports
+
+ 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 });
+ }
+ };
+
+ if (!fs.existsSync(reportsRoot)) {
+ process.exit(0);
+ }
+
+ for (const scope of fs.readdirSync(reportsRoot, { withFileTypes: true })) {
+ if (!scope.isDirectory() || !['manual', 'pr-'].some((prefix) => scope.name.startsWith(prefix))) {
+ continue;
+ }
+
+ 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;
+ }
+
+ 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
+
+ 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 push origin HEAD:gh-pages
+ else
+ echo "No stale Playwright report pages to prune."
+ fi
+
+ - name: Publish Playwright report to gh-pages
+ if: always()
+ uses: peaceiris/actions-gh-pages@47f197a2200bb9de68ba5f48fad1c088eb1c4a32 # v4.0.0
+ with:
+ github_token: ${{ secrets.GITHUB_TOKEN }}
+ publish_dir: .e2e-artifacts/playwright-report
+ destination_dir: ${{ needs.run-e2e.outputs.report_dir }}
+ keep_files: true
+
+ - name: Publish Playwright reports index to gh-pages
+ if: always()
+ uses: peaceiris/actions-gh-pages@47f197a2200bb9de68ba5f48fad1c088eb1c4a32 # v4.0.0
+ with:
+ github_token: ${{ secrets.GITHUB_TOKEN }}
+ publish_dir: .pages-index
+ keep_files: true
diff --git a/.github/workflows/playwright-pages.yml b/.github/workflows/playwright-pages.yml
deleted file mode 100644
index fa1b51b43f6..00000000000
--- a/.github/workflows/playwright-pages.yml
+++ /dev/null
@@ -1,47 +0,0 @@
-name: Playwright Report to GitHub Pages
-
-on:
- workflow_dispatch:
- push:
- branches: [main]
-
-permissions:
- contents: read
- pages: write
- id-token: write
-
-jobs:
- test-and-deploy:
- runs-on: ubuntu-latest
-
- environment:
- name: github-pages
- url: ${{ steps.deployment.outputs.page_url }}
-
- steps:
- - name: Checkout
- uses: actions/checkout@v4
-
- - name: Setup Node
- uses: actions/setup-node@v4
- with:
- node-version: 20
-
- - name: Install dependencies
- run: npm ci
-
- - name: Install Playwright browsers
- run: npx playwright install --with-deps
-
- - name: Run Playwright tests
- run: npx playwright test
- continue-on-error: true
-
- - name: Upload report to Pages
- uses: actions/upload-pages-artifact@v3
- with:
- path: playwright-report
-
- - name: Deploy to GitHub Pages
- id: deployment
- uses: actions/deploy-pages@v4
diff --git a/docs/ai/implementation/feature-github-actions-pipelines.md b/docs/ai/implementation/feature-github-actions-pipelines.md
index f36171fd074..41524ba4371 100644
--- a/docs/ai/implementation/feature-github-actions-pipelines.md
+++ b/docs/ai/implementation/feature-github-actions-pipelines.md
@@ -252,6 +252,19 @@ Developer → Act CLI → Docker → Workflow Execution
- Use `if: failure()` condition for cleanup steps
- Set appropriate timeout values
+### Playwright E2E Report Publishing
+
+The E2E workflow publishes every Playwright HTML report to GitHub Pages under a unique path so repeated runs do not overwrite earlier reports:
+
+```text
+reports/pr-/run--attempt-/
+reports/manual/run--attempt-/
+```
+
+The workflow uploads the Playwright report as an artifact from the test job, then a separate `Publish Playwright E2E report` job downloads that artifact and deploys it. The deploy uses `peaceiris/actions-gh-pages@v4.0.0` with `destination_dir` set to the computed report path and `keep_files: true`. A second Pages deploy updates the root report index while preserving previously published report directories. The GitHub Actions step summary includes the full Pages URL for the report from the current run.
+
+The report index page is generated from `.github/pages/e2e-reports-index.html` by `scripts/generate-e2e-report-index.js`. The generator writes `.pages-index/index.html` and `.pages-index/reports/index.json`, keeps only the latest 20 report entries, and embeds lightweight client-side search, filtering, and sorting controls.
+
## Performance Considerations
**How do we keep it fast?**
diff --git a/docs/ai/testing/feature-github-actions-pipelines.md b/docs/ai/testing/feature-github-actions-pipelines.md
index 75318c9b373..577f311c7b4 100644
--- a/docs/ai/testing/feature-github-actions-pipelines.md
+++ b/docs/ai/testing/feature-github-actions-pipelines.md
@@ -156,6 +156,8 @@ description: Define testing approach, test cases, and quality assurance
- GitHub Actions provides built-in workflow run reports
- Cypress test results uploaded as artifacts
- Screenshots/videos uploaded on test failure
+- Playwright E2E HTML reports are published to GitHub Pages under unique run/attempt paths, linked from the E2E job summary, and listed from the root Pages index
+- The root Pages index keeps the latest 20 reports and supports search, outcome filtering, and sorting
## Manual Testing
**What requires human validation?**
@@ -165,6 +167,10 @@ description: Define testing approach, test cases, and quality assurance
- [ ] Verify workflow runs show correct status
- [ ] Verify artifacts appear in workflow run details
- [ ] Verify artifact download works from UI
+- [ ] Verify the E2E workflow graph shows report publishing as a separate job after `Run Playwright E2E`
+- [ ] Verify the E2E summary links to the full Playwright Pages report for that run
+- [ ] Verify the root GitHub Pages index lists the newest E2E report without removing older report links
+- [ ] Verify the root GitHub Pages index search/filter/sort controls work on desktop and mobile widths
- [ ] Verify re-run functionality works (re-run jobs)
- [ ] Verify manual workflow dispatch works
diff --git a/scripts/generate-e2e-report-index.js b/scripts/generate-e2e-report-index.js
new file mode 100644
index 00000000000..fe787877a56
--- /dev/null
+++ b/scripts/generate-e2e-report-index.js
@@ -0,0 +1,107 @@
+#!/usr/bin/env node
+
+const fs = require('node:fs');
+const path = require('node:path');
+
+const DEFAULT_LIMIT = 20;
+
+const parseArgs = (argv) => {
+ const args = {};
+ for (let index = 2; index < argv.length; index += 1) {
+ const key = argv[index];
+ const value = argv[index + 1];
+ if (!key.startsWith('--') || value === undefined || value.startsWith('--')) {
+ throw new Error(`Expected value after ${key}`);
+ }
+ args[key.slice(2)] = value;
+ index += 1;
+ }
+ return args;
+};
+
+const readExistingReports = (filePath) => {
+ try {
+ const parsed = JSON.parse(fs.readFileSync(filePath, 'utf8'));
+ return Array.isArray(parsed) ? parsed : [];
+ } catch {
+ return [];
+ }
+};
+
+const requireEnv = (name) => {
+ const value = process.env[name];
+ if (!value) {
+ throw new Error(`${name} is required`);
+ }
+ return value;
+};
+
+const parseLimit = (value) => {
+ const parsed = Number.parseInt(value || `${DEFAULT_LIMIT}`, 10);
+ if (!Number.isFinite(parsed) || parsed < 1) {
+ return DEFAULT_LIMIT;
+ }
+ return parsed;
+};
+
+const main = () => {
+ const args = parseArgs(process.argv);
+ const existingPath = args.existing;
+ const outputDir = args.output || '.pages-index';
+ const templatePath = args.template || '.github/pages/e2e-reports-index.html';
+ const limit = parseLimit(args.limit);
+
+ if (!existingPath) {
+ throw new Error('--existing is required');
+ }
+
+ const generatedAt = new Date().toISOString();
+ const report = {
+ url: requireEnv('REPORT_URL'),
+ path: requireEnv('REPORT_DIR'),
+ runId: requireEnv('GITHUB_RUN_ID'),
+ runAttempt: requireEnv('GITHUB_RUN_ATTEMPT'),
+ prNumber: process.env.PR_NUMBER || null,
+ ref: process.env.REF_NAME || process.env.GITHUB_REF_NAME || 'unknown',
+ sha: process.env.COMMIT_SHA || process.env.GITHUB_SHA || 'unknown',
+ outcome: process.env.E2E_STEP_OUTCOME || 'unknown',
+ previewUrl: process.env.BASE_URL || null,
+ e2eRef: process.env.E2E_REF || 'master',
+ generatedAt,
+ };
+
+ const reportsByPath = new Map();
+ for (const item of readExistingReports(existingPath)) {
+ if (item && typeof item.path === 'string') {
+ reportsByPath.set(item.path, item);
+ }
+ }
+ reportsByPath.set(report.path, report);
+
+ const reports = [...reportsByPath.values()]
+ .sort((a, b) => {
+ const left = Date.parse(a.generatedAt || '') || 0;
+ const right = Date.parse(b.generatedAt || '') || 0;
+ return right - left;
+ })
+ .slice(0, limit);
+
+ const reportsDir = path.join(outputDir, 'reports');
+ fs.mkdirSync(reportsDir, { recursive: true });
+ fs.writeFileSync(path.join(reportsDir, 'index.json'), `${JSON.stringify(reports, null, 2)}\n`);
+ fs.writeFileSync(path.join(reportsDir, 'retained-paths.txt'), `${reports.map((item) => item.path).join('\n')}\n`);
+
+ const template = fs.readFileSync(templatePath, 'utf8');
+ const html = template
+ .replaceAll('__GENERATED_AT__', generatedAt)
+ .replaceAll('__REPORT_LIMIT__', `${limit}`);
+
+ fs.writeFileSync(path.join(outputDir, 'index.html'), html);
+};
+
+try {
+ main();
+} catch (error) {
+ console.error(error instanceof Error ? error.message : error);
+ process.exit(1);
+}