E2Eが落ちる。表示されている値が、直す前のものになっている。ソースは直っている。ビルドし直しても変わらない。

犯人はコードではなかった。前のセッションで起動したまま生き残っていたプレビューサーバーで、Playwrightがそれを掴んでいた。テストは正しく動いていて、ただ「1時間前のビルド」に対して実行されていただけだった。

reuseExistingServer は何をしているか

Playwrightの webServer 設定には reuseExistingServer というオプションがある。多くのプロジェクトがこう書いている。

webServer: {
  command: "npm run dev",
  url: `http://localhost:${PORT}/`,
  reuseExistingServer: !process.env.CI,
  timeout: 120_000,
},

意味はそのままで、「指定したURLで既にサーバーが応答しているなら、新しく起動せずそれを使う」。ローカルでの開発体験としては正しい。テストのたびにサーバーを立ち上げ直したら遅くてかなわない。

問題は、Playwrightが見ているのが「そのURLが応答するか」だけだという点だ。応答していれば再利用する。それが何分前に起動したものか、いま手元にあるビルド成果物と一致しているか、そういうことは一切見ていない。見ようがない。

astro dev を使っているなら、ここはあまり問題にならない。devサーバーはファイルを監視していて、変更があれば勝手に読み直すからだ。古いサーバーを掴んでも中身は新しい。

astro preview を使っている構成だと、これが牙を剥く。 previewは dist を配信するだけで、ファイルの監視をしない。ビルドし直しても、起動済みのpreviewは古い dist を握ったまま配信し続ける可能性がある。

そして「テストの前に必ずビルドする」構成にしていると、事態はさらに分かりにくくなる。

astro build && playwright test

ビルドは毎回走る。だから「ビルドが古い」とは疑わない。でも配信しているサーバーは前のセッションのもので、そちらが握っている dist は別物かもしれない。ビルドは新しく、配信は古いという、一番気づきにくい組み合わせができあがる。

Astro 7 でpreviewがバックグラウンドに回るようになった

ここにもうひとつの変更が乗ってきた。

Astro 7 に上げたところ、astro preview の挙動が変わっていた。前面プロセスが即座に exit 0 して、サーバー本体はバックグラウンドに回る。デーモン化したわけだ。

Playwrightの webServer はこれを理解できない。command に指定したプロセスが終了したら「起動に失敗した」と判断する。実際にはサーバーは動いているのに、テストが始まる前に落ちる。

Error: Process from config.webServer exited early.

これはこれで困るのだが、エラーが出るだけまだ親切だった。厄介なのは、この状態で reuseExistingServer と組み合わさったときだ。

  1. 前のセッションのpreviewがバックグラウンドで生き残っている
  2. 新しくテストを回す
  3. Playwrightが「URLが応答する」ので再利用を選ぶ
  4. 起動失敗のエラーは出ない。テストは普通に走る
  5. ただし配信されているのは古い dist

エラーが出ないぶん、原因にたどり着くまでが長い。

直し方: webServer を捨ててnpm scripts側で明示する

結論としては、Playwrightの webServer に任せるのをやめた。起動と停止を自分で書く。

"test": "astro build && astro preview stop >/dev/null 2>&1; astro preview >/dev/null && playwright test; s=$?; astro preview stop >/dev/null 2>&1; exit $s"

やっていることは4つだ。

  1. ビルドする
  2. astro preview stop で、生き残っている可能性のあるpreviewを先に殺す(いなくてもエラーにしない)
  3. previewを起動してテストを回す
  4. 終了コードを控えて、previewを止めてから、その終了コードで抜ける

s=$? で終了コードを保持しているのは、後始末の stop が成功すると終了コードを上書きしてしまい、テストが落ちてもCI上は成功に見えるからだ。ここを忘れると、今度は「落ちているのに気づけない」という別の事故になる。

ポイントは 2番の「先に殺す」 だ。これで reuseExistingServer の罠が原理的に消える。再利用できるサーバーが存在しない状態から始まるので、掴みようがない。

Astro 7 のデーモン化は、この書き方だと逆に都合がいい。起動コマンドが即座に返ってくれるので、シェルで && で繋ぐだけで次に進める。バックグラウンドに回す & も、起動を待つ wait-on も要らない。

応用: 「落ちた値が直す前の値」なら鮮度を疑う

この一件から、デバッグの手順をひとつ追加した。

テストが落ちていて、その落ちた値が「直す前の値」と一致しているなら、まずコードではなくビルドと配信の鮮度を疑う。

コードのバグなら、落ちる値は普通「何か別の値」になる。ぴったり修正前の値が出ているというのは、修正前のコードが動いていることを強く示唆する。そういうときは:

# いま何が待ち受けているか
ss -tlnp | grep 4905

# そのプロセスはいつ起動したか
ps -o pid,lstart,args -p <PID>

起動時刻が今日の作業より前なら、それが犯人だ。

プロセスを探すときに pkill -f を使うのはおすすめしない。パターンに自分のシェルのコマンドラインが引っかかって、自分自身を殺すことがある。PIDを取ってから個別に kill したほうが安全だ。

ついでに: Astro 7 移行で踏んだ他の穴

同じ移行で踏んだものを並べておく。npm audit fix --force では通らなかった。

  • @astrojs/tailwind がAstro 7 非対応。 peerが ^3||^4||^5 止まり。@tailwindcss/vite + Tailwind v4 に移す。tailwind.config.mjs は消して、中身をCSS側に書く(@custom-variant dark が旧 darkMode: 'class'、@theme が旧 theme.extend)
  • legacy content collections がAstro 6 で廃止。 src/content/config.ts を src/content.config.ts に移し、type: 'data' / type: 'content' を glob() loaderに置き換える
  • ビルド時のファイル読みが壊れる。 プリレンダ用チャンクの出力先が変わったため、fileURLToPath(new URL('../../data/x.csv', import.meta.url)) がENOENTになる。Viteの ?raw インポート(import csv from '../../data/x.csv?raw')に変えると実行位置に依存しなくなる
  • SSRサイトは locals.runtime が廃止。 import { env } from "cloudflare:workers" に移す。型は素の interface Env ではなく Cloudflare.Env にマージしないと env から見えない

上げる前にE2Eを回して「元から落ちているテスト」を控えておくのも大事だった。移行後に落ちたとき、それが移行のせいなのか元からなのかが分からないと切り分けができない。

まとめ

  • reuseExistingServer は「URLが応答するか」しか見ない。ビルドとの一致は見ていない
  • astro dev なら実害は小さいが、astro preview は dist を握ったまま配信するので古いままになる
  • Astro 7 で astro preview がデーモン化し、webServer が「起動に失敗した」と誤検知するようになった
  • 直し方は webServer を捨てて、npm scripts側で stop → start → test → stop を明示する。終了コードは s=$? で保持する(後始末で上書きされると落ちても成功に見える)
  • 落ちた値が「直す前の値」なら、コードではなくビルドと配信の鮮度を疑う
  • プロセスを探すとき pkill -f は自分のシェルを巻き込むことがある。PIDを取ってから個別に kill する