-
Notifications
You must be signed in to change notification settings - Fork 90
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Enable page object composition via stored definitions #430
Enable page object composition via stored definitions #430
Conversation
A side effect of this PR solves a bug in the current implementation which prevents deep merging when collections are present. A test as simple as this will fail: test('returns an item', async function(assert) {
let definition = {
foo: collection('span', {
text: text()
})
};
let extendedDefinition = $.extend(true, {}, definition);
let page = create(extendedDefinition);
await this.adapter.createTemplate(this, page, `
<span>Lorem</span>
<span>Ipsum</span>
`);
assert.equal(page.foo.objectAt(0).text, 'Lorem');
}); with the error message because of the assertion:
refering to this block of Ceibo code: function buildDescriptor(node, blueprintKey, descriptor /*, descriptorBuilder*/) {
if (typeof descriptor.setup === 'function') {
descriptor.setup(node, blueprintKey);
}
if (descriptor.value) {
defineProperty(node, blueprintKey, descriptor.value);
} else {
defineProperty(node, blueprintKey, undefined, function() {
return descriptor.get.call(this, blueprintKey);
});
}
} The else path is being taken because the passed descriptor doesn't have a export function collection(scope, definition) {
let descriptor = {
isDescriptor: true,
setup(node, key) {
// Set the value on the descriptor so that it will be picked up and applied by Ceibo.
// This does mutate the descriptor, but because `setup` is always called before the
// value is assigned we are guaranteed to get a new, unique Collection instance each time.
descriptor.value = new Collection(scope, definition, node, key);
if (window.Proxy) {
descriptor.value = proxyIt(descriptor.value);
}
}
};
return descriptor;
} As you can see, this definition.foo !== extendedDefinition.foo
definition.foo.setup === extendedDefinition.foo.setup
//imagining you could access the closure scope of a function directly
extendedDefinition.foo.setup.closureScope.descriptor === definition.foo So when we invoke the I've fixed that here by always recreating collections within a call to |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
First of all, thank you for taking your time to explore this area!
I think there are actually 2 things you are trying to address in the PR:
- Fixing
create
behavior when dealing with instantiated page objects - Introducing new
extend
API
I'd like to keep it in separate PRs in order to better understand scope of the changes.
However, let me share my general thoughts regarding these topics.
create
It took me some time to get used to live with a single top level create in my codebases. I just forgot the issue exists at all 😆 That's why persisting of original definition in order to recreate page objects on demand made me feel uncomfortable for a moment. However I realize, it may be the only straightforward approach to the issue.
There are few observations regarding the current implementation:
- Since the need to store definitions comes from getter properties it might make sense store only them rather than the whole definition.
- As far as I understand we currently only store definition at the top level page object passed to create.
I suspect the following scenario can break:
const a = create({
b: {
scope: '.b'
}
});
create({
b: a.b
})
If that's true we should probably integrate a definition persistence trick with ceibo builder methods(buildObject, buildChainObject) to be recursion aware.
extend
Being honest, I don't think this is a good feature to have(but I can be wrong!).
If think in classes, I associate definitions with classes and page objects with objects created from a class with a new
operator:
class A {} // or even EmberObject.extend({})
const a = new A(); // or A.create();
a.extend() // ???
I think extend
on JS object instances doesn't make sense. But that might be just a naming issue(merge, override,... as a possible alternative?).
I don't actually know how does $.extend
work, but assuming it does a deep merge, I'm in doubt if we really want a deep merge. It just feels too complex to me. Again, if thinking in classes deepMerge even doesn't have an analogy.
I'll think about it more and comment in the ticket you've linked to in the PR.
addon-test-support/create.js
Outdated
// is mutated with a `value` property. Simply storing a converted definition | ||
// and always reinvoking the `collection` function before `create` is called | ||
// avoids unintended shared state | ||
function recreateCollections(definition){ |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
is this only needed because of setup
issue you've mentioned in the comment #430 (comment) ?
If so, I think we might have a possible fix for that #406
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes, I just validated that if this PR was merged, I would no longer need to reinitialize all collections. Anything in particular holding this up from merging?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks for confirming!
Well, my original intention to keep it un-merged was not to pollute v1.15(which is already quite full) with non-critical, but potentially harmful refactorings Unfortunatelly cutting a stable release of v1.15 hanged longer than I expected.
I think since there are no any user reports about any issues related to the current collection setup
we can leave it un-merged some more time...
Meanwhile I think you can cherry-pick it to your branch to avoid the issue you have faced.
opts = assign({}, Ceibo.meta(opts).pageObjectDefinition); | ||
} | ||
let overrides = convertPageObjectPropsToDefinitions(assign({}, opts)); | ||
return $.extend(true, {}, Ceibo.meta(pageObject).pageObjectDefinition, overrides); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think in a long run we aim to get rid of jquery. I'd like not to introduce new jquery usages if possible
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I could rely on assign
for shallow merging. For deep merge without jquery, an out of the box solution that's tiny is deepmerge. Alternatively, hand rolling my own isn't that complex, it just requires specially handling of what object
types are mergeable and a choice on how to handle arrays
I thought about using
Would a user think it was necessary to call
What @san650 suggests here is what I have used in a number of projects. The approach certainly works but feels like a workaround at best. I considered originally something along the lines of
Look at my example. In such an example, being able to insert/override certain properties (ie deep merge) is crucial to avoid repeating myself averywhere. Ie, without the facility to deep merge, the
where I target a specific instance of the I realized now, though, that the extend function should take the deep merge as an option in the same way that |
I cannot realistically open both simultaneously since both of these PR's require a mechanism for navigating a page object without firing the getters. If you feel it necessary, I can redact the I chose storing the definition since I didn't see any drawbacks and it seemed easiest. To your point
wouldn't work due to the Now that I think about it, storing the definition may not even be necessary. Since all page object's have a root node that isn't getter based, a recursive descent using
If we did store the definition per property, we'd avoid the sharing of the |
I've just commented in the ticket with my objections to deep merge and shared an alternative approach to the problem. I believe it should address most of the concerns and might mitigate the immediate need for
I actually love having defs as POJOs 😊 It's very simple and flexible to me. |
Are you and @san650 and the rest of the maintainers opposed to the idea of composition altogether? As far as I can tell, this PR is fundamentally exactly the same approach as storing / exporting both the definition and the page objects, but absolves the user from that kind of overheard. You can continue to use your pojo composition approach, while anyone who wants just page objects can compose them in a way they would have always expected to work out of the box. Supporting page objects within definitions seems like a clear win for everybody. And maybe the value of export function merge(){
let sanitizedArgs = ([...arguments]).map((opt) => {
if(isPageObject(opt)){
return assign({}, Ceibo.meta(opts).pageObjectDefinition);
}else{
return convertPageObjectPropsToDefinitions(assign({}, opt)
}
})
return create(Ember.assign({}, ...sanitizedArgs);
} is the best approach? Assuming we can move forward this, I will:
|
Of course no. I believe we just have some disagreement on a topic of page object instances extensibility.
Agree! I think the fact that we currently can't pass a page object instance to the
TBH that's still true to me. I think I understand your need of a built-in capability for extension, however I still think page object instance is not a good place to be extended/merged. I'd prefer to keep this feature separate from the
I'd like to keep the 3rd bullet point separate. Maybe it's even worth to create a ticket for the new proposed design and use cases. I think I just need some more time to think about it... |
That probably didn't come out like intended (ie I wasn't being snarky). I meant against composing page objects, as in supporting them within definitions. I ran across this closed PR this morning which made me concerned that this feature wasn't wanted at all. I'll do 1 and 2 and remove the extend for now. |
addon-test-support/index.js
Outdated
@@ -23,7 +23,7 @@ import { visitable } from './properties/visitable'; export { visitable }; | |||
|
|||
export { findElement } from './extend/find-element'; | |||
export { findElementWithAssert } from './extend/find-element-with-assert'; | |||
export { buildSelector, getContext } from './-private/helpers'; | |||
export { buildSelector, getContext, isPageObject } from './-private/helpers'; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Let's not make it public?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
fixed
assert.ok(pageComposer); | ||
assert.ok(pageComposer.somePage); | ||
|
||
assert.notOk(Ceibo.meta(pageComposer.somePage).pageObjectDefinition, "child objects based on page objects do not store their definition"); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think this is a bug. Since we are going to fix create
for page object instances it should be fixed for any level of nesting:
const { b } = create({
b: {
scope: '.b'
}
});
const c = create({ b })
I believe b
is also a page object instance, the same as a b
's parent is.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
fixed
} | ||
|
||
export function collection(scope, definition) { | ||
if(isPageObject(definition)){ | ||
definition = assign({}, Ceibo.meta(definition).pageObjectDefinition) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I would actually prefer to have a specialized methods for getting and setting of pageObjectDefinition
like we do for isPageObject
. Also can we make it harder to access it in the user land by adding a bazzilion of underscrores in the name __pageObjectDefinition__
or maybe even using a Symbol
(not sure if the later is applicable here).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
yeah good idea. fixed
addon-test-support/create.js
Outdated
let page = Ceibo.create(definition, assign({ builder }, options)); | ||
|
||
if (page) { | ||
page.render = render; | ||
page.setContext = setContext; | ||
page.removeContext = removeContext; | ||
|
||
Ceibo.meta(page).pageObjectDefinition = definitionToStore; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't know exactly how to do it yet, but... but I think we really need to do it in custom ceibo builders land in order to recursively store definitions per instance. Doing it in create
only partially solves the issue
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
wow.. seems like I have something working:
function buildObject(node, blueprintKey, blueprint, defaultBuilder) {
// here we check if we are going to create against the page object instance
// in case if that's the case use its definition
let definition;
if (Ceibo.meta(blueprint)) {
definition = Ceibo.meta(blueprint).__poDef__;
} else {
definition = blueprint;
}
blueprint = assign({}, dsl, definition);
const [ instance, blueprintToApply ] = defaultBuilder(node, blueprintKey, blueprint, defaultBuilder);
// persist definiion once we have an instance
Ceibo.meta(instance).__poDef__ = definition;
return [ instance, blueprintToApply ];
}
I've replicated the same trick to the buildChainObject
.
I've just hacked it in node modules of my one tiny project and didn't test it seriously. But at the first glance seems like it works..
What do you think? can we use it instead create-only?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Definitely the right track, which I've tailored into a working implementation. Above will fail here:
let definition = {
foo: {
baz: "prop"
}
};
let page = create(definition);
let pageComposer = create({
somePage: page
});
because the initial blueprint isn't a page object, which means that blueprintToApply
will contain the page object and will fire off getters in the next processNode
when buildObject
is called again and blueprint = assign({}, blueprint);
will fire off a getter
as soon as it encounters somePage
.
That can be refactored away, but even still, there's a subtler problem. The first invocation of buildObject
on
{
somePage: page
}
will lead to an definition still containing page objects to be stored since the definition object isn't currently a page object. Any subsequent creation will would fail to compose this page object properly since the stored definition must be a pojo.
I find it much more straightforward to always sanitize the definitions as soon as possible such that we are dealing with pojos once in Ceibo.create
. It's also more performant than adding those additional checks with then buildObject
itself.
For the record, this latest build failure is a result of ember-cli-addon-tests 199 |
Sorry, I can't reproduce a failure you've described. PO seems to work fine w/o any |
// a copy of their pojo definition representation to facilitate composition | ||
// (since accessing the page object's properties during creation here | ||
// would fire off the getters). | ||
export function convertPageObjectPropsToDefinitions(definition){ |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think we should avoid introducing of one more recursive mechanism. We already have ceibo
for that.
I was originally confused why you'd done anything here.
This seemed like harmless delegation, so I only used your code in Two remaining points then:
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Just left few comments. At this point I need some time to get confidence about what we are doing with a _chainedTree
. Going to play with it this weekend.
I think we don't have to test "re-creation" for all the existing attributes. I'd suggest to test just few of them:
- some action
- some getter
- collection
- probably
alias
,as
Also I think it makes sense to group such tests in a single(or maybe few?) file, just to have a better perspective of the feature test coverage.
@@ -1,4 +1,5 @@ | |||
export { assign } from '@ember/polyfills'; | |||
import { assign } from '@ember/polyfills'; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
we can revert to just re-exporting it.
}; | ||
|
||
let page = create(definition); | ||
assert.ok(Ceibo.meta(page).__poDef__); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can we use getPageObjectDefinition(page)
instead of Ceibo.meta(page).__poDef__
?
pageComposerDef.somePage = pageDef; | ||
|
||
assert.deepEqual(pageComposerDef, { | ||
somePage: definition |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
not sure what is the intention here. The pageComposerDef.somePage
is manually set to pageDef
a line above. So how is this assert different from simply comparing pageDef
and definition
?
assert.deepEqual(pageDef, definition);
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
you're right. that was actually not the best way to test it (nor was it really testing anything). My updated test should be clearer.
my intent was to more or less manually do what ceibo does recursively just to further prove that the definitions are stored correctly and that substitution produces the expected definition obj (as if only definition pojos were merged in the first place)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This replacement trick still feels dirty to me. I can't see how it's better than simply:
assert.deepEqual(somePageStoredDef, definition);
prove that the definitions are stored correctly and that substitution produces the expected definition obj
Do you want to make sure that there is only somePage
defined inside of pageComposerDef
? if such we could additionally check for all the existing keys:
assert.deepEqual(Object.keys(pageComposerDef), ['somePage']);
tests/acceptance/composition-test.js
Outdated
clickOnText | ||
} from 'ember-cli-page-object'; | ||
|
||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Just wondering how does eslint ignore multiple empty lines. Probably something wrong with its setup 🤔
@ro0gr Understandable. I reasoned the removal of the separate My logic for not storing the I may have missed something, just wanted to pass on my thinking |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Just left some comments.
I've verified _chainedTree
behavior and I'm glad we've got rid of that unused/confusing part 👍
Also I've detected an issue with passing a page object as a root of create
:
const a = create({
// ...
})
const b = create(a); // <= this fails
I think, in order to be a complete solution, this use-case should be handled as well. What do you think?
|
||
export function getPageObjectDefinition(node){ | ||
if(!isPageObject(node)){ | ||
return; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think if we ever reach this line - we have a bug. I'd prefer to change this to throw an exception, in order to detect possible issues faster.
pageComposerDef.somePage = pageDef; | ||
|
||
assert.deepEqual(pageComposerDef, { | ||
somePage: definition |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This replacement trick still feels dirty to me. I can't see how it's better than simply:
assert.deepEqual(somePageStoredDef, definition);
prove that the definitions are stored correctly and that substitution produces the expected definition obj
Do you want to make sure that there is only somePage
defined inside of pageComposerDef
? if such we could additionally check for all the existing keys:
assert.deepEqual(Object.keys(pageComposerDef), ['somePage']);
@ro0gr I added support for page objects directly as the definition to create. What would be the use case? |
@mistahenry I don't think there is an immediate use case for that. It just doesn't feel good to me to not handle the use case. As an alternative solution we could throw in case of re-creation dirrectly from the PO instance, but what would be the reason to fail? I think POs re-creation can be helpful for #433. With your last fix for direct page object re-creation it may be possible to avoid introducing new APIs like |
@@ -156,18 +169,19 @@ export function create(definitionOrUrl, definitionOrOptions, optionsOrNothing) { | |||
options = definitionOrOptions || {}; | |||
} | |||
|
|||
definition = assign({}, definition); | |||
let { context } = definition; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
context
seems unused
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
page.setContext(context);
at the end of create
.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Sure, sorry, missed that 🙈
Would you mind to squash commits into one please? There is some strange diff in the collections which seems obsolete. I guess squashing should fix it. |
2a51536
to
15f9f51
Compare
@mistahenry PR looks good to me. Going to test it against my current app tomorrow(haven't had a chance today) and merge it in case of no issues detected. I'm pretty sure it will be fine! Meanwhile, I think it's worth to update PR description to exclude Thanks for taking your time! |
Just verified it and found an issue(#406 (comment)) related to my recent collection change. Not sure if we depend on that change here anymore. Will revert it soon and verify again. |
@ro0gr the change for using a getter instead of the |
Just merged #440, please update your branch and let's see how to make it work |
@ro0gr No change necessary. I only had problems with the the collection's |
Thank you! |
going to publish it as a patch version soon. @mistahenry @san650 objections? |
@ro0gr sounds good |
published as v1.15.1 |
Updated description
Edit: Originally this PR was an attempt to solve both composition and extension of page objects. During the course of review, the extension aspect was removed. While it no longer solves #181 , the tools are in place for an extension approach via some sort of custom
merge
function should someone wish to write that in app space (or should such an approach be vetted and accepted here).Leveraging Ceibo's ability to take custom
buildObject
functions, the definitions for each page object are stored in the Ceibo metadata so that that during composition, the page object can be substituted with its definition. This is necessary since Page object's are "special" in that they fire functions expecting a test context and make assertions about the number of elements they select when accessed. Definition storing is preferable to a reflection-based approach in which we would extract the property descriptors and clone the getters since this can easily lead to closure scoping bugs without a great deal of care. See this comment for an example of this type of hard to track bug.Now, the following syntax is possible:
Collections can also now take a page object as the definition directly
Besides unlocking the possibility to merge page objects (while accessing private members at ones own risk), which would allow users to only store page objects should they so wish, this PR removes a pitfall of page objects that I'm sure I wasn't the only one to run into.
Original Description
(since a lot of the subsequent conversation refers to these notions): This PR solves #181 and enables page objects to be used directly within definitions as well as exposes an
extend
function to be used when creating new page objects from existing ones. The general idea was to store a sanitized version of the page object's definition within the page object itself in its Ceibo metadata. Sanitized in that if the definition contains page objects, it will replace the child page object with said object's definition before storing.I'm happy to add documentation but wanted this approach to be finalized first.
An example of what's valid now: