playwrightの中身追ってみる

getByRole
ってどう実装してるんだろって気になって、testing-libraryはmugiさんが過去に追っていたのであとでコードだけちゃんと読むとして、playwrightの方も追ってみる

一応playwrightのリンク

page.getByRole()
がこれ
その中で使ってる、frameのgetByRole()
がこれ(frameって何→これ: https://playwright.dev/docs/api/class-frame らしい)
そこで出てきたのが、getByRoleSelector()
と
frameのlocator()
locator()
はLocator
インスタンスを作ってて、getByRole()
したときに最終的に返ってくるやつがこれ
Locator
class自体はここ

getByRoleSelector()
の全貌
getByRoleSelector()
では、internal:role=${role}
の後ろに、roleとともにつけられた色々なオプションをつけて、全部合体した文字列をreturnしている
これがselector
となって、さっき見たようにlocator()
でnew Locator()
される
playwright詳しくないのですが、locator()
って普通にユーザーが使うこともあるので、internal:
みたいなprefixをつけて区別してるっぽいですね、多分

const button = page.getByRole('button', {name: 'Click!', disabled: true}).first()
みたいにして取得したボタンを
await button.click()
してクリックしたり、
button.getByRole('img')
してボタンの中にある画像を取得したり
button.innerHTML()
してHTMLを見たりするわけなので、ここらへんの実装も見ていきます
.first()
selectorに追記してもう1回new Locator()
するらしいです
.click()
frameのclick()
に戻ってます
frameのclick()
を見てみると、_channel.click()
なるものを使ってます。後述
.getByRole()
Locator
の、.locator()
を使ってます
.innerHTML()
frameのinnerHTML()
に戻ってます
frameのinnerHTML()
を見てみると、こちらも_channel
なるものの.innerHTML()
を使っています

そもそもFrame
はChannelOwner
をextendしています
ChannelOwner
はこんな感じ。一番下の_channel
がさっきの_channel
ですね
T extends channels.Channel = channels.Channel
なので、importしてるchannels
を見に行きます
@protocol/channels
は、playwright内の別パッケージにあります
これはおそらく、playwrightのブラウザと通信するための何かです。protocol.yml
でコード内検索かけると、いくつかスクリプトっぽいのがマッチします
なんかいつの間にか敬語になってた

innerHTML
で検索かけてたら、_callOnElementOnceMatches
なる関数に辿り着きました
resolveInjectedForSelector
を追ってみると...
resolveFrameForSelector
に飛びます
_jumpToAriaRefFrameIfNeeded
とかいうそれらしきものを見つけたので、飛んでみます
'aria-ref'
という何かのフラグのようなものがあるので、追ってみます
さっき見たようなinternal:
から始まるものの羅列の一番下に、aria-ref
がありました
しかし、先ほどの_jumpToAriaRefFrameIfNeeded
をよく見てみると、page.lastSnapshotFrameIds
というのがあります
また、_createAriaRefEngine
に飛んでみても、同様にthis._lastAriaSnapshot
を取っています
よってこれはSnapshot testing | Playwright 関連の機能で、今回は関係なさそうです(後述)
しかし、たくさんthis._engines.set
が羅列されている中で、'internal:role'
もあるので一応createRoleEngine(true)
の中身も見てみると...
queryRole
がありました
ついにたどり着きました。どうやらこれが正解のようです
本質的には、
for (const element of root.querySelectorAll('*')) {
match(element);
の部分でmatch(element)
で全探索してマッチする要素を探しているっぽいですね
testing-libraryでは先にroleでquerySelectorAll
してたので、ここがちょっと違う部分です
_createAriaRefEngine
について補足に移ります
ここで'internal:aria-id'
から'aria-ref'
に変わっていて、
'internal:aria-id'
はこのPRで誕生しています
@playwright/experimental-tools
なるパッケージが作られているのですが、特にPRにもコードにも説明が書かれていないので何者なのか分かりません
packages/playwright-tools/src/examples/browser-openai.ts
でAIを使った何かが行われていそうで、@playwright/experimental-tools
からはbrowser
がimportされているので、AIがブラウザを操作できる何かを作っているのかもしれません

終わり

全然終わってなかった
まだできてないことメモ
RoleEngineOptions
の中身読む。selectorとの繋がりが分かってない
多分、resolveFrameForSelector
からselectorを渡して、繋がる

this._engines.set()
がInjectedScript
のconstructor
で実行されていたので、resolveFrameForSelector
の、const injectedScript = await context.injectedScript();
に着目
FrameExecutionContext
のinjectedScript
に辿り着き、source
にnew (module.exports.InjectedScript())
を含んでいることが分かります。おそらくここでInjectedScript
を作っているのではないでしょうか

今度こそ終わり

終わってなかった
roleやaccessible name、aria stateなどのオプションをどのようにマッチさせてるのかを見る
roleからrole="なんとか"
が付与されていれば、getExplicitAriaRole()
で取得
そうでなければ、getImplicitAriaRole()
でタグ名からkImplicitRoleByTagName
のマッピングで取ってきたり、presentation roleのconflictなるものの処理をしたりしている。presentation roleのconflictが何かは今度調べてみるけど、コード中にリンクも貼られている
aria stateも大体同じ。selectedで見てみる。タグ名から、何か該当する属性があればそれを確認。今回なら<option>
要素のときに、selected
属性を確認することができる
なければ、aria-selected
をつけてOKなrole(kAriaSelectedRoles
で挙げられているrole)のときのみ、aria-selected
を確認している

accsessible name
nameFromがprohibitedでないroleのときのみ、getTextAlternativeInternal
で計算
getTextAlternativeInternal
はめっちゃ長い
step 2aとかstep 2bとか書いてあって、それぞれの説明文からも、accname1.2に沿っているようです。これはtesting-libraryで使われていたdom-accessibility-api
と同じですね
accname 1.2についてはこちら

今度こそ終わり

aria-queryとかdom-accessibility-apiは最終更新がそこそこ前だから、playwrightはそこらへんも考慮して自前実装してるのかなとか思ったりした

気になって調べたのでメモ
snapshot testのsnapshotはここで作ってそう