From ab1417590f9dc88a74b19ecb2b47bf2281a0db5c Mon Sep 17 00:00:00 2001 From: Seiya KONNO Date: Sat, 31 Oct 2026 13:53:37 +0900 Subject: [PATCH 1/5] ci: run the full E2E suite on two workers The nightly full run failed the same timing-sensitive tests every night: on the 4-vCPU runner, four software-rendered browsers space frames far enough apart that presses miss their windows or a short chart ends before the test acts (#792). Run the full scope on two workers with a 150-minute limit, keep four for the smoke run, and let a manual run narrow the tests with a grep input. --- .github/workflows/e2e.yml | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index bcfc93d6d..639dd542b 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 @@ -54,11 +58,13 @@ jobs: always() && (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 }} + timeout-minutes: ${{ github.event_name == 'pull_request' && 30 || 150 }} env: CI: 'true' - E2E_WORKERS: 4 - E2E_GREP: ${{ (github.event_name == 'pull_request' || inputs.scope == 'smoke') && '@smoke' || '' }} + # The 4-vCPU runner renders in software: four 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 smoke run keeps 4. + E2E_WORKERS: ${{ (github.event_name == 'pull_request' || inputs.scope == 'smoke') && 4 || 2 }} + E2E_GREP: ${{ inputs.grep || ((github.event_name == 'pull_request' || inputs.scope == 'smoke') && '@smoke' || '') }} steps: - name: Checkout From f2e14e17ea66c09f5b40e872e4283079b45ed696 Mon Sep 17 00:00:00 2001 From: Seiya KONNO Date: Sat, 31 Oct 2026 14:12:00 +0900 Subject: [PATCH 2/5] test: keep timing-sensitive E2E off Synesthesia's software rendering On the CI runner Synesthesia's software rendering still starved four tests on two workers: the course maker's input never resolved, and the auto-timing and fixed-display presses missed their windows (#792). Specs that name no built-in skin now open Plain; the two option- semantics loops that kept failing run on Plain and Native only. Synesthesia stays covered where a spec names it. --- packages/app-play/e2e/play-options.spec.ts | 6 ++++-- packages/app-play/e2e/support.ts | 6 ++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/packages/app-play/e2e/play-options.spec.ts b/packages/app-play/e2e/play-options.spec.ts index c4e45da4e..78c3228ce 100644 --- a/packages/app-play/e2e/play-options.spec.ts +++ b/packages/app-play/e2e/play-options.spec.ts @@ -341,7 +341,8 @@ for (const skin of ['plain', 'native', 'synesthesia'] as const) { }); } -for (const skin of ['plain', 'native', 'synesthesia'] as const) { +// Option semantics, not skin drawing: Synesthesia's software rendering on CI misses the timing these need (#792). +for (const skin of ['plain', 'native'] as const) { test(`fixed display area ignores cover keys and restores floating time on ${skin}`, async ({ player }) => { await player.open({ 'be-music.built-in-skin': skin }); await player.dropText('fixed-area.bme', '#TITLE Fixed display area\n#BPM 120\n#00103:F0\n#00811:01\n'); @@ -419,7 +420,8 @@ for (const skin of ['plain', 'native', 'synesthesia'] as const) { } } -for (const skin of ['plain', 'native', 'synesthesia'] as const) { +// Option semantics, not skin drawing: Synesthesia's software rendering on CI misses the timing these need (#792). +for (const skin of ['plain', 'native'] as const) { test(`beatoraja automatic display timing is recorded and replayed on ${skin}`, async ({ player }) => { await player.open({ 'be-music.built-in-skin': skin }); await player.dropText('auto-timing.bms', '#TITLE Auto timing\n#BPM 120\n#RANK 3\n#00111:01\n#00311:01\n'); diff --git a/packages/app-play/e2e/support.ts b/packages/app-play/e2e/support.ts index fa4636274..df50db111 100644 --- a/packages/app-play/e2e/support.ts +++ b/packages/app-play/e2e/support.ts @@ -60,7 +60,9 @@ export class Player { /** * Opens the player with the test hook on the empty select screen. `storage` seeds `localStorage` (the settings the - * player remembers) before it starts; the built-in skin's effects are off unless a spec asks for them. + * player remembers) before it starts; the built-in skin's effects are off unless a spec asks for them. A spec that + * names no built-in skin gets Plain: the CI runner renders in software, and Synesthesia there spaces frames far + * enough apart to starve timing-sensitive tests (#792). Specs that test a skin name it. */ async open(storage: Readonly> = {}): Promise { await this.page.route(`**${FILE_ROUTE}**`, async (route) => { @@ -71,7 +73,7 @@ export class Player { (entries) => { for (const [key, value] of Object.entries(entries)) window.localStorage.setItem(key, value); }, - { 'be-music.skin-effects': 'off', ...storage }, + { 'be-music.skin-effects': 'off', 'be-music.built-in-skin': 'plain', ...storage }, ); await this.page.goto('/?e2e=1'); return this.waitFor((state) => state.scene.kind === 'select', 'the select screen'); From 8d6040471db78c31e0ef5824e91c3126608c6958 Mon Sep 17 00:00:00 2001 From: Seiya KONNO Date: Sat, 31 Oct 2026 15:54:13 +0900 Subject: [PATCH 3/5] Revert "test: keep timing-sensitive E2E off Synesthesia's software rendering" This reverts commit f2e14e17ea66c09f5b40e872e4283079b45ed696. --- packages/app-play/e2e/play-options.spec.ts | 6 ++---- packages/app-play/e2e/support.ts | 6 ++---- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/packages/app-play/e2e/play-options.spec.ts b/packages/app-play/e2e/play-options.spec.ts index 78c3228ce..c4e45da4e 100644 --- a/packages/app-play/e2e/play-options.spec.ts +++ b/packages/app-play/e2e/play-options.spec.ts @@ -341,8 +341,7 @@ for (const skin of ['plain', 'native', 'synesthesia'] as const) { }); } -// Option semantics, not skin drawing: Synesthesia's software rendering on CI misses the timing these need (#792). -for (const skin of ['plain', 'native'] as const) { +for (const skin of ['plain', 'native', 'synesthesia'] as const) { test(`fixed display area ignores cover keys and restores floating time on ${skin}`, async ({ player }) => { await player.open({ 'be-music.built-in-skin': skin }); await player.dropText('fixed-area.bme', '#TITLE Fixed display area\n#BPM 120\n#00103:F0\n#00811:01\n'); @@ -420,8 +419,7 @@ for (const skin of ['plain', 'native', 'synesthesia'] as const) { } } -// Option semantics, not skin drawing: Synesthesia's software rendering on CI misses the timing these need (#792). -for (const skin of ['plain', 'native'] as const) { +for (const skin of ['plain', 'native', 'synesthesia'] as const) { test(`beatoraja automatic display timing is recorded and replayed on ${skin}`, async ({ player }) => { await player.open({ 'be-music.built-in-skin': skin }); await player.dropText('auto-timing.bms', '#TITLE Auto timing\n#BPM 120\n#RANK 3\n#00111:01\n#00311:01\n'); diff --git a/packages/app-play/e2e/support.ts b/packages/app-play/e2e/support.ts index df50db111..fa4636274 100644 --- a/packages/app-play/e2e/support.ts +++ b/packages/app-play/e2e/support.ts @@ -60,9 +60,7 @@ export class Player { /** * Opens the player with the test hook on the empty select screen. `storage` seeds `localStorage` (the settings the - * player remembers) before it starts; the built-in skin's effects are off unless a spec asks for them. A spec that - * names no built-in skin gets Plain: the CI runner renders in software, and Synesthesia there spaces frames far - * enough apart to starve timing-sensitive tests (#792). Specs that test a skin name it. + * player remembers) before it starts; the built-in skin's effects are off unless a spec asks for them. */ async open(storage: Readonly> = {}): Promise { await this.page.route(`**${FILE_ROUTE}**`, async (route) => { @@ -73,7 +71,7 @@ export class Player { (entries) => { for (const [key, value] of Object.entries(entries)) window.localStorage.setItem(key, value); }, - { 'be-music.skin-effects': 'off', 'be-music.built-in-skin': 'plain', ...storage }, + { 'be-music.skin-effects': 'off', ...storage }, ); await this.page.goto('/?e2e=1'); return this.waitFor((state) => state.scene.kind === 'select', 'the select screen'); From 042ed1592d7e8a1e28a6d35258d070dbc7a31b8a Mon Sep 17 00:00:00 2001 From: Seiya KONNO Date: Sat, 31 Oct 2026 15:54:27 +0900 Subject: [PATCH 4/5] ci: shard the full E2E run with one browser per runner Two workers still starved 31 tests on the full run (#792): presses on the page missed their windows, text and audio probes fell under their thresholds. Split the full run over four runners with one browser each, so each software-rendered browser has the runner to itself; the smoke run and grep runs stay on one runner. --- .github/workflows/e2e.yml | 27 ++++++++++++++++----------- 1 file changed, 16 insertions(+), 11 deletions(-) diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 639dd542b..731511fd3 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -51,20 +51,27 @@ 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: | always() && (github.event_name != 'pull_request' || needs.changes.outputs.player == 'true') runs-on: ubuntu-24.04-arm - timeout-minutes: ${{ github.event_name == 'pull_request' && 30 || 150 }} + 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' - # The 4-vCPU runner renders in software: four 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 smoke run keeps 4. - E2E_WORKERS: ${{ (github.event_name == 'pull_request' || inputs.scope == 'smoke') && 4 || 2 }} + 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 @@ -89,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/ From b195efccaf0b866b4a9c3af721f7de1d26157cbe Mon Sep 17 00:00:00 2001 From: Seiya KONNO Date: Sat, 31 Oct 2026 17:32:24 +0900 Subject: [PATCH 5/5] test: measure E2E outcomes the CI runner can reproduce CI traces showed the features working while the probes failed (#792): Linux draws the chart text thin, so few pixels pass a near-white test; a 50 ms frame can land a page-timed press in GOOD; and the course maker spends seconds per click. Count bright pixels and compare the cleared text with the shown one, count GOOD as a hit where the test checks key binding or gauge policy, and give the course maker a longer timeout. --- packages/app-play/e2e/bmson-generic.spec.ts | 4 +++- packages/app-play/e2e/bmson-zero-total.spec.ts | 4 +++- packages/app-play/e2e/chart-text.spec.ts | 7 +++++-- packages/app-play/e2e/course-maker.spec.ts | 2 ++ 4 files changed, 13 insertions(+), 4 deletions(-) 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');