ローカルでは通る 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 は開発機ほど速くない。時間制限のある画面のテストは、操作が遅れる前提で組む