GitHubのGPUランナーとPlaywrightでCesiumアプリケーションのスクリーンショットを撮影する

2024-07-24

GitHubのGPUランナーとPlaywrightでCesiumアプリケーションのスクリーンショットを撮影する

こんにちは、CTOの井上です。

Eukaryaではサービスの品質向上に取り組んでおり、その一環としてE2Eテストの自動化に取り組んでいます。

E2Eテストを自動で行うツールはいくつもありますが、最近はPlaywrightを使うことが多いです。Playwrightを使うと、JavaScript/TypeScriptによる簡潔な記述でWebブラウザ上でE2Eテストが実行できて、大変良いです。

しかし、Eukaryaで扱うアプリケーションには、Re:EarthやPLATEAU VIEWなど、WebGLを使ったアプリケーションが多いです。これらのアプリケーションをPlaywrightでテストするには、通常のアプリケーションのテストよりも少し工夫が必要でしたので、ここではポイントを説明していこうと思います。

GPUランナーの設定

まずWebGLアプリケーションをテストするには、GPUが必要です。

ChromiumではGPUがなくても実はWebGLを動かすこと自体はできてしまうのですが、描画にかなり時間がかかってしまい実用的ではありません。

幸いGitHubでは最近、GitHub ActionsでGPUランナーが使えるようになりましたので、GitHub ActionsでもGPUを使用した自動テストの道が開かれました!

事前にGPUランナーを使用できるように設定する必要があるのですが、手順が少しわかりづらかったのでメモっておきます。※記事執筆時点のUIです。UIは変更される可能性があります。

まずはOrganizationの設定画面から、Actions/Runnersの設定画面に移動します。URLは以下のような感じです。

https://github.com/organizations/{{ORGANIZATION}}/settings/actions/runners

New Runnerをクリックすると選択肢が表示されますが「New GitHub-hosted runner」を選びます。するとランナーの設定画面になります。

まずはNameを入力しますが、これが後述するGitHub Actionsのワークフローファイルの runs-on に書く文字列となります。小文字のケバブケースが良いでしょう。ここでは gpu としておきます。

PlatformはLinux x86のままSaveボタンを押して次へ行き、Imageの設定で、「Partner」というタブをクリックすると、「NVIDIA GPU-Optimized Image for AI and HPC」の選択肢が現れます。これがGPUです(このPartnerタブを見つけられなくて時間を浪費しました…)。

このT4は本来はAI向けのGPUではありますが、実際には後述の通り3Dグラフィックスもアクセラレートしてくれます。

残りは通常通り設定すれば、ランナーが使える状態になります。

ちょっと試すだけならランナーグループはDefaultで良いですが、後述するようにGPUランナーは無料枠がなく料金がかかりますので、意図しない乱用を防ぐためにランナーグループを分離するのも良いと思います。

料金についてですが、GPUランナー(Ubuntu GPU 4-core)の料金は執筆時点で、$0.07/minで、通常のランナー(Ubuntu 2-Core)にある無料枠は存在しません。また、通常のランナーは$0.008/minなので、GPUランナーはやはり割高ではあります。GPUランナーはGPUを使用したアプリケーションのテストにのみピンポイントで使うようにし、その他の通常の画面のE2Eテストは通常のランナーで実行したほうがいいでしょう。

Playwrightの準備

GPUランナーが設定できたら、早速リポジトリを作成し、Playwrightの初期化を行いましょう。Node.jsが使える環境であれば

npm init playwright@latest

で初期化OKです。

playwright.config.tsを変更し、ここではChromiumだけが実行されるようにしておきます。リトライ回数は0回にし、タイムアウトがデフォルトで30秒なので10分に伸ばします。また、スクリーンショット等のスナップショットを保存するディレクトリを snapshotDir で指定しておけば、CIでのアーティファクトのアップロードも楽です。

なお、GPUを使用するためのChromiumの特殊なargsなどは一切指定不要です。

export default defineConfig({
	// ...
	retries: 0,
  timeout: 10 * 60 * 1000, // 10m
  snapshotDir: './snapshots',
  projects: [
    {
      name: 'chromium',
      use: {
        ...devices['Desktop Chrome']
      },
    },
  ]
});

