Gestire lo Shadow DOM nei test automatizzati del browser
Lo Shadow DOM offre un vero incapsulamento per i componenti web. Gli stili non entrano dall’esterno, i selettori non escono e i dettagli interni dei componenti restano privati. Grandi sistemi di design — il framework Katal di Amazon, Salesforce Lightning Web Components, Material Web Components — usano ampiamente lo Shadow DOM proprio perché questo incapsulamento rende i componenti prevedibili e portabili.
Il problema è che lo stesso incapsulamento che rende lo Shadow DOM utile in produzione crea notevoli difficoltà nei test automatizzati. La maggior parte dei framework di automazione, tra cui Selenium, Cypress e in parte Playwright, è stata costruita attorno a document.querySelector, mentre lo Shadow DOM è progettato appositamente per essere invisibile a document.querySelector.
Perché lo Shadow DOM mette in difficoltà l’automazione
Il problema principale è il confine dello Shadow DOM. Quando un componente esegue il rendering in una shadow root, il suo sottoalbero DOM risiede in un frammento di documento separato che le normali query DOM non possono attraversare. Una chiamata come document.querySelector(‘.checkout-button’) non trova un elemento con quella classe se si trova in una shadow root, anche se è visibile nella pagina e pienamente interattivo per un utente umano.
Non è un bug del browser: la specifica funziona come previsto. L’incapsulamento dello Shadow DOM impedisce ai selettori CSS e alle query DOM di accedere dall’esterno all’interno dei componenti; JavaScript esterno può accedervi solo tramite un riferimento esplicito, come la proprietà shadowRoot di una root aperta. I metodi querySelector disponibili su document o sui normali elementi DOM si fermano ai confini dello Shadow DOM. Considera questa struttura:
<!-- Light DOM -->
<katal-button>
#shadow-root
<button class="kds-button">Add to cart</button>
</katal-button>
Un selettore come document.querySelector(‘katal-button .kds-button’) restituisce null. L’elemento esiste nel DOM, ma la query non può raggiungerlo. I framework di automazione che si basano su un’unica stringa di selezione dalla radice del documento falliscono senza segnalazioni in questo caso — l’elemento non manca, è incapsulato.
Shadow root aperte e chiuse
Le shadow root hanno due modalità: open e closed. Questa distinzione incide notevolmente sugli approcci di automazione disponibili.
Una shadow root aperta espone i propri elementi interni tramite la proprietà shadowRoot dell’elemento host:
const host = document.querySelector('katal-button');
const button = host.shadowRoot.querySelector('.kds-button');
Una shadow root chiusa restituisce null per element.shadowRoot. Alcuni sistemi di componenti aziendali usano shadow root chiuse in produzione per impedire al codice esterno di dipendere dai dettagli di implementazione interni. Le root chiuse esistono proprio per bloccare il modello precedente.
Nella pratica, la maggior parte dei tecnici dell’automazione incontra più spesso shadow root aperte. Anche gli elementi nativi del browser come <input type="date"> e <video> renderizzano i propri controlli in alberi shadow, ma queste root dello user agent non sono esposte né agli script della pagina né ai locator di Playwright. Le root chiuse compaiono principalmente nei sistemi di design aziendali e richiedono un approccio radicalmente diverso, che affronteremo separatamente.
Attraversamento manuale con shadowRoot.querySelector
Per le shadow root aperte, l’approccio più semplice è l’attraversamento esplicito. Invece di usare un solo selettore dalla radice del documento, attraversi i confini dello Shadow DOM per passaggi. In Playwright, il metodo evaluate esegue JavaScript nel contesto della pagina e può attraversare direttamente le shadow root:
// Doesn't work — evaluate can't return DOM elements by value
const button = await page.evaluate(() => {
const host = document.querySelector('katal-button');
return host?.shadowRoot?.querySelector('.kds-button');
});
// Works — evaluateHandle returns a handle to the live element
const buttonHandle = await page.evaluateHandle(() => {
const host = document.querySelector('katal-button');
return host?.shadowRoot?.querySelector('.kds-button');
});
await buttonHandle.asElement()?.click();
Qui è importante distinguere tra evaluate ed evaluateHandle. evaluate serializza il valore restituito nel passaggio tra browser e Node.js — i nodi DOM non sono serializzabili, quindi ottieni undefined anziché l’elemento. evaluateHandle restituisce un oggetto handle che mantiene attivo il riferimento nel processo del browser, consentendo successive chiamate di interazione.
In realtà, però, questo modello è fragile. Un aggiornamento del componente che renderizza nuovamente o sostituisce il nodo interno lascia l’handle a puntare a un elemento scollegato dal DOM. Con annidamenti profondi — una shadow root dentro un’altra, a sua volta dentro un’altra — il codice di attraversamento diventa prolisso e fragile. Esiste un approccio migliore.
Il selettore pierce
I locator CSS di Playwright attraversano le shadow root aperte per impostazione predefinita, quindi page.locator('katal-button .kds-button') raggiunge già il pulsante. Il token >> concatena i selettori, eseguendo ogni query rispetto alla corrispondenza precedente e rendendo esplicita la struttura che va dall’host ai suoi elementi interni:
// Pierce through the shadow root of katal-button
const button = page.locator('katal-button >> .kds-button');
await button.click();
Con >>, la parte sinistra individua l’elemento host e quella destra esegue la query rispetto a esso; poiché il motore CSS di Playwright attraversa le shadow root aperte, questo include la shadow root dell’host e le eventuali root aperte annidate al suo interno. Per annidamenti più profondi si possono concatenare i selettori:
// Three levels deep
const input = page.locator('my-form >> my-field >> input[type="text"]');
Questo approccio è molto più chiaro dell’attraversamento manuale e resiste meglio agli aggiornamenti strutturali dei componenti rispetto ai modelli con evaluateHandle. La logica di nuovo tentativo integrata in Playwright fa sì che il locator riprovi a individuare l’elemento finché non compare, gestendo automaticamente il rendering asincrono.
I locator semantici di Playwright — getByRole, getByLabel, getByText — attraversano anch’essi le shadow root aperte; in Playwright lo fanno tutti i tipi di locator tranne XPath. Per i componenti conformi ad ARIA, questa è l’opzione più facile da mantenere, perché verifica il comportamento anziché la struttura:
// Works if the shadow DOM button has accessible role and text
await page.getByRole('button', { name: 'Add to cart' }).click();
Il limite dei selettori pierce è che funzionano solo con shadow root aperte. Quelle chiuse non sono supportate dai locator di Playwright.
Attendere la creazione e il popolamento delle shadow root
Lo Shadow DOM introduce un problema di tempistica che raramente emerge nei test del light DOM. Quando un elemento personalizzato viene inizializzato e associa una shadow root, si verificano due eventi asincroni distinti: la comparsa dell’elemento nel DOM e il popolamento della shadow root con il contenuto. Attendere l’elemento host non garantisce che il contenuto della shadow root sia pronto.
// Unreliable — waits for the host, not the shadow content
await page.waitForSelector('katal-button');
// This may still fail immediately after
await page.locator('katal-button >> .kds-button').click();
L’approccio corretto attende direttamente il contenuto della shadow root. Poiché i locator di Playwright riprovano automaticamente, locator.click() gestisce già questo caso nella maggior parte delle situazioni — ma, per componenti basati sui dati che recuperano la configurazione prima del rendering, il timeout dei tentativi può scadere prima che il contenuto compaia. Un’attesa esplicita con un selettore significativo è più affidabile:
// Wait for a specific shadow DOM element to become visible
await page.locator('katal-button >> .kds-button').waitFor({ state: 'visible' });
await page.locator('katal-button >> .kds-button').click();
// For async-loaded components, wait for a data-ready indicator
await page.locator('katal-product-card >> [data-loaded="true"]').waitFor();
La chiamata esplicita a waitFor produce anche messaggi di errore più informativi. Un timeout su waitForSelector(‘katal-button’) indica che l’host non è stato trovato; un timeout su waitFor({ state: ‘visible’ }) applicato al locator pierce indica invece che non era pronto il contenuto della shadow root. La distinzione conta nella diagnosi dei test con esiti intermittenti.
Propagazione degli eventi attraverso le shadow root
La gestione degli eventi attraverso i confini dello Shadow DOM presenta alcune particolarità che influiscono sui test di interazione. Gli eventi dell’interfaccia utente emessi dal browser, come quelli di clic, input e tastiera, sono composti (composed: true), quindi attraversano questi confini e risalgono fino al documento esterno. La maggior parte degli altri eventi, compresi quelli sintetici creati senza composed: true, non lo è.
La proprietà target, però, viene riassegnata. Un listener sull’elemento host vede come target l’host stesso, non l’elemento interno dello Shadow DOM da cui è partito l’evento. Questo conta se i test verificano i target degli eventi o se il codice applicativo usa event.target per identificare l’elemento con cui è avvenuta l’interazione:
host.addEventListener('click', (e) => {
console.log(e.target); // logs the host element, not the internal button
console.log(e.composedPath()); // logs the full path including shadow internals
});
Per le shadow root aperte, event.composedPath() è il modo affidabile per esaminare che cosa sia realmente accaduto durante l’interazione; per quelle chiuse, i listener esterni all’host vedono il percorso fermarsi all’host. Nei test che verificano il comportamento di emissione degli eventi, controllare composedPath() anziché target restituisce il quadro corretto.
Gli eventi personalizzati sono più problematici. Per impostazione predefinita hanno composed: false, quindi non attraversano affatto i confini dello Shadow DOM. Un componente che emette un evento personalizzato interno senza impostare esplicitamente composed: true non sarà osservabile dall’esterno dell’host:
// Inside the component — this event will NOT reach outside listeners
this.shadowRoot.dispatchEvent(new CustomEvent('katal-select', {
bubbles: true,
composed: false, // default — event stays inside shadow root
detail: { value: selectedItem },
}));
// Outside the component — this listener never fires
host.addEventListener('katal-select', handler); // never called
Quando un componente non espone gli eventi al light DOM, verificarne l’emissione dall’esterno è impossibile con gli strumenti standard. Le opzioni pratiche sono usare page.evaluate per aggiungere un osservatore all’interno della shadow root prima dell’azione, oppure verificare il cambiamento di stato osservabile che dovrebbe derivare dall’evento, anziché l’evento stesso. Quest’ultima scelta è di solito più significativa — verificare che una selezione si rifletta negli attributi pubblici del componente è più resistente ai cambiamenti che verificare l’emissione di un evento.
Che cosa richiedono le shadow root chiuse
Per le shadow root chiuse, nessuno degli approcci basati su selettori funziona. La proprietà shadowRoot restituisce null, i selettori pierce non possono attraversare un confine chiuso e l’attraversamento con evaluateHandle fallisce nello stesso punto. Le alternative sono sostanzialmente diverse.
Usa l’API pubblica. I componenti web ben progettati espongono attributi, proprietà o slot che ne controllano lo stato senza richiedere accesso interno. Se un componente supporta aria-checked, fare clic e verificare l’attributo ARIA è adatto all’automazione e semanticamente corretto. Testare ciò che il componente espone è più facile da mantenere che testare ciò che nasconde.
Esegui test a livello di componente. I componenti Shadow DOM possono spesso essere testati isolatamente con strumenti come @web/test-runner o le utilità di test di Lit, che offrono un ambiente controllato per testare direttamente il componente tramite la sua API. Non aprono da soli una root chiusa, ma rendono semplice testare l’API pubblica in isolamento. I test end-to-end a livello di pagina dovrebbero verificare il comportamento dell’integrazione; quelli a livello di componente possono verificare la correttezza interna.
Confrontati con l’autore del componente. Le shadow root chiuse sono una scelta deliberata. Se una libreria di componenti blocca una legittima automazione dei test senza offrire interfacce pubbliche verificabili, è un problema di qualità fondato da segnalare. La modalità aperta, con una documentazione chiara degli elementi interni stabili, è un compromesso ragionevole.
Centralizzare i locator dello Shadow DOM
I modelli affidabili nelle suite di test in produzione evitano attraversamenti ingegnosi scritti direttamente nei test, preferendo definizioni esplicite e centralizzate dei locator. Un modulo di utilità che racchiude i percorsi pierce comuni mantiene leggibili i test e circoscrive l’impatto degli aggiornamenti dei componenti:
// helpers/katal.ts
import type { Page } from '@playwright/test';
export const katalButton = (page: Page, text: string) =>
page.locator('katal-button >> button').filter({ hasText: text });
export const katalSelect = (page: Page, label: string) =>
page.locator(`katal-select[label="${label}"] >> select`);
export const katalInput = (page: Page, name: string) =>
page.locator(`katal-input[name="${name}"] >> input`);
// In tests
await katalButton(page, 'Add to cart').click();
await katalInput(page, 'email').fill('[email protected]');
Quando Katal aggiorna la struttura interna, modifichi una sola funzione anziché cercare in ogni file di test. È lo stesso principio che rende utili i Page Object Model, applicato specificamente al livello di incapsulamento introdotto dallo Shadow DOM. Il confine del componente diventa il confine dell’astrazione per le utilità di test.
Il problema di fondo
La difficoltà di automatizzare lo Shadow DOM indica che gli strumenti di test non si sono ancora pienamente adeguati al modello a componenti. I framework costruiti attorno all’accesso globale tramite querySelector presuppongono un DOM piatto e accessibile — l’opposto di ciò che lo Shadow DOM è progettato per offrire.
In realtà, l’incapsulamento è corretto. I componenti che espongono solo l’interfaccia pubblica e nascondono i dettagli di implementazione sono più facili da mantenere, aggiornare e combinare. Le difficoltà nei test riflettono una lacuna nel modo in cui gli strumenti concepiscono l’accesso al DOM, non un difetto dello Shadow DOM stesso. I locator di Playwright che attraversano lo Shadow DOM e i locator semantici rappresentano un progresso verso un modello di test che lavora con l’incapsulamento.
La conclusione pratica: usa selettori pierce per le shadow root aperte, verifica lo stato osservabile anziché la struttura interna per quelle chiuse e centralizza i locator che attraversano lo Shadow DOM, così che gli aggiornamenti dei componenti richiedano modifiche in un solo punto invece che nell’intera suite. Lo Shadow DOM è destinato a restare — gli approcci di automazione devono tenerne conto.
Approfondimenti correlati
- Automazione del browser su larga scala senza blocchi — eseguire Playwright in modo affidabile sui siti in produzione