diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index bcfc93d6d..731511fd3 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -16,6 +16,10 @@ on: - full - smoke default: full + grep: + description: Only the tests whose title matches this regular expression (overrides scope) + type: string + default: '' permissions: contents: read @@ -47,7 +51,7 @@ jobs: - '!**/*.md' e2e: - name: E2E (${{ github.event_name == 'pull_request' && 'smoke' || inputs.scope || 'full' }}) + name: E2E (${{ github.event_name == 'pull_request' && 'smoke' || inputs.scope || 'full' }}${{ matrix.shards > 1 && format(' {0}/{1}', matrix.shard, matrix.shards) || '' }}) needs: changes # `changes` is skipped outside pull requests; run regardless of that, but only on a player change in a pull request. if: | @@ -55,10 +59,19 @@ jobs: (github.event_name != 'pull_request' || needs.changes.outputs.player == 'true') runs-on: ubuntu-24.04-arm timeout-minutes: ${{ github.event_name == 'pull_request' && 30 || 90 }} + # The 4-vCPU runner renders in software: several browsers at once space frames far enough apart that tests pressing + # keys at a time on the page miss their windows or race the end of a short chart (#792). The full run is split over + # four runners with one browser each; the smoke run stays on one runner with four. + strategy: + fail-fast: false + matrix: + shard: ${{ fromJSON((github.event_name == 'pull_request' || inputs.scope == 'smoke' || inputs.grep != '' && inputs.grep != null) && '[1]' || '[1, 2, 3, 4]') }} + shards: ${{ fromJSON((github.event_name == 'pull_request' || inputs.scope == 'smoke' || inputs.grep != '' && inputs.grep != null) && '[1]' || '[4]') }} env: CI: 'true' - E2E_WORKERS: 4 - E2E_GREP: ${{ (github.event_name == 'pull_request' || inputs.scope == 'smoke') && '@smoke' || '' }} + E2E_WORKERS: ${{ (github.event_name == 'pull_request' || inputs.scope == 'smoke') && 4 || 1 }} + E2E_GREP: ${{ inputs.grep || ((github.event_name == 'pull_request' || inputs.scope == 'smoke') && '@smoke' || '') }} + E2E_SHARD: ${{ matrix.shard }}/${{ matrix.shards }} steps: - name: Checkout @@ -83,17 +96,15 @@ jobs: shell: bash run: | set -euo pipefail - if [ -n "$E2E_GREP" ]; then - pnpm run test:e2e --grep "$E2E_GREP" - else - pnpm run test:e2e - fi + args=(--shard "$E2E_SHARD") + if [ -n "$E2E_GREP" ]; then args+=(--grep "$E2E_GREP"); fi + pnpm run test:e2e "${args[@]}" - name: Upload report if: failure() || cancelled() uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: - name: e2e-report-${{ github.run_id }} + name: e2e-report-${{ github.run_id }}-${{ matrix.shard }} path: | packages/app-play/e2e-report/ packages/app-play/e2e-results/ diff --git a/packages/app-play/e2e/bmson-generic.spec.ts b/packages/app-play/e2e/bmson-generic.spec.ts index 366e47be5..4fd9494b5 100644 --- a/packages/app-play/e2e/bmson-generic.spec.ts +++ b/packages/app-play/e2e/bmson-generic.spec.ts @@ -96,7 +96,9 @@ for (const family of ['native', 'canvas', 'lr2', 'beatoraja'] as const) { 'bound F5 instead of restart', ); const result = await player.waitForResult(0); - expect(result.results[0]!.judged.pgreat + result.results[0]!.judged.great).toBe(count === 70 ? 5 : 3); + // Every bound key hits its note; a slow CI frame can land a page-timed press in GOOD (#792). + const judged = result.results[0]!.judged; + expect(judged.pgreat + judged.great + judged.good).toBe(count === 70 ? 5 : 3); expect(result.results[0]!.judged.poor).toBe(0); const replay = await player.lastReplay(); expect(replay?.chart.laneMode).toBe(`${count} KEY GENERIC`); diff --git a/packages/app-play/e2e/bmson-zero-total.spec.ts b/packages/app-play/e2e/bmson-zero-total.spec.ts index 98d5fd647..43ce87f7f 100644 --- a/packages/app-play/e2e/bmson-zero-total.spec.ts +++ b/packages/app-play/e2e/bmson-zero-total.spec.ts @@ -30,7 +30,9 @@ for (const family of ['native', 'canvas', 'lr2', 'beatoraja'] as const) { await player.waitForResult(0); const replay = await player.lastReplay(); expect(replay?.play.bmsonTotalMode).toBe('spec'); - expect((replay?.results?.native?.judge.pgreat ?? 0) + (replay?.results?.native?.judge.great ?? 0)).toBe(2); + // Both notes are hit; a slow CI frame can land a page-timed press in GOOD (#792). + const judge = replay?.results?.native?.judge; + expect((judge?.pgreat ?? 0) + (judge?.great ?? 0) + (judge?.good ?? 0)).toBe(2); expect(replay?.results?.native?.gauge.final).toBe(20); if (family === 'native') { await player.leaveResult(); diff --git a/packages/app-play/e2e/chart-text.spec.ts b/packages/app-play/e2e/chart-text.spec.ts index 1d517090e..d4fb9dfb7 100644 --- a/packages/app-play/e2e/chart-text.spec.ts +++ b/packages/app-play/e2e/chart-text.spec.ts @@ -16,7 +16,8 @@ async function textPixels(player: Player): Promise { for (let y = 0; y < image.height * 0.35; y++) for (let x = image.width * 0.1; x < image.width * 0.9; x++) { const offset = (y * image.width + Math.floor(x)) * 4; - if (data[offset]! > 235 && data[offset + 1]! > 235 && data[offset + 2]! > 235) count++; + // Bright, not pure white: Linux renders the thin text with fewer fully white pixels (#792). + if (data[offset]! > 160 && data[offset + 1]! > 160 && data[offset + 2]! > 160) count++; } return count; }, @@ -45,6 +46,7 @@ for (const family of ['native', 'canvas', 'lr2', 'beatoraja'] as const) { const before = await textPixels(player); await player.waitFor((state) => (state.play?.seconds ?? 0) > 2.2, 'text selection'); await expect.poll(() => textPixels(player), { timeout: 2000, intervals: [100] }).toBeGreaterThan(before + 500); + const shown = await textPixels(player); await player.key('Space'); const paused = (await player.state()).play?.seconds; await player.page.screenshot({ path: info.outputPath('chart-text.png') }); @@ -53,7 +55,8 @@ for (const family of ['native', 'canvas', 'lr2', 'beatoraja'] as const) { expect((await player.state()).play?.seconds).toBeCloseTo(paused!, 2); await player.key('Space'); await player.waitFor((state) => (state.play?.seconds ?? 0) > 6.2, 'text clear'); - await expect.poll(() => textPixels(player), { timeout: 2000, intervals: [100] }).toBeLessThan(before + 300); + // Cleared: back nearer the empty count than the shown one (other HUD text keeps changing). + await expect.poll(() => textPixels(player), { timeout: 2000, intervals: [100] }).toBeLessThan((before + shown) / 2); if (family === 'native') await expect(player.page.locator('[data-chart-text]')).toBeHidden(); }); } diff --git a/packages/app-play/e2e/course-maker.spec.ts b/packages/app-play/e2e/course-maker.spec.ts index 68c19ee8e..f8ddbd225 100644 --- a/packages/app-play/e2e/course-maker.spec.ts +++ b/packages/app-play/e2e/course-maker.spec.ts @@ -34,6 +34,8 @@ async function downloadedText(download: Download): Promise { } test('makes, saves, cancels, renames, deletes and exports courses', async ({ player }) => { + // Hundreds of UI steps: the CI runner spends seconds per click (#792). + test.setTimeout(360_000); const { page } = player; await openWithLibrary(player); const card = page.locator('aside.course-maker-card');