axe-core icon indicating copy to clipboard operation
axe-core copied to clipboard

[Feature request] @axe-core/playwright relative path selectors for nested shadow dom tree

Open marinamelin opened this issue 2 years ago • 6 comments

Product: @axe-core/playwright 4.6.0

We started using the support for shadow dom selectors. It is a great feature that we were looking forward to. We have a feature request how to make it even better.

Our shadow dom tree example: element path

Expectation: We would like to be able to use relative path selectors even for a nested tree. Such as:

axeBuilder.include([['sports-card']]);

Actual: It seems that a full node path must be specified to test a shadow tree node. Only this worked for our case:

axeBuilder.include([['edge-chromium-page', 'grid-view-feed', 'cs-super-container', 'cs-personalized-feed', 'cs-feed-layout', 'fluent-card[style="grid-area:slot4;contain:unset;content-visibility:visible"]']]);

Motivation: Tests start to fail if shadow dom tree structure changes on the page. Using absolute path selectors makes test owners to do extra work every time the page is re-arranged. It would save a lot of test maintenance time to use relative path selectors. Requesting to add support for relative path selectors.

marinamelin avatar Feb 06 '23 18:02 marinamelin

Thanks for the suggestion. When you say relative path selectors, what is it relative to? Axe runs on the entire page so everything is relative to the root document.

straker avatar Feb 09 '23 15:02 straker

Thanks for getting back to us!

I provided the shadow dom tree above. In our case it has multiple nested layers.

When we call axeBuilder.include([['sports-card']]); the result is Error: No elements found for include in page Context

We have to provide every level of the shadow dom tree in the selector to be able to run a test for this component. Only this worked:

axeBuilder.include([['edge-chromium-page', 'grid-view-feed', 'cs-super-container', 'cs-personalized-feed', 'cs-feed-layout', 'fluent-card[style="grid-area:slot4;contain:unset;content-visibility:visible"]']]);

Extra test maintenance is required if any of the element selectors in the tree above changed. This feature request asks to make selectors from deeper levels of the shadow dom tree not cause an Error: No elements found for include in page Context.

marinamelin avatar Feb 09 '23 16:02 marinamelin

I'm going to move this over to axe-core since it controls the selectors.

straker avatar Feb 10 '23 14:02 straker

Hi @straker , is there any update on this item? Do you have a link to the transferred one? Thanks!

marinamelin avatar Mar 18 '25 13:03 marinamelin

