Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 59 additions & 3 deletions .github/workflows/validate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -762,6 +817,7 @@ jobs:
- acceptance-features
- fe-unit
- fe-e2e
- fe-e2e-coverage
- openapi-sync
runs-on: ubuntu-latest
timeout-minutes: 5
Expand Down
26 changes: 13 additions & 13 deletions services/frontend/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 7 additions & 3 deletions services/frontend/scripts/run-e2e-with-coverage.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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})

Expand All @@ -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",
[
Expand All @@ -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)
Expand Down
Loading