diff --git a/integration/web-specs/spec/screenplay/interactions/Scroll.spec.ts b/integration/web-specs/spec/screenplay/interactions/Scroll.spec.ts index 6c5a3173cd2..baa6a11448d 100644 --- a/integration/web-specs/spec/screenplay/interactions/Scroll.spec.ts +++ b/integration/web-specs/spec/screenplay/interactions/Scroll.spec.ts @@ -1,7 +1,7 @@ import 'mocha'; import { expect } from '@integration/testing-tools'; -import { Ensure, isGreaterThan, isLessThan } from '@serenity-js/assertions'; +import { Ensure, equals } from '@serenity-js/assertions'; import { actorCalled } from '@serenity-js/core'; import { By, ExecuteScript, LastScriptExecution, Navigate, PageElement, Scroll } from '@serenity-js/web'; @@ -10,26 +10,94 @@ describe('Scroll', function () { const Page = { Execute_Button: PageElement.located(By.id('cast')).describedAs('the "Cast!" button'), + Body: PageElement.located(By.tagName('body')) }; + before(() => actorCalled('Gandalf').attemptsTo( + Navigate.to('/screenplay/interactions/scroll/long_page.html') + )); + + afterEach(() => actorCalled('Gandalf').attemptsTo( + Scroll.to(Page.Body) + )); + /** @test {Scroll.to} */ it('allows the actor to scroll to a given target so that it appears in the viewport', () => actorCalled('Gandalf').attemptsTo( - Navigate.to('/screenplay/interactions/scroll/long_page.html'), - ExecuteScript.sync(`return arguments[0].getBoundingClientRect().top;`).withArguments(Page.Execute_Button), - Ensure.that(LastScriptExecution.result(), isGreaterThan(9000)), + Ensure.that(LastScriptExecution.result(), equals(10000)), Scroll.to(Page.Execute_Button), ExecuteScript.sync(`return arguments[0].getBoundingClientRect().top;`).withArguments(Page.Execute_Button), - Ensure.that(LastScriptExecution.result(), isLessThan(9000)), + Ensure.that(LastScriptExecution.result(), equals(0)) )); - /** @test {Scroll.to} */ - /** @test {Scroll#toString} */ - it('provides a sensible description of the interaction being performed', () => { - expect(Scroll.to(Page.Execute_Button).toString()) - .to.equal(`#actor scrolls to the "Cast!" button`); + /** @test {Scroll.alignedTo(true)} */ + it('allows the actor to scroll to a given target so that it appears in the top of the viewport', () => + actorCalled('Gandalf').attemptsTo( + ExecuteScript.sync(`return arguments[0].getBoundingClientRect().top;`).withArguments(Page.Execute_Button), + Ensure.that(LastScriptExecution.result(), equals(10000)), + + Scroll.alignedTo(true).to(Page.Execute_Button), + + ExecuteScript.sync(`return arguments[0].getBoundingClientRect().top;`).withArguments(Page.Execute_Button), + Ensure.that(LastScriptExecution.result(), equals(0)) + )); + + it('allows the actor to scroll to a given target so that it appears in the bottom of the viewport', () => + actorCalled('Gandalf').attemptsTo( + ExecuteScript.sync(`return arguments[0].getBoundingClientRect().bottom;`).withArguments(Page.Execute_Button), + Ensure.that(LastScriptExecution.result(), equals(10021)), + + Scroll.alignedTo(false).to(Page.Execute_Button), + + ExecuteScript.sync(`return arguments[0].getBoundingClientRect().bottom;`).withArguments(Page.Execute_Button), + Ensure.that(LastScriptExecution.result(), equals(21)) + )); + + it('allows the actor to scroll to a given target so that it appears in the bottom of the viewport (using options object)', () => + actorCalled('Gandalf').attemptsTo( + ExecuteScript.sync(`return arguments[0].getBoundingClientRect().bottom;`).withArguments(Page.Execute_Button), + Ensure.that(LastScriptExecution.result(), equals(10021)), + + Scroll.alignedTo({ block: 'end' }).to(Page.Execute_Button), + + ExecuteScript.sync(`return arguments[0].getBoundingClientRect().bottom;`).withArguments(Page.Execute_Button), + Ensure.that(LastScriptExecution.result(), equals(21)) + )); + + describe('toString', function () { + /** @test {Scroll.to} */ + /** @test {Scroll#toString} */ + it('provides a sensible description of the interaction being performed', () => { + expect(Scroll.to(Page.Execute_Button).toString()) + .to.equal(`#actor scrolls to the "Cast!" button`); + }); + + /** @test {Scroll.alignedTo} */ + /** @test {Scroll#toString} */ + it('provides a sensible description of the interaction being performed when the element is aligned to the top', () => { + expect(Scroll.alignedTo(true).to(Page.Execute_Button).toString()) + .to.equal(`#actor scrolls to the 'top' of the "Cast!" button aligned to the 'top' of the visible area of the scrollable ancestor`); + }); + + /** @test {Scroll.alignedTo} */ + /** @test {Scroll#toString} */ + it('provides a sensible description of the interaction being performed when the element is aligned to the bottom', () => { + expect(Scroll.alignedTo(false).to(Page.Execute_Button).toString()) + .to.equal(`#actor scrolls to the 'bottom' of the "Cast!" button aligned to the 'bottom' of the visible area of the scrollable ancestor`); + }); + + /** @test {Scroll.alignedTo} */ + /** @test {Scroll#toString} */ + it('provides a sensible description of the interaction being performed when the element is aligned using specific configuration', () => { + expect(Scroll.alignedTo({ + behavior: 'smooth', + block: 'end', + inline: 'nearest' + }).to(Page.Execute_Button).toString()) + .to.equal(`#actor scrolls to the "Cast!" button with the following view options '{"behavior":"smooth","block":"end","inline":"nearest"}'`); + }); }); }); diff --git a/packages/protractor/tsconfig.json b/packages/protractor/tsconfig.json index 9542eda8950..332a20ca6ae 100644 --- a/packages/protractor/tsconfig.json +++ b/packages/protractor/tsconfig.json @@ -1,7 +1,7 @@ { "compilerOptions": { "target": "es2019", - "lib": ["es2019"], + "lib": ["es2019", "dom"], "module": "commonjs", "emitDecoratorMetadata": true, "experimentalDecorators": true, diff --git a/packages/web/src/screenplay/interactions/Scroll.ts b/packages/web/src/screenplay/interactions/Scroll.ts index c397879b7cb..bf239e0cfe0 100644 --- a/packages/web/src/screenplay/interactions/Scroll.ts +++ b/packages/web/src/screenplay/interactions/Scroll.ts @@ -2,6 +2,7 @@ import { Answerable, AnswersQuestions, Interaction, UsesAbilities } from '@seren import { formatted } from '@serenity-js/core/lib/io'; import { PageElement } from '../models'; +import { ScrollBuilder } from './ScrollBuilder'; /** * @desc @@ -37,6 +38,30 @@ import { PageElement } from '../models'; * Ensure.that(Form.submitButton, isVisible()), * ); * + * @example Scrolling to element view at the bottom + * import { actorCalled } from '@serenity-js/core'; + * import { Ensure } from '@serenity-js/assertions'; + * import { BrowseTheWeb, Scroll, isVisible } from '@serenity-js/webdriverio'; + * + * actorCalled('Sara') + * .whoCan(BrowseTheWeb.using(browser)) + * .attemptsTo( + * Scroll.alignedToTop(false).to(Form.submitButton), + * Ensure.that(Form.submitButton, isVisible()), + * ); + * + * @example Scrolling to element view at the bottom + * import { actorCalled } from '@serenity-js/core'; + * import { Ensure } from '@serenity-js/assertions'; + * import { BrowseTheWeb, Scroll, isVisible } from '@serenity-js/webdriverio'; + * + * actorCalled('Sara') + * .whoCan(BrowseTheWeb.using(browser)) + * .attemptsTo( + * Scroll.alignedToTop({ behavior: "smooth", block: "end", inline: "nearest" }).to(Form.submitButton), + * Ensure.that(Form.submitButton, isVisible()), + * ); + * * @see {@link BrowseTheWeb} * @see {@link Target} * @see {@link isVisible} @@ -51,7 +76,7 @@ export class Scroll extends Interaction { * Instantiates this {@link @serenity-js/core/lib/screenplay~Interaction}. * * @param {Answerable} target - * The element to be scroll to + * The element to be scrolled to * * @returns {@serenity-js/core/lib/screenplay~Interaction} */ @@ -59,11 +84,48 @@ export class Scroll extends Interaction { return new Scroll(target); } + /** + * @desc + * Instantiates a version of this {@link @serenity-js/core/lib/screenplay~Interaction} + * configured to align the element into specific part of the viewport + * + * @param {boolean | ScrollIntoViewOptions} alignedToTop + * A boolean value: + * If true, the top of the element will be aligned to the top of the visible area of the scrollable ancestor. + * Corresponds to scrollIntoViewOptions: {block: "start", inline: "nearest"}. This is the default value. + * + * If false, the bottom of the element will be aligned to the bottom of the visible area of the scrollable ancestor. + * Corresponds to scrollIntoViewOptions: {block: "end", inline: "nearest"}. + * + * An Object with the following properties: + * behavior (optional) + * Defines the transition animation. One of auto or smooth. Defaults to auto. + * + * block (optional) + * Defines vertical alignment. One of start, center, end, or nearest. Defaults to start. + * + * inline (optional) + * Defines horizontal alignment. One of start, center, end, or nearest. Defaults to nearest. + * + * @returns {ScrollBuilder} + */ + static alignedTo(alignedToTop: boolean | ScrollIntoViewOptions): ScrollBuilder { + return { + to: (target: Answerable): Interaction => + new Scroll(target, alignedToTop) + }; + } + /** * @param {Answerable} target - * The element to be scroll to + * The element to be scrolled to + * @param scrollIntoViewOptions + * The options to place the element in the visible area */ - constructor(private readonly target: Answerable) { + constructor( + private readonly target: Answerable, + private readonly scrollIntoViewOptions?: boolean | ScrollIntoViewOptions + ) { super(); } @@ -94,6 +156,15 @@ export class Scroll extends Interaction { * @returns {string} */ toString(): string { - return formatted `#actor scrolls to ${ this.target }`; + if (typeof this.scrollIntoViewOptions === 'boolean') { + const alignedTo = this.scrollIntoViewOptions ? 'top' : 'bottom'; + return formatted`#actor scrolls to the ${alignedTo} of ${this.target} aligned to the ${alignedTo} of the visible area of the scrollable ancestor`; + } + + if (typeof this.scrollIntoViewOptions === 'object') { + return formatted`#actor scrolls to ${this.target} with the following view options ${JSON.stringify(this.scrollIntoViewOptions)}`; + } + + return formatted`#actor scrolls to ${this.target}`; } } diff --git a/packages/web/src/screenplay/interactions/ScrollBuilder.ts b/packages/web/src/screenplay/interactions/ScrollBuilder.ts new file mode 100644 index 00000000000..5d07f5abd43 --- /dev/null +++ b/packages/web/src/screenplay/interactions/ScrollBuilder.ts @@ -0,0 +1,28 @@ +import { Answerable, Interaction } from '@serenity-js/core'; + +import { PageElement } from '../models'; + +/** + * @desc + * Fluent interface to make the instantiation of + * the {@link @serenity-js/core/lib/screenplay~Interaction} + * to {@link Scroll} more configurable. + * + * @see {@link Scroll} + * + * @interface + */ +export interface ScrollBuilder { + + /** + * @desc + * Instantiates an {@link @serenity-js/core/lib/screenplay~Interaction} + * to {@link Scroll}. + * + * @param {Answerable} target + * @returns {@serenity-js/core/lib/screenplay~Interaction} + * + * @see {@link Target} + */ + to(target: Answerable) : Interaction; +}