bendemboski / fractal-page-object

A lightweight page object implementation with a focus on simplicity and extensibility
MIT License
30 stars 9 forks source link

selector cannot select within an open shadowDOM #109

Open sukima opened 1 year ago

sukima commented 1 year ago

The current querySelector/querySelectorAll doesn’t have a syntax to select within a shadowDOM.

Some solutions:

  1. Create a new helper called shadowSelector that knows to use the element.shadowRoot.querySelector(). But we would need to allow the helper to take a second query for scoping.
  2. Create the above helper but force consumers to nest their selectors thus internally using this.element.shadowRoot.querySelector.
  3. Introduce a custom selector like :shadow to allow diving into from the selector helper like: myButton = selector(‘foo-bar::shadow button’); But that means parsing the string before passing it to querySelector.
sukima commented 1 year ago

1 could look like

class FooBar extends PageObject {
  myButton = shadowSelector(‘custom-element’, ‘button’);
}

2 might look like

class FooBar extends PageObject {
  shadow = shadowSelector(‘custom-element’, class extends PageObject {
    theButton = selector(‘button’);
  });

  get myButton() {
    return this.shadow.theButton;
  }
}

3 might look like:

class FooBar extends PageObject {
  myButton = selector(‘custom-element::shadow button’);
}
sukima commented 1 year ago

It is actually not possible to wrap a ShadowRoot with a PageObject at the moment making this a blocker for using fractal for custom elements.

class FooBar extends PageObject {
  get shadow() {
    return new PageObject(this.element.shadowRoot);
  }
}

Demo with QUnit

bendemboski commented 1 year ago

Thanks for the issue! In my mind, I've broken this down into two separate issues:

  1. Allowing PageObjects to wrap DocumentFragments (this covers ShadowRoots because they are DocumentFragments)
  2. Providing a nice declarative API for accessing the shadow DOM

1 is implemented in #110 (which is nice because now PageObjects can be used on DocumentFragments in any other context as well). I haven't touched 2 yet, because I think it requires more thought and maybe experimentation, but I think what I've implemented allows you to implement API-compatible forms of your three options as helpers utilities in your app. If not, let me know!

bendemboski commented 1 year ago

Oh, and have a look at #110 and tell me what you think -- as long as you don't see any glaring issues, I'll merge it and cut a release so you can try it out.

sukima commented 1 year ago

Looks like #110 would satisfy the feature.

If we were to explore options for a more declarative API what are your thoughts on that interface? How do you feel about introducing an unofficial ::shadow selector?

bendemboski commented 1 year ago

I'd definitely like to explore other options first. That would require doing our own parsing of selectors, which is something I'd very much like to avoid.

fractal-page-object kinda tries to wrap itself pretty tightly around native APIs, and CSS selectors, and as far as I know the ::shadow pseudo-selector was an abortive experiment in the ecosystem, so I don't think it's a good idea to introduce it into fractal-page-object. Currently "getting into" the shadow DOM in Javascript requires more than just a single CSS selector/query however you do it, so I think I want to just treat that as a limitation and see what declarative APIs we can build that embrace that.

bendemboski commented 1 year ago

v0.5.0 released with low-level shadow DOM support

sukima commented 10 months ago

Just out of curiosity would an API like this work?

class FooBar extends PageObject {
  somethingWhichHasAShadowRoot = shadowSelector('.something', class extends PageObject {
    somethingWithinTheShadowRoot = selector('.something-inside');
  });
}

Then it would look like page.somethingWhichHasAShadowRoot.somethingWithinTheShadowRoot.element but under the hood shadowSelector performs an extra step to assert this.element and return new PageObject(this.element.shadowRoot)?

Or if that doesn't support lazy evaluation maybe a different ShadowPageObject to extend from that knows to look for this.element.shadowRoot on demand?

class FooBar extends PageObject {
  somethingWhichHasAShadowRoot = selector('.something', class extends ShadowPageObject {
    somethingWithinTheShadowRoot = selector('.something-inside');
  });
}
bendemboski commented 10 months ago

@sukima I've put some thought into this, and need to put some more in, but I think making the shadow DOM vs regular DOM distinction on the page object that is the root of the shadow DOM may not be the way to go. If I want to be able to interact with both the element's shadow DOM and its children/descendants in the regular DOM, I'd have to create two separate page objects, e.g.

class FooBar extends PageObject {
  somethingWhichHasAShadowRoot = shadowSelector('.something', class extends PageObject {
    somethingInTheShadowDOM = selector('.something-shadowy');
  });
  somethingWhichHasAShadowRoot2 = selector('.something', class extends PageObject {
    somethingInTheRegularDOM = selector('.something-not-shadowy');
  });
}

and that seems counter to how we like to model our DOMs with page objects. I think what we probably want it to end up looking like is something more like

class FooBar extends PageObject {
  somethingWhichHasAShadowRoot = selector('.something', class extends PageObject {
    somethingInTheShadowDOM = shadowSelector('.something-shadowy');
    somethingInTheRegularDOM = selector('.something-not-shadowy');
  });
}

where shadowSelector() can be used to select something in the shadow DOM attached to its parent, the same as selector() can be used to select something in the regular DOM "attached to" (i.e. descendants of) its parent.

This forces you to define a page object for the element with the shadow root, which maybe isn't a huge problem, but also might not be ideal. So maybe it would make sense to overload shadowSelector() to either accept a single selector as its argument, or two selectors, and in the two selector case, the first one selects the element with the shadow root and the second one selects within the shadow DOM. So with

class FooBar extends PageObject {
  somethingWhichHasAShadowRoot = selector('.something', class extends PageObject {
    somethingInTheShadowDOM = shadowSelector('.something-shadowy');
  });

  somethingInTheShadowDOM2 = shadowSelector('.something', '.something-shadowy');
}

new FooBar().somethingWhichHasAShadowRoot.somethingInTheShadowDOM.element would be the same as new FooBar().somethingInTheShadowDOM2.element.

I'll have to think some more about the feasibility of implementing an API like this, but hopefully it's doable.

But just from an API design standpoint, what do you think?

sukima commented 8 months ago

@bendemboski Apologies, this dropped below the fold. I think we are on the same page. Some thoughts:

  1. I can't speak for everyone but I'm so used to making sub-PageObjects it is kind of second nature to me. So much so that I tend to name them instead of defining them anonymously inline. I'm OK with this suggestion.
  2. The second option to provide two selectors would also work. Though slightly different from the selector interface since it is a different function the API can change slightly without breaking the mental model. I'm OK with this suggestion.
  3. If it helps, for me, I tend to have the mental model that a shadow DOM is a child of the parent anyway. Least that is how the Web Dev Tools renders them. Thus the idea that shadowSelector drills into the parent's shadow DOM makes sense to me. I understand it is slightly different then the mental model of multiple selectors separated by spaces that nested selector() implies. (foo bar versus foo > bar). I don't know how best to reconcile this with shadow DOMs and perhaps maybe we shouldn't try.

The suggestions you gave would work for my purposes and if you are still comfortable with that proposed interface I would not mind looking deeper into the current source code―no guarantees I'll get something done though 😉