シミュレーター動作確認レポート
シミュレーターでの動作確認の各ステップを撮影し、HTMLレポート・1枚画像(PNG)・スクリーンショット一式をデスクトップのフォルダに出力して届ける。
なぜこの形式か
- 成果物はデスクトップの1フォルダにまとめる。ユーザーはテスト直後にFinderからすぐ開き、Slack添付やPRコメント貼り付けに使うため
- HTMLは画像をbase64で埋め込んだ単一ファイルで、それ1つで共有が完結する
- PNGはHTMLレポート全体を1枚に描画したもので、GitHubのPRコメントにそのままドラッグ&ドロップできる
- シミュレーターMCPのscreenshotアクションは自分(エージェント)が画面を見るためのもので、ファイルには残らない。レポートに載せる画像はファイルに残るもので撮る —
xcrun simctl ioと Maestro のtakeScreenshotのどちらでもよく、実測で出力は等価(同じ解像度のフルスクリーンPNG)。分かれるのは撮る瞬間を操作の途中に置けるかだけで、置く必要があるときだけ後者を使う
手順
0. テストケースを作ってレビューを受ける
シミュレーターを操作する前に、確認項目の一覧をユーザーに提示してレビューを受ける。合意してから実施に入る。
作るのは test-case-builder サブエージェントに任せる。 ここも撮影と同じで往復が多い。diff を読み、画面マップを読み、route.py を何度か叩いて直す。1ターンのコストはその時点のコンテキストの大きさでほぼ決まるので、実装作業で膨らんだ文脈のまま往復すると、中身とほぼ無関係にターン数ぶんのコストが乗る。外したときに作り直しになるのも、サブエージェントなら安い。
呼ぶ前に決める
サブエージェントは文脈を持たないので、次は先に確定させる。
- 何を確認したいか。 PR番号やブランチ(差分から立てる)でも、画面名・機能名(画面マップの
actionsから立てる)でも、観点(「一覧と詳細の内容が一致するか」)でもよい。差分が無くても成立する — マップがあれば、そこに書かれた操作と結果がそのまま項目の素になる - iPadも対象にするか。 どのシミュレーターを実際に使うかは手順1で起動中のものから選ぶが、対象にするかはここで決める。 レイアウトが分岐する変更(サイズクラス、Split View、ポップオーバー、レギュラー幅での段組み)は iPad を入れる価値が高い
- 証跡の出力先。
~/Desktop/sim-test-report-<テーマのslug>/shots/。--shotのパスに要るので、ここで確定させる - ファイル名に使う端末名(
iphone/ipad) - 対象アプリの bundle id(
xcrun simctl listapps <UDID>)。どのみち sim-driver に渡す
これらと対象アプリのリポジトリのパスを渡して test-case-builder を呼ぶ。画面マップの有無はサブエージェントが見る(無いこともあるので、無ければ項目だけ返る)。
返ってきたものを読む
返るのは案であって合意ではない。ユーザーに出す前に自分で読む。
- 項目が変更と噛み合っているか。 ここを外していると全項目が的外れになる。噛み合っていなければ、観点を伝えて組み直させる
- 選んだ画面が観点と合っているか。 画面を選ぶのは
summaryを読んだ柔らかい判断で、取り違えてもフローは通る(その画面のanchorを確かめるだけなので)。機械の側に気づく手がかりが無いので、項目に書かれた画面idを読んで確かめるのが唯一の歯止め - 「候補が絞れなかった項目」は、決めてから出す。 サブエージェントはユーザーに聞けないので候補を並べて返す。実装を知っているのは自分なので、ここで選ぶ
- 「マップに足す候補」は捨てない。 手順3の報告に持っていく
経路が組めなかった項目は、2択にしてユーザーに選んでもらう
組めないことは、シミュレーターを触る前に分かっている。 だから「探索で撮ります」と勝手に決めずに、レビューで選択肢として出す。
4. 履歴から項目を開き直せる [history]
⚠ history 画面のマップがありません。2つの進め方があります。
a) このまま探索で撮る
すぐ撮れる。ただし導線は実行時に探すので、この項目だけ
「その画面に着いた」という機械的な確認が付かず、判定は証跡だけが根拠になる
b) 先に history 画面のマップを作る
screen-map スキルで、アプリのソースに accessibilityIdentifier を振る
(=アプリ側の変更。別ブランチ・別PRになる)。実測での検証も要るので
時間がかかるが、この画面は以降ずっとフローで撮れる
- 2択は対等ではない。 b はアプリのソースを編集する。時間だけでなく、別PRが増えることを明示する
- 理由は
route.pyが返した文言をそのまま使う(「画面 settings がマップに無い」「in_tree: false(座標が要る)」)。言い換えない - どの項目がその画面に依存しているかを並べる。 1画面のマップが無いせいで3項目が探索になるなら、b を選ぶ価値が変わる
- a を選ばれた項目は、手順3の報告で「マップに足す候補」として改めて挙げる。そのときに断ればよく、いま決め打ちする理由はない
レビューは3段で出す。 何を確認するか、そのために何を動かすか、そのうちどこが機械で裏付けられるか。3つとも同じ材料から出るので、1回のレビューに並べる(分けて聞くと、作り直しのたびに往復が増える)。
1段目 — 何を確認するか
3. キーワードで絞り込むと一覧が入れ替わる [browse]
番号付きの一覧。対象の画面idを添える(手順0の柔らかい判断がここに出る)。
2段目 — 何を動かすか
route.py flow --out を1回叩くと、走るファイルと、レビューに見せる操作列の両方が出る。
python3 $R flow --app <bundle id> --map <アプリのリポジトリ> \
--goto browse --shot <出力先>/shots/iphone_01_list \
--goto detail --shot <出力先>/shots/iphone_02_detail \
--out-dir ~/.claude/skills/sim-test-report/.work/flows/A/
--out-dir は --shot ごとにフローを分けて書く。 1本=1枚=1ダンプになり、証跡と同名のダンプ(iphone_01_list.png と iphone_01_list.txt)が揃う。判定のとき、画像で見て同名のダンプで裏を取れる。
2本目以降は --from の続きなので起動し直さない。分割の代償は1本あたり1秒弱で、LLMには戻らない(sim-driver が && で繋いで1往復で走らせる)。
標準出力がそのままレビューの2段目・3段目になる。accessibilityIdentifier で書かれた実際の操作列で、どこを撮るかも入っている。
browse → detail
browse 起点 ✓ browse が出ている
撮影 .../iphone_01_list_all
browse text browse.searchField "牛乳" ✓ browse.countLabel が出ている
撮影 .../iphone_02_debounce
browse tap Search — 機械判定なし。証跡で見る
撮影 .../iphone_03_filtered
browse tap browse.cell.* [index 0] ✓ detail に着いたことを確認
撮影 .../iphone_04_detail
ここで出る指摘が一番多い。 「1件目ではなく特定の1件で」「送信キーを押さずデバウンスを見たい」は、操作列を見て初めて言える。
3段目 — どこが機械で裏付けられるか
path の右側と最後の1行がそれ。
機械判定 3件 / 証跡でしか見られない 1件
補足: 「tap Search」の結果を確かめる expect がマップに無い
✓はフローのextendedWaitUntilになる。 通らなければその場で落ちるので、写真を見るまでもなく分かる—は証跡のPNGだけが根拠。 判定するのは自分(手順2)で、見落としたら誰も気づかない—が多い項目は、確認として弱いことを正直に伝える。マップにexpectを足せばそのまま✓になるので、そこも2択にできる(このまま撮る / 先にexpectを足す)
実行するフロー
整形した要約を別に作らない。 2段目・3段目は --out が走るファイルと同じ実行から出した文なので、要約ではない。フローそのものを見たいと言われたら、そのファイルを見せる。
appId: com.example.MyApp
---
- stopApp
- launchApp
# 起点: browse
- extendedWaitUntil:
visible:
id: '^itemList\.title$'
timeout: 10000
...
- 直す先は2つある。導線がテストケースに合っていないなら
--doや--inputを変える。マップが実装と違うなら screen-map の仕事で、ここで yaml を手で書き換えて辻褄を合わせない - 1〜2箇所の手直しは自分で
route.pyを叩く。 サブエージェントに投げ直すと文脈を渡し直すぶんの方が高い。項目の立て方から見直すときだけ呼び直す - 合意できたら、sim-driver にはそのファイルのパスを渡す。 中身を渡さない
中身をプロンプトに貼ると、sim-driver が打ち直すことになる。 1文字変わっても誰も気づかず、しかも変わった状態で通ってしまう。パスなら中身はモデルの文脈を素通りする。「書き換えない」と指示するより、書き写す機会を作らない。
レビューに見せた文と走るファイルは同じ実行から出ている。 別々に叩くと引数が1つ違うだけで食い違い、しかも気づけない。--out を使えばその余地が無い。
--out は組めたときだけ書くので、失敗したのに空のファイルや前回の残りが走ることもない(> のリダイレクトだと空ファイルができる)。置き場は .work/。git 管理外で、sweep が14日より古い .yaml を消す。
フローが通ることはこの時点では確かめない。 通らなければ sim-driver が報告して止まる(マップと実装がずれた証拠なので、そこは隠さない)。
1. 撮影は sim-driver サブエージェントに任せる
シミュレーターの操作は往復が多い。1ターンのコストはその時点のコンテキストの大きさでほぼ決まるので、実装作業で膨らんだ文脈のまま往復すると、何をするかとほぼ無関係にターン数ぶんのコストが乗る。サブエージェントなら文脈がゼロから始まり、終了時に破棄される。
撮影に入る前に、いま起動しているシミュレーターを確認する。
xcrun simctl list devices booted
その一覧から使う端末を選び、「iPhone 11 Pro (iOS 17.5) と iPad Air 13-inch (M3) (iOS 26.1) で撮ります」と機種名・OSバージョンを挙げて提示してから撮影に入る。 ここで挙げた内容がそのままレポートの確認環境になる。食い違ったまま進めると全項目が撮り直しになる。
手順0で対象にした端末が起動していない場合、1台も起動していない場合、同じ機種が複数あってどれか決まらない場合は、ユーザーに確認する。勝手に起動したり、起動中の別の端末で代用したりしない。
sim-driver エージェントに次を渡して呼ぶ。
- テストケース一覧(手順0で合意したもの、項番つき)と、組めた項目の経路(フロー)
- 対象デバイスのUDID(
xcrun simctl list devices bootedで確認したもの)と、ファイル名に使う端末名(iphone/ipadなど) - 証跡の出力先ディレクトリ。
~/Desktop/sim-test-report-<テーマのslug>/shots/(例:sim-test-report-favorite-from-list)。呼ぶ前にmkdir -pで作っておく。リポジトリ内には作らない - 前提条件(アカウント、必要なデータ、事前設定)
- 対象アプリの bundle id。手順0で
route.py flow --appに渡したものと同じ。探索で撮る項目でも Maestro のフローに要る - 進捗ログのパス。
~/Desktop/sim-test-report-<slug>/progress_<端末名>.log(証跡ではないのでshots/の外に置く)。端末ごとに分ける。 同じファイルに2台が書くと行が混ざる
呼ぶ前に進捗ログを作り、Monitor を張る。sim-driver は1項目終えるごとにここへ1行書き、その行がそのまま通知として届く。
python3 <このスキルのディレクトリ>/scripts/maestrod.py sweep
touch ~/Desktop/sim-test-report-<slug>/progress_<端末名>.log
sweep は14日より古い作業用ファイルを消す。.work/ は用途ごとに分かれていて、消えるのは dumps/*.json(生)と flows/(使い捨てのフロー)と Maestro の出力。dumps/*.txt(抽出結果)と state/(直近のダンプ。tap が読む生きた状態)は残るので、過去の実行で何を見ていたかは追える。
Monitor(command: "tail -f ~/Desktop/sim-test-report-<slug>/progress_<端末名>.log",
description: "sim-driver の進捗", persistent: false, timeout_ms: 1800000)
待つためではなく、早く止めるために張る。 3項目目で導線を外しているのが見えた時点で sim-driver を TaskStop すれば、最後まで走らせてから全部撮り直すより早い。サブエージェントの途中経過を見る手段はこれ以外に無い(出力ファイルは会話トランスクリプトの実体なので、読むとコンテキストが溢れる)。
tail -f は自分では終わらないので、sim-driver の完了通知が来たら monitor も TaskStop で畳む。
複数端末を見る場合は端末ごとに呼ぶ。maestrod.py が同時に握れるのは1台だけで、別の端末を要求すると前のデーモンが止まって初回の約10秒を払い直す。行き来させると、そのたびにこれが乗る。探索で撮る項目では、画面のポイント寸法が機種ごとに違うぶん座標の取り違えも増える。
撮影が終わったら、sim-driver が常駐させた Maestro のデーモンを止める。
python3 <このスキルのディレクトリ>/scripts/maestrod.py stop
sim-driver 自身も終了時に止めるが、途中で TaskStop した場合は残る。XCUITest ドライバが residual として残ると、次の実行で1台のデバイスに2本繋がり、全操作が Device became unreachable で落ちる。
返ってくるのは項番とファイル名で、OK/NGの判定は含まれない。判定は手順2で自分が下す。
経路を添えた項目は、観測した事実は返らない。 代わりに証跡と同名のダンプが shots/ に並ぶので、判定はそれを読む。sim-driver はダンプを読まない(読ませると文脈が太るだけで、判定は呼び出し元の仕事)。探索で撮った項目にはダンプが無いので、そちらは事実が文章で返る。
shots/iphone_02_detail.png 見た目(レイアウト・重なり・色)
shots/iphone_02_detail.txt 構造(要素の状態・入力欄の中身・正確な文言と並び順)
TaskStop すると、撮れた証跡は残るがそこで終わる。 導線を外しているのが見えた時点で止めるのは正しいが、止める前に progress_<端末名>.log を見て、どこまで進んだかを確かめる。
写っている情報で判断できなければ、追加の観点を伝えて撮り直させる。
連番はレポートで見せたい順にする。操作順と一致しなくてよいし、撮り逃した画面は後から撮り直させてよい。
確認項目がいまのデータやタイミングでは踏めない場合、一時的にコードを変えて再現してよい。仕込みと revert は呼び出し元(自分)が行い、サブエージェントには撮影だけさせる。 編集をサブに渡すとその往復が見えなくなり、revert 漏れに気づけない。
- 変更箇所に
// FIXME: 動作確認用の一時コードと、本来の実装をコメントで残す - 撮影後に必ず revert し、
git statusがクリーンであることを確認する - 使った一時コードは全てレポートのfooterに書く(どのファイルの何を、どの確認項目のために変えたか)
- 一時コード入りのビルドで撮ったスクリーンショットは、そのセクションの説明にもその旨を書く
トーストやスナックバーなど短時間で消えるUIは、タップとスクリーンショットのツール往復が表示時間を超えて撮り逃す。手順0で --shot を操作の直後に置けば、撮影が同じ実行に入って往復が挟まらない。 経路を組めなかった項目では sim-driver が同じことをフローの中でやる。それでも間に合わない場合は、表示時間を延ばす一時コード(例: hideDelay を60秒にする)を入れてから撮る。撮れないことを「表示されない」と判断しない。
2. 証跡を読んで判定し、レポートを生成する
読み方
レポートに載せる画像は、載せる前に読む。 載せるという行為が「これがこの項目を示している」という主張で、セクションには OK/NG を書く。読んでいない証拠について OK と書かない。
そのうえで、ダンプは精度のために読む。
| トークン | 読み取り | |
|---|---|---|
ダンプ .txt |
約1,000 | 文言・要素の状態・並び順は確実 |
証跡 .png |
約2,500 | 見た目はこれでしか分からない。 文言は取り違えが起きる |
- 文言・件数・並び順・要素の状態は、ダンプの行で確定させる。 画像から読み取った文字をレポートに書かない。「8,432歩と表示された」のような記述は、ダンプに同じ行があるかで裏を取る
- レイアウト崩れ・要素の重なり・色・ルビの描画は画像でしか分からない。 ダンプに行が出ない要素(テキストもidも持たないタップ領域、画像)も同じ。この種の破綻は sim-driver の報告にも出てこないので、気づけるのは証跡を読む自分だけ
- 撮ったが載せない画像は、読まなくてよい。 撮り直して使わなかったもの、同じ画面が重複しているもの。ただし「重複している」と判断するには両方読む必要がある
- 撮り直しが要ると分かっても、その場で止めない。 最後まで読んでから、まとめて sim-driver に指示する
- 項目をまたぐ食い違いを探す。 同じ画像が2枚並んでいないか、一覧と詳細で表示が食い違っていないか。1項目ずつ見ていては出てこない
落とし穴が3つある。
- ダンプがあるのはフロー項目だけ。 探索で撮った項目には無いので、そちらは画像と sim-driver が返した事実で判定する
- 「ダンプに無い=画面に無い」と判断しない。 iOS は遅延描画するので、画面から遠い要素はツリーに存在しない
- 重なりはダンプから判定できない。 画面内
○でもモーダルの下にあることがある
セクション情報をJSONマニフェスト(~/Desktop/sim-test-report-<slug>/manifest.json)にまとめ、同梱スクリプトで生成する:
python3 <このスキルのディレクトリ>/scripts/build_report.py <manifest.json>
HTMLと同じ場所に、PRコメント貼り付け用の1枚画像(同名の.png)もデフォルトで生成される(headless Chromeを使用。不要な場合のみ --no-png)。
マニフェストの形式は scripts/build_report.py 冒頭のdocstringを参照。書く内容の指針:
- title: 「<変更内容> — 動作確認レポート」の形
- meta: Issue/PR番号、ブランチ名、確認環境(機種・OSバージョン)、実施日。読み手が再現・追跡できる情報を優先する
- sections: レポートの本体。1確認項目 = 1セクションで、タイトルは「何が確認できたか」を言い切る形にする。説明には期待挙動と実際の観測結果を書き、変更前との差分(「変更前は〜だった」)があると読み手に親切
- images: 同じ項目を複数端末で撮った場合は1セクションに並べる。
{"src": "shots/iphone_02_toast.png", "label": "iPhone 11 Pro"}の形。labelは撮影した端末の機種名で、metaの確認環境と1対1で対応が付く表記にする(「iPhone」「iPad」のような系統名だと、同じ系統を2台使ったときにどちらか分からない)。OSバージョンはカードごとに繰り返すと冗長なのでmetaに任せる。1枚だけなら"image": "shots/01_foo.png"でもよい - result: 確認できたら "OK"。**複数端末を撮った項目は、全端末で確認できたときだけ "OK" にする。片方でもNGならセクション全体をNGとし、どちらがどう駄目だったかをdescに書く。**片方だけの成功を成功として書かない。NGや保留があった場合も隠さずそのまま書く(レポートは事実の記録であり、成功アピールではない)
- footer: 画面では確認できなかった項目の補足。コードで裏取りした内容、確認をスキップした理由、テストで作成したデータの後始末情報などをここに書く
3. 届ける
SendUserFileでHTML(display: "render"=その場でプレビュー)とPNG(display: "attach"=PR貼り付け用)を送り、デスクトップのフォルダパスも伝える。ファイルサイズは気にしなくてよい。画質を落として縮めるより、証跡として読めることを優先する。
注意
- テスト実行(xcode-bridge等)はシミュレーターのアプリを再インストールし、ログイン状態を消すことがある。動作確認とテスト実行を同じシミュレーターで行う場合は、先にその影響をユーザーに伝える
- 動作確認でレコード等のデータを作成した場合は、レポートのfooterにその旨を残す(ユーザーが後片付けを判断できるようにする)