新しい iPhone の解像度を 1 つ足すたびに、どこかの画面でレイアウトが半ピクセルずれていないか不安になります。私が個人開発で運営している壁紙アプリ群は、対応端末が増えるほどスクリーンショットの枚数が増え、最後は目視で一枚ずつ見比べることになっていました。iPhone Air の 420×912、iPhone 17 Pro の 402×874 を追加したとき、グリッドの余白が一箇所だけ狂っているのを公開直前まで見落としていたのです。
本日 6/18、Gemini CLI が Antigravity CLI へ統合され、Antigravity 2.0 の並列オーケストレーションが実運用の前提になりました。公式が挙げる並列実行の例の 3 つ目が「別のエージェントがヘッドレスブラウザで視覚回帰テストを走らせる」というものです。これは、私のように見た目のずれを最後まで目視に頼っていた人間にとって、地味ですが効く話だと感じています。以下では、その視覚回帰エージェントを実際に組むための最小ハーネスと、本番で踏んだ落とし穴を、順を追ってお見せしていきます。
なぜ視覚回帰を「別エージェント」に切り出すのか
視覚回帰テスト(撮ったスクリーンショットを基準画像と比べ、差分が出たら止める)は、実装そのものとは時間軸がずれた作業です。コードを書いている最中に走らせると待ち時間が生まれ、後でまとめて走らせると「どの変更が崩したか」が曖昧になります。
Antigravity 2.0 がもたらした変化は、この検査をメインの実装エージェントから物理的に切り離せることです。あるエージェントがコンポーネントを書き換えている裏で、別のエージェントが前のコミットのベースラインと差分を取り続ける。実装と検査が同じ会話の中で奪い合っていた時間が、並列に流れるようになります。
私はこの分離を、4 つのサイトを並行運用する中で覚えた発想と同じだと考えています。生成を回すエージェントとゲート検証をかけるエージェントを分けておくと、片方が詰まってももう片方が止まりません。視覚回帰も同じで、「見た目の番人」を独立したプロセスに任せておくと、実装の速度を落とさずに安全網だけが常時動きます。
最小構成: ヘッドレスで撮って、基準画像と差分する
まずは並列もエージェントも抜きにして、純粋な視覚回帰の芯だけを作ります。Playwright で複数のビューポートを撮り、pixelmatch で差分ピクセル数を数えるだけの小さなハーネスです。
// vr/capture.mjs — 指定 URL を複数ビューポートで撮影する
import { chromium } from "playwright";
import { mkdir } from "node:fs/promises";
// 私のアプリのプロモ用 LP を例に、実機に近い解像度を並べています
const VIEWPORTS = [
{ name: "iphone-air", width: 420, height: 912 },
{ name: "iphone-17-pro", width: 402, height: 874 },
{ name: "iphone-16-promax", width: 440, height: 956 },
{ name: "desktop", width: 1280, height: 800 },
];
export async function capture(url, outDir) {
await mkdir(outDir, { recursive: true });
const browser = await chromium.launch(); // ヘッドレスがデフォルト
try {
for (const vp of VIEWPORTS) {
const page = await browser.newPage({
viewport: { width: vp.width, height: vp.height },
deviceScaleFactor: 2, // Retina 相当で撮る
});
await page.goto(url, { waitUntil: "networkidle" });
await page.screenshot({
path: `${outDir}/${vp.name}.png`,
fullPage: true,
});
await page.close();
}
} finally {
await browser.close();
}
}
差分側は、基準画像と新しい画像を同じサイズに揃えてからピクセル比較します。pixelmatch は差分ピクセル数を返すので、全体に対する割合をしきい値で判定します。
// vr/diff.mjs — 基準画像と新画像の差分割合を返す
import { PNG } from "pngjs";
import pixelmatch from "pixelmatch";
import { readFileSync, writeFileSync } from "node:fs";
export function diff(baselinePath, currentPath, diffOutPath) {
const base = PNG.sync.read(readFileSync(baselinePath));
const cur = PNG.sync.read(readFileSync(currentPath));
// サイズが違う時点で「崩れ」とみなして即失敗させます
if (base.width !== cur.width || base.height !== cur.height) {
return { changed: 1.0, reason: "dimension-mismatch" };
}
const { width, height } = base;
const out = new PNG({ width, height });
const mismatched = pixelmatch(
base.data, cur.data, out.data, width, height,
{ threshold: 0.1, includeAA: false } // includeAA:false がフレーキー対策の肝
);
writeFileSync(diffOutPath, PNG.sync.write(out));
const ratio = mismatched / (width * height);
return { changed: ratio, reason: ratio > 0 ? "pixel-diff" : "identical" };
}
この 2 つを繋ぐだけで、「撮る → 比べる → 差分画像を残す」という回帰検査の最小ループが回ります。ここまでは特別な仕組みは要りません。
ベースライン(基準画像)の保管と更新を設計する
視覚回帰でいちばん設計が要るのは、テストコードではなくベースラインの扱いです。基準画像をどこに置き、いつ更新するかを決めないと、すぐに「全部が赤い」状態になって誰も見なくなります。
私は次の 3 つの状態を明示的に分けるようにしています。
- 承認済みベースライン: リポジトリにコミットされた、正とみなす画像。
vr/baseline/ に置きます。
- 今回の撮影結果: CI やエージェントが生成した一時画像。
vr/current/ に出し、git 管理外にします。
- 差分画像: 人間が目で確認するためのオーバーレイ。
vr/diff/ に出します。
更新は「意図した見た目の変更」のときだけ、明示的なコマンドで行います。暗黙に上書きしないことが重要です。
// vr/run.mjs — 比較モードと承認モードを 1 本のスクリプトで切り替える
import { capture } from "./capture.mjs";
import { diff } from "./diff.mjs";
import { cpSync, readdirSync } from "node:fs";
const APPROVE = process.argv.includes("--approve");
const THRESHOLD = 0.002; // 全体の 0.2% を超える差分で失敗扱い
const URL = process.env.VR_URL ?? "http://localhost:3000";
await capture(URL, "vr/current");
if (APPROVE) {
// 意図した変更を承認: current を baseline に昇格させる
cpSync("vr/current", "vr/baseline", { recursive: true });
console.log("✅ ベースラインを更新しました");
process.exit(0);
}
let failed = false;
for (const file of readdirSync("vr/current")) {
const r = diff(`vr/baseline/${file}`, `vr/current/${file}`, `vr/diff/${file}`);
const pct = (r.changed * 100).toFixed(3);
if (r.changed > THRESHOLD) {
console.error(`❌ ${file}: ${pct}% 変化 (${r.reason})`);
failed = true;
} else {
console.log(`✅ ${file}: ${pct}%`);
}
}
process.exit(failed ? 1 : 0);
--approve を付けたときだけベースラインが動く、という一点を守るだけで、「気づいたら基準がずれていた」という事故がほぼ消えます。私はこの承認操作を人間の手に残し、エージェントには絶対に渡さないことを推奨します。基準を書き換える判断は、見た目の最終責任そのものだからです。
アンチエイリアス由来のフレーキーをしきい値で抑える
視覚回帰を導入して最初にぶつかる壁は、「何も変えていないのに毎回少しだけ差分が出る」という現象です。原因の多くはフォントのアンチエイリアスとサブピクセルレンダリングで、環境や GPU が変わると境界の数ピクセルが揺れます。
対処は 2 段構えにしています。まず pixelmatch の includeAA: false でアンチエイリアス境界を差分から除外します。それでも残る揺れは、全体に対する割合のしきい値(私の場合は 0.2%)で吸収します。1 ピクセルでも違えば失敗、という設定は理想に見えて、現実には誰も通せないテストになります。
// しきい値の決め方: まず同一ページを2回撮って「ノイズ床」を測る
import { capture } from "./capture.mjs";
import { diff } from "./diff.mjs";
await capture(process.env.VR_URL, "vr/_a");
await capture(process.env.VR_URL, "vr/_b");
// 同じ画面同士の差分 = 環境ノイズの下限。しきい値はこの値の数倍に置きます
const r = diff("vr/_a/desktop.png", "vr/_b/desktop.png", "vr/_noise.png");
console.log(`ノイズ床: ${(r.changed * 100).toFixed(4)}%`);
// 例: 出力が 0.03% なら、しきい値は 0.1〜0.2% あたりが現実的です
しきい値を勘で決めず、まず同一画面のノイズ床を実測してから 3〜5 倍に置く。これだけで「フレーキーすぎて無視される回帰テスト」になる確率がぐっと下がります。
Antigravity 2.0 の並列パイプラインに組み込む
ここからが本題です。Antigravity CLI は非対話(ヘッドレス)で起動でき、標準出力に結果を流せます。実装エージェントとは別に、視覚回帰だけを担うエージェントを並列で立てます。
考え方はシンプルで、メインのエージェントが UI を書き換えるたびに、回帰エージェントが「ビルド → ローカル起動 → 撮影 → 差分」を回し、差分が出たらメイン側へ差し戻す、という二者のループです。CLI を使うなら次のように、検査だけを 1 つのジョブとして切り出せます。
# vr/agent-check.sh — 回帰エージェントが回す検査ジョブ(非対話)
set -euo pipefail
npm run build
# プレビューサーバーを起動し、立ち上がりを待つ
npm run preview &
SERVER_PID=$!
npx wait-on http://localhost:3000
# 視覚回帰を実行。失敗したら差分画像のパスを stdout に出す
if ! VR_URL=http://localhost:3000 node vr/run.mjs; then
echo "VR_FAILED diff_dir=vr/diff"
kill $SERVER_PID
exit 1
fi
kill $SERVER_PID
echo "VR_PASSED"
このジョブを Antigravity の並列タスクとして登録し、実装タスクとは別レーンで走らせます。AGENTS.md 側では、回帰エージェントの責務を「差分の判定と報告まで。ベースラインの更新はしない」と明記しておくのが安全です。
<!-- AGENTS.md(抜粋): 回帰エージェントの境界を明文化する -->
## visual-regression エージェント
- 役割: UI 変更後に `vr/agent-check.sh` を実行し、差分の有無を報告する
- 出力契約: 成功時 `VR_PASSED` / 失敗時 `VR_FAILED diff_dir=...` を stdout 末尾に出す
- 禁止: `--approve` の実行、`vr/baseline/` への書き込み
- 失敗時: 差分が出た画面名と変化率を実装エージェントへ差し戻す
「出力契約」を 1 行決めておくと、メインのエージェントは回帰エージェントの最後の行だけを見て次の判断ができます。自然言語のやり取りに頼らず、VR_PASSED / VR_FAILED という固定の合図で繋ぐ。並列エージェント同士を協調させるときは、この機械的な握りが効きます。
本番運用で踏んだ落とし穴
実際に回し始めると、テストの中身より「撮影の再現性」でつまずきます。私が止まった代表的な罠を挙げておきます。
フォントの読み込みレース。networkidle を待っても Web フォントの適用が間に合わず、初回だけ差分が出ることがあります。撮影前に await page.evaluate(() => document.fonts.ready) を 1 行挟むと安定しました。
動的コンテンツの混入。日付・ランダムな並び・アニメーションは毎回変わるため、そのまま撮ると永遠に赤いままです。該当要素を撮影前に visibility: hidden でマスクするか、アニメーションを prefers-reduced-motion で止めて撮ります。
ビューポートと実機のズレ。CSS ピクセルと物理解像度を混同すると、deviceScaleFactor を入れ忘れて全画面がぼやけた差分になります。実機の解像度はおおむね物理ピクセルなので、論理ビューポートに割り戻す必要があります。私は App Store のスクリーンショット要件と突き合わせて、deviceScaleFactor: 2 を基準に揃えました。
CI と手元の非決定性。同じコードでも、Linux の CI と macOS の手元ではレンダリングが微妙に違います。ベースラインは「撮影する環境」ごとに 1 セット持つのが安全で、私は CI 環境で撮ったものだけを正のベースラインにしています。
これらはどれも公式ドキュメントには大きく書かれていませんが、視覚回帰を本番運用に乗せるかどうかは、ほぼこの再現性の詰め切りで決まると感じています。
どこまでエージェントに任せ、どこを人間が見るか
最後に運用の線引きです。私は「差分を検出して止める」までをエージェントに任せ、「差分が正しい変更か」の判断は人間に残しています。回帰エージェントが赤を出したとき、それが意図したリデザインなのか事故なのかは、見た目の意図を持っている人間にしか決められません。
具体的には、回帰エージェントの権限を検査と報告に限定し、ベースライン更新(--approve)は私自身が差分画像を見てから手で実行します。エージェントに承認まで任せると、崩れたまま「正」として固定される最悪の事故が起きえます。並列化で速くなるのは検査の回転であって、見た目の最終責任までは委譲しない、という分担が私の結論です。
まず手元のプロジェクトで vr/capture.mjs と vr/diff.mjs の 2 ファイルだけ置いて、同一画面のノイズ床を測ってみてください。しきい値の肌感がつかめれば、並列エージェントへの接続はその延長線上にあります。