0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

CIでだけPlaywrightのE2Eが落ちる原因が、PixiJSの描画の重さだった話

0
Last updated at Posted at 2026-10-18

ローカルでは通る E2E が、CI でだけタイムアウトしていました。
アプリのバグではなく、canvas の描画が重すぎて Playwright の操作が間に合っていなかったので、備忘録として残します。

何が起きていたのか

ブラウザ3つで同じ対戦部屋に入って遊ぶ E2E です。
ボタンのクリックで次のエラーを繰り返し、120秒でタイムアウトしていました。

element was detached from the DOM

ゲームは「入力フェーズ → 演出フェーズ」を一定時間で回していて、入力フェーズ中だけ選択欄が開きます。
CI ではテストを速くするために時間を縮めていて、入力フェーズは3秒しかありませんでした。

原因: 1操作に0.5秒かかっていた

  • ブラウザを3つ開くと、PixiJS がソフトウェア描画で描き続け、各ページが約8fpsまで落ちる
  • その状態だと Playwright の操作1回に約0.5秒かかる(2コアの環境で計測)
  • 選択は2回クリックが要るので、途中で演出フェーズに切り替わって選択欄が閉じる

閉じた欄のボタンを掴もうとして detached になっていました。
アプリの挙動自体は正しく、入力フェーズ中はちゃんと開いたままでした。

開発機が速いと操作時間に差が出ない(1ms)ので、ローカルでは再現しません。

描画の重さを比べる

fps だとブレるので、WebGL の drawElements が呼ばれた回数を数えて比べました。

await page.addInitScript(() => {
  (window as any).__draws = 0;
  for (const C of [WebGLRenderingContext, WebGL2RenderingContext]) {
    const orig = C.prototype.drawElements;
    C.prototype.drawElements = function (...args: Parameters<typeof orig>) {
      (window as any).__draws++;
      return orig.apply(this, args);
    };
  }
});

// しばらく動かしたあとで
const draws = await page.evaluate(() => (window as any).__draws);

直したこと1: 動きがあるときだけ描く

Pixi の自動レンダリングを外し、動きがあるときだけ app.render() するようにしました。
あわせて30fps上限にし、タブが非表示なら ticker を止めています。

const app = new Application();
await app.init({ autoStart: false, resizeTo: el });

const ticker = new Ticker();
ticker.maxFPS = 30;
ticker.add(() => {
  const moved = updateAnimations(ticker.deltaMS);
  if (moved) app.render();
});
ticker.start();

document.addEventListener('visibilitychange', () => {
  document.hidden ? ticker.stop() : ticker.start();
});

updateAnimations は自前の更新処理で、何か動いたら true を返すだけです。
ローカルで計測したところ、描画回数は約1/3〜1/4になりました。

直したこと2: テストを入力フェーズに合わせる

描画を軽くしても、フェーズの切り替わりと重なる可能性は残ります。
そこで、操作を入力フェーズの中で行い、失敗したら次の入力フェーズでやり直すヘルパーを作りました。

async function inInputPhase(page: Page, fn: () => Promise<void>) {
  await expect(async () => {
    // 入力フェーズかどうかの判定はアプリに合わせて
    await expect(page.getByTestId('input-phase')).toBeVisible({ timeout: 1000 });
    await fn();
  }).toPass({ timeout: 30_000 });
}

expect(...).toPass() は、中の処理が通るまでリトライしてくれます。

あわせて、CI の時間の縮め方も 0.3倍 から 0.5倍 にゆるめました。

ついでに: 複数ページの確認は並べる

3人分の操作や確認を1ページずつ順番に行うと、描画が遅いぶん間に状態が進んで落ちます。
Promise.all で並べます。

await Promise.all(pages.map((p) => inInputPhase(p, () => choose(p))));

最後に

  • canvas を描き続けるアプリの E2E が CI でだけ落ちたら、テストやアプリより先に「ブラウザを何個開いていて、1操作に何秒かかっているか」を見る
  • CI は開発機ほど速くない。時間制限のある画面のテストは、操作が遅れる前提で組む

参考

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?