diff --git a/.github/workflows/release-pages.yml b/.github/workflows/release-pages.yml
index e3c52f96f..79e8a97b6 100644
--- a/.github/workflows/release-pages.yml
+++ b/.github/workflows/release-pages.yml
@@ -96,6 +96,52 @@ jobs:
workflow_conclusion: success
path: ./.pages/vitest-reports
+ - name: Download Playwright E2E reports (source run)
+ id: fetch_playwright_trigger
+ continue-on-error: true
+ if: github.event_name == 'workflow_run' && github.event.workflow_run.name == 'Tests CI'
+ uses: dawidd6/action-download-artifact@v6
+ with:
+ github_token: ${{ secrets.GITHUB_TOKEN }}
+ workflow: ci-tests-e2e.yaml
+ name_is_regexp: true
+ name: playwright-report-.*
+ run_id: ${{ github.event.workflow_run.id }}
+ path: ./playwright-reports-temp
+
+ - name: Download Playwright E2E reports (latest successful run on main)
+ continue-on-error: true
+ if: steps.fetch_playwright_trigger.outcome != 'success'
+ uses: dawidd6/action-download-artifact@v6
+ with:
+ github_token: ${{ secrets.GITHUB_TOKEN }}
+ workflow: ci-tests-e2e.yaml
+ name_is_regexp: true
+ name: playwright-report-.*
+ branch: main
+ workflow_conclusion: success
+ path: ./playwright-reports-temp
+
+ - name: Organize Playwright reports by browser
+ if: always()
+ run: |
+ mkdir -p ./.pages/playwright-reports
+
+ # Move each browser report to its own directory
+ if [ -d "./playwright-reports-temp" ]; then
+ for dir in ./playwright-reports-temp/playwright-report-*; do
+ if [ -d "$dir" ]; then
+ browser_name=$(basename "$dir" | sed 's/playwright-report-//')
+ mkdir -p "./.pages/playwright-reports/${browser_name}"
+ cp -r "$dir"/* "./.pages/playwright-reports/${browser_name}/"
+ fi
+ done
+ fi
+
+ - name: Create Playwright reports index page
+ if: always()
+ run: node scripts/create-playwright-index.js
+
- name: Build static assets (with artifact reuse)
run: ./scripts/build-pages.sh
diff --git a/scripts/create-playwright-index.js b/scripts/create-playwright-index.js
new file mode 100755
index 000000000..de8ac433a
--- /dev/null
+++ b/scripts/create-playwright-index.js
@@ -0,0 +1,290 @@
+#!/usr/bin/env node
+/**
+ * Creates an index page for Playwright test reports with test statistics
+ * Reads JSON reports from each browser and creates a landing page with cards
+ */
+import fs from 'fs'
+import path from 'path'
+import { fileURLToPath } from 'url'
+
+const __filename = fileURLToPath(import.meta.url)
+const __dirname = path.dirname(__filename)
+const reportsDir = path.join(__dirname, '..', '.pages', 'playwright-reports')
+
+function getTestStats(reportPath) {
+ try {
+ const reportJsonPath = path.join(reportPath, 'report.json')
+ if (!fs.existsSync(reportJsonPath)) {
+ console.warn(`No report.json found at ${reportJsonPath}`)
+ return null
+ }
+
+ const reportData = JSON.parse(fs.readFileSync(reportJsonPath, 'utf-8'))
+
+ let passed = 0
+ let failed = 0
+ let skipped = 0
+ let flaky = 0
+
+ // Parse Playwright JSON report format
+ if (reportData.suites) {
+ const countResults = (suites) => {
+ for (const suite of suites) {
+ if (suite.specs) {
+ for (const spec of suite.specs) {
+ if (!spec.tests || spec.tests.length === 0) continue
+
+ const test = spec.tests[0]
+ const results = test.results || []
+
+ // Check if test is flaky (has both pass and fail results)
+ const hasPass = results.some((r) => r.status === 'passed')
+ const hasFail = results.some((r) => r.status === 'failed')
+
+ if (hasPass && hasFail) {
+ flaky++
+ } else if (results.some((r) => r.status === 'passed')) {
+ passed++
+ } else if (results.some((r) => r.status === 'failed')) {
+ failed++
+ } else if (results.some((r) => r.status === 'skipped')) {
+ skipped++
+ }
+ }
+ }
+ if (suite.suites) {
+ countResults(suite.suites)
+ }
+ }
+ }
+
+ countResults(reportData.suites)
+ }
+
+ return { passed, failed, skipped, flaky }
+ } catch (error) {
+ console.error(`Error reading report at ${reportPath}:`, error.message)
+ return null
+ }
+}
+
+function generateIndexHtml(browsers) {
+ const cards = browsers
+ .map((browser) => {
+ const { name, stats } = browser
+ if (!stats) return ''
+
+ const total = stats.passed + stats.failed + stats.skipped + stats.flaky
+ const passRate = total > 0 ? ((stats.passed / total) * 100).toFixed(1) : 0
+
+ return `
+
+ ${name}
+ ${passRate}%
+