次に、GitHub Actionsも編集します。

  • さきほど設定したGPUランナーのラベル名を runs-on に記述します。
  • 今回はスクリーンショットを撮りたいので、スクリーンショットもアーティファクトとしてアップロードされるようにしておきます。また、テストを開始する前に、前回のアーティファクトをダウンロードします。
  • スナップショットを更新したくなったらできるようにinputsを設定しておきます。
name: Playwright Tests
on:
  workflow_dispatch:
    inputs:
      update_snapshots:
        description: 'Update snapshots'
        required: false
        default: false
        type: boolean
  # 毎日0時に実行したい場合はコメントアウト
  # schedule:
  #   - cron: '0 0 * * *'
  push:
    branches: main
jobs:
  test:
    timeout-minutes: 60
    runs-on: gpu # 設定したGPUランナーのラベル名
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: lts/*
      - name: Restore artifacts
        uses: dawidd6/action-download-artifact@v6
        with:
          name: playwright-snapshots
          path: snapshots/
          workflow_conclusion: success
          if_no_artifact_found: warn
      - name: Install dependencies
        run: npm ci
	      - name: Install Playwright Browsers
        run: npx playwright install --with-deps

      - name: Run Playwright tests
        if: ${{ !inputs.update_snapshots && github.event_name != 'push' }}
        run: npx playwright test

      - name: Run playwright test with snapshot update
        if: ${{ inputs.update_snapshots || github.event_name == 'push' }}
        run: npx playwright test --update-snapshots

      - uses: actions/upload-artifact@v4
        if: always()
        with:
          name: playwright-snapshots
          path: snapshots/
          retention-days: 30
      - uses: actions/upload-artifact@v4
        if: always()
        with:
          name: playwright-report
          path: playwright-report/
          retention-days: 30

テストを書く

tests/example.spec.ts を編集します。

ここではPLATEAU VIEWを訪問し、30秒待ってからスクリーンショットを撮るテストを書いてみます。

ついでに、Cesiumがちゃんとデータを読み込んでいるのか確認したり、予期せぬエラーが発生していないか確認したりするために、ネットワークリクエストやコンソールログが発生するたびにログに吐き出すようにしています。こうすることで、ログが長くなってしまいますが、突然テストが失敗したときにGitHub上で原因がすぐに確認できます。

import { test, type Page } from '@playwright/test';

test('take screenshot after loading', async ({ page }) => {
	printConsole(page);
	
  await page.goto('https://plateauview.mlit.go.jp/');
  
  await page.waitForTimeout(30 * 1000); // 30s
  
  await page.screenshot({ path: 'snapshots/screenshot.png' });
});

function printConsole(page: Page) {
  page.on('pageerror', exception => {
    console.error(``, exception);
  });

  page.on('requestfinished', request => {
    console.error(``, request.url());
  });

  page.on("requestfailed", request => {
    console.error(``, request.url() + ' ' + request.failure()?.errorText);
  });

  page.on('console', async msg => {
    const values: any[] = [];
    for (const arg of msg.args())
      values.push(await arg.jsonValue());
    if (values.length > 0) {
        console.log("ℹ", ...values);
    }
  });
}

ここまでできたら、以下のコマンドでテストを実行します。

npx playwright test

これでテスト自体は実行できるようになり、GPUランナーであれば問題なくスクリーンショットが撮影され、snapshotsディレクトリ内にスクショが保存されます!

画面が安定するまでスクリーンショットを撮り続ける

上記アプローチでは、スクショ撮影前の待ち時間を手動で設定しました。しかし実際に試すと、以下のように、データ読み込み途中の状態でスクショが撮影されることがしばしばあります。

まだ一部の建築物しか読み込まれていない。場合によってはタイルも読込中で解像度が荒いことも。
まだ一部の建築物しか読み込まれていない。場合によってはタイルも読込中で解像度が荒いことも。

通常、こうした地図アプリケーションを開くと、タイルをはじめとする様々なデータの読み込みが大量に発生し、データの読み込みができ次第その部分が遅れて描画されることが多いです。そのため、何秒待てば完全にデータの読み込み終わった状態でスクショが撮影できるのかは、アプリケーションだけでなくネットワークや配信サーバーの状況に左右され、固定されておらず予測が難しいです。

await page.waitForLoadState('networkidle'); も試しましたが、筆者マシンで試してみたところ60秒程度待たされてしまいますし、そもそもこのnetworkidleの使用は非推奨とされています(Playwrightの公式ドキュメントを参照)。かといってDOMのAuto-retrying assertionsのような、WebGL上の特定のオブジェクトが表示されるまで自動的に待ってくれるという機能もありません。

そこで、ネットワークリクエストの有無を見るよりは、画面の変化が一定時間以上起きなくなっていればスクショ撮影するには問題がなかろうということで、以下の方法を考えました。

数秒おきに繰り返しスクショを撮影し、撮影するたびに、前回撮影したスクショと比較します。3回連続でスクショの内容が前回から変化しなかったら、データ読み込みがすべて終了して画面が安定したとみなして、テストを完了させます。

それでは実際に実装してみましょう。Bufferの比較には Buffer.compare が使えます。

import { type Page, test } from '@playwright/test';

// スクショ撮影頻度は実際に試してみて調整が必要
const defaultInterval = process.env.CI ? 5000 : 1000;

async function waitForStableScreen(page: Page, path?: string = undefined, interval = defaultInterval, retries = 3): Promise<Buffer> {
  let previousScreenshot: Buffer | null = null;
  let noChangeCount = 0;
  let count = 0;

  while (true) {
    const screenshot = await page.screenshot({
	    path, 
      fullPage: true
    });
    if (previousScreenshot) {
      if (Buffer.compare(previousScreenshot, screenshot) === 0) {
        noChangeCount++;
        if (noChangeCount >= retries) {
          console.log(`📷 stable screen! it takes ${interval * count} ms`);
          return screenshot;
        }
      } else {
        noChangeCount = 0;
      }
    }

    previousScreenshot = screenshot;
    count++;
    console.log(`📷 wait ${interval} ms (x${count}${noChangeCount > 0 ? `, no change x${noChangeCount}/${retries}` : ""})`);
    await page.waitForTimeout(interval);
  }
}

// ...

test('take screenshot after loading', async ({ page }) => {
	// ...
  await page.goto('https://plateauview.mlit.go.jp/');

  await waitForStableScreen(page, 'snapshots/screenshot.png');
});

この実装で以下のように、完全に読み込みが完了した状態のスクショを安定して撮影できるようになりました。

すべてのデータが読み込まれ、画面がこれ以上変化しなくなった状態。
すべてのデータが読み込まれ、画面がこれ以上変化しなくなった状態。

筆者マシンで試したところ20秒程度で撮影が完了し、GitHubのGPUランナーでは7分〜10分程度かかります。おそらくランナーがアメリカにあるのか、日本のサーバーに置いてあるデータのダウンロードが遅く、テスト完了まで時間がかかる原因になっていると思われます。

なお、このテクニックはPLATEAU VIEWのような、何も操作しなければアニメーションが発生しないようなアプリケーションのみ有効です。アニメーションが常時発生するようなアプリケーションではこのテクニックは使えないことに注意してください(ちなみに、DOMやCSSのアニメーションであれば、PlaywrightではCSSアニメーションの無効化やDOM要素のマスクが可能です)。

コストについてですが、GPUランナーの料金は記事執筆時点では$0.07/minなので、このシンプルなテストを1回動かすたびに10分かかるとすると、$0.7のコストがかかります。毎日1回実行する場合、$21/monthくらいになります。

なおGPUランナーではない通常の ubuntu-latest でも同様のテストを試してみましたが、そもそも描画が非常に遅く、所要時間が1時間を軽く超えました(実際にどこまでかかるかは未測定)。やはりGPUは必要ですね。

マウス操作をさせてカメラを動かしてみる

Playwrightでは、指定した座標で任意のマウス操作ができるので、例えばカメラのドラッグや地物の選択なども実行可能です。

例えば、canvasの中央から200px分下にマウスドラッグさせて、少しカメラを移動させてみましょう。

import { expect, type Page, test } from '@playwright/test';
// ...

test('move camera', async ({ page }) => {
  await page.goto('https://plateauview.mlit.go.jp/');
  await page.waitForTimeout(5000);

  const box = await page.locator('.cesium-widget > canvas').boundingBox();
  if (!box) throw new Error('no canvas found');

  const x = box.x + box.width / 2;
  const y = box.y + box.height / 2;

  await page.mouse.move(x, y);
  await page.mouse.down();
  await page.mouse.move(x, y + 200);
  await page.mouse.up();
	
  const screenshot = await waitForStableScreen(page, 'snapshots/screenshot.png');
});

これにより、以下のようなスクショが撮れました。前述のスクショよりカメラの位置が動いていることが確認できるかと思います。

カメラ移動後のスクショ。前述のスクショよりカメラの位置が動いている。
カメラ移動後のスクショ。前述のスクショよりカメラの位置が動いている。

ビジュアルリグレッションテストの完成

ここまででスクショが取れるようになったので、スナップショットテストができるようになります。このスナップショットテストを定期的に行い、前回のスクショとの差分を比較することで、サービスの異常や意図しないデグレを検知することができるようになります。これがビジュアルリグレッションテスト(Visual Regression Testing・VRT)です。

前述したGitHub Actionsのワークフローでは、スナップショットをアーティファクトとして保存し、次回テスト時に前回のアーティファクトをダウンロードして使用することで、比較を実行できます。

import { expect, type Page, test } from '@playwright/test';
// ...

test('take screenshot after loading', async ({ page }) => {
	// ...
  await page.goto('https://plateauview.mlit.go.jp/');

  const screenshot = await waitForStableScreen(page);
  expect(screenshot).toMatchSnapshot('screenshot.png');
});

ここでは詳しくは紹介しませんが、reg-suitなどのツールと組み合わせると、スナップショットを外部に保存しておくこともできますし、より視覚的にスクショの差分を表示できて便利かもしれません!

おわりに

意外と簡単に、PlaywrightでWebGLを使用したCesiumアプリケーションのビジュアルリグレッションテストができてしまいました。これもGitHubがGPUランナーをホスティングしてくれているからですね。GPUが手軽に使えて便利です。

話は少し変わりますが、SREのObservabilityの観点から見ると、Cesiumのような地図アプリケーションが正しく動いているかどうかを監視するには、単純なHTTPリクエストの外形監視だけでは不十分なので、こういった場合にもこのPlaywrightとGPUランナーによるE2Eテストの定期実行は有効なのではないかと思います。

ただし、データのダウンロードが大量に発生するCesiumのような地図アプリケーションでは、安定したスクリーンショットの撮影には時間がかかることがあるため、テストケースの設計を行うときにはその点を考慮しなければいけません。

テストをなるべく並列で実行したり、(今はできませんが)ランナーのリージョンを地理的にデータの近くに変更したり、データそのものを最適化したりするなどの対策ができれば、より高速にスクショ撮影ができそうです。

最後に、Eukaryaでは、このようなテスト自動化を推進していただけるQAエンジニアを募集しています!GISや3DCGの領域でこうしたQAに取り組む会社は珍しく、前例も少ないので、様々なチャレンジができるのではと思います。

Japanese

Eukaryaでは様々な職種で積極的にエンジニア採用を行っています!OSSにコントリビュートしていただける皆様からの応募をお待ちしております!

Eukarya 採用ページ

Eukarya is hiring for various positions! We are looking forward to your application from everyone who can contribute to OSS!

Eukarya Careers

Eukaryaは、Re:Earthと呼ばれるWebGISのSaaSの開発運営・研究開発を行っています。Web上で3Dを含むGIS(地図アプリの公開、データ管理、データ変換等)に関するあらゆる業務を完結できることを目指しています。ソースコードはほとんどOSSとしてGitHubで公開されています。

Eukarya Webサイト / ➔ note / ➔ GitHub

Eukarya is developing and operating a WebGIS SaaS called Re:Earth. We aim to complete all GIS-related tasks including 3D (such as publishing map applications, data management, and data conversion) on the web. Most of the source code is published on GitHub as OSS.

Eukarya Official Page / ➔ Medium / ➔ GitHub