Shadow DOM
automationplaywright
Shadow DOM là cơ chế đóng gói DOM của Web Components — markup bên trong shadow root bị cách ly với document ngoài nên CSS/XPath thường không chạm tới. Điểm mạnh của Playwright: engine css và text mặc định 'pierce' (xuyên) shadow DOM, nên đa số trường hợp bạn thao tác như DOM thường mà không cần trick.
Khái niệm & Bản chất
- Shadow DOM cho phép gắn một cây DOM riêng (shadow tree) vào element host, cách ly style + DOM với trang ngoài — nền tảng của Web Components (
<my-button>), và cũng được browser dùng nội bộ cho<video>,<input type="range">... - Open vs Closed shadow root: open thì JS đọc được qua
el.shadowRoot; closed thìel.shadowRoottrả vềnull— nhưng Playwright vẫn xuyên được cả hai vì nó làm ở tầng rendering, không qua JS API. - Selector CSS thường (querySelector) dừng ở shadow boundary:
document.querySelector('my-comp .inner')không tìm thấy.innerbên trong shadow root. - Playwright khắc phục bằng cách để engine
css/texttự leo qua từng shadow root khi resolve selector — gọi là 'piercing', và nó bật mặc định. - Hệ quả:
page.locator('my-component button')hoạt động dùbuttonnằm sâu 3 tầng shadow root — code test gần như không biết shadow DOM tồn tại.
Cách hoạt động & API chính
- Không cần API riêng:
page.locator('css=...'),getByRole,getByText... đều xuyên shadow root mặc định — đây là khác biệt lớn nhất so với Selenium (phảigetShadowRoot()từng tầng). - Query bằng role/text ổn định hơn css:
page.getByRole('button', { name: 'OK' })tìm trong toàn bộ accessibility tree kể cả shadow DOM. locator.evaluate((el) => el.shadowRoot)khi cần truy cập shadow root trực tiếp để đọc thứ selector không lấy được.- Kết hợp
>>chain:page.locator('outer-comp >> inner-comp >> css=.target')vẫn pierce từng tầng tự động. toHaveText,click,fill... mọi action/assert hoạt động y hệt trên element trong shadow root.
Ví dụ & Cú pháp thực tế
- Thao tác component có shadow DOM như DOM thường:
``
ts // <user-card> chứa shadow root bên trong có <button class="follow"> await page.locator('user-card >> css=button.follow').click(); // hoặc đơn giản hơn nếu text đủ duy nhất: await page.getByRole('button', { name: 'Theo dõi' }).click();`` - Assert text trong shadow root:
``
ts await expect(page.locator('status-badge')).toContainText('Hoạt động'); // Playwright tự pierce để lấy textContent bên trong shadow`` - Lọc component theo nội dung rồi thao tác bên trong:
``
ts const card = page.locator('product-card', { hasText: 'iPhone 16' }); await card.getByRole('button', { name: 'Mua' }).click(); await expect(card.locator('.price')).toHaveText('25.990.000đ');`` - Đọc trực tiếp shadowRoot khi cần:
``
ts const innerHtml = await page.locator('my-widget').evaluate( el => el.shadowRoot?.querySelector('.value')?.textContent );`` - Input trong shadow DOM vẫn fill bình thường:
``
ts await page.locator('custom-input input').fill('0901234567');``
Best practices & Khi nào dùng
- Ưu tiên role/text selector:
getByRolevừa xuyên shadow vừa bền với refactor class/CSS — đúng philosophy của Playwright. - Khi phải dùng css, thêm prefix tag của component (
user-card button) để giới hạn phạm vi, tránh match nhầm element cùng kiểu ở light DOM. - Kiểm tra element có nằm trong shadow root không bằng DevTools: Elements panel hiển thị node
#shadow-root (open)— hoặc chạy$0.getRootNode()xem có phảiShadowRootkhông. - Với component library nội bộ (Lit, Stencil), nên đóng gói selector vào Page Object — thay đổi cấu trúc shadow chỉ sửa 1 chỗ.
- Test closed shadow root: đừng cố evaluate
el.shadowRoot(sẽ null), cứ dùng selector bình thường vì Playwright pierce qua CDP.
Gotchas, So sánh & Lưu ý
- XPath KHÔNG pierce shadow DOM:
page.locator('xpath=//button')dừng ở shadow boundary — một lý do nữa để bỏ XPath sang css/role/text. - Pierce mặc định chỉ áp dụng cho selector của Playwright — nếu
evaluateJS thuần (document.querySelector) thì vẫn bị chặn như thường. - Element trong closed shadow root: selector vẫn tìm được, nhưng screenshot/accessibility đôi khi khác biệt nhỏ; test vẫn chạy ổn trong đa số case.
- So với Selenium/Cypress: Selenium phải
shadowRoot = driver.execute_script('return arguments[0].shadowRoot', el)từng tầng; Cypress cần.shadow(). Playwright làm hộ sẵn — code ngắn hơn đáng kể. has-text/text=cũng pierce —page.locator('text=Thanh toán')match cả text trong shadow tree.- Cẩn thận khi viết selector quá rộng: vì pierce mọi shadow root,
page.locator('button')đếm luôn button ẩn trong component → strict mode violation. Thu hẹp bằnggetByRole(..., { name })hoặc scope vào component cha.
