Wiki

← Tất cả bài wiki

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.shadowRoot trả 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 .inner bên trong shadow root.
  • Playwright khắc phục bằng cách để engine css/text tự 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ù button nằ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ải getShadowRoot() 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: getByRole vừ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ải ShadowRoot khô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 evaluate JS 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ằng getByRole(..., { name }) hoặc scope vào component cha.