最近の E2E 戦略(2026 年 10 月現在)
この記事に書いてあること
E2E を主要シナリオだけでなく網羅的に整備した
実装から拾った確認対象とシナリオの対応漏れをチェックし漏れがあれば CI で落とす
開発ワークフローの品質ゲートにローカル E2E 実行を組み込んでいる
毎回全シナリオを回すのではなく、実装に関連する箇所のみ回している(でかいリファクタの時は回帰テストとして全シナリオ回す)
やるなら e2e ライブラリがいいよという話
前提となる構成
Web も API も TypeScript で書いている業務アプリ
pnpm workspaces でのモノレポ構成
packages/
├── web/ React・React Router・Vite
├── api/ Hono・Drizzle ORM・PostgreSQL
├── domain/ 計算や入力制約などの業務ルール
├── shared/ 共通の型・ユーティリティ
└── e2e/ E2Eテスト・実行基盤・実装とシナリオの対応表
下層のテストは Vitest、E2E のランナーは e2e を使っている。
はじめに - なんで E2E って主要シナリオしかやってなかったんだっけ
シンプルに書くのしんどいしメンテもしんどかった。
ちゃんと書いてもフレーキーだし、ノーコード系のやつもなんかライブラリのバージョンが上がっただけで全部動かなくなったりするし、マジでしんどかった。
主要シナリオは担保するから他は勘弁してくれ。という気持ちで整備していた。
CI で回そうもんならアクション代も馬鹿にならないし、大人しくテストピラミッド、テスティングトロフィーに従ってやればいいんですよという気持ちしかなかった。
っていうネガティブな理由で主要シナリオに絞っていただけで、これらの負担が減ったなら、網羅的にやったほうがよくない?と思ったのが出発点。
今は整備するのしんどいんだっけ
まったくしんどくない。なぜなら書かないから。おわり。
フレーキー問題も、ネイティブアプリだと特にしんどかったけど、AI に伴走させることで失敗の原因調査やテストの修正はだいぶ楽になってきてる
アクション代の問題もローカルで回せばよい話で、今はプロジェクトごとに開発ワークフロースキルを整備しているケースが多いと思っていて、大抵そのワークフローに品質チェックのゲートが用意されているはずで、その中に E2E も含めちゃえばという話。
ちょっと話ずれるけど前まではメンバーそれぞれがローカルでどこまで品質チェックしてるかわからないから CI でチェックしたいモチベが高かったけど、今はワークフローに則ってその結果も PR に貼ってあるならば、少なくとも実行漏れを防ぐために品質チェックを集約する役割については、CI の価値って昔より相対的に下がってきてるよねという気持ちがある(なくてもいいまでは言ってない)
それでも残るめんどくささ
それはシナリオの管理。
既存のユニットテストなどと違って E2E ではより抽象度の高い " シナリオ " を扱う。
広く整備しようとすると、日々増えたり変わったり消えたりする機能に、シナリオも追従させる必要がある。主要シナリオだけと割り切っていたときにはなかっためんどくささが出てくる。
(ここでいう網羅的は、画面・操作・API・権限など、決めた対象と観点を広く押さえるという意味。全入力・全状態・全操作順の組み合わせではないし、未実装や理由付きの例外も残っている)
なので、実装とシナリオの対応表を作った
機能が増えたらテストも増やしたいし、機能を消したら不要なテストも片付けたい。
でも、そのたびに全テストを読んで「これ、どこで確認してたっけ?」と探すのはしんどい。
なので、「どの実装を、どのシナリオで確認するか」を対応表にした。
たとえばプロフィール周りには実際にこんなテストがある。
実装
確認すること
シナリオ ID
氏名の編集・保存
姓を変更して保存し、開き直しても残る
AUTH-024
氏名の入力チェック
長すぎる姓を受け付けない
AUTH-011
氏名の保存
同時編集した変更も残る
AUTH-025
保存処理を直したら、この表から見直すシナリオを探せる。
作り方も、大きくはこれだけ。
まず、画面や API、画面から呼ぶ操作などをソースから拾う。
ここは自作スクリプトで自動化した。さすがにここを人力は厳しい。
最初は AI によしなに整理してもらおうと思ったが、それだとなんか漏れそうだったのと、対応表は静的に扱いたかったのでスクリプトにした。(下の方に全文載せてるので気になる方は是非)
次に AI が実装とテストを読み、「この操作は、このテストで確認している」と記入する。テストがなければ追加する。
対象の洗い出しは自動、テストとの紐付けは内容を読んで行う。
対応表は JSON で管理している。covers に確認する実装、aspects に正常動作・保存後も値が残ることなどの確認内容を書く。テストにも同じシナリオ ID を付ける。
packages/e2e/ledger/scenarios/auth.json
{
"id": "AUTH-024",
"title": "プロフィールで姓を変えると「氏名」に印が付き、保存すると「保存しました」と出て開き直しても残る",
"status": "implemented",
"pendingReason": null,
"roles": ["probe"],
"viewports": ["pc", "mobile"],
"aspects": ["normal", "persist"],
"covers": [
"flow:components/settings/settings-page-header.tsx#event:tap_settings_save",
"flow:pages/settings/profile-settings-page.tsx#op:useNameDraft",
"flow:pages/settings/profile-settings-page.tsx#op:useSettingsSaveFlow"
],
"tags": ["exclusive"]
}
テストの追加・修正と一緒に、この対応表も AI に更新させる。
ただ、それだけだと更新を忘れることはあるので、書き忘れを CI で検出するようにした。
対応表の更新漏れを CI で落とす
せっかく対応表を作っても更新を忘れたら意味がない。
ということで CI で毎回ソースを読み直し、「今の実装」「対応表」「実際のテスト」の 3 つを照合する。
たとえば、プロフィール画面を変更した場合はこうなる。
変更
更新を忘れるとどうなるか
新しい操作を追加する
担当するシナリオがなく、CI で落ちる
既存画面にボタンを増やす
操作ハンドラ数が記録と合わず、CI で落ちる
氏名編集の処理を削除する
消えた処理を指す対応が残っていると、CI で落ちる
AUTH-024 のテストを消す
対応表では実装済みなのに実テストがなく、CI で落ちる
ここで検出するのは、抽出した対象と対応表・テスト ID の不整合。
操作ハンドラ数だけ更新しても、その操作を確かめる assertion が増えたかまでは検査しないし、数が変わらない振る舞いの変更もある。
テストが変更後の仕様を十分に確かめているかは別途レビューが必要(これは AI にやらせる)
※ 以下 2 つの折り畳みはやや AI Slop なので折り畳んだやつ。ただ実際のスクリプトなどをそのまま掲載しているので、もしこの記事を読んでお手元のプロジェクトにも導入してみたいとなったら、これをそのまま読ませれば、いい感じに構築してくれるのでは?という期待感で載せている。
実装を詳しく見る:検査コードとルール
実コード抜粋:各ファイルの責務、抽出から JSON への変換、照合、CI 接続
大きくは、拾う処理と照合する処理。
それを呼び出す入口を、ローカルと CI で共通にしている。
役割
ファイル
アプリから検証対象を拾う
scripts/ci/e2e-inventory.mjs
抽出した一覧を保存する
packages/e2e/ledger/inventory.generated.json
対象とシナリオの対応を書く
packages/e2e/ledger/scenarios/*.json
テストから ID・タグを拾う
scripts/ci/e2e-test-ids.mjs
対応の不足・矛盾を検査する
scripts/ci/e2e-ledger-core.mjs
ファイル・Git の読み込み、各処理の呼び出し、結果表示をする
scripts/ci/e2e-ledger.mjs
ここから、さっきのプロフィール画面がこの流れをどう通るかを見ていく。
照合器:追加した対象に、担当シナリオがあるかを見る
抽出した一覧と、対応表の covers を照合する。
一覧にある操作
↓ 同じキーをcoversに持つシナリオを探す
AUTH-024など
↓
実装済みか確認する
担当シナリオを探す処理がこれ。
// scripts/ci/e2e-ledger-core.mjs
const covering = (key) => coverIndex.get(key) ?? []
const implementedOf = (key) =>
covering(key).filter((s) => s.status === "implemented")
coverIndex は、対象からシナリオを探すための逆引き表。
実装済みの担当がなく、pending や理由付き例外にもなっていなければ落とす。
別の画面に氏名変更を追加すれば、その画面と操作の組が新しく拾われる。
プロフィール画面のテストがあるから、別画面も確認済み、にはならない。
既存のシナリオを拡張して担当させてもいいので、対象ごとに新しいテストを作る必要はない。
照合器:消した対象への参照も探す
逆に、プロフィール画面から氏名変更をなくしたとする。
これは変更した場合の例で、現在の実装から削除されているわけではない。
抽出一覧から対象が消えたのに、covers に残っていたら落とす。
// scripts/ci/e2e-ledger-core.mjs
for (const s of scenarios) {
for (const key of s.covers) {
if (!population.has(key)) {
fail(
4,
シナリオ ${s.id} の covers「${key}」が母集団に無い。対象が削除または改名された。covers から外す,
)
}
}
}
population、つまり母集団は、今のソースから拾った対象の集合。
対応表にある対象が、今も存在するかを確認している。
複数の操作を担当するシナリオなら、全部消す必要はない。
なくなった対象の参照と、テスト内の該当操作を外す。
ID 抽出器と照合器:実テストの存在も確認する
対応表だけ実装済みにして、テストを書き忘れても困る。
なので、テストコードから ID・タグを拾う。
// scripts/ci/e2e-test-ids.mjs
// テストから取り出した情報を記録する部分
tests.push({
file,
title,
ids: tags.filter((tag) => SCENARIO_ID_PATTERN.test(tag)),
tags,
isSetup: kind === "setup",
})
tags はテスト定義から読み取ったタグ。
その中から AUTH-024 のような形式の ID を取り出している。
照合器は、この ID と対応表を突き合わせる。
// scripts/ci/e2e-ledger-core.mjs
for (const s of scenarios) {
if (s.status === "implemented" && !testsById.has(s.id)) {
fail(
9,
実装済みシナリオ ${s.id} に対応するテストが無い。tests/${s.area.toLowerCase()}/ に tags へ "${s.id}" を書いたテストを追加する,
)
}
}
対応表では実装済みなのにテストがない場合も、対応表から消したシナリオのテストだけ残っている場合も落ちる。
なお、implemented はテストが存在するという意味。
実行して成功したかは、別に確認する。
照合器:既存の不足は残し、新しく増やさない
既存機能のテストを一度で全部書けたわけではないので、不足は pending として残している。
記録
意味
シナリオの pending
このシナリオのテストは未実装
pending baseline
この対象・観点は、実装済みテストで満たせていない
導入時の不足を baseline として保存し、通常の PR では新しい不足を追加できないようにした。
毎回「あとでやる」で通せてしまうのを防ぐため。
// scripts/ci/e2e-ledger-core.mjs
const added = sortStrings(
[...pendingSet].filter((x) => !baseSet.has(x) && !acceptedTargets.has(x)),
)
現在の pending から、変更前にはなかった項目を取り出している。
acceptedTargets は、UI ファイルの移動・改名として認めた付け替え先。
件数ではなく項目を比較するので、1 件減らして別の 1 件を足しても検出できる。
解消した pending が残っている場合も、整理するように落とす。
保存しない処理の永続化など、適用できない観点は理由付きの例外にする。
例外の追加は差分を出し、その理由が妥当かはレビューで確認している。
台帳ゲートの検査規則
形式検査に加え、11 種類の規則がある。
規則
検査内容
1
ソースの抽出結果と、コミットされた inventory の一致
2
母集団の漏れ
3
必須観点の漏れ
4
削除済み対象などの残骸
5
不要になった例外
6
pending・例外の理由の空欄
7
満たされたのに残っている pending
8
surface の登録と操作ハンドラ数の一致
9
シナリオとテストの対応
10
外部送信一覧と書き込み API の不整合
11
base と比べた pending baseline の増加
検出した問題はまとめて日本語で列挙する。pnpm check に含まれるため、通常の品質チェックで台帳も確認される。
抽出回避への対処と、ゲートの限界
認識できない操作・宛先は、黙って無視せず抽出エラーにする。
抽出器を対応させるか、理由付き例外で説明する。
URL を変数へ隠すなど、検出を消すためだけの書き換えは禁止する。
ハンドラ数が変わったら、シナリオを見直す。
routeMeta・例外・covers の意味の正しさはレビューで補う。
限界も明確にする。
ガード付き画面なのに guarded を宣言し忘れたことは、意味まで理解して自動検出できない。
surface は、ファイルとハンドラ数を管理するもので、各ハンドラの実行を計測するコードカバレッジではない。
covers に書かれた対象を、本当に十分な assertion で確かめているかは別問題。
対象・観点の網羅と、要件そのものの妥当性は別問題。
根拠は .agent-config/rules/e2e-scenarios.md と scripts/ci/e2e-ledger-core.mjs。
自分の環境で試したい人向け:抽出器の全文と導入例
実運用の抽出器は scripts/ci/e2e-inventory.mjs。業務ルールは含まないが、React Router や API クライアント、ディレクトリ構成はこのプロジェクト向けなので、環境に合わせた変更が必要。
以下は、抽出器の全文と、それを小さな入力例で呼び出すためのコード。実運用ファイルとの一致と依存導入は確認したが、この呼び出し例は未検証。出力例も実行結果ではなく期待値。
scripts/e2e-inventory.mjs ソースを解析する(実運用コード)
scripts/extract.mjs ファイルを読み、解析結果を表示する(説明用)
example/ 解析対象の入力コード(説明用)
他の環境で変更する箇所と、この抽出器の範囲
箇所
今の前提
変更が必要な場合
パス・import
WEB_SRC など冒頭のパス、@/*、@polyco-cloud/api の型解決除外
配置・alias・API パッケージ名が違う
UI の判定
pages/・layouts/・features/.../components/・components/。components/ui/ などは対象外
UI を別の場所に置く
ルート
router.tsx の routes 配列、React Router の Navigate や遷移 API
Next.js のファイルルーティングなど別方式
書き込み API
api-client.ts の client、$post など、fetch・authenticatedFetch、/api/
クライアント・ラッパー・パス形式が違う
計測・権限・機能
track*・event_name、PolicyName・OrganizationFeatureKey・OrgFeature、EXTERNAL_SEND_APIS
SDK や宣言方法が違う
パスだけ変更すれば十分とは限らない。ルーターや API クライアントが違えば、extractRoutes・resolveHcSegments・analyzeFile などの読み取り規則も合わせる。
extractInventory の入力は次の 3 つ。repoRoot は絶対パス、コールバックが扱うパスはそこからの相対パス。readFile は読めない場合 undefined を返し、listFiles は指定ディレクトリ配下のファイルを再帰的に列挙する。
この抽出器は全 8 種類を一緒に読むため、権限・機能フラグ・外部送信一覧のファイルも前提にしている。使わない種類を外すなら、定数の変更だけでなく該当する読取処理も変更する。入力例のダミー型を実プロダクトの定義として持ち込む必要はない。
返るのは対象一覧と抽出エラー。担当テストを決めること、covers・aspects の意味が正しいと証明すること、テストを実行することは含まれない。
テストや Storybook などは除外する。対応する解析で操作や宛先を確定できない場合は errors・unresolved へ返す。今回の呼び出しコードは、そのエラーを無視せず終了コード 1 にしている。
準備と実行手順
実行に必要な依存を固定し、次のコマンドで呼び出す。依存導入は Node24.14.0・pnpm10.26.0・TypeScript5.9.3 で行った。呼び出し例の実行状況は下の表に分けている。
{
"private": true,
"type": "module",
"dependencies": { "typescript": "5.9.3" }
}
pnpm install
node scripts/extract.mjs example
実行用スクリプト全文:scripts/extract.mjs
末尾に掲載した抽出器の全文を scripts/e2e-inventory.mjs へ保存し、同じディレクトリに次のファイルを置く。引数に調べるディレクトリを指定すると、結果を標準出力へ出す。抽出エラーがあれば終了コード 1 にする。
import { existsSync, readdirSync, readFileSync } from "node:fs"
import { join, resolve } from "node:path"
import { extractInventory } from "./e2e-inventory.mjs"
const repoRoot = resolve(process.argv[2] ?? ".")
function listFiles(directory) {
if (!existsSync(join(repoRoot, directory))) return []
return readdirSync(join(repoRoot, directory), { withFileTypes: true })
.sort((a, b) => a.name.localeCompare(b.name))
.filter((entry) => !["node_modules", "dist"].includes(entry.name))
.flatMap((entry) => {
const path = ${directory}/${entry.name}
return entry.isDirectory() ? listFiles(path) : [path]
})
}
const result = extractInventory({
repoRoot,
listFiles,
readFile: (path) => {
try { return readFileSync(join(repoRoot, path), "utf8") }
catch { return undefined }
},
})
console.log(JSON.stringify(result, null, 2))
process.exitCode = result.errors.length === 0 ? 0 : 1
入力ファイルの全体:example/配下の 8 ファイル
demo_manage などはこの例だけのダミー値。API クライアントも解析に必要な形だけで、hc() や HTTP リクエストを実行するわけではない。
example/packages/domain/src/authorization/policy.ts
export type PolicyName = "demo_manage"
example/packages/domain/src/organization-feature/organization-feature.ts
export type OrganizationFeatureKey = "demo_feature"
example/packages/e2e/src/external-send.ts
export const EXTERNAL_SEND_APIS = []
example/packages/shared/src/org-features.ts
export type OrgFeature = "demo_flag"
example/packages/web/src/hooks/use-name-draft.ts
import { client } from "@/lib/api-client"
export function useNameDraft() {
return {
save: () => client.api.auth.name.$patch({ json: { lastName: "Example" } }),
}
}
example/packages/web/src/lib/api-client.ts
declare const hc: any
export const client = hc()
example/packages/web/src/pages/settings/profile-settings-page.tsx
import { useNameDraft } from "@/hooks/use-name-draft"
export function ProfileSettingsPage() {
const draft = useNameDraft()
return <button onClick={draft.save}>変更を保存する</button>
}
example/packages/web/src/router.tsx
export const routes = [{ path: "/settings/profile", element: <ProfileSettingsPage /> }]
期待する出力の全体(未検証)
{
"items": [
{
"key": "api:PATCH /api/auth/name",
"kind": "api",
"source": "packages/web/src/hooks/use-name-draft.ts"
},
{
"key": "feature:entitlement:demo_feature",
"kind": "feature",
"source": "packages/domain/src/organization-feature/organization-feature.ts"
},
{
"key": "feature:org:demo_flag",
"kind": "feature",
"source": "packages/shared/src/org-features.ts"
},
{
"key": "flow:pages/settings/profile-settings-page.tsx#op:useNameDraft",
"kind": "flow",
"source": "packages/web/src/pages/settings/profile-settings-page.tsx"
},
{
"key": "policy:demo_manage",
"kind": "policy",
"source": "packages/domain/src/authorization/policy.ts"
},
{
"key": "route:/settings/profile",
"kind": "route",
"redirect": false,
"source": "packages/web/src/router.tsx"
},
{
"key": "surface:pages/settings/profile-settings-page.tsx",
"kind": "surface",
"interactions": 1,
"source": "packages/web/src/pages/settings/profile-settings-page.tsx"
}
],
"errors": [],
"unresolved": []
}
掲載例の確認状況
内容
状況
抽出器の全文
実運用ファイルと一致することを確認。変更なし
TypeScript5.9.3 の依存導入
今回成功
新しい呼び出しコード+8 ファイルの入力例
未検証。共有資源管理の machine_constrained で起動待ちとなり、検証コマンドは起動前に終了。実行 attempt は 0 件
掲載した出力例
コードから作った期待出力。実行ログではない
製品のブラウザ E2E
今回未実行
以前の名前指定の最小スクリプトによる useNameDraft 抽出は実行確認しているが、それを今回の全文版・呼び出し例の検証済み実績には数えない。
抽出器の全文:e2e-inventory.mjs(実運用版・1,787 行・変更なし)
依存は typescript。以下は実運用ファイルのそのままの掲載で、今回の全文版の再実行はしていない。入出力の読み書きは呼び出し側へ渡しているため、このファイルだけ実行しても JSON は出力しない。
import ts from "typescript"
export const WEB_SRC = "packages/web/src"
export const ROUTER_FILE = ${WEB_SRC}/router.tsx
export const API_CLIENT_FILE = ${WEB_SRC}/lib/api-client.ts
export const POLICY_FILE = "packages/domain/src/authorization/policy.ts"
export const ENTITLEMENT_FILE =
"packages/domain/src/organization-feature/organization-feature.ts"
export const ORG_FEATURE_FILE = "packages/shared/src/org-features.ts"
export const EXTERNAL_SEND_FILE = "packages/e2e/src/external-send.ts"
const WRITE_METHODS = new Map([
["$post", "POST"],
["$put", "PUT"],
["$patch", "PATCH"],
["$delete", "DELETE"],
])
const FETCH_CALLEES = new Set(["fetch", "authenticatedFetch"])
const PLACEHOLDER = "\u0001"
// biome-ignore lint/suspicious/noTemplateCurlyInString: 例外キーに載せる ${} という文字列そのもの
const TEMPLATE_EXPRESSION_MARK = "${}"
// テスト・stories・生成物は母集団に含めない
const isExcludedSource = (relativePath) =>
/.(test|spec|stories|fixtures|harness).tsx?$/.test(relativePath) ||
/-(fixtures|fixture|test-utils|test-support|scenarios).tsx?$/.test(
relativePath,
) ||
/(^|\/)(tests|mocks|test-helpers|test-support|test-fixtures)\//.test(
relativePath,
) ||
/.generated.tsx?$/.test(relativePath) ||
relativePath.endsWith(".d.ts")
// pages / layouts / features の components / components(ui を除く)の .tsx
const isUiFile = (webRelativePath) =>
webRelativePath.endsWith(".tsx") &&
(webRelativePath.startsWith("pages/") ||
webRelativePath.startsWith("layouts/") ||
/^features\/(?:.+\/)?components\//.test(webRelativePath) ||
(webRelativePath.startsWith("components/") &&
!webRelativePath.startsWith("components/ui/")))
const unwrap = (node) => {
let current = node
while (
ts.isParenthesizedExpression(current) ||
ts.isAsExpression(current) ||
ts.isSatisfiesExpression(current) ||
ts.isNonNullExpression(current) ||
ts.isTypeAssertionExpression(current)
) {
current = current.expression
}
return current
}
// ["path"] のような文字列リテラルの computed key は通常のキーと同じに読む
const propertyNameText = (name) => {
if (ts.isIdentifier(name) || ts.isStringLiteralLike(name)) return name.text
if (ts.isComputedPropertyName(name)) {
const inner = unwrap(name.expression)
if (ts.isStringLiteralLike(inner)) return inner.text
}
return undefined
}
const findProperty = (objectLiteral, propertyName) =>
objectLiteral.properties.find(
(property) =>
ts.isPropertyAssignment(property) &&
propertyNameText(property.name) === propertyName,
)
// ---- プログラム(型チェッカー)の構築 ----
function createProgramFor({ repoRoot, sources, readFile }) {
const root = repoRoot.replace(/\/+$/, "")
const cache = new Map()
const read = (absolutePath) => {
if (!absolutePath.startsWith(${root}/)) return undefined
const relativePath = absolutePath.slice(root.length + 1)
if (cache.has(relativePath)) return cache.get(relativePath)
const text = sources.get(relativePath) ?? readFile(relativePath)
cache.set(relativePath, text)
return text
}
const options = {
target: ts.ScriptTarget.ES2022,
module: ts.ModuleKind.ESNext,
moduleResolution: ts.ModuleResolutionKind.Bundler,
jsx: ts.JsxEmit.ReactJSX,
strict: true,
noEmit: true,
skipLibCheck: true,
types: [],
baseUrl: root,
paths: { "@/*": [${WEB_SRC}/*] },
}
// メモリ上のソースだけを渡すテストでは標準ライブラリが読めないため noLib にする
options.noLib = read(ts.getDefaultLibFilePath(options)) === undefined
const host = {
getSourceFile: (fileName, languageVersion) => {
const text = read(fileName)
return text === undefined
? undefined
: ts.createSourceFile(fileName, text, languageVersion, true)
},
getDefaultLibFileName: (compilerOptions) =>
ts.getDefaultLibFilePath(compilerOptions),
writeFile: () => {},
getCurrentDirectory: () => root,
getDirectories: () => [],
fileExists: (fileName) => read(fileName) !== undefined,
readFile: read,
getCanonicalFileName: (fileName) => fileName,
useCaseSensitiveFileNames: () => true,
getNewLine: () => "\n",
// api の型グラフ全体(AppType)を読むと抽出に 1 分近くかかる。web 側のシンボル解決には不要
resolveModuleNameLiterals: (moduleLiterals, containingFile) =>
moduleLiterals.map((literal) =>
literal.text === "@polyco-cloud/api"
? { resolvedModule: undefined }
: ts.resolveModuleName(literal.text, containingFile, options, host),
),
}
const rootNames = [...sources.keys()].map((path) => ${root}/${path})
const program = ts.createProgram({ rootNames, options, host })
return { program, checker: program.getTypeChecker(), root }
}
// ---- route ----
const LAZY_ONLY_KEYS = new Set(["lazy", "hydrateFallbackElement"])
// 戻り値の型が Pick<_, "lazy" | "hydrateFallbackElement"> に限られる関数の名前。
// この展開は path・index・children を持ち込めないため、route の母集団に影響しない
function collectLazyOnlyFunctions(sourceFile) {
const names = new Set()
for (const statement of sourceFile.statements) {
if (!ts.isFunctionDeclaration(statement) || statement.name === undefined) {
continue
}
const type = statement.type
if (
type === undefined ||
!ts.isTypeReferenceNode(type) ||
type.typeName.getText() !== "Pick" ||
type.typeArguments?.length !== 2
) {
continue
}
const keys = type.typeArguments[1]
const members = ts.isUnionTypeNode(keys) ? keys.types : [keys]
const literals = members.map((member) =>
ts.isLiteralTypeNode(member) && ts.isStringLiteral(member.literal)
? member.literal.text
: undefined,
)
if (literals.every((key) => key !== undefined && LAZY_ONLY_KEYS.has(key))) {
names.add(statement.name.text)
}
}
return names
}
function isLazyOnlySpread(property, context) {
if (!ts.isSpreadAssignment(property)) return false
const call = unwrap(property.expression)
return (
ts.isCallExpression(call) &&
ts.isIdentifier(call.expression) &&
context.lazyOnlyFunctions.has(call.expression.text)
)
}
function parseRouteNodes(arrayLiteral, context) {
const nodes = []
for (const element of arrayLiteral.elements) {
const objectLiteral = unwrap(element)
if (!ts.isObjectLiteralExpression(objectLiteral)) {
context.fail(
${ROUTER_FILE} の routes にオブジェクトリテラルでない要素がある。route の抽出は静的なオブジェクトリテラルに限る,
)
continue
}
const node = { path: undefined, index: false, redirect: false }
for (const property of objectLiteral.properties) {
if (isLazyOnlySpread(property, context)) continue
if (!ts.isPropertyAssignment(property)) {
// spread・shorthand・メソッドは path / children の寄与を静的に決められず、route の追加を取りこぼす
context.fail(
${ROUTER_FILE} の route にプロパティ代入でない要素がある(${property.getText()})。spread・shorthand は使わずリテラルで書く,
)
continue
}
const name = propertyNameText(property.name)
if (name === undefined && ts.isComputedPropertyName(property.name)) {
context.fail(
${ROUTER_FILE} の route に静的に読めない computed key がある(${property.name.getText()})。文字列リテラルのキーで書く,
)
continue
}
const value = unwrap(property.initializer)
if (name === "path") {
if (ts.isStringLiteralLike(value)) node.path = value.text
else
context.fail(
${ROUTER_FILE} の route の path が文字列リテラルでない(${value.getText()}),
)
} else if (name === "index") {
if (
value.kind === ts.SyntaxKind.TrueKeyword ||
value.kind === ts.SyntaxKind.FalseKeyword
) {
node.index = value.kind === ts.SyntaxKind.TrueKeyword
} else {
context.fail(
${ROUTER_FILE} の route の index が true / false のリテラルでない(${value.getText()}),
)
}
} else if (name === "element") {
node.redirect = isRedirectElement(value)
} else if (name === "children") {
if (ts.isArrayLiteralExpression(value))
node.children = parseRouteNodes(value, context)
else
context.fail(
${ROUTER_FILE} の route の children が配列リテラルでない,
)
}
}
nodes.push(node)
}
return nodes
}
function isRedirectElement(element) {
let tag
if (ts.isJsxElement(element)) tag = element.openingElement.tagName
else if (ts.isJsxSelfClosingElement(element)) tag = element.tagName
else return false
const name = tag.getText()
return name === "Navigate" || name.endsWith("Redirect")
}
const trimTrailingSlash = (path) =>
path.endsWith("/") ? path.slice(0, -1) : path
const prefixWithSlash = (path) => (path.startsWith("/") ? path : /${path})
const isWildcardWithChildren = (route) =>
route.path?.endsWith("*") === true && (route.children?.length ?? 0) > 0
// sentry-route-patterns.ts の appendRoutePath / collectRoutePatterns と同じ組み立て規則
function appendRoutePath(parentPattern, route) {
if (route.index) return parentPattern
const path = route.path
if (path === undefined || path === "" || isWildcardWithChildren(route)) {
return parentPattern
}
const segment =
path.startsWith("/") || parentPattern.endsWith("/") ? path : /${path}
return trimTrailingSlash(parentPattern) + prefixWithSlash(segment)
}
const normalizePattern = (pattern) => {
if (pattern === "") return "/"
return pattern.length > 1 ? trimTrailingSlash(pattern) : pattern
}
function collectRoutes(routeNodes) {
const byPattern = new Map()
const visit = (nodes, parentPattern) => {
for (const route of nodes) {
const pattern = appendRoutePath(parentPattern, route)
const contributes =
route.index ||
(route.path !== undefined &&
route.path !== "" &&
!isWildcardWithChildren(route))
if (contributes) {
const normalized = normalizePattern(pattern)
const entry = byPattern.get(normalized) ?? { leafRedirects: [] }
const isLeaf = route.index || (route.children?.length ?? 0) === 0
if (isLeaf) entry.leafRedirects.push(route.redirect)
byPattern.set(normalized, entry)
}
if (route.children !== undefined) visit(route.children, pattern)
}
}
visit(routeNodes, "")
return [...byPattern].map(([pattern, entry]) => ({
pattern,
redirect:
entry.leafRedirects.length > 0 && entry.leafRedirects.every(Boolean),
}))
}
function extractRoutes(program, root, context) {
const sourceFile = program.getSourceFile(${root}/${ROUTER_FILE})
if (sourceFile === undefined) {
context.fail(${ROUTER_FILE} を読めない。route を抽出できない)
return []
}
let routesArray
for (const statement of sourceFile.statements) {
if (!ts.isVariableStatement(statement)) continue
for (const declaration of statement.declarationList.declarations) {
if (
ts.isIdentifier(declaration.name) &&
declaration.name.text === "routes" &&
declaration.initializer !== undefined
) {
const initializer = unwrap(declaration.initializer)
if (ts.isArrayLiteralExpression(initializer)) routesArray = initializer
}
}
}
if (routesArray === undefined) {
context.fail(
${ROUTER_FILE} に配列リテラルで初期化された routes が無い。route を抽出できない,
)
return []
}
context.lazyOnlyFunctions = collectLazyOnlyFunctions(sourceFile)
return collectRoutes(parseRouteNodes(routesArray, context))
}
// ---- リンク先と route の照合 ----
const splitSegments = (path) => path.split("/").filter((s) => s !== "")
// 候補の ${...} は 1 セグメントの任意値。param には優先して一致させ、
// リテラルの route セグメントには param が無い場合だけ一致させる
function matchRoute(candidateSegments, routeSegments) {
if (candidateSegments.length !== routeSegments.length) return undefined
let placeholderToLiteral = 0
let literalToParam = 0
for (const [index, candidate] of candidateSegments.entries()) {
const routeSegment = routeSegments[index]
const isParam = routeSegment.startsWith(":")
if (candidate.includes(PLACEHOLDER)) {
if (!isParam) placeholderToLiteral += 1
} else if (candidate !== routeSegment) {
if (!isParam) return undefined
literalToParam += 1
}
}
return [placeholderToLiteral, literalToParam]
}
function resolveLinkTarget(rawText, routePatterns) {
const queryIndex = rawText.search(/[?#]/)
const pathOnly = queryIndex === -1 ? rawText : rawText.slice(0, queryIndex)
const candidateSegments = splitSegments(pathOnly)
let best
for (const pattern of routePatterns) {
const cost = matchRoute(candidateSegments, splitSegments(pattern))
if (cost === undefined) continue
if (
best === undefined ||
cost[0] < best.cost[0] ||
(cost[0] === best.cost[0] && cost[1] < best.cost[1])
) {
best = { pattern, cost }
}
}
return best?.pattern
}
const isInternalLinkCandidate = (text) =>
text.startsWith("/") &&
!text.startsWith("//") &&
text !== "/api" &&
!text.startsWith("/api/") &&
!text.startsWith("/api?")
// スキーム付き(外部 URL・mailto 等)・アンカー・クエリだけのもの以外で、/ で始まらない文字列は相対パスか式始まり
const isRelativeLinkCandidate = (text) =>
text !== "" &&
!text.startsWith("/") &&
!text.startsWith("#") &&
!text.startsWith("?") &&
!/^[A-Za-z][A-Za-z0-9+.-]*:/.test(text)
// ---- 変数 event_name の型展開 ----
function literalUnionOf(type) {
const members = type.isUnion() ? type.types : [type]
const values = []
for (const member of members) {
if (!member.isStringLiteral()) return undefined
values.push(member.value)
}
return values
}
// undefined を許す union(optional プロパティの型用)。文字列リテラル以外が混ざれば undefined を返す
function literalUnionAllowingUndefined(type) {
const members = type.isUnion() ? type.types : [type]
const values = []
let hasUndefined = false
for (const member of members) {
if (member.flags & (ts.TypeFlags.Undefined | ts.TypeFlags.Void)) {
hasUndefined = true
} else if (member.isStringLiteral()) {
values.push(member.value)
} else {
return undefined
}
}
return { values, hasUndefined }
}
const isAnyLike = (type) =>
(type.flags & (ts.TypeFlags.Any | ts.TypeFlags.Unknown)) !== 0
// オブジェクトのキーが取り得る名前。computed key は型チェッカーで文字列リテラルの union に解決できたときだけ読む
function keyNames(name, checker) {
if (
ts.isIdentifier(name) ||
ts.isStringLiteralLike(name) ||
ts.isNumericLiteral(name)
) {
return [name.text]
}
if (ts.isComputedPropertyName(name)) {
const inner = unwrap(name.expression)
if (ts.isStringLiteralLike(inner)) return [inner.text]
return literalUnionOf(checker.getTypeAtLocation(name.expression))
}
return undefined
}
function declarationOf(identifier, checker) {
const symbol = checker.getSymbolAtLocation(identifier)
return symbol?.valueDeclaration ?? symbol?.declarations?.[0]
}
// 式が(プロパティ連鎖を含めて)関数のパラメータから始まるか。パラメータをそのまま渡す転送の判定に使う
function isParameterDerived(expression, checker) {
let node = unwrap(expression)
while (
ts.isPropertyAccessExpression(node) ||
ts.isElementAccessExpression(node)
) {
node = unwrap(node.expression)
}
if (!ts.isIdentifier(node)) return false
const declaration = declarationOf(node, checker)
return (
declaration !== undefined &&
(ts.isParameter(declaration) ||
(ts.isBindingElement(declaration) && isInParameter(declaration)))
)
}
function isInParameter(node) {
for (let current = node.parent; current; current = current.parent) {
if (ts.isParameter(current)) return true
if (ts.isVariableDeclaration(current)) return false
}
return false
}
// const で初期化された識別子の初期化子。変数に切り出された定数を元の式までたどる
function constInitializerOf(expression, checker) {
const node = unwrap(expression)
if (!ts.isIdentifier(node)) return undefined
const declaration = declarationOf(node, checker)
if (
declaration === undefined ||
!ts.isVariableDeclaration(declaration) ||
declaration.initializer === undefined ||
!ts.isIdentifier(declaration.name) ||
!(declaration.parent.flags & ts.NodeFlags.Const)
) {
return undefined
}
return declaration.initializer
}
const WRITE_METHOD_NAMES = ["POST", "PUT", "PATCH", "DELETE"]
// ---- hc / fetch の正規化 ----
function resolveHcSegments(expression, checker, root, depth) {
if (depth > 8) return undefined
const node = unwrap(expression)
if (ts.isPropertyAccessExpression(node)) {
const base = resolveHcSegments(node.expression, checker, root, depth)
return base === undefined ? undefined : [...base, node.name.text]
}
if (ts.isElementAccessExpression(node)) {
const argument = unwrap(node.argumentExpression)
if (!ts.isStringLiteralLike(argument)) return undefined
const base = resolveHcSegments(node.expression, checker, root, depth)
return base === undefined ? undefined : [...base, argument.text]
}
if (ts.isIdentifier(node)) {
let symbol = checker.getSymbolAtLocation(node)
if (symbol === undefined) return undefined
if (symbol.flags & ts.SymbolFlags.Alias) {
symbol = checker.getAliasedSymbol(symbol)
}
const declaration = symbol.valueDeclaration ?? symbol.declarations?.[0]
if (declaration === undefined || !ts.isVariableDeclaration(declaration)) {
return undefined
}
const file = declaration.getSourceFile().fileName
if (
file === ${root}/${API_CLIENT_FILE} &&
ts.isIdentifier(declaration.name) &&
declaration.name.text === "client"
) {
return []
}
if (declaration.initializer === undefined) return undefined
return resolveHcSegments(declaration.initializer, checker, root, depth + 1)
}
return undefined
}
// 連鎖の根が api-client の client に届くか。キーが静的に読めない区間(要素アクセス・呼び出し)も許して根だけ見る
function rootsAtHcClient(expression, checker, root, depth) {
if (depth > 8) return false
const node = unwrap(expression)
if (
ts.isPropertyAccessExpression(node) ||
ts.isElementAccessExpression(node) ||
ts.isCallExpression(node)
) {
return rootsAtHcClient(node.expression, checker, root, depth + 1)
}
if (!ts.isIdentifier(node)) return false
let symbol = checker.getSymbolAtLocation(node)
if (symbol === undefined) return false
if (symbol.flags & ts.SymbolFlags.Alias) {
symbol = checker.getAliasedSymbol(symbol)
}
const declaration = symbol.valueDeclaration ?? symbol.declarations?.[0]
if (declaration === undefined || !ts.isVariableDeclaration(declaration)) {
return false
}
if (
declaration.getSourceFile().fileName === ${root}/${API_CLIENT_FILE} &&
ts.isIdentifier(declaration.name) &&
declaration.name.text === "client"
) {
return true
}
return declaration.initializer === undefined
? false
: rootsAtHcClient(declaration.initializer, checker, root, depth + 1)
}
function pathParameterName(expression) {
if (ts.isIdentifier(expression)) return expression.text
if (ts.isPropertyAccessExpression(expression)) return expression.name.text
return undefined
}
const toApiPath = (built) => {
const path = built.split(/[?#]/)[0]
return path.startsWith("/api/") ? path : undefined
}
function normalizeApiUrl(expression) {
const node = unwrap(expression)
if (ts.isStringLiteralLike(node)) return toApiPath(node.text)
if (!ts.isTemplateExpression(node)) return undefined
let built = node.head.text
for (const [index, span] of node.templateSpans.entries()) {
const spanExpression = unwrap(span.expression)
const isBase =
index === 0 &&
built === "" &&
((ts.isIdentifier(spanExpression) && spanExpression.text === "apiBase") ||
(ts.isPropertyAccessExpression(spanExpression) &&
spanExpression.name.text === "apiBase"))
// クエリ部分の値は無視してよい。パス部分の ${識別子} は :識別子 のパラメータとして読む
if (!isBase && !built.includes("?")) {
const parameter = pathParameterName(spanExpression)
if (parameter === undefined) return undefined
built += :${parameter}
}
built += span.literal.text
}
return toApiPath(built)
}
// URL 式が取り得る /api/... のパスすべて。リテラル・テンプレート・分岐・const 定数・文字列リテラルの union に解決し、
// 1 つでも解決できなければ undefined
function resolveApiPaths(expression, checker, depth) {
if (depth > 6) return undefined
const node = unwrap(expression)
if (ts.isStringLiteralLike(node) || ts.isTemplateExpression(node)) {
const path = normalizeApiUrl(node)
return path === undefined ? undefined : [path]
}
if (ts.isConditionalExpression(node)) {
const whenTrue = resolveApiPaths(node.whenTrue, checker, depth + 1)
const whenFalse = resolveApiPaths(node.whenFalse, checker, depth + 1)
return whenTrue === undefined || whenFalse === undefined
? undefined
: [...whenTrue, ...whenFalse]
}
const initializer = constInitializerOf(node, checker)
if (initializer !== undefined) {
return resolveApiPaths(initializer, checker, depth + 1)
}
const values = literalUnionOf(checker.getTypeAtLocation(node))
if (values === undefined || values.length === 0) return undefined
const paths = values.map(toApiPath)
return paths.every((path) => path !== undefined) ? paths : undefined
}
// ---- 1 ファイルの走査 ----
function topLevelUnits(sourceFile) {
const units = []
for (const statement of sourceFile.statements) {
if (ts.isImportDeclaration(statement) || ts.isExportDeclaration(statement))
continue
if (
ts.isFunctionDeclaration(statement) ||
ts.isClassDeclaration(statement)
) {
const isDefault = statement.modifiers?.some(
(modifier) => modifier.kind === ts.SyntaxKind.DefaultKeyword,
)
units.push({
name: statement.name?.text ?? (isDefault ? "default" : "<anonymous>"),
node: statement,
})
} else if (ts.isVariableStatement(statement)) {
for (const declaration of statement.declarationList.declarations) {
units.push({
name: ts.isIdentifier(declaration.name)
? declaration.name.text
: "<destructured>",
node: declaration,
})
}
} else {
units.push({ name: "<module>", node: statement })
}
}
return units
}
// 参照先の宣言を含むトップレベルの文から、topLevelUnits と同じ規則で単位名を決める。
// 宣言の形(無名 default・分割代入・オブジェクトのメンバーなど)で参照を落とさない
function declarationUnitKey(symbol, checker, webRelative) {
let target = symbol
if (target.flags & ts.SymbolFlags.Alias) {
target = checker.getAliasedSymbol(target)
}
const declaration = target.valueDeclaration ?? target.declarations?.[0]
if (declaration === undefined) return undefined
let statement = declaration
while (statement.parent !== undefined && !ts.isSourceFile(statement.parent)) {
statement = statement.parent
}
if (statement.parent === undefined || !ts.isSourceFile(statement.parent)) {
return undefined
}
let name
if (ts.isFunctionDeclaration(statement) || ts.isClassDeclaration(statement)) {
const isDefault = statement.modifiers?.some(
(modifier) => modifier.kind === ts.SyntaxKind.DefaultKeyword,
)
name = statement.name?.text ?? (isDefault ? "default" : "<anonymous>")
} else if (ts.isVariableStatement(statement)) {
const owner = statement.declarationList.declarations.find(
(candidate) =>
candidate.pos <= declaration.pos && declaration.end <= candidate.end,
)
name =
owner !== undefined && ts.isIdentifier(owner.name)
? owner.name.text
: "<destructured>"
} else if (
ts.isExportAssignment(statement) ||
ts.isExpressionStatement(statement) ||
ts.isModuleDeclaration(statement)
) {
name = "<module>"
} else {
// 型・interface・enum は実行時の操作を持たない
return undefined
}
const relativePath = webRelative(statement.parent.fileName)
return relativePath === undefined ? undefined : ${relativePath}#${name}
}
const MAX_SPREAD_HANDLERS = 30
const LINK_CALLEES = new Set(["navigate", "redirect"])
const isLocationText = (text) => /(^|.)location$/.test(text)
const collapse = (text) => text.replace(/\s+/g, " ").trim()
const isStringish = (type) =>
isAnyLike(type) ||
(type.isUnion() ? type.types : [type]).some(
(member) =>
member.flags &
(ts.TypeFlags.StringLike | ts.TypeFlags.Any | ts.TypeFlags.Unknown),
)
function analyzeFile({
sourceFile,
webPath,
isUi,
checker,
root,
nameSet,
context,
shared,
}) {
const units = []
const webRelative = shared.webRelative
for (const unit of topLevelUnits(sourceFile)) {
const record = {
key: ${webPath}#${unit.name},
name: unit.name,
direct: false,
refs: new Set(),
}
units.push(record)
const visit = (node) => {
if (ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) return
if (ts.isCallExpression(node)) inspectCall(node, record)
if (ts.isObjectLiteralExpression(node)) inspectEventLiteral(node, record)
if (
ts.isPropertyAccessExpression(node) ||
ts.isElementAccessExpression(node)
) {
inspectHcAccess(node, record)
}
if (ts.isBindingElement(node)) inspectHcBinding(node, record)
if (ts.isIdentifier(node) || ts.isPropertyAccessExpression(node)) {
inspectFetchReference(node, record)
}
if (isUi) inspectUiNode(node, record)
if (ts.isIdentifier(node) && nameSet.has(node.text)) {
const symbol = checker.getSymbolAtLocation(node)
if (symbol !== undefined) {
const key = declarationUnitKey(symbol, checker, webRelative)
if (key !== undefined && key !== record.key) record.refs.add(key)
}
}
ts.forEachChild(node, visit)
}
visit(unit.node)
}
function inspectCall(call, record) {
const callee = unwrap(call.expression)
let calleeName
let receiver
if (ts.isIdentifier(callee)) {
calleeName = callee.text
} else if (ts.isPropertyAccessExpression(callee)) {
calleeName = callee.name.text
receiver = callee.expression
} else if (
ts.isElementAccessExpression(callee) &&
ts.isStringLiteralLike(unwrap(callee.argumentExpression))
) {
calleeName = unwrap(callee.argumentExpression).text
receiver = callee.expression
}
if (calleeName === undefined) return
const isFetchCall =
(receiver === undefined && FETCH_CALLEES.has(calleeName)) ||
(receiver !== undefined && calleeName === "authenticatedFetch") ||
(receiver !== undefined &&
calleeName === "fetch" &&
/^(window|globalThis|self)$/.test(receiver.getText()))
if (isFetchCall) inspectFetch(call, record)
if (calleeName.startsWith("track")) {
for (const argument of call.arguments) {
inspectTrackArgument(argument, record, 0)
}
}
if (!isUi) return
const first = call.arguments[0]
if (first === undefined) return
const isLinkCall =
(receiver === undefined && LINK_CALLEES.has(calleeName)) ||
(receiver !== undefined && calleeName === "navigate") ||
(receiver !== undefined &&
(calleeName === "assign" || calleeName === "replace") &&
isLocationText(receiver.getText())) ||
(receiver !== undefined &&
calleeName === "open" &&
/^(window|globalThis|self)$/.test(receiver.getText()))
if (isLinkCall) addLinkCandidates(first)
}
// event_name を持つオブジェクトリテラルはすべて、アナリティクスイベントの発火箇所として読む。
// computed key は型チェッカーで "event_name" に解決できるものを通常のキーと同じに読む。
// track を転送するだけの関数(引数がパラメータ)は発火箇所にならず、呼び出し側のリテラルが拾われる
function inspectEventLiteral(objectLiteral, record) {
let property
for (const p of objectLiteral.properties) {
if (!ts.isPropertyAssignment(p) && !ts.isShorthandPropertyAssignment(p)) {
continue
}
const names = keyNames(p.name, checker)
if (names === undefined || !names.includes("event_name")) continue
if (names.length > 1) {
markDynamicEvent(record)
return
}
property = p
}
if (property === undefined) return
record.direct = true
const valueNode = ts.isPropertyAssignment(property)
? property.initializer
: property.name
const literal = unwrap(valueNode)
const names = ts.isStringLiteralLike(literal)
? [literal.text]
: literalUnionOf(checker.getTypeAtLocation(valueNode))
if (names === undefined || names.length === 0) {
markDynamicEvent(record)
return
}
for (const name of names) shared.events.push({ webPath, isUi, name })
}
// track 系の呼び出しに渡るオブジェクトは、キーを静的に読めない形(解決できない computed key・
// event_name を運ぶ spread)を見逃さない。パラメータをそのまま渡す転送は呼び出し側で拾う
function inspectTrackArgument(argument, record, depth) {
if (depth > 4 || ts.isSpreadElement(argument)) return
const node = unwrap(argument)
const initializer = constInitializerOf(node, checker)
if (initializer !== undefined) {
inspectTrackArgument(initializer, record, depth + 1)
return
}
if (!ts.isObjectLiteralExpression(node)) return
for (const property of node.properties) {
if (ts.isSpreadAssignment(property)) {
if (isParameterDerived(property.expression, checker)) continue
const spreadType = checker.getTypeAtLocation(property.expression)
if (isAnyLike(spreadType)) {
markDynamicEvent(record)
continue
}
const eventName = spreadType.getProperty("event_name")
if (eventName === undefined) continue
const names = literalUnionOf(
checker.getTypeOfSymbolAtLocation(eventName, property.expression),
)
if (names === undefined || names.length === 0) {
markDynamicEvent(record)
continue
}
record.direct = true
for (const name of names) shared.events.push({ webPath, isUi, name })
} else if (
(ts.isPropertyAssignment(property) ||
ts.isShorthandPropertyAssignment(property)) &&
keyNames(property.name, checker) === undefined
) {
markDynamicEvent(record)
}
}
}
function markDynamicEvent(record) {
record.direct = true
const key = dynamic-event:${webPath}#${record.name}
context.unresolve(
key,
${webPath} の ${record.name} の track の event_name を文字列リテラルの union に絞れない(string などに広がっている、または解決できない computed key・spread を含む),
型を union に絞るか、例外 ${key} に理由を書く,
)
}
// options の method が取り得る値(GET は未指定の既定)。解決できなければ undefined。
// 定数に切り出したオプション・spread・型から読める optional プロパティを辿る
function optionMethods(expression, depth) {
const info = typeMethodInfo(expression, depth)
if (info === undefined) return undefined
return info.mayBeAbsent
? [...new Set(["GET", ...info.values])]
: info.values
}
// オブジェクトリテラルの method。method を一度も書かなければ「未指定(mayBeAbsent)」で、既定の GET への
// 置き換えは呼び出し側が決める(後続の spread が method を持たないだけで GET に戻してはならない)
function literalMethodInfo(node, depth) {
if (depth > 6) return undefined
// unknown は「後ろの method で上書きされなければ決まらない」状態
let state = { values: [], mayBeAbsent: true, unknown: false }
for (const property of node.properties) {
if (ts.isSpreadAssignment(property)) {
const spread = typeMethodInfo(property.expression, depth + 1)
if (spread === undefined) {
state = { ...state, unknown: true }
} else if (spread.mayBeAbsent) {
state = {
...state,
values: [...new Set([...state.values, ...spread.values])],
}
} else {
state = { values: spread.values, mayBeAbsent: false, unknown: false }
}
continue
}
if (
!ts.isPropertyAssignment(property) &&
!ts.isShorthandPropertyAssignment(property)
) {
return undefined
}
const names = keyNames(property.name, checker)
if (names === undefined) return undefined
if (!names.includes("method")) continue
if (names.length > 1) return undefined
const values = methodPropertyValues(property)
if (values === undefined) return undefined
state = { values, mayBeAbsent: false, unknown: false }
}
return state.unknown
? undefined
: { values: state.values, mayBeAbsent: state.mayBeAbsent }
}
function methodPropertyValues(property) {
// shorthand の名前位置は代入先プロパティの(広がった)型になるため、値側のシンボルから型を引く
const methodNode = ts.isShorthandPropertyAssignment(property)
? property.name
: unwrap(property.initializer)
if (ts.isStringLiteralLike(methodNode))
return [methodNode.text.toUpperCase()]
const valueSymbol = ts.isShorthandPropertyAssignment(property)
? checker.getShorthandAssignmentValueSymbol(property)
: undefined
const values = literalUnionOf(
valueSymbol === undefined
? checker.getTypeAtLocation(methodNode)
: checker.getTypeOfSymbolAtLocation(valueSymbol, methodNode),
)
if (values === undefined || values.length === 0) return undefined
return values.map((value) => value.toUpperCase())
}
// オブジェクトを表す式(変数・プロパティ参照・呼び出し結果)の method を、定数の初期化子か型から読む。
// method を持たないと型で確定できるものは「未指定(mayBeAbsent)」として返す
function typeMethodInfo(expression, depth) {
if (depth > 6) return undefined
const node = unwrap(expression)
if (ts.isObjectLiteralExpression(node)) {
return literalMethodInfo(node, depth + 1)
}
const initializer = constInitializerOf(node, checker)
if (initializer !== undefined) return typeMethodInfo(initializer, depth + 1)
if (
node.kind === ts.SyntaxKind.NullKeyword ||
(ts.isIdentifier(node) && node.text === "undefined")
) {
return { values: [], mayBeAbsent: true }
}
const type = checker.getNonNullableType(checker.getTypeAtLocation(node))
if (isAnyLike(type)) return undefined
if (type.isUnion()) {
const members = type.types.map((member) => methodInfoOfType(member, node))
if (members.some((member) => member === undefined)) return undefined
return {
values: [...new Set(members.flatMap((member) => member.values))],
mayBeAbsent: members.some((member) => member.mayBeAbsent),
}
}
return methodInfoOfType(type, node)
}
function methodInfoOfType(type, node) {
if (isAnyLike(type)) return undefined
// index signature は method を運び得るため、持たないとは確定できない
if (checker.getIndexInfosOfType(type).length > 0) return undefined
const property = type.getProperty("method")
if (property === undefined) return { values: [], mayBeAbsent: true }
const result = literalUnionAllowingUndefined(
checker.getTypeOfSymbolAtLocation(property, node),
)
if (result === undefined) return undefined
const optional = (property.flags & ts.SymbolFlags.Optional) !== 0
return {
values: [
...result.values.map((v) => v.toUpperCase()),
...(result.hasUndefined ? ["GET"] : []),
],
mayBeAbsent: optional,
}
}
function inspectFetch(call, record) {
const [urlArgument, options] = call.arguments
if (urlArgument === undefined) return
if (
ts.isSpreadElement(urlArgument) ||
ts.isSpreadElement(options ?? urlArgument)
) {
markUnresolvedFetch(record, "引数に spread がある")
return
}
// URL が関数のパラメータをそのまま渡している転送ラッパーは呼び出し箇所ではない(呼び出し側のリテラルが拾われる)
const isForwarder = isParameterDerived(urlArgument, checker)
if (options === undefined) return
const methods = optionMethods(options, 0)
if (methods === undefined) {
if (!isForwarder) {
markUnresolvedFetch(record, "method を文字列リテラルに解決できない")
}
return
}
const writeMethods = methods.filter((m) => WRITE_METHOD_NAMES.includes(m))
if (writeMethods.length === 0) return
record.direct = true
if (isForwarder) {
markUnresolvedFetch(
record,
"URL がパラメータだが method が書き込みに固定されており、呼び出し側を辿れない",
)
return
}
const paths = resolveApiPaths(urlArgument, checker, 0)
if (paths === undefined) {
markUnresolvedFetch(record, "URL を /api/... の固定パスに正規化できない")
return
}
for (const method of writeMethods) {
for (const path of paths) {
shared.apis.push({ webPath, key: api:${method} ${path} })
}
}
}
// fetch / authenticatedFetch を呼び出し以外の形(別名への代入・引数渡し・.call)で参照している箇所。
// 呼び出しを静的に辿れないため、API の追加を取りこぼす
function inspectFetchReference(node, record) {
let name
if (ts.isIdentifier(node)) name = node.text
else if (
ts.isPropertyAccessExpression(node) &&
/^(window|globalThis|self)$/.test(node.expression.getText())
) {
name = node.name.text
}
if (name === undefined || !FETCH_CALLEES.has(name)) return
const parent = node.parent
if (ts.isPropertyAccessExpression(node) === false) {
if (
(ts.isPropertyAccessExpression(parent) && parent.name === node) ||
ts.isTypeQueryNode(parent) ||
(ts.isPropertyAssignment(parent) && parent.name === node) ||
ts.isImportSpecifier(parent) ||
ts.isExportSpecifier(parent) ||
ts.isPropertySignature(parent) ||
ts.isPropertyDeclaration(parent) ||
ts.isMethodSignature(parent) ||
ts.isMethodDeclaration(parent) ||
((ts.isFunctionDeclaration(parent) ||
ts.isVariableDeclaration(parent) ||
ts.isParameter(parent) ||
ts.isBindingElement(parent)) &&
parent.name === node)
) {
return
}
if (name === "fetch") {
const declaration = declarationOf(node, checker)
if (
declaration !== undefined &&
webRelative(declaration.getSourceFile().fileName) !== undefined
) {
return
}
}
}
const isCallee =
ts.isCallExpression(parent) && unwrap(parent.expression) === node
if (isCallee) return
markUnresolvedFetch(record, ${name} を呼び出し以外の形で参照している, {
direct: false,
})
}
function markUnresolvedFetch(record, reason, { direct = true } = {}) {
if (direct) record.direct = true
const key = fetch-unresolved:${webPath}#${record.name}
context.unresolve(
key,
${webPath} の ${record.name} の fetch を api に正規化できない(${reason}),
URL を固定パスにするか、例外 ${key} に理由を書く,
)
}
function markUnresolvedHc(record, detail) {
record.direct = true
const key = api-unresolved:${webPath}#${record.name}
context.unresolve(
key,
${webPath} の ${record.name} の ${detail} を api に正規化できない(hc クライアントの連鎖を静的にたどれない),
連鎖を client.api... の直書きにするか、例外 ${key} に理由を書く,
)
}
// hc の書き込みメソッドを取り出す式(receiver.$post / receiver["$post"] / 分割代入)の共通処理
function recordHcWrite(methodName, receiver, record) {
const method = WRITE_METHODS.get(methodName)
if (method === undefined) return
const segments = resolveHcSegments(receiver, checker, root, 0)
if (segments === undefined || segments[0] !== "api") {
markUnresolvedHc(record, ${methodName} 呼び出し)
return
}
record.direct = true
shared.apis.push({
webPath,
key: api:${method} /${segments.join("/")},
})
}
function inspectHcAccess(access, record) {
if (ts.isPropertyAccessExpression(access)) {
recordHcWrite(access.name.text, access.expression, record)
return
}
const argument = unwrap(access.argumentExpression)
const names = ts.isStringLiteralLike(argument)
? [argument.text]
: literalUnionOf(checker.getTypeAtLocation(argument))
if (names !== undefined && names.length > 0) {
for (const name of names) recordHcWrite(name, access.expression, record)
return
}
// キーが解決できない要素アクセスは、呼び出されていて receiver が hc クライアントなら書き込みを隠し得る。
// パスの区間(projects[id])は呼び出されないので対象外
const isCallee =
ts.isCallExpression(access.parent) &&
unwrap(access.parent.expression) === access
if (isCallee && rootsAtHcClient(access.expression, checker, root, 0)) {
markUnresolvedHc(record, "要素アクセスによるメソッド呼び出し")
}
}
function inspectHcBinding(element, record) {
const pattern = element.parent
if (!ts.isObjectBindingPattern(pattern)) return
const declaration = pattern.parent
if (
!ts.isVariableDeclaration(declaration) ||
declaration.initializer === undefined
) {
return
}
if (element.dotDotDotToken !== undefined) {
if (rootsAtHcClient(declaration.initializer, checker, root, 0)) {
markUnresolvedHc(record, "分割代入の rest")
}
return
}
const key = element.propertyName ?? element.name
const names = keyNames(key, checker)
if (names === undefined || names.length === 0) {
if (rootsAtHcClient(declaration.initializer, checker, root, 0)) {
markUnresolvedHc(record, "分割代入のキー")
}
return
}
for (const name of names) {
recordHcWrite(name, declaration.initializer, record)
}
}
// リンク先として読める文字列。分岐・const 定数・文字列リテラルの union に解決し、
// 解決できなかった式は opaque に生のソースを残す
function linkTargets(expression, depth, out) {
const node = unwrap(expression)
if (depth > 6) {
out.opaque.push(collapse(node.getText()))
return
}
if (ts.isStringLiteralLike(node)) {
out.texts.push(node.text)
} else if (ts.isTemplateExpression(node)) {
let text = node.head.text
for (const [index, span] of node.templateSpans.entries()) {
const spanExpression = unwrap(span.expression)
const isApiBase =
index === 0 &&
text === "" &&
((ts.isIdentifier(spanExpression) &&
spanExpression.text === "apiBase") ||
(ts.isPropertyAccessExpression(spanExpression) &&
spanExpression.name.text === "apiBase"))
// ${apiBase}/api/... は API の URL で、画面のリンク先ではない
text += (isApiBase ? "" : PLACEHOLDER) + span.literal.text
}
out.texts.push(text)
} else if (ts.isConditionalExpression(node)) {
linkTargets(node.whenTrue, depth + 1, out)
linkTargets(node.whenFalse, depth + 1, out)
} else if (
ts.isBinaryExpression(node) &&
[ts.SyntaxKind.QuestionQuestionToken, ts.SyntaxKind.BarBarToken].includes(
node.operatorToken.kind,
)
) {
linkTargets(node.left, depth + 1, out)
linkTargets(node.right, depth + 1, out)
} else if (
ts.isBinaryExpression(node) &&
node.operatorToken.kind === ts.SyntaxKind.AmpersandAmpersandToken
) {
linkTargets(node.right, depth + 1, out)
} else if (ts.isObjectLiteralExpression(node)) {
// react-router の to={{ pathname, search }}。pathname が無ければ同じ route 内の遷移
const pathname = findProperty(node, "pathname")
if (pathname !== undefined)
linkTargets(pathname.initializer, depth + 1, out)
} else if (
ts.isNumericLiteral(node) ||
ts.isPrefixUnaryExpression(node) ||
node.kind === ts.SyntaxKind.NullKeyword ||
node.kind === ts.SyntaxKind.TrueKeyword ||
node.kind === ts.SyntaxKind.FalseKeyword ||
(ts.isIdentifier(node) && node.text === "undefined")
) {
// navigate(-1) など遷移先の文字列ではない値
} else {
const initializer = c
Mentions
e2e just crossed 500k weekly downloads on npm 🔥 check it out: tester-army/e2e t.co
memo. 自然言語でテストとアサートをかけるやつって認識で良いですか? [tester-army/e2e: Next generation e2e testing framework for web and mobile apps.](tester-army/e2e)
Framework e2e: Testes Ponta a Ponta para Web e Mobile O que é O e2e é um framework de testes ponta a ponta de código aberto (sob licença Apache-2.0) voltado para aplicações web e mobile. Ele permite descrever os objetivos do teste em linguagem natural para que um agente interaja diretamente com a aplicação. É possível combinar esses comandos em linguagem natural com localizadores e verificações tradicionais no mesmo teste. Quando uma etapa do agente é executada, o framework grava as ações realizadas. Nas execuções seguintes, os passos são reproduzidos sem realizar novas chamadas ao modelo de inteligência artificial, a menos que a aplicação seja alterada. O desenvolvedor pode utilizar sua própria assinatura, chave de API ou um modelo local. Para quem serve A ferramenta serve para equipes de desenvolvimento que constroem sistemas web ou aplicativos móveis. No ambiente web, ela suporta navegadores como Chromium, Firefox e WebKit. No ambiente móvel, atende plataformas iOS e Android em simuladores e emuladores. O repositório inclui projetos de exemplo configurados para diversas tecnologias, como Vite, Next.js, Astro, Angular, Expo, SwiftUI, Jetpack Compose, Kotlin Multiplatform, Flutter e .NET MAUI. Como começar Para iniciar o uso do framework em um projeto, execute o comando de inicialização no terminal: npx e2e init O assistente solicitará a escolha do motor (web ou mobile) e do provedor de modelo, gerando o arquivo de configuração e um teste de exemplo. Por padrão, a CLI envia dados anônimos de uso e falhas. Caso prefira desativar o envio de telemetria, rode o comando npx e2e telemetry disable ou defina a variável de ambiente E2E_TELEMETRY_DISABLED=1. Instalação npx e2e init 🔗 Repositório oficial: tester-army/e2e Veja em 1 minuto {% embed youtube.com %} 📚 Mais conteúdos do canal: gist.github.com
新時代のE2Eテスト、TesterArmyのe2eをReact Nativeアプリに導入してみた こんにちは!テラーノベルでiOS/Android/Webとフロントエンド周りを担当している@kazutoyoです! 以前、Coding AgentにiOS/Androidアプリの「目と手」を与えるツールとしてagent-deviceを紹介しました。 zenn.dev 今回は、そのagent-deviceをエンジンに使っている、TesterArmyのE2Eテストフレームワーク e2e を紹介します。 AIキャラクターとチャットするアプリのめろ(Expo / React Native)に最近導入して、PRでシナリオテストを回しています。 mero.ist e2eは、ゴールを自然言語で書くとエージェントがアプリを操作してくれる、いわゆるAIテストツールです。 ただ、全部をAIに任せるツールではなく、AIに任せる部分と、従来どおりロケーターで確かめる部分を1本のテストの中で書き分けられるのが特徴です。 この記事では、e2eの紹介と既存ツールとの比較、めろでの使い方をまとめます。 e2eとは e2eはTesterArmyが開発している、Webとモバイルアプリ向けのE2Eテストフレームワークです。 npmのパッケージ名はそのまま e2e(SDK・ランナー・CLI) エンジンは別パッケージになっている @e2e-dev/web: Playwright経由でChromium/Firefox/WebKitを動かす @e2e-dev/mobile: agent-device経由でiOS Simulator/Android Emulatorを動かす ほかに、結果をPRにコメントするレポーター(@e2e-dev/github)や、クラウドの端末・ブラウザのプロバイダー(EAS Simulators、Limrun、Kernelなど)がある テストはこのように書きます(公式のREADMEより、コメントは筆者が追加)。 import { test, expect } from 'e2e'; // テスト名: 「メンバーがProプランにアップグレードできる」 test('a member upgrades to Pro', async ({ app, agent, screen }) => { // 支払い設定の画面を開く await app.open('/settings/billing'); // 「ワークスペースをProプランにアップグレードする」をゴールとして渡し、操作はエージェントに任せる await agent.act('upgrade the workspace to the Pro plan'); // 「請求のプレビューに日割りの金額が出ている」かをAIに判定させる await agent.assert('the invoice preview shows a prorated amount'); // 最後はロケーターで、ステータスに「Pro」と出ていることを確かめる await expect(screen.getByRole('status')).toContainText('Pro'); }); 画面を開いたら、プランを変える操作はAIに任せ、結果をAIとロケーターの両方で確かめる、という流れです。 e2e.tester.army 3種類のステップ e2eのテストは、次の3種類のステップを組み合わせて書きます。 ステップ API 何をするか モデル呼び出し ゴール agent.act 「ストーリーを1つ開く」のようなゴールを渡すと、エージェントが画面を読んでタップや入力をする あり(キャッシュがあれば無し) AIによる判定 agent.assert / agent.waitFor / agent.extract 「入力欄が画面の下端にある」のような画面の状態をAIが判定する あり ロケーター screen.getByRole(...) / expect(...) Playwrightと同じ感覚で、決まった仕様を確かめる なし エージェントは画面のアクセシビリティツリーを読んで操作します。 agent.assert に { vision: true } を付けると、画面のピクセルも見せて判定させられます。 e2e.tester.army リプレイキャッシュ agent.act の操作は、そのあとのロケーターの検証や agent.assert が通ったときだけ記録されます。 次の実行からはその記録を再生するだけなので、モデルを呼びません。 UIが変わって記録どおりに操作できなくなったときは、その場面からエージェントが引き継ぎます。 AI 4.1k tokens · 2 model calls · openai/gpt-6-luna-fast Cache 4 replayed · 1 handed off · 1 missed 実行の最後には、このようにキャッシュの結果が出ます。 replayed は記録の再生だけで終わったステップ、handed off は再生の途中からエージェントが引き継いだステップ、missed は記録が無く最初からエージェントが操作したステップです。 引き継いだ場合も、エージェントがゴールを達成できれば、テストはそのまま続きます。 AIテストは毎回トークンを使い、毎回動きが変わるのが気になるところですが、検証が通った操作だけを記録しておく仕組みでそこを抑えています。 ただし、キャッシュされるのは agent.act の操作だけです。 agent.assert などのAIによる判定は毎回モデルを呼ぶので、その分のコストは実行のたびにかかります。 e2e.tester.army ChatGPTなどのサブスクでも動かせる エージェントのモデルには、AI SDKのプロバイダーをそのまま渡せます。 APIキーのほかに、ChatGPTやGitHub Copilotなどのサブスクに npx e2e login openai でログインして使うこともできます。 めろでは、OpenAIの低価格なモデルのGPT-6 Lunaを使っていて、手元ではChatGPTのサブスク、CIではOpenRouter経由のAPIキーで呼んでいます。 e2e.tester.army 他のE2Eテストツールとの比較 代表的なE2Eテストのツールと比べると、次のようになります。 Maestro Detox Appium e2e 対象 iOS/Android/Web React Native iOS/Androidほか Web + iOS/Android 書き方 YAML JS/TS 各言語 TS + 自然言語 アプリへの組み込み 不要 必要 不要 不要 要素の指定 テキスト / id testIDなど セレクタ ロケーター or 自然言語 AIによる操作 なし なし なし あり(agent.act) AIによる判定 あり(assertWithAI など) なし なし あり(agent.assert など) 大きな違いは、アプリへの組み込みが要らないことと、判定だけでなく操作までAIに任せられることかなと思います。 Maestroとの違い MaestroはYAMLのフローで手軽に書けます。 repeat や条件付きの runFlow があり、runScript / evalScript でJavaScript(HTTPクライアントも使えます)を実行できるので、ロジックも書けます。 AIについても、assertWithAI や extractTextWithAI で画面の判定や読み取りができます。 e2eとの違いは2つあります。 1つは、テスト全体をTypeScriptで書く点です。ヘルパー関数や型、Vitestでのユニットテストなど、アプリと同じ道具がそのまま使えます。 もう1つは、AIに判定だけでなく操作も任せられる点です。Maestroでは操作はコマンドで書きますが、e2eでは agent.act にゴールを渡すと、エージェントが操作を決めます。 Detoxとの違い Detoxはアプリの内部と同期するグレーボックスのテストなので安定しますが、アプリ側にDetoxを組み込む必要があります。 e2eはアクセシビリティツリーを外から読むので、いつものDevClientのビルドをそのままテストできます。 React Nativeの testID は、iOS/Androidのどちらでも getByTestId で引けます。 自然言語だけで書くAIテストツールとの違い 自然言語だけでシナリオを書くツールは、書くのは楽な一方で、何を確かめたのかが曖昧になりやすいです。 e2eのドキュメントでは、各ゴールの結果を expect で確かめることや、最後の判定をロケーターで決定的に書くことを勧めていて、AIは変わりやすい操作の部分を受け持つ位置づけです。 e2eの弱いところ AIのステップにはコストと待ち時間がかかるので、キャッシュが効く書き方にする必要がある アクセシビリティツリーに出ないもの(OSのFace IDのダイアログや、Androidでツリーに出ない要素など)は、visionに頼るか工夫が要る まだ0.xで、APIの変化が速い めろでの活用例 ここからは、めろでの使い方を、どんなテストをしたいときにどう書いているかでまとめます。 めろは生成AIのチャットアプリなので、おすすめの一覧やサーバーが返すフォームなど、毎回中身が変わる画面が多いです。 そうした画面の操作や判定はAIに任せ、仕様として決まっていることはロケーターで確かめる、という書き分けを基本にしています。 アプリ固有の用語をエージェントに伝えたいとき エージェントの設定の context に、アプリの説明や用語を書いておけます。 export default { targets: [ { name: "ios", engine: mobile({ platform: "ios" }), app: { bundleId: "..." } }, { name: "android", engine: mobile({ platform: "android" }), app: { bundleId: "..." } }, ], agents: { default: { // 手元では ChatGPT のサブスク、CI は API キー model: process.env.CI ? openrouter("openai/gpt-6-luna") : chatgpt("gpt-6-luna"), system: "You are a thorough QA agent. Verify every outcome on screen.", context: "mero は AI キャラクターとチャットする日本語のアプリ。画面の文言は日本語。「ストーリー」はキャラクターとの会話の題材、「トーク」はキャラクターとのチャット。", }, }, } satisfies E2EConfig; アプリの中だけで通じる言葉をここで教えておくと、ゴールの文を短く書けるようになりました。 並びが毎回変わる一覧から、どれか1つを開きたいとき おすすめの一覧のように並びが毎回変わる画面では、「何番目をタップする」とは書けません。 こういう操作は agent.act に任せて、開いた先の画面はロケーターで確かめています。 // どれを開くかはエージェントに任せる await agent.act("ホームの一覧からストーリーを 1 つ開く"); // 開いた先の画面は、ボタンの表示で確かめる await expect(screen.getByRole("button", /^トークを(はじめる|続ける)$/)).toBeVisible(); 一覧からagent.actで1つ開き、会話を始める レイアウトの崩れのように、ロケーターでは確かめにくい見た目を確かめたいとき チャットの画面で、キーボードを開いたままAIの返信が届き、そのあとキーボードを閉じると、入力欄が浮いたまま、一覧の下が真っ黒、ヘッダーが隠れたまま、という不具合が以前ありました。 こうした見た目の崩れは、要素がツリーにあるかどうかでは分かりません。 入力欄が下に戻ったことは位置をロケーターで確かめ、画面全体の見た目は agent.assert に { vision: true } を付けて、画面を見て判定させています。 // 入力欄の位置を測る const inputTop = async () => (await screen.getByTestId("talk-input").boundingBox())?.y ?? 0; const openedTop = await inputTop(); // 一覧をタップしてキーボードを閉じると、入力欄が下に戻る await screen.getByTestId("talk-message-list").tap(); await expect.poll(inputTop).toBeGreaterThan(openedTop); // 画面全体が崩れていないかは、画面を見て AI に判定させる await agent.assert( "キーボードは閉じていて、上部に戻るボタンとストーリー名のヘッダーが見え、入力欄は画面の下端にあり、一覧に真っ黒な領域は無い", { vision: true }, ); サーバー側で中身が変わるフォームを、最後まで進めたいとき めろには、質問に答えていくだけでAIキャラクターを作れる「かんたん作成」という機能があります。 相手の性別、キャラクター、言葉づかい、舞台といった質問に、候補から選んで答えていく形です。 この質問や選択肢はサーバーが返していて、運営側の設定で変わります。 そのため、質問ごとにテストを書くとすぐに壊れてしまいます。 回答はエージェントにまとめて任せて、確認画面に着いたことだけをロケーターで確かめています。 await agent.act( "各ステップでは、すべての質問について候補のボタンを 1 つずつ押して答えてから「次へ」を押す。確認の画面(「これで作る」ボタンがある)まで進む。「これで作る」は押さない", ); await expect(screen.getByRole("button", "これで作る")).toBeVisible({ timeout: 30_000 }); await agent.assert("確認の画面に、回答が表示されている項目が 1 つ以上ある", { vision: true }); そのあとの「編集して保存すると確認画面に戻る」「一時保存して閉じると、続きから再開できる」といった遷移は、ロケーターで書いています。 フォームの中身が変わってもテストは壊れず、確かめたい仕様は固定できます。 agent.actが質問に答えて辿り着いた確認画面 生体認証やバックグラウンドからの復帰を試したいとき @e2e-dev/mobile の device フィクスチャで、生体認証や権限、ホームボタン、アプリの切り替えも操作できます。 Face IDでのアプリロックは、次の流れで確かめています。 device.enrollBiometrics("faceid", true) でFace IDを登録する 設定からロックを有効にする device.home() から device.openApp() でアプリに戻り、ロックをかける device.setBiometrics("faceid", "nonmatch") で認証に失敗させ、ロックが外れないことを確かめる 再試行で "match" を返して解除する OSのダイアログはアプリのツリーに出ないので、Face IDの結果がアプリに反映されるまで送り直すヘルパーを用意しています。 送り直すのは20秒までで、それを過ぎても反映されなければテストを失敗にしているので、失敗を隠すことはありません。 アナリティクスのイベントが正しく送られたかも確かめたいとき これまでは、アナリティクスのイベントが送られているかを、実装のたびに手で確かめていました。 e2eでテストを書くついでに、イベントも同じテストで確かめられないかと思って試したのがこの仕組みです。 e2eのテストは普通のTypeScriptなので、テストの外の仕組みとも組み合わせられます。 めろでは、開発中にJSのバンドルを配信しているReact NativeのMetroサーバーに、ミドルウェアで /__e2e/events という受け口を足して、開発ビルドのアプリがアナリティクスのイベント(Firebase Analyticsなどに送るユーザーの行動ログ)を送るたびにそこへPOSTするようにしています。 テストからはそれを読んで、操作のあとに期待したイベントが送られたか、イベントのパラメータが正しいかを確かめています。 Metroサーバーと同じポートを使うので、CIでポートを変えても、Androidエミュレータ(10.0.2.2)からでもそのまま届きます。 アプリを一通り見て回って、画面の一覧を作りたいとき e2eには、ゴールだけを渡してアプリを探索させる e2e explore があります。 めろでは、この e2e explore を使って、画面マップを自作しています。 画面マップはe2eの機能ではなく、e2eの実行結果を元に自分たちで作っている社内のドキュメントです。 ホームや検索、詳細画面といった領域ごとに e2e explore で探索させて、画面ごとのスクリーンショットと送られたアナリティクスのイベントを集め、自作のスクリプトで画面と遷移をつないでいます。 社内のドキュメントサイトで画面を選ぶと、その画面の説明や仕様テストの内容、探索で見たことが横に出ます。 stagingのデータを変えないよう、探索には別のエージェントを定義して、context で行動を絞っています。 見るだけにすること。してよいのは、画面を開く・戻る・タブの切り替え・スクロール… してはいけないこと: メッセージの送信、購入や課金のボタンを押す… e2e.tester.army PRごとに回して、結果をレビューで見たいとき GitHub Actionsでは、次の流れで回しています。 @expo/fingerprint でネイティブ部分のハッシュを取り、同じハッシュのDevClientのビルドがキャッシュにあればダウンロード、なければビルドしてアップロードする Metroサーバーを起動して pnpm test:e2e を実行する @e2e-dev/github のレポーターが結果をPRに1件のコメントとして貼る(再実行すると同じコメントを更新する) レポートやスクリーンショットをアーティファクトとして保存する JSだけの変更ならネイティブのビルドを飛ばせるので、実行時間のほとんどはテストそのものです。 PRに付くコメントは次のようになります。 この回は全体で7分22秒かかり、AIを使ったのは5ステップ、モデルの呼び出しは12回、費用は$0.0041でした(モデルはGPT-6 Luna)。 agent.assert の判定は毎回モデルを呼ぶので、費用はキャッシュが効いても0にはならず、記録が使えない回はもう少し増えます。 e2e.tester.army 今はすべてのテストを回していますが、今後はe2eのテストフィルターも取り入れたいと考えています。 PRで --changed-since を付けて実行すると、Jevのような決定モデルがPRの差分とテストを読んで、その変更で影響を受けそうにないテストを外してくれます。 テストが増えても、PRで回すテストの数と時間を抑えられそうです。 e2e.tester.army Coding Agent向けのスキル e2eには、Claude CodeやCodex、CursorなどのCoding Agent向けのスキルが、現在3つ用意されています。 スキル 何をするか e2e テストを書く・実行する・失敗の原因を調べる。e2e mcp で起動中のアプリを操作して、画面を確かめたりロケーターを試したりもできる create-verification-skill プロジェクト専用の verify-<アプリ名> スキルと機能の一覧を作り、Coding Agentが自分の変更をユーザーと同じ操作で確かめられるようにする migrate-to-e2e Playwright・Cypress・Selenium・Detox・Maestroのテストを少しずつe2eに移す。進み具合は MIGRATION.md に残るので、次のセッションで続きから再開できる e2e のスキルは npx e2e init で .agents/skills/ と .claude/skills/ に入ります。めろでもこれで入れています。 3つまとめて入れたい場合は、Claude Code・Codex・Cursor向けのプラグインがあります(Claude Codeの例)。 claude plugin marketplace add tester-army/e2e claude plugin install e2e@e2e e2e.tester.army バグバッシュ e2e のスキルには、バグバッシュの手順も入っています。 「このブランチのチェックアウトとアカウント設定をバグバッシュして」のように頼むと、Coding Agentが次の流れで進めてくれます。 アプリの画面とブランチの差分を読んで、探索の目標を5〜10個立てる 目標ごとに e2e explore を走らせる(4つずつ並列) 見つかった問題のうち、探索の限界や意図した仕様によるものを外す 残った問題ごとに再現テストを書いて実行し、テストが失敗したものだけをバグとして報告する AIが見つけた問題をそのまま報告するのではなく、再現テストで確かめてから報告してくれるのが良いところです。 めろではまだ試せていませんが、リリース前の確認などで使ってみたいと思っています。 e2e.tester.army 導入してみて よかった点 変わる部分と変わらない仕様を分けて書ける。おすすめの一覧やサーバー駆動のフォーム、ロケーターでは確かめにくい見た目など、めろには従来のE2Eと相性の悪い部分が多いですが、そこをAIに任せつつ、確かめたい仕様はロケーターで固定できます 普通のTypeScriptで書ける。ヘルパーをVitestでテストしたり、アナリティクスのイベントの検証や画面マップの生成を自分たちで足したりと、アプリの開発と同じ感覚で広げられます アプリに何も組み込まなくていい。いつものDevClientのビルドをそのまま使えます Coding Agentに書かせやすい。e2eはパッケージにドキュメントとエージェント向けのスキルを同梱しているので、Claude Codeに「この機能のe2eを書いて」と頼むと、規約に沿ったテストを書いてくれます 気をつけている点 1つのゴールに1つの結果。agent.act に複数のことを頼まず、ゴールのあとには必ず expect を置きます(キャッシュもこれが前提です) 状態を残さない。下書きやロックの設定など、前の実行の残りで次のテストが変わらないよう、テストの最初と最後で片付けます プラットフォームの差は書いておく。Androidではツリーに出ない要素があるので、確かめられない部分はコメントに書いてiOSだけで確かめています まとめ e2eは、AIを使うところを人が選び、最後の判定はロケーターで決定的に行う設計なので、実際の運用に乗せやすいと感じています。 npx e2e init このコマンドでWeb/モバイルのどちらかのエンジンとモデルを選ぶだけで導入でき、設定とサンプルのテストもできあがります。 Maestroなどからの移行用のスキル(migrate-to-e2e)も用意されているので、既存のMaestroのフローがあれば、それを元に試すこともできます。 e2e.tester.army 生成AIを使ったアプリのように、出力が毎回変わる画面のE2Eに悩んでいる方は、まずは触ってみることをおすすめします! それでは良いReact Nativeライフを! 参考リンク e2e(GitHub) e2eドキュメント Core concepts Replay cache Subscriptions Test filters Coding agents Bug bash Maestroからの移行ガイド agent-device(GitHub) Rozeniteとagent-deviceを紹介した過去記事
最近の E2E 戦略(2026 年 10 月現在) この記事に書いてあること E2E を主要シナリオだけでなく網羅的に整備した 実装から拾った確認対象とシナリオの対応漏れをチェックし漏れがあれば CI で落とす 開発ワークフローの品質ゲートにローカル E2E 実行を組み込んでいる 毎回全シナリオを回すのではなく、実装に関連する箇所のみ回している(でかいリファクタの時は回帰テストとして全シナリオ回す) やるなら e2e ライブラリがいいよという話 前提となる構成 Web も API も TypeScript で書いている業務アプリ pnpm workspaces でのモノレポ構成 packages/ ├── web/ React・React Router・Vite ├── api/ Hono・Drizzle ORM・PostgreSQL ├── domain/ 計算や入力制約などの業務ルール ├── shared/ 共通の型・ユーティリティ └── e2e/ E2Eテスト・実行基盤・実装とシナリオの対応表 下層のテストは Vitest、E2E のランナーは e2e を使っている。 はじめに - なんで E2E って主要シナリオしかやってなかったんだっけ シンプルに書くのしんどいしメンテもしんどかった。 ちゃんと書いてもフレーキーだし、ノーコード系のやつもなんかライブラリのバージョンが上がっただけで全部動かなくなったりするし、マジでしんどかった。 主要シナリオは担保するから他は勘弁してくれ。という気持ちで整備していた。 CI で回そうもんならアクション代も馬鹿にならないし、大人しくテストピラミッド、テスティングトロフィーに従ってやればいいんですよという気持ちしかなかった。 っていうネガティブな理由で主要シナリオに絞っていただけで、これらの負担が減ったなら、網羅的にやったほうがよくない?と思ったのが出発点。 今は整備するのしんどいんだっけ まったくしんどくない。なぜなら書かないから。おわり。 フレーキー問題も、ネイティブアプリだと特にしんどかったけど、AI に伴走させることで失敗の原因調査やテストの修正はだいぶ楽になってきてる アクション代の問題もローカルで回せばよい話で、今はプロジェクトごとに開発ワークフロースキルを整備しているケースが多いと思っていて、大抵そのワークフローに品質チェックのゲートが用意されているはずで、その中に E2E も含めちゃえばという話。 ちょっと話ずれるけど前まではメンバーそれぞれがローカルでどこまで品質チェックしてるかわからないから CI でチェックしたいモチベが高かったけど、今はワークフローに則ってその結果も PR に貼ってあるならば、少なくとも実行漏れを防ぐために品質チェックを集約する役割については、CI の価値って昔より相対的に下がってきてるよねという気持ちがある(なくてもいいまでは言ってない) それでも残るめんどくささ それはシナリオの管理。 既存のユニットテストなどと違って E2E ではより抽象度の高い " シナリオ " を扱う。 広く整備しようとすると、日々増えたり変わったり消えたりする機能に、シナリオも追従させる必要がある。主要シナリオだけと割り切っていたときにはなかっためんどくささが出てくる。 (ここでいう網羅的は、画面・操作・API・権限など、決めた対象と観点を広く押さえるという意味。全入力・全状態・全操作順の組み合わせではないし、未実装や理由付きの例外も残っている) なので、実装とシナリオの対応表を作った 機能が増えたらテストも増やしたいし、機能を消したら不要なテストも片付けたい。 でも、そのたびに全テストを読んで「これ、どこで確認してたっけ?」と探すのはしんどい。 なので、「どの実装を、どのシナリオで確認するか」を対応表にした。 たとえばプロフィール周りには実際にこんなテストがある。 実装 確認すること シナリオ ID 氏名の編集・保存 姓を変更して保存し、開き直しても残る AUTH-024 氏名の入力チェック 長すぎる姓を受け付けない AUTH-011 氏名の保存 同時編集した変更も残る AUTH-025 保存処理を直したら、この表から見直すシナリオを探せる。 作り方も、大きくはこれだけ。 まず、画面や API、画面から呼ぶ操作などをソースから拾う。 ここは自作スクリプトで自動化した。さすがにここを人力は厳しい。 最初は AI によしなに整理してもらおうと思ったが、それだとなんか漏れそうだったのと、対応表は静的に扱いたかったのでスクリプトにした。(下の方に全文載せてるので気になる方は是非) 次に AI が実装とテストを読み、「この操作は、このテストで確認している」と記入する。テストがなければ追加する。 対象の洗い出しは自動、テストとの紐付けは内容を読んで行う。 対応表は JSON で管理している。covers に確認する実装、aspects に正常動作・保存後も値が残ることなどの確認内容を書く。テストにも同じシナリオ ID を付ける。 packages/e2e/ledger/scenarios/auth.json { "id": "AUTH-024", "title": "プロフィールで姓を変えると「氏名」に印が付き、保存すると「保存しました」と出て開き直しても残る", "status": "implemented", "pendingReason": null, "roles": ["probe"], "viewports": ["pc", "mobile"], "aspects": ["normal", "persist"], "covers": [ "flow:components/settings/settings-page-header.tsx#event:tap_settings_save", "flow:pages/settings/profile-settings-page.tsx#op:useNameDraft", "flow:pages/settings/profile-settings-page.tsx#op:useSettingsSaveFlow" ], "tags": ["exclusive"] } テストの追加・修正と一緒に、この対応表も AI に更新させる。 ただ、それだけだと更新を忘れることはあるので、書き忘れを CI で検出するようにした。 対応表の更新漏れを CI で落とす せっかく対応表を作っても更新を忘れたら意味がない。 ということで CI で毎回ソースを読み直し、「今の実装」「対応表」「実際のテスト」の 3 つを照合する。 たとえば、プロフィール画面を変更した場合はこうなる。 変更 更新を忘れるとどうなるか 新しい操作を追加する 担当するシナリオがなく、CI で落ちる 既存画面にボタンを増やす 操作ハンドラ数が記録と合わず、CI で落ちる 氏名編集の処理を削除する 消えた処理を指す対応が残っていると、CI で落ちる AUTH-024 のテストを消す 対応表では実装済みなのに実テストがなく、CI で落ちる ここで検出するのは、抽出した対象と対応表・テスト ID の不整合。 操作ハンドラ数だけ更新しても、その操作を確かめる assertion が増えたかまでは検査しないし、数が変わらない振る舞いの変更もある。 テストが変更後の仕様を十分に確かめているかは別途レビューが必要(これは AI にやらせる) ※ 以下 2 つの折り畳みはやや AI Slop なので折り畳んだやつ。ただ実際のスクリプトなどをそのまま掲載しているので、もしこの記事を読んでお手元のプロジェクトにも導入してみたいとなったら、これをそのまま読ませれば、いい感じに構築してくれるのでは?という期待感で載せている。 実装を詳しく見る:検査コードとルール 実コード抜粋:各ファイルの責務、抽出から JSON への変換、照合、CI 接続 大きくは、拾う処理と照合する処理。 それを呼び出す入口を、ローカルと CI で共通にしている。 役割 ファイル アプリから検証対象を拾う scripts/ci/e2e-inventory.mjs 抽出した一覧を保存する packages/e2e/ledger/inventory.generated.json 対象とシナリオの対応を書く packages/e2e/ledger/scenarios/*.json テストから ID・タグを拾う scripts/ci/e2e-test-ids.mjs 対応の不足・矛盾を検査する scripts/ci/e2e-ledger-core.mjs ファイル・Git の読み込み、各処理の呼び出し、結果表示をする scripts/ci/e2e-ledger.mjs ここから、さっきのプロフィール画面がこの流れをどう通るかを見ていく。 照合器:追加した対象に、担当シナリオがあるかを見る 抽出した一覧と、対応表の covers を照合する。 一覧にある操作 ↓ 同じキーをcoversに持つシナリオを探す AUTH-024など ↓ 実装済みか確認する 担当シナリオを探す処理がこれ。 // scripts/ci/e2e-ledger-core.mjs const covering = (key) => coverIndex.get(key) ?? [] const implementedOf = (key) => covering(key).filter((s) => s.status === "implemented") coverIndex は、対象からシナリオを探すための逆引き表。 実装済みの担当がなく、pending や理由付き例外にもなっていなければ落とす。 別の画面に氏名変更を追加すれば、その画面と操作の組が新しく拾われる。 プロフィール画面のテストがあるから、別画面も確認済み、にはならない。 既存のシナリオを拡張して担当させてもいいので、対象ごとに新しいテストを作る必要はない。 照合器:消した対象への参照も探す 逆に、プロフィール画面から氏名変更をなくしたとする。 これは変更した場合の例で、現在の実装から削除されているわけではない。 抽出一覧から対象が消えたのに、covers に残っていたら落とす。 // scripts/ci/e2e-ledger-core.mjs for (const s of scenarios) { for (const key of s.covers) { if (!population.has(key)) { fail( 4, シナリオ ${s.id} の covers「${key}」が母集団に無い。対象が削除または改名された。covers から外す, ) } } } population、つまり母集団は、今のソースから拾った対象の集合。 対応表にある対象が、今も存在するかを確認している。 複数の操作を担当するシナリオなら、全部消す必要はない。 なくなった対象の参照と、テスト内の該当操作を外す。 ID 抽出器と照合器:実テストの存在も確認する 対応表だけ実装済みにして、テストを書き忘れても困る。 なので、テストコードから ID・タグを拾う。 // scripts/ci/e2e-test-ids.mjs // テストから取り出した情報を記録する部分 tests.push({ file, title, ids: tags.filter((tag) => SCENARIO_ID_PATTERN.test(tag)), tags, isSetup: kind === "setup", }) tags はテスト定義から読み取ったタグ。 その中から AUTH-024 のような形式の ID を取り出している。 照合器は、この ID と対応表を突き合わせる。 // scripts/ci/e2e-ledger-core.mjs for (const s of scenarios) { if (s.status === "implemented" && !testsById.has(s.id)) { fail( 9, 実装済みシナリオ ${s.id} に対応するテストが無い。tests/${s.area.toLowerCase()}/ に tags へ "${s.id}" を書いたテストを追加する, ) } } 対応表では実装済みなのにテストがない場合も、対応表から消したシナリオのテストだけ残っている場合も落ちる。 なお、implemented はテストが存在するという意味。 実行して成功したかは、別に確認する。 照合器:既存の不足は残し、新しく増やさない 既存機能のテストを一度で全部書けたわけではないので、不足は pending として残している。 記録 意味 シナリオの pending このシナリオのテストは未実装 pending baseline この対象・観点は、実装済みテストで満たせていない 導入時の不足を baseline として保存し、通常の PR では新しい不足を追加できないようにした。 毎回「あとでやる」で通せてしまうのを防ぐため。 // scripts/ci/e2e-ledger-core.mjs const added = sortStrings( [...pendingSet].filter((x) => !baseSet.has(x) && !acceptedTargets.has(x)), ) 現在の pending から、変更前にはなかった項目を取り出している。 acceptedTargets は、UI ファイルの移動・改名として認めた付け替え先。 件数ではなく項目を比較するので、1 件減らして別の 1 件を足しても検出できる。 解消した pending が残っている場合も、整理するように落とす。 保存しない処理の永続化など、適用できない観点は理由付きの例外にする。 例外の追加は差分を出し、その理由が妥当かはレビューで確認している。 台帳ゲートの検査規則 形式検査に加え、11 種類の規則がある。 規則 検査内容 1 ソースの抽出結果と、コミットされた inventory の一致 2 母集団の漏れ 3 必須観点の漏れ 4 削除済み対象などの残骸 5 不要になった例外 6 pending・例外の理由の空欄 7 満たされたのに残っている pending 8 surface の登録と操作ハンドラ数の一致 9 シナリオとテストの対応 10 外部送信一覧と書き込み API の不整合 11 base と比べた pending baseline の増加 検出した問題はまとめて日本語で列挙する。pnpm check に含まれるため、通常の品質チェックで台帳も確認される。 抽出回避への対処と、ゲートの限界 認識できない操作・宛先は、黙って無視せず抽出エラーにする。 抽出器を対応させるか、理由付き例外で説明する。 URL を変数へ隠すなど、検出を消すためだけの書き換えは禁止する。 ハンドラ数が変わったら、シナリオを見直す。 routeMeta・例外・covers の意味の正しさはレビューで補う。 限界も明確にする。 ガード付き画面なのに guarded を宣言し忘れたことは、意味まで理解して自動検出できない。 surface は、ファイルとハンドラ数を管理するもので、各ハンドラの実行を計測するコードカバレッジではない。 covers に書かれた対象を、本当に十分な assertion で確かめているかは別問題。 対象・観点の網羅と、要件そのものの妥当性は別問題。 根拠は .agent-config/rules/e2e-scenarios.md と scripts/ci/e2e-ledger-core.mjs。 自分の環境で試したい人向け:抽出器の全文と導入例 実運用の抽出器は scripts/ci/e2e-inventory.mjs。業務ルールは含まないが、React Router や API クライアント、ディレクトリ構成はこのプロジェクト向けなので、環境に合わせた変更が必要。 以下は、抽出器の全文と、それを小さな入力例で呼び出すためのコード。実運用ファイルとの一致と依存導入は確認したが、この呼び出し例は未検証。出力例も実行結果ではなく期待値。 scripts/e2e-inventory.mjs ソースを解析する(実運用コード) scripts/extract.mjs ファイルを読み、解析結果を表示する(説明用) example/ 解析対象の入力コード(説明用) 他の環境で変更する箇所と、この抽出器の範囲 箇所 今の前提 変更が必要な場合 パス・import WEB_SRC など冒頭のパス、@/*、@polyco-cloud/api の型解決除外 配置・alias・API パッケージ名が違う UI の判定 pages/・layouts/・features/.../components/・components/。components/ui/ などは対象外 UI を別の場所に置く ルート router.tsx の routes 配列、React Router の Navigate や遷移 API Next.js のファイルルーティングなど別方式 書き込み API api-client.ts の client、$post など、fetch・authenticatedFetch、/api/ クライアント・ラッパー・パス形式が違う 計測・権限・機能 track*・event_name、PolicyName・OrganizationFeatureKey・OrgFeature、EXTERNAL_SEND_APIS SDK や宣言方法が違う パスだけ変更すれば十分とは限らない。ルーターや API クライアントが違えば、extractRoutes・resolveHcSegments・analyzeFile などの読み取り規則も合わせる。 extractInventory の入力は次の 3 つ。repoRoot は絶対パス、コールバックが扱うパスはそこからの相対パス。readFile は読めない場合 undefined を返し、listFiles は指定ディレクトリ配下のファイルを再帰的に列挙する。 この抽出器は全 8 種類を一緒に読むため、権限・機能フラグ・外部送信一覧のファイルも前提にしている。使わない種類を外すなら、定数の変更だけでなく該当する読取処理も変更する。入力例のダミー型を実プロダクトの定義として持ち込む必要はない。 返るのは対象一覧と抽出エラー。担当テストを決めること、covers・aspects の意味が正しいと証明すること、テストを実行することは含まれない。 テストや Storybook などは除外する。対応する解析で操作や宛先を確定できない場合は errors・unresolved へ返す。今回の呼び出しコードは、そのエラーを無視せず終了コード 1 にしている。 準備と実行手順 実行に必要な依存を固定し、次のコマンドで呼び出す。依存導入は Node24.14.0・pnpm10.26.0・TypeScript5.9.3 で行った。呼び出し例の実行状況は下の表に分けている。 { "private": true, "type": "module", "dependencies": { "typescript": "5.9.3" } } pnpm install node scripts/extract.mjs example 実行用スクリプト全文:scripts/extract.mjs 末尾に掲載した抽出器の全文を scripts/e2e-inventory.mjs へ保存し、同じディレクトリに次のファイルを置く。引数に調べるディレクトリを指定すると、結果を標準出力へ出す。抽出エラーがあれば終了コード 1 にする。 import { existsSync, readdirSync, readFileSync } from "node:fs" import { join, resolve } from "node:path" import { extractInventory } from "./e2e-inventory.mjs" const repoRoot = resolve(process.argv[2] ?? ".") function listFiles(directory) { if (!existsSync(join(repoRoot, directory))) return [] return readdirSync(join(repoRoot, directory), { withFileTypes: true }) .sort((a, b) => a.name.localeCompare(b.name)) .filter((entry) => !["node_modules", "dist"].includes(entry.name)) .flatMap((entry) => { const path = ${directory}/${entry.name} return entry.isDirectory() ? listFiles(path) : [path] }) } const result = extractInventory({ repoRoot, listFiles, readFile: (path) => { try { return readFileSync(join(repoRoot, path), "utf8") } catch { return undefined } }, }) console.log(JSON.stringify(result, null, 2)) process.exitCode = result.errors.length === 0 ? 0 : 1 入力ファイルの全体:example/配下の 8 ファイル demo_manage などはこの例だけのダミー値。API クライアントも解析に必要な形だけで、hc() や HTTP リクエストを実行するわけではない。 example/packages/domain/src/authorization/policy.ts export type PolicyName = "demo_manage" example/packages/domain/src/organization-feature/organization-feature.ts export type OrganizationFeatureKey = "demo_feature" example/packages/e2e/src/external-send.ts export const EXTERNAL_SEND_APIS = [] example/packages/shared/src/org-features.ts export type OrgFeature = "demo_flag" example/packages/web/src/hooks/use-name-draft.ts import { client } from "@/lib/api-client" export function useNameDraft() { return { save: () => client.api.auth.name.$patch({ json: { lastName: "Example" } }), } } example/packages/web/src/lib/api-client.ts declare const hc: any export const client = hc() example/packages/web/src/pages/settings/profile-settings-page.tsx import { useNameDraft } from "@/hooks/use-name-draft" export function ProfileSettingsPage() { const draft = useNameDraft() return <button onClick={draft.save}>変更を保存する</button> } example/packages/web/src/router.tsx export const routes = [{ path: "/settings/profile", element: <ProfileSettingsPage /> }] 期待する出力の全体(未検証) { "items": [ { "key": "api:PATCH /api/auth/name", "kind": "api", "source": "packages/web/src/hooks/use-name-draft.ts" }, { "key": "feature:entitlement:demo_feature", "kind": "feature", "source": "packages/domain/src/organization-feature/organization-feature.ts" }, { "key": "feature:org:demo_flag", "kind": "feature", "source": "packages/shared/src/org-features.ts" }, { "key": "flow:pages/settings/profile-settings-page.tsx#op:useNameDraft", "kind": "flow", "source": "packages/web/src/pages/settings/profile-settings-page.tsx" }, { "key": "policy:demo_manage", "kind": "policy", "source": "packages/domain/src/authorization/policy.ts" }, { "key": "route:/settings/profile", "kind": "route", "redirect": false, "source": "packages/web/src/router.tsx" }, { "key": "surface:pages/settings/profile-settings-page.tsx", "kind": "surface", "interactions": 1, "source": "packages/web/src/pages/settings/profile-settings-page.tsx" } ], "errors": [], "unresolved": [] } 掲載例の確認状況 内容 状況 抽出器の全文 実運用ファイルと一致することを確認。変更なし TypeScript5.9.3 の依存導入 今回成功 新しい呼び出しコード+8 ファイルの入力例 未検証。共有資源管理の machine_constrained で起動待ちとなり、検証コマンドは起動前に終了。実行 attempt は 0 件 掲載した出力例 コードから作った期待出力。実行ログではない 製品のブラウザ E2E 今回未実行 以前の名前指定の最小スクリプトによる useNameDraft 抽出は実行確認しているが、それを今回の全文版・呼び出し例の検証済み実績には数えない。 抽出器の全文:e2e-inventory.mjs(実運用版・1,787 行・変更なし) 依存は typescript。以下は実運用ファイルのそのままの掲載で、今回の全文版の再実行はしていない。入出力の読み書きは呼び出し側へ渡しているため、このファイルだけ実行しても JSON は出力しない。 import ts from "typescript" export const WEB_SRC = "packages/web/src" export const ROUTER_FILE = ${WEB_SRC}/router.tsx export const API_CLIENT_FILE = ${WEB_SRC}/lib/api-client.ts export const POLICY_FILE = "packages/domain/src/authorization/policy.ts" export const ENTITLEMENT_FILE = "packages/domain/src/organization-feature/organization-feature.ts" export const ORG_FEATURE_FILE = "packages/shared/src/org-features.ts" export const EXTERNAL_SEND_FILE = "packages/e2e/src/external-send.ts" const WRITE_METHODS = new Map([ ["$post", "POST"], ["$put", "PUT"], ["$patch", "PATCH"], ["$delete", "DELETE"], ]) const FETCH_CALLEES = new Set(["fetch", "authenticatedFetch"]) const PLACEHOLDER = "\u0001" // biome-ignore lint/suspicious/noTemplateCurlyInString: 例外キーに載せる ${} という文字列そのもの const TEMPLATE_EXPRESSION_MARK = "${}" // テスト・stories・生成物は母集団に含めない const isExcludedSource = (relativePath) => /.(test|spec|stories|fixtures|harness).tsx?$/.test(relativePath) || /-(fixtures|fixture|test-utils|test-support|scenarios).tsx?$/.test( relativePath, ) || /(^|\/)(tests|mocks|test-helpers|test-support|test-fixtures)\//.test( relativePath, ) || /.generated.tsx?$/.test(relativePath) || relativePath.endsWith(".d.ts") // pages / layouts / features の components / components(ui を除く)の .tsx const isUiFile = (webRelativePath) => webRelativePath.endsWith(".tsx") && (webRelativePath.startsWith("pages/") || webRelativePath.startsWith("layouts/") || /^features\/(?:.+\/)?components\//.test(webRelativePath) || (webRelativePath.startsWith("components/") && !webRelativePath.startsWith("components/ui/"))) const unwrap = (node) => { let current = node while ( ts.isParenthesizedExpression(current) || ts.isAsExpression(current) || ts.isSatisfiesExpression(current) || ts.isNonNullExpression(current) || ts.isTypeAssertionExpression(current) ) { current = current.expression } return current } // ["path"] のような文字列リテラルの computed key は通常のキーと同じに読む const propertyNameText = (name) => { if (ts.isIdentifier(name) || ts.isStringLiteralLike(name)) return name.text if (ts.isComputedPropertyName(name)) { const inner = unwrap(name.expression) if (ts.isStringLiteralLike(inner)) return inner.text } return undefined } const findProperty = (objectLiteral, propertyName) => objectLiteral.properties.find( (property) => ts.isPropertyAssignment(property) && propertyNameText(property.name) === propertyName, ) // ---- プログラム(型チェッカー)の構築 ---- function createProgramFor({ repoRoot, sources, readFile }) { const root = repoRoot.replace(/\/+$/, "") const cache = new Map() const read = (absolutePath) => { if (!absolutePath.startsWith(${root}/)) return undefined const relativePath = absolutePath.slice(root.length + 1) if (cache.has(relativePath)) return cache.get(relativePath) const text = sources.get(relativePath) ?? readFile(relativePath) cache.set(relativePath, text) return text } const options = { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.ESNext, moduleResolution: ts.ModuleResolutionKind.Bundler, jsx: ts.JsxEmit.ReactJSX, strict: true, noEmit: true, skipLibCheck: true, types: [], baseUrl: root, paths: { "@/*": [${WEB_SRC}/*] }, } // メモリ上のソースだけを渡すテストでは標準ライブラリが読めないため noLib にする options.noLib = read(ts.getDefaultLibFilePath(options)) === undefined const host = { getSourceFile: (fileName, languageVersion) => { const text = read(fileName) return text === undefined ? undefined : ts.createSourceFile(fileName, text, languageVersion, true) }, getDefaultLibFileName: (compilerOptions) => ts.getDefaultLibFilePath(compilerOptions), writeFile: () => {}, getCurrentDirectory: () => root, getDirectories: () => [], fileExists: (fileName) => read(fileName) !== undefined, readFile: read, getCanonicalFileName: (fileName) => fileName, useCaseSensitiveFileNames: () => true, getNewLine: () => "\n", // api の型グラフ全体(AppType)を読むと抽出に 1 分近くかかる。web 側のシンボル解決には不要 resolveModuleNameLiterals: (moduleLiterals, containingFile) => moduleLiterals.map((literal) => literal.text === "@polyco-cloud/api" ? { resolvedModule: undefined } : ts.resolveModuleName(literal.text, containingFile, options, host), ), } const rootNames = [...sources.keys()].map((path) => ${root}/${path}) const program = ts.createProgram({ rootNames, options, host }) return { program, checker: program.getTypeChecker(), root } } // ---- route ---- const LAZY_ONLY_KEYS = new Set(["lazy", "hydrateFallbackElement"]) // 戻り値の型が Pick<_, "lazy" | "hydrateFallbackElement"> に限られる関数の名前。 // この展開は path・index・children を持ち込めないため、route の母集団に影響しない function collectLazyOnlyFunctions(sourceFile) { const names = new Set() for (const statement of sourceFile.statements) { if (!ts.isFunctionDeclaration(statement) || statement.name === undefined) { continue } const type = statement.type if ( type === undefined || !ts.isTypeReferenceNode(type) || type.typeName.getText() !== "Pick" || type.typeArguments?.length !== 2 ) { continue } const keys = type.typeArguments[1] const members = ts.isUnionTypeNode(keys) ? keys.types : [keys] const literals = members.map((member) => ts.isLiteralTypeNode(member) && ts.isStringLiteral(member.literal) ? member.literal.text : undefined, ) if (literals.every((key) => key !== undefined && LAZY_ONLY_KEYS.has(key))) { names.add(statement.name.text) } } return names } function isLazyOnlySpread(property, context) { if (!ts.isSpreadAssignment(property)) return false const call = unwrap(property.expression) return ( ts.isCallExpression(call) && ts.isIdentifier(call.expression) && context.lazyOnlyFunctions.has(call.expression.text) ) } function parseRouteNodes(arrayLiteral, context) { const nodes = [] for (const element of arrayLiteral.elements) { const objectLiteral = unwrap(element) if (!ts.isObjectLiteralExpression(objectLiteral)) { context.fail( ${ROUTER_FILE} の routes にオブジェクトリテラルでない要素がある。route の抽出は静的なオブジェクトリテラルに限る, ) continue } const node = { path: undefined, index: false, redirect: false } for (const property of objectLiteral.properties) { if (isLazyOnlySpread(property, context)) continue if (!ts.isPropertyAssignment(property)) { // spread・shorthand・メソッドは path / children の寄与を静的に決められず、route の追加を取りこぼす context.fail( ${ROUTER_FILE} の route にプロパティ代入でない要素がある(${property.getText()})。spread・shorthand は使わずリテラルで書く, ) continue } const name = propertyNameText(property.name) if (name === undefined && ts.isComputedPropertyName(property.name)) { context.fail( ${ROUTER_FILE} の route に静的に読めない computed key がある(${property.name.getText()})。文字列リテラルのキーで書く, ) continue } const value = unwrap(property.initializer) if (name === "path") { if (ts.isStringLiteralLike(value)) node.path = value.text else context.fail( ${ROUTER_FILE} の route の path が文字列リテラルでない(${value.getText()}), ) } else if (name === "index") { if ( value.kind === ts.SyntaxKind.TrueKeyword || value.kind === ts.SyntaxKind.FalseKeyword ) { node.index = value.kind === ts.SyntaxKind.TrueKeyword } else { context.fail( ${ROUTER_FILE} の route の index が true / false のリテラルでない(${value.getText()}), ) } } else if (name === "element") { node.redirect = isRedirectElement(value) } else if (name === "children") { if (ts.isArrayLiteralExpression(value)) node.children = parseRouteNodes(value, context) else context.fail( ${ROUTER_FILE} の route の children が配列リテラルでない, ) } } nodes.push(node) } return nodes } function isRedirectElement(element) { let tag if (ts.isJsxElement(element)) tag = element.openingElement.tagName else if (ts.isJsxSelfClosingElement(element)) tag = element.tagName else return false const name = tag.getText() return name === "Navigate" || name.endsWith("Redirect") } const trimTrailingSlash = (path) => path.endsWith("/") ? path.slice(0, -1) : path const prefixWithSlash = (path) => (path.startsWith("/") ? path : /${path}) const isWildcardWithChildren = (route) => route.path?.endsWith("*") === true && (route.children?.length ?? 0) > 0 // sentry-route-patterns.ts の appendRoutePath / collectRoutePatterns と同じ組み立て規則 function appendRoutePath(parentPattern, route) { if (route.index) return parentPattern const path = route.path if (path === undefined || path === "" || isWildcardWithChildren(route)) { return parentPattern } const segment = path.startsWith("/") || parentPattern.endsWith("/") ? path : /${path} return trimTrailingSlash(parentPattern) + prefixWithSlash(segment) } const normalizePattern = (pattern) => { if (pattern === "") return "/" return pattern.length > 1 ? trimTrailingSlash(pattern) : pattern } function collectRoutes(routeNodes) { const byPattern = new Map() const visit = (nodes, parentPattern) => { for (const route of nodes) { const pattern = appendRoutePath(parentPattern, route) const contributes = route.index || (route.path !== undefined && route.path !== "" && !isWildcardWithChildren(route)) if (contributes) { const normalized = normalizePattern(pattern) const entry = byPattern.get(normalized) ?? { leafRedirects: [] } const isLeaf = route.index || (route.children?.length ?? 0) === 0 if (isLeaf) entry.leafRedirects.push(route.redirect) byPattern.set(normalized, entry) } if (route.children !== undefined) visit(route.children, pattern) } } visit(routeNodes, "") return [...byPattern].map(([pattern, entry]) => ({ pattern, redirect: entry.leafRedirects.length > 0 && entry.leafRedirects.every(Boolean), })) } function extractRoutes(program, root, context) { const sourceFile = program.getSourceFile(${root}/${ROUTER_FILE}) if (sourceFile === undefined) { context.fail(${ROUTER_FILE} を読めない。route を抽出できない) return [] } let routesArray for (const statement of sourceFile.statements) { if (!ts.isVariableStatement(statement)) continue for (const declaration of statement.declarationList.declarations) { if ( ts.isIdentifier(declaration.name) && declaration.name.text === "routes" && declaration.initializer !== undefined ) { const initializer = unwrap(declaration.initializer) if (ts.isArrayLiteralExpression(initializer)) routesArray = initializer } } } if (routesArray === undefined) { context.fail( ${ROUTER_FILE} に配列リテラルで初期化された routes が無い。route を抽出できない, ) return [] } context.lazyOnlyFunctions = collectLazyOnlyFunctions(sourceFile) return collectRoutes(parseRouteNodes(routesArray, context)) } // ---- リンク先と route の照合 ---- const splitSegments = (path) => path.split("/").filter((s) => s !== "") // 候補の ${...} は 1 セグメントの任意値。param には優先して一致させ、 // リテラルの route セグメントには param が無い場合だけ一致させる function matchRoute(candidateSegments, routeSegments) { if (candidateSegments.length !== routeSegments.length) return undefined let placeholderToLiteral = 0 let literalToParam = 0 for (const [index, candidate] of candidateSegments.entries()) { const routeSegment = routeSegments[index] const isParam = routeSegment.startsWith(":") if (candidate.includes(PLACEHOLDER)) { if (!isParam) placeholderToLiteral += 1 } else if (candidate !== routeSegment) { if (!isParam) return undefined literalToParam += 1 } } return [placeholderToLiteral, literalToParam] } function resolveLinkTarget(rawText, routePatterns) { const queryIndex = rawText.search(/[?#]/) const pathOnly = queryIndex === -1 ? rawText : rawText.slice(0, queryIndex) const candidateSegments = splitSegments(pathOnly) let best for (const pattern of routePatterns) { const cost = matchRoute(candidateSegments, splitSegments(pattern)) if (cost === undefined) continue if ( best === undefined || cost[0] < best.cost[0] || (cost[0] === best.cost[0] && cost[1] < best.cost[1]) ) { best = { pattern, cost } } } return best?.pattern } const isInternalLinkCandidate = (text) => text.startsWith("/") && !text.startsWith("//") && text !== "/api" && !text.startsWith("/api/") && !text.startsWith("/api?") // スキーム付き(外部 URL・mailto 等)・アンカー・クエリだけのもの以外で、/ で始まらない文字列は相対パスか式始まり const isRelativeLinkCandidate = (text) => text !== "" && !text.startsWith("/") && !text.startsWith("#") && !text.startsWith("?") && !/^[A-Za-z][A-Za-z0-9+.-]*:/.test(text) // ---- 変数 event_name の型展開 ---- function literalUnionOf(type) { const members = type.isUnion() ? type.types : [type] const values = [] for (const member of members) { if (!member.isStringLiteral()) return undefined values.push(member.value) } return values } // undefined を許す union(optional プロパティの型用)。文字列リテラル以外が混ざれば undefined を返す function literalUnionAllowingUndefined(type) { const members = type.isUnion() ? type.types : [type] const values = [] let hasUndefined = false for (const member of members) { if (member.flags & (ts.TypeFlags.Undefined | ts.TypeFlags.Void)) { hasUndefined = true } else if (member.isStringLiteral()) { values.push(member.value) } else { return undefined } } return { values, hasUndefined } } const isAnyLike = (type) => (type.flags & (ts.TypeFlags.Any | ts.TypeFlags.Unknown)) !== 0 // オブジェクトのキーが取り得る名前。computed key は型チェッカーで文字列リテラルの union に解決できたときだけ読む function keyNames(name, checker) { if ( ts.isIdentifier(name) || ts.isStringLiteralLike(name) || ts.isNumericLiteral(name) ) { return [name.text] } if (ts.isComputedPropertyName(name)) { const inner = unwrap(name.expression) if (ts.isStringLiteralLike(inner)) return [inner.text] return literalUnionOf(checker.getTypeAtLocation(name.expression)) } return undefined } function declarationOf(identifier, checker) { const symbol = checker.getSymbolAtLocation(identifier) return symbol?.valueDeclaration ?? symbol?.declarations?.[0] } // 式が(プロパティ連鎖を含めて)関数のパラメータから始まるか。パラメータをそのまま渡す転送の判定に使う function isParameterDerived(expression, checker) { let node = unwrap(expression) while ( ts.isPropertyAccessExpression(node) || ts.isElementAccessExpression(node) ) { node = unwrap(node.expression) } if (!ts.isIdentifier(node)) return false const declaration = declarationOf(node, checker) return ( declaration !== undefined && (ts.isParameter(declaration) || (ts.isBindingElement(declaration) && isInParameter(declaration))) ) } function isInParameter(node) { for (let current = node.parent; current; current = current.parent) { if (ts.isParameter(current)) return true if (ts.isVariableDeclaration(current)) return false } return false } // const で初期化された識別子の初期化子。変数に切り出された定数を元の式までたどる function constInitializerOf(expression, checker) { const node = unwrap(expression) if (!ts.isIdentifier(node)) return undefined const declaration = declarationOf(node, checker) if ( declaration === undefined || !ts.isVariableDeclaration(declaration) || declaration.initializer === undefined || !ts.isIdentifier(declaration.name) || !(declaration.parent.flags & ts.NodeFlags.Const) ) { return undefined } return declaration.initializer } const WRITE_METHOD_NAMES = ["POST", "PUT", "PATCH", "DELETE"] // ---- hc / fetch の正規化 ---- function resolveHcSegments(expression, checker, root, depth) { if (depth > 8) return undefined const node = unwrap(expression) if (ts.isPropertyAccessExpression(node)) { const base = resolveHcSegments(node.expression, checker, root, depth) return base === undefined ? undefined : [...base, node.name.text] } if (ts.isElementAccessExpression(node)) { const argument = unwrap(node.argumentExpression) if (!ts.isStringLiteralLike(argument)) return undefined const base = resolveHcSegments(node.expression, checker, root, depth) return base === undefined ? undefined : [...base, argument.text] } if (ts.isIdentifier(node)) { let symbol = checker.getSymbolAtLocation(node) if (symbol === undefined) return undefined if (symbol.flags & ts.SymbolFlags.Alias) { symbol = checker.getAliasedSymbol(symbol) } const declaration = symbol.valueDeclaration ?? symbol.declarations?.[0] if (declaration === undefined || !ts.isVariableDeclaration(declaration)) { return undefined } const file = declaration.getSourceFile().fileName if ( file === ${root}/${API_CLIENT_FILE} && ts.isIdentifier(declaration.name) && declaration.name.text === "client" ) { return [] } if (declaration.initializer === undefined) return undefined return resolveHcSegments(declaration.initializer, checker, root, depth + 1) } return undefined } // 連鎖の根が api-client の client に届くか。キーが静的に読めない区間(要素アクセス・呼び出し)も許して根だけ見る function rootsAtHcClient(expression, checker, root, depth) { if (depth > 8) return false const node = unwrap(expression) if ( ts.isPropertyAccessExpression(node) || ts.isElementAccessExpression(node) || ts.isCallExpression(node) ) { return rootsAtHcClient(node.expression, checker, root, depth + 1) } if (!ts.isIdentifier(node)) return false let symbol = checker.getSymbolAtLocation(node) if (symbol === undefined) return false if (symbol.flags & ts.SymbolFlags.Alias) { symbol = checker.getAliasedSymbol(symbol) } const declaration = symbol.valueDeclaration ?? symbol.declarations?.[0] if (declaration === undefined || !ts.isVariableDeclaration(declaration)) { return false } if ( declaration.getSourceFile().fileName === ${root}/${API_CLIENT_FILE} && ts.isIdentifier(declaration.name) && declaration.name.text === "client" ) { return true } return declaration.initializer === undefined ? false : rootsAtHcClient(declaration.initializer, checker, root, depth + 1) } function pathParameterName(expression) { if (ts.isIdentifier(expression)) return expression.text if (ts.isPropertyAccessExpression(expression)) return expression.name.text return undefined } const toApiPath = (built) => { const path = built.split(/[?#]/)[0] return path.startsWith("/api/") ? path : undefined } function normalizeApiUrl(expression) { const node = unwrap(expression) if (ts.isStringLiteralLike(node)) return toApiPath(node.text) if (!ts.isTemplateExpression(node)) return undefined let built = node.head.text for (const [index, span] of node.templateSpans.entries()) { const spanExpression = unwrap(span.expression) const isBase = index === 0 && built === "" && ((ts.isIdentifier(spanExpression) && spanExpression.text === "apiBase") || (ts.isPropertyAccessExpression(spanExpression) && spanExpression.name.text === "apiBase")) // クエリ部分の値は無視してよい。パス部分の ${識別子} は :識別子 のパラメータとして読む if (!isBase && !built.includes("?")) { const parameter = pathParameterName(spanExpression) if (parameter === undefined) return undefined built += :${parameter} } built += span.literal.text } return toApiPath(built) } // URL 式が取り得る /api/... のパスすべて。リテラル・テンプレート・分岐・const 定数・文字列リテラルの union に解決し、 // 1 つでも解決できなければ undefined function resolveApiPaths(expression, checker, depth) { if (depth > 6) return undefined const node = unwrap(expression) if (ts.isStringLiteralLike(node) || ts.isTemplateExpression(node)) { const path = normalizeApiUrl(node) return path === undefined ? undefined : [path] } if (ts.isConditionalExpression(node)) { const whenTrue = resolveApiPaths(node.whenTrue, checker, depth + 1) const whenFalse = resolveApiPaths(node.whenFalse, checker, depth + 1) return whenTrue === undefined || whenFalse === undefined ? undefined : [...whenTrue, ...whenFalse] } const initializer = constInitializerOf(node, checker) if (initializer !== undefined) { return resolveApiPaths(initializer, checker, depth + 1) } const values = literalUnionOf(checker.getTypeAtLocation(node)) if (values === undefined || values.length === 0) return undefined const paths = values.map(toApiPath) return paths.every((path) => path !== undefined) ? paths : undefined } // ---- 1 ファイルの走査 ---- function topLevelUnits(sourceFile) { const units = [] for (const statement of sourceFile.statements) { if (ts.isImportDeclaration(statement) || ts.isExportDeclaration(statement)) continue if ( ts.isFunctionDeclaration(statement) || ts.isClassDeclaration(statement) ) { const isDefault = statement.modifiers?.some( (modifier) => modifier.kind === ts.SyntaxKind.DefaultKeyword, ) units.push({ name: statement.name?.text ?? (isDefault ? "default" : "<anonymous>"), node: statement, }) } else if (ts.isVariableStatement(statement)) { for (const declaration of statement.declarationList.declarations) { units.push({ name: ts.isIdentifier(declaration.name) ? declaration.name.text : "<destructured>", node: declaration, }) } } else { units.push({ name: "<module>", node: statement }) } } return units } // 参照先の宣言を含むトップレベルの文から、topLevelUnits と同じ規則で単位名を決める。 // 宣言の形(無名 default・分割代入・オブジェクトのメンバーなど)で参照を落とさない function declarationUnitKey(symbol, checker, webRelative) { let target = symbol if (target.flags & ts.SymbolFlags.Alias) { target = checker.getAliasedSymbol(target) } const declaration = target.valueDeclaration ?? target.declarations?.[0] if (declaration === undefined) return undefined let statement = declaration while (statement.parent !== undefined && !ts.isSourceFile(statement.parent)) { statement = statement.parent } if (statement.parent === undefined || !ts.isSourceFile(statement.parent)) { return undefined } let name if (ts.isFunctionDeclaration(statement) || ts.isClassDeclaration(statement)) { const isDefault = statement.modifiers?.some( (modifier) => modifier.kind === ts.SyntaxKind.DefaultKeyword, ) name = statement.name?.text ?? (isDefault ? "default" : "<anonymous>") } else if (ts.isVariableStatement(statement)) { const owner = statement.declarationList.declarations.find( (candidate) => candidate.pos <= declaration.pos && declaration.end <= candidate.end, ) name = owner !== undefined && ts.isIdentifier(owner.name) ? owner.name.text : "<destructured>" } else if ( ts.isExportAssignment(statement) || ts.isExpressionStatement(statement) || ts.isModuleDeclaration(statement) ) { name = "<module>" } else { // 型・interface・enum は実行時の操作を持たない return undefined } const relativePath = webRelative(statement.parent.fileName) return relativePath === undefined ? undefined : ${relativePath}#${name} } const MAX_SPREAD_HANDLERS = 30 const LINK_CALLEES = new Set(["navigate", "redirect"]) const isLocationText = (text) => /(^|.)location$/.test(text) const collapse = (text) => text.replace(/\s+/g, " ").trim() const isStringish = (type) => isAnyLike(type) || (type.isUnion() ? type.types : [type]).some( (member) => member.flags & (ts.TypeFlags.StringLike | ts.TypeFlags.Any | ts.TypeFlags.Unknown), ) function analyzeFile({ sourceFile, webPath, isUi, checker, root, nameSet, context, shared, }) { const units = [] const webRelative = shared.webRelative for (const unit of topLevelUnits(sourceFile)) { const record = { key: ${webPath}#${unit.name}, name: unit.name, direct: false, refs: new Set(), } units.push(record) const visit = (node) => { if (ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) return if (ts.isCallExpression(node)) inspectCall(node, record) if (ts.isObjectLiteralExpression(node)) inspectEventLiteral(node, record) if ( ts.isPropertyAccessExpression(node) || ts.isElementAccessExpression(node) ) { inspectHcAccess(node, record) } if (ts.isBindingElement(node)) inspectHcBinding(node, record) if (ts.isIdentifier(node) || ts.isPropertyAccessExpression(node)) { inspectFetchReference(node, record) } if (isUi) inspectUiNode(node, record) if (ts.isIdentifier(node) && nameSet.has(node.text)) { const symbol = checker.getSymbolAtLocation(node) if (symbol !== undefined) { const key = declarationUnitKey(symbol, checker, webRelative) if (key !== undefined && key !== record.key) record.refs.add(key) } } ts.forEachChild(node, visit) } visit(unit.node) } function inspectCall(call, record) { const callee = unwrap(call.expression) let calleeName let receiver if (ts.isIdentifier(callee)) { calleeName = callee.text } else if (ts.isPropertyAccessExpression(callee)) { calleeName = callee.name.text receiver = callee.expression } else if ( ts.isElementAccessExpression(callee) && ts.isStringLiteralLike(unwrap(callee.argumentExpression)) ) { calleeName = unwrap(callee.argumentExpression).text receiver = callee.expression } if (calleeName === undefined) return const isFetchCall = (receiver === undefined && FETCH_CALLEES.has(calleeName)) || (receiver !== undefined && calleeName === "authenticatedFetch") || (receiver !== undefined && calleeName === "fetch" && /^(window|globalThis|self)$/.test(receiver.getText())) if (isFetchCall) inspectFetch(call, record) if (calleeName.startsWith("track")) { for (const argument of call.arguments) { inspectTrackArgument(argument, record, 0) } } if (!isUi) return const first = call.arguments[0] if (first === undefined) return const isLinkCall = (receiver === undefined && LINK_CALLEES.has(calleeName)) || (receiver !== undefined && calleeName === "navigate") || (receiver !== undefined && (calleeName === "assign" || calleeName === "replace") && isLocationText(receiver.getText())) || (receiver !== undefined && calleeName === "open" && /^(window|globalThis|self)$/.test(receiver.getText())) if (isLinkCall) addLinkCandidates(first) } // event_name を持つオブジェクトリテラルはすべて、アナリティクスイベントの発火箇所として読む。 // computed key は型チェッカーで "event_name" に解決できるものを通常のキーと同じに読む。 // track を転送するだけの関数(引数がパラメータ)は発火箇所にならず、呼び出し側のリテラルが拾われる function inspectEventLiteral(objectLiteral, record) { let property for (const p of objectLiteral.properties) { if (!ts.isPropertyAssignment(p) && !ts.isShorthandPropertyAssignment(p)) { continue } const names = keyNames(p.name, checker) if (names === undefined || !names.includes("event_name")) continue if (names.length > 1) { markDynamicEvent(record) return } property = p } if (property === undefined) return record.direct = true const valueNode = ts.isPropertyAssignment(property) ? property.initializer : property.name const literal = unwrap(valueNode) const names = ts.isStringLiteralLike(literal) ? [literal.text] : literalUnionOf(checker.getTypeAtLocation(valueNode)) if (names === undefined || names.length === 0) { markDynamicEvent(record) return } for (const name of names) shared.events.push({ webPath, isUi, name }) } // track 系の呼び出しに渡るオブジェクトは、キーを静的に読めない形(解決できない computed key・ // event_name を運ぶ spread)を見逃さない。パラメータをそのまま渡す転送は呼び出し側で拾う function inspectTrackArgument(argument, record, depth) { if (depth > 4 || ts.isSpreadElement(argument)) return const node = unwrap(argument) const initializer = constInitializerOf(node, checker) if (initializer !== undefined) { inspectTrackArgument(initializer, record, depth + 1) return } if (!ts.isObjectLiteralExpression(node)) return for (const property of node.properties) { if (ts.isSpreadAssignment(property)) { if (isParameterDerived(property.expression, checker)) continue const spreadType = checker.getTypeAtLocation(property.expression) if (isAnyLike(spreadType)) { markDynamicEvent(record) continue } const eventName = spreadType.getProperty("event_name") if (eventName === undefined) continue const names = literalUnionOf( checker.getTypeOfSymbolAtLocation(eventName, property.expression), ) if (names === undefined || names.length === 0) { markDynamicEvent(record) continue } record.direct = true for (const name of names) shared.events.push({ webPath, isUi, name }) } else if ( (ts.isPropertyAssignment(property) || ts.isShorthandPropertyAssignment(property)) && keyNames(property.name, checker) === undefined ) { markDynamicEvent(record) } } } function markDynamicEvent(record) { record.direct = true const key = dynamic-event:${webPath}#${record.name} context.unresolve( key, ${webPath} の ${record.name} の track の event_name を文字列リテラルの union に絞れない(string などに広がっている、または解決できない computed key・spread を含む), 型を union に絞るか、例外 ${key} に理由を書く, ) } // options の method が取り得る値(GET は未指定の既定)。解決できなければ undefined。 // 定数に切り出したオプション・spread・型から読める optional プロパティを辿る function optionMethods(expression, depth) { const info = typeMethodInfo(expression, depth) if (info === undefined) return undefined return info.mayBeAbsent ? [...new Set(["GET", ...info.values])] : info.values } // オブジェクトリテラルの method。method を一度も書かなければ「未指定(mayBeAbsent)」で、既定の GET への // 置き換えは呼び出し側が決める(後続の spread が method を持たないだけで GET に戻してはならない) function literalMethodInfo(node, depth) { if (depth > 6) return undefined // unknown は「後ろの method で上書きされなければ決まらない」状態 let state = { values: [], mayBeAbsent: true, unknown: false } for (const property of node.properties) { if (ts.isSpreadAssignment(property)) { const spread = typeMethodInfo(property.expression, depth + 1) if (spread === undefined) { state = { ...state, unknown: true } } else if (spread.mayBeAbsent) { state = { ...state, values: [...new Set([...state.values, ...spread.values])], } } else { state = { values: spread.values, mayBeAbsent: false, unknown: false } } continue } if ( !ts.isPropertyAssignment(property) && !ts.isShorthandPropertyAssignment(property) ) { return undefined } const names = keyNames(property.name, checker) if (names === undefined) return undefined if (!names.includes("method")) continue if (names.length > 1) return undefined const values = methodPropertyValues(property) if (values === undefined) return undefined state = { values, mayBeAbsent: false, unknown: false } } return state.unknown ? undefined : { values: state.values, mayBeAbsent: state.mayBeAbsent } } function methodPropertyValues(property) { // shorthand の名前位置は代入先プロパティの(広がった)型になるため、値側のシンボルから型を引く const methodNode = ts.isShorthandPropertyAssignment(property) ? property.name : unwrap(property.initializer) if (ts.isStringLiteralLike(methodNode)) return [methodNode.text.toUpperCase()] const valueSymbol = ts.isShorthandPropertyAssignment(property) ? checker.getShorthandAssignmentValueSymbol(property) : undefined const values = literalUnionOf( valueSymbol === undefined ? checker.getTypeAtLocation(methodNode) : checker.getTypeOfSymbolAtLocation(valueSymbol, methodNode), ) if (values === undefined || values.length === 0) return undefined return values.map((value) => value.toUpperCase()) } // オブジェクトを表す式(変数・プロパティ参照・呼び出し結果)の method を、定数の初期化子か型から読む。 // method を持たないと型で確定できるものは「未指定(mayBeAbsent)」として返す function typeMethodInfo(expression, depth) { if (depth > 6) return undefined const node = unwrap(expression) if (ts.isObjectLiteralExpression(node)) { return literalMethodInfo(node, depth + 1) } const initializer = constInitializerOf(node, checker) if (initializer !== undefined) return typeMethodInfo(initializer, depth + 1) if ( node.kind === ts.SyntaxKind.NullKeyword || (ts.isIdentifier(node) && node.text === "undefined") ) { return { values: [], mayBeAbsent: true } } const type = checker.getNonNullableType(checker.getTypeAtLocation(node)) if (isAnyLike(type)) return undefined if (type.isUnion()) { const members = type.types.map((member) => methodInfoOfType(member, node)) if (members.some((member) => member === undefined)) return undefined return { values: [...new Set(members.flatMap((member) => member.values))], mayBeAbsent: members.some((member) => member.mayBeAbsent), } } return methodInfoOfType(type, node) } function methodInfoOfType(type, node) { if (isAnyLike(type)) return undefined // index signature は method を運び得るため、持たないとは確定できない if (checker.getIndexInfosOfType(type).length > 0) return undefined const property = type.getProperty("method") if (property === undefined) return { values: [], mayBeAbsent: true } const result = literalUnionAllowingUndefined( checker.getTypeOfSymbolAtLocation(property, node), ) if (result === undefined) return undefined const optional = (property.flags & ts.SymbolFlags.Optional) !== 0 return { values: [ ...result.values.map((v) => v.toUpperCase()), ...(result.hasUndefined ? ["GET"] : []), ], mayBeAbsent: optional, } } function inspectFetch(call, record) { const [urlArgument, options] = call.arguments if (urlArgument === undefined) return if ( ts.isSpreadElement(urlArgument) || ts.isSpreadElement(options ?? urlArgument) ) { markUnresolvedFetch(record, "引数に spread がある") return } // URL が関数のパラメータをそのまま渡している転送ラッパーは呼び出し箇所ではない(呼び出し側のリテラルが拾われる) const isForwarder = isParameterDerived(urlArgument, checker) if (options === undefined) return const methods = optionMethods(options, 0) if (methods === undefined) { if (!isForwarder) { markUnresolvedFetch(record, "method を文字列リテラルに解決できない") } return } const writeMethods = methods.filter((m) => WRITE_METHOD_NAMES.includes(m)) if (writeMethods.length === 0) return record.direct = true if (isForwarder) { markUnresolvedFetch( record, "URL がパラメータだが method が書き込みに固定されており、呼び出し側を辿れない", ) return } const paths = resolveApiPaths(urlArgument, checker, 0) if (paths === undefined) { markUnresolvedFetch(record, "URL を /api/... の固定パスに正規化できない") return } for (const method of writeMethods) { for (const path of paths) { shared.apis.push({ webPath, key: api:${method} ${path} }) } } } // fetch / authenticatedFetch を呼び出し以外の形(別名への代入・引数渡し・.call)で参照している箇所。 // 呼び出しを静的に辿れないため、API の追加を取りこぼす function inspectFetchReference(node, record) { let name if (ts.isIdentifier(node)) name = node.text else if ( ts.isPropertyAccessExpression(node) && /^(window|globalThis|self)$/.test(node.expression.getText()) ) { name = node.name.text } if (name === undefined || !FETCH_CALLEES.has(name)) return const parent = node.parent if (ts.isPropertyAccessExpression(node) === false) { if ( (ts.isPropertyAccessExpression(parent) && parent.name === node) || ts.isTypeQueryNode(parent) || (ts.isPropertyAssignment(parent) && parent.name === node) || ts.isImportSpecifier(parent) || ts.isExportSpecifier(parent) || ts.isPropertySignature(parent) || ts.isPropertyDeclaration(parent) || ts.isMethodSignature(parent) || ts.isMethodDeclaration(parent) || ((ts.isFunctionDeclaration(parent) || ts.isVariableDeclaration(parent) || ts.isParameter(parent) || ts.isBindingElement(parent)) && parent.name === node) ) { return } if (name === "fetch") { const declaration = declarationOf(node, checker) if ( declaration !== undefined && webRelative(declaration.getSourceFile().fileName) !== undefined ) { return } } } const isCallee = ts.isCallExpression(parent) && unwrap(parent.expression) === node if (isCallee) return markUnresolvedFetch(record, ${name} を呼び出し以外の形で参照している, { direct: false, }) } function markUnresolvedFetch(record, reason, { direct = true } = {}) { if (direct) record.direct = true const key = fetch-unresolved:${webPath}#${record.name} context.unresolve( key, ${webPath} の ${record.name} の fetch を api に正規化できない(${reason}), URL を固定パスにするか、例外 ${key} に理由を書く, ) } function markUnresolvedHc(record, detail) { record.direct = true const key = api-unresolved:${webPath}#${record.name} context.unresolve( key, ${webPath} の ${record.name} の ${detail} を api に正規化できない(hc クライアントの連鎖を静的にたどれない), 連鎖を client.api... の直書きにするか、例外 ${key} に理由を書く, ) } // hc の書き込みメソッドを取り出す式(receiver.$post / receiver["$post"] / 分割代入)の共通処理 function recordHcWrite(methodName, receiver, record) { const method = WRITE_METHODS.get(methodName) if (method === undefined) return const segments = resolveHcSegments(receiver, checker, root, 0) if (segments === undefined || segments[0] !== "api") { markUnresolvedHc(record, ${methodName} 呼び出し) return } record.direct = true shared.apis.push({ webPath, key: api:${method} /${segments.join("/")}, }) } function inspectHcAccess(access, record) { if (ts.isPropertyAccessExpression(access)) { recordHcWrite(access.name.text, access.expression, record) return } const argument = unwrap(access.argumentExpression) const names = ts.isStringLiteralLike(argument) ? [argument.text] : literalUnionOf(checker.getTypeAtLocation(argument)) if (names !== undefined && names.length > 0) { for (const name of names) recordHcWrite(name, access.expression, record) return } // キーが解決できない要素アクセスは、呼び出されていて receiver が hc クライアントなら書き込みを隠し得る。 // パスの区間(projects[id])は呼び出されないので対象外 const isCallee = ts.isCallExpression(access.parent) && unwrap(access.parent.expression) === access if (isCallee && rootsAtHcClient(access.expression, checker, root, 0)) { markUnresolvedHc(record, "要素アクセスによるメソッド呼び出し") } } function inspectHcBinding(element, record) { const pattern = element.parent if (!ts.isObjectBindingPattern(pattern)) return const declaration = pattern.parent if ( !ts.isVariableDeclaration(declaration) || declaration.initializer === undefined ) { return } if (element.dotDotDotToken !== undefined) { if (rootsAtHcClient(declaration.initializer, checker, root, 0)) { markUnresolvedHc(record, "分割代入の rest") } return } const key = element.propertyName ?? element.name const names = keyNames(key, checker) if (names === undefined || names.length === 0) { if (rootsAtHcClient(declaration.initializer, checker, root, 0)) { markUnresolvedHc(record, "分割代入のキー") } return } for (const name of names) { recordHcWrite(name, declaration.initializer, record) } } // リンク先として読める文字列。分岐・const 定数・文字列リテラルの union に解決し、 // 解決できなかった式は opaque に生のソースを残す function linkTargets(expression, depth, out) { const node = unwrap(expression) if (depth > 6) { out.opaque.push(collapse(node.getText())) return } if (ts.isStringLiteralLike(node)) { out.texts.push(node.text) } else if (ts.isTemplateExpression(node)) { let text = node.head.text for (const [index, span] of node.templateSpans.entries()) { const spanExpression = unwrap(span.expression) const isApiBase = index === 0 && text === "" && ((ts.isIdentifier(spanExpression) && spanExpression.text === "apiBase") || (ts.isPropertyAccessExpression(spanExpression) && spanExpression.name.text === "apiBase")) // ${apiBase}/api/... は API の URL で、画面のリンク先ではない text += (isApiBase ? "" : PLACEHOLDER) + span.literal.text } out.texts.push(text) } else if (ts.isConditionalExpression(node)) { linkTargets(node.whenTrue, depth + 1, out) linkTargets(node.whenFalse, depth + 1, out) } else if ( ts.isBinaryExpression(node) && [ts.SyntaxKind.QuestionQuestionToken, ts.SyntaxKind.BarBarToken].includes( node.operatorToken.kind, ) ) { linkTargets(node.left, depth + 1, out) linkTargets(node.right, depth + 1, out) } else if ( ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.AmpersandAmpersandToken ) { linkTargets(node.right, depth + 1, out) } else if (ts.isObjectLiteralExpression(node)) { // react-router の to={{ pathname, search }}。pathname が無ければ同じ route 内の遷移 const pathname = findProperty(node, "pathname") if (pathname !== undefined) linkTargets(pathname.initializer, depth + 1, out) } else if ( ts.isNumericLiteral(node) || ts.isPrefixUnaryExpression(node) || node.kind === ts.SyntaxKind.NullKeyword || node.kind === ts.SyntaxKind.TrueKeyword || node.kind === ts.SyntaxKind.FalseKeyword || (ts.isIdentifier(node) && node.text === "undefined") ) { // navigate(-1) など遷移先の文字列ではない値 } else { const initializer = c
I had 21 Claude agents running in parallel for 37 hours and i managed to reverse engineer tester.army I'm making it available here so that you can save your tokens - 100% free & open-source: tester-army/e2e
tester-army/e2e-iframes: Example repository to demonstrate how you can use e2e to test iframes e.g. for payments Payments are notoriously difficult to end-to-end test iframes inside of iframes, iframe for every field… hell on earth for developers but not for agents! define e2e tests in plain english and agents take care of the rest
Nowa generacja testów AI dla aplikacji webowych i mobilnych! Poznaj e2e — otwartoźródłowy framework do testowania aplikacji. ✅ Łączy sztuczną inteligencję z deterministycznymi testami. 🤖 Obsługuje OpenAI, Claude i dowolne modele AI. 📱 Jedno API do testowania aplikacji webowych, iOS i Androida. GitHub — zobacz projekt.
Records successful agent journeys once, then replays them cheaply until the app changes. tester-army/e2e
REA 말고 뜬 깃허브 이번 주 TOP 5 2026-10-13 기준 깃허브 주간 트렌딩(전체와 언어별 페이지)에서 이번 주 늘어난 별이 많은 개발 도구 다섯 개를 골랐습니다. REA는 따로 다뤘고, AnyPS5(게임 실행 파일 변환)와 개발 도구가 아닌 저장소는 뺐습니다. 이번 주 별: skills +11,139, moli +8,485(러스트 트렌딩), e2e +5,582, hyperframes +4,482, agent-skills +4,175. 누적 별은 skills 28만 8천, flutter 18만. moli의 메모리 수치(중간값 73 MiB vs 크롬 헤드리스 773 MiB, 공개 웹 192곳 중 유용한 페이지 103 vs 101)는 저장소 README의 자체 측정입니다. 이 쇼츠는 실제로 HyperFrames로 렌더링했습니다. 화면은 각 저장소의 깃허브 페이지를 그날 캡처한 것입니다. e2e와 hyperframes는 익명 사용 통계가 기본으로 켜져 있으니 README의 끄는 방법을 확인하세요. 출처 - github.com/trending - trending/rust - trending/javascript - addyosmani/agent-skills - heygen-com/hyperframes - tester-army/e2e - lexmount/moli - mattpocock/skills - flutter/flutter