@marinamelin This ticket currently is in the axe-core repo (https://github.com/dequelabs/axe-core/issues/3909). No update on the request unfortunately.

straker avatar Mar 19 '25 14:03 straker

@straker The solution here is to build a dynamic path to the target element that is inclusive of all shadow root parents. I have created a workaround that accomplishes this by building the selector array on the client using Playwright’s page.evaluate(). Here’s an example of how it works:

test('Demo test', async ({ page }) => {
    // Go to a page with a dynamically-added, deeply nested shadow root
    // Example: https://codepen.io/aarongustafson/pen/emmZEdd (view in Debug mode)
    await page.goto('https://domain.tld/');

    // If testing with the example, use "element-5"
    const deep_shadow_selector = "my-custom-element";

    // Code that finds the dynamic path to the selector
    const dynamic_selector_array = await page.evaluate((selector) => {

        // Find the target of the selector, starting at the document level
        function querySelectorAllShadowRoots(node, selector) {
          const nodes = [...node.querySelectorAll(selector)];
          const nodeIterator = document.createNodeIterator(
            node,
            NodeFilter.SHOW_ELEMENT,
            (node) => (node.shadowRoot ?
              NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT)
          );
          let currentNode = nodeIterator.nextNode();
          while (currentNode) {
            nodes.push(
              ...querySelectorAllShadowRoots(
                currentNode.shadowRoot, selector
              )
            );
            currentNode = nodeIterator.nextNode();
          }
          return nodes;
        }

        // Build the selector array axe-core needs for `include()`
        function getShadowSelectorArray( $el ){
          let selector_array = [$el.nodeName.toLowerCase()];
          let $curr_root = $el.getRootNode();
          while ($curr_root instanceof ShadowRoot) { 
            $el = $curr_root.host;
            selector_array.unshift( $el.nodeName.toLowerCase() );
            $curr_root = $el.getRootNode();
          }
          return selector_array;
        }

        // find the element
        const $el = querySelectorAllShadowRoots(document, selector)[0];

        // resolve the promise with the selector array to reach it
        return Promise.resolve(
            getShadowSelectorArray( $el )
        );
    }, deep_shadow_selector);

    const containerResults = await new AxeBuilder({ page })
        .include({ fromShadowDom: dynamic_selector_array })  // Scope the analysis to this selector
        .analyze();
});

Poking around a bit more in the source code of both axe and @axe-core/playwright, it looks like there is a getShadowSelector() utility but it’s unclear whether that would accomplish the same thing or how to use it from within @axe-core/playwright. It’s implemented as axeShadowSelect() but I don’t see that code being referenced elsewhere in @axe-core/playwright, nor does it appear to be exported in the final distribution.

I think we can solve this and I’m happy to put together a PR if someone can provide a bit of direction about how y’all would like it to be handled. It probably makes the most sense to resolve the path to the target element if fromShadowDom is passed to either include() or exclude() — allowing you to supply the name of the target element only and figuring out the dynamic path behind the scenes — but I don’t want to go down the wrong path.

aarongustafson avatar May 06 '25 18:05 aarongustafson

I don't think we'd be able to change axe's default selector resolution behavior outside of a breaking change release, but I do see the utility in it and I think it'd be good for us to consider options for allowing this behavior that avoid a breaking change in the short term.

I think it'd be possible to add support for this in a non-breaking way by doing something similar to how we added fromShadowDom and fromFrames support to axe's context parameter, where we add some new type of syntax that represents opting-in to asking for a shadow-relative selector. I could imagine something like these:

await axe.run([
  // Option 1: inspired by common glob syntaxes
  { fromShadowDom: ['**', 'custom-element-name'] },
  // Option 2: more verbose, but maybe less magic-looking and more obvious how
  // you'd do "opt-out" if we ever made relative the default in a future major release
  { fromShadowDom: ['custom-element-name'], absolute: false },
], options);

We could then later/separately consider if axe-core's default behavior should change to this relative form whenever we do a 5.0 release.

I think we can solve this and I’m happy to put together a PR if someone can provide a bit of direction about how y’all would like it to be handled.

@aarongustafson Thanks for this offer! @straker and @WilcoFiers , how would you feel about an opt-in form of this behavior (either one of the options I suggested above or something similar) as a contribution?

dbjorge avatar Jun 23 '25 03:06 dbjorge

I don't think it's a bad idea, but I'm not sure how it would be implemented. When we create the Context object we use the node array to traverse the DOM to the specific element(s) to include. If that node array doesn't have a direct path to the desired node, I'm not sure how we would try to handle finding the desired node to include. Would we have to get every element on the page, check to see if it has a shadowRoot, and if so then traverse all those nodes and their shadowRoots recursively until we find the desired node(s)? We can't short circuit either (we can't stop at the first node found) so we'd have to traverse every shadow and nested shadow root.

straker avatar Jun 24 '25 14:06 straker

Yeah, I think you'd probably have to walk the whole tree at least once - but axe already has to do that anyway for stuff like grid formation, I don't think that's necessarily a blocker.

dbjorge avatar Jun 24 '25 20:06 dbjorge

When I was re-reading this, using fromShadowDom also came to my mind. I think the same could / should apply to frames. I don't think we'd want to do this for shadow DOM but not for frames. The same concept applies here.

I'd lean more towards a new option, similar to fromShadowDom / fromFrame, rather than a boolean flag. Something like this:

// Matches any button in shadowDOM trees, but only in the top window
await axe.run({ crossShadowDom: 'button' })

// Matches any button in any frame, but only in the light DOM
await axe.run({ crossFrame: 'button' })

// Matches any button in any frame in both light and shadow DOM trees
await axe.run({ crossTree: 'button' })

// The array would then allow matching across trees, irrespective of if that's a frame
// or a shadow DOM tree:
await axe.run({ crossTree: ['iframe', 'shadow-host', 'button']})

WilcoFiers avatar Jun 25 '25 10:06 WilcoFiers

I'd lean more towards a new option, similar to fromShadowDom / fromFrame, rather than a boolean flag. Something like this:

I'm fine with using a new option name and agree that it makes sense to keep shadow/frame handling consistent. I'm a bit unclear on the exact semantics you're proposing for the array form of crossTree though (and we should probably specify what we'd expect for array forms of crossShadowDom/crossFrame, if any). Is your intent that there can be any combination of frame/shadow boundaries in between any two elements of the crossTree array? ie, if we have:

<title>page a</title>
<iframe id="frame-a-b" src="page-b">
  #document
    <custom-iframe-container>
      #document
        <iframe id="frame-b-c" src="page-c">
          #document
            <shadow-host id="host-a">
              #document
                <button id="btn-1" />
                <other-custom-elm>
                  #document
                    <button id="btn-2" />
                    <shadow-host id="host-b">
                      #document
                        <button id="btn-3" />
                        <iframe id="frame-c-d" src="page-d">
                          #document
                            <button id="btn-4" />
                        </iframe>
                    </shadow-host>
                </other-custom-elm>
            </shadow-host>
        </iframe>
    </custom-iframe-container>
</iframe>

...would you expect that { crossTree: ['#frame-a-b', 'shadow-host', 'button'] } would match all 4 of the buttons? Would there be array forms of crossShadowDom and crossFrame that would be similar (ie, crossShadowDom would allow 0-to-many shadow boundaries in between any two array elements but wouldn't allow frame boundaries)?

Personally, I'd be ok with starting simpler with only supporting a single string arg for the new formats and deferring array forms for separate work.

dbjorge avatar Jun 25 '25 18:06 dbjorge

Yes, I would expect { crossTree: ['iframe', 'shadow-host', 'button']} to match all 4 buttons in your example. For including I'm not sure that's very useful, but to have some cross-tree way of excluding specific elements seems like it would be helpful.

The crossFrame and crossShadowDom options should also allow arrays. These crossX array selectors should basically just mean "I don't care what tree it's in". I think these should match even if elements are in the same tree. So { crossTree: ['main', 'h1'] } would match all of these:

<main> <h1> </h1> </main>
<main> <iframe srcdoc="<h1></h1>"></iframe> </main>
<iframe srcdoc="<main> <h1></h1> </main>"></iframe>
<main> <template shadowrootmode="open"> <h1></h1> </template> </main>
<main> <template shadowrootmode="open"> 
     <iframe srcdoc="<h1></h1>"></iframe> 
 </template> </main>
<iframe srcdoc=" <main> <template shadowrootmode='open'> <h1></h1> </template> </main> "></iframe> 

I don't think supporting arrays is significantly more difficult than supporting single-string selectors, so I wouldn't break that up. Considering how infrequent things like this get done I'd suggest we do the whole thing in one go (although probably it should be done in separate PRs).

WilcoFiers avatar Jun 26 '25 09:06 WilcoFiers