diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 72d233bbb..da6536adc 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -644,9 +644,18 @@ jobs: if-no-files-found: error fe-e2e: - name: Frontend end-to-end tests + # Split the suite across N parallel shards. Playwright's own `--shard` + # partitions by spec file, so a spec always lands on the same shard and a + # failure is easy to place. The build in front of the run is 38s against + # ~12m of tests, so paying for it six times still wins by a wide margin. + name: Frontend e2e (shard ${{ matrix.shard }}/6) needs: fe-static runs-on: ubuntu-latest + timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + shard: [ 1, 2, 3, 4, 5, 6 ] defaults: run: working-directory: services/frontend @@ -663,17 +672,63 @@ jobs: - name: Install Playwright browser dependencies run: yarn playwright install --with-deps chromium + # --no-report leaves the raw coverage unconverted: a shard only ever holds + # part of the picture, so fe-e2e-coverage below merges the six. - name: Run frontend end-to-end tests - run: yarn test:e2e + run: yarn test:e2e --no-report --shard=${{ matrix.shard }}/6 - name: Upload frontend e2e traces if: failure() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: - name: frontend-e2e-traces + # Per-shard artifact name so parallel jobs don't collide. + name: frontend-e2e-traces-shard-${{ matrix.shard }} path: services/frontend/test-results if-no-files-found: ignore + - name: Upload frontend e2e raw coverage + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: frontend-e2e-coverage-raw-${{ matrix.shard }} + path: services/frontend/coverage/e2e/raw + if-no-files-found: error + + fe-e2e-coverage: + # Fan-in for the sharded suite: one report over all six slices, under the + # name the single job used to publish. The converter walks its raw directory + # recursively and merges every JSON it finds, so the per-shard subdirectories + # the download leaves behind need no flattening. + name: Frontend e2e coverage report + needs: fe-e2e + if: always() && needs.fe-e2e.result != 'skipped' + runs-on: ubuntu-latest + timeout-minutes: 10 + defaults: + run: + working-directory: services/frontend + steps: + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Set up Node.js and Yarn + uses: ./.github/actions/setup-node-yarn + with: + node-version: ${{ env.NODE_VERSION }} + yarn-version: ${{ env.YARN_VERSION }} + + - name: Download every shard's raw coverage + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + path: services/frontend/coverage/e2e/raw + pattern: frontend-e2e-coverage-raw-* + + - name: Merge the shards into one report + run: | + yarn node ./scripts/convert-frontend-coverage.mjs \ + --raw-dir coverage/e2e/raw \ + --out-dir coverage/e2e + - name: Upload frontend e2e coverage artifacts if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 @@ -762,6 +817,7 @@ jobs: - acceptance-features - fe-unit - fe-e2e + - fe-e2e-coverage - openapi-sync runs-on: ubuntu-latest timeout-minutes: 5 diff --git a/services/frontend/playwright.config.ts b/services/frontend/playwright.config.ts index 9879a5fc4..94731638d 100644 --- a/services/frontend/playwright.config.ts +++ b/services/frontend/playwright.config.ts @@ -9,21 +9,21 @@ export default defineConfig({ timeout: 5_000, }, fullyParallel: true, - // The e2e suite is I/O/wait-bound (browser navigation, rendering, mocked - // network) rather than CPU-bound, so oversubscribing the CI runner's vCPUs - // cuts wall-clock time: workers mostly await the browser, leaving CPU free. - // The bulk of the suite runs against a prebuilt app served by `vite preview` - // (see webServer below), which removes the dev-server compile bottleneck, so - // 8 workers on the 4-vCPU runner stays stable — concurrency-sensitive specs - // no longer race the compiler. See #424. - workers: process.env.CI ? 8 : undefined, - // Eight workers on the runner's four vCPUs stretch a local 0.8s test to ~6s, so a - // single starved assertion can blow the 5s cap on an otherwise green suite. The one - // retry also lets the trace above be captured, which retries: 0 made impossible. - retries: process.env.CI ? 1 : 0, + // CI runs the suite as six `--shard` slices, one job each, so a slice holds + // ~150 tests rather than 878. A short slice has no long tail to hide, which + // is what oversubscription bought: one worker per runner vCPU is enough, and + // it keeps a 0.8s test at 0.8s instead of stretching it toward the 5s cap. + workers: process.env.CI ? 4 : undefined, + // A retry under four workers would hide a real flake rather than absorb a + // starved assertion, so a failure is a failure. `trace` below is what makes + // that first failure readable. + retries: 0, reporter: "list", use: { - trace: "on-first-retry", + // retries: 0 means there is no second attempt to record, so the trace has + // to come off the first one. Passes are discarded, leaving a green run with + // no artifacts. + trace: "retain-on-failure", actionTimeout: 5_000, navigationTimeout: 5_000, // Every project but the motion one runs as a visitor who asked for reduced diff --git a/services/frontend/scripts/run-e2e-with-coverage.mjs b/services/frontend/scripts/run-e2e-with-coverage.mjs index c3a6d46ab..359d8b6a0 100644 --- a/services/frontend/scripts/run-e2e-with-coverage.mjs +++ b/services/frontend/scripts/run-e2e-with-coverage.mjs @@ -6,7 +6,11 @@ import process from "node:process" const coverageDir = "coverage/e2e" const rawCoverageDir = `${coverageDir}/raw` -const extraArgs = process.argv.slice(2) +// CI shards the suite, so each slice only ever holds part of the picture: it +// uploads its raw dir and a fan-in job merges the six into one report. Anything +// but a shard still reports for itself. +const report = !process.argv.includes("--no-report") +const extraArgs = process.argv.slice(2).filter((arg) => arg !== "--no-report") rmSync(coverageDir, {recursive: true, force: true}) @@ -26,7 +30,7 @@ const e2eResult = spawnSync( const hasRawCoverage = existsSync(rawCoverageDir) && readdirSync(rawCoverageDir).some((file) => file.endsWith(".json")) -if (hasRawCoverage) { +if (hasRawCoverage && report) { const reportResult = spawnSync( "yarn", [ @@ -44,7 +48,7 @@ if (hasRawCoverage) { if (reportResult.status !== 0 && e2eResult.status === 0) { process.exit(reportResult.status ?? 1) } -} else { +} else if (!hasRawCoverage) { const message = `No raw coverage was captured in ${rawCoverageDir}` if (e2eResult.status === 0) { console.error(message)