Skip to content
Permalink
Browse files
feat(protractor): EXPERIMENTAL: Custom extensions can be mixed into B…
…rowseTheWeb

Custom Extensions can be mixed into the BrowseTheWeb Ability to modify the protractor.browser before
the ability is used by the actor, or to provide additional features BrowseTheWeb itself doesn't
provide.

Please note that this feature is experimental, so its APIs can change without affecting the major
version number of the framework.
See https://serenity-js.org/handbook/integration/versioning.html#experimental-apis
  • Loading branch information
jan-molak committed Nov 25, 2020
1 parent e537ae9 commit 3b26baa
Show file tree
Hide file tree
Showing 12 changed files with 281 additions and 22 deletions.
File renamed without changes.
@@ -0,0 +1,11 @@
import { Extension } from './Extension';
import { ExtensionType } from './ExtensionType';

/**
* @experimental
*/
export interface Extendable<Subject> {
extendedWith(extension: Extension<Subject>): this;

extension<E extends Extension<Subject>>(extensionType: ExtensionType<Subject, E>): E;
}
@@ -0,0 +1,6 @@
/**
* @experimental
*/
export interface Extension<Subject> {
applyTo(subject: Subject): Promise<Subject> | Subject;
}
@@ -0,0 +1,6 @@
import { Extension } from './Extension';

/**
* @experimental
*/
export type ExtensionType<Subject, ET extends Extension<Subject> = Extension<Subject>> = new (...args: any[]) => ET
@@ -1,8 +1,8 @@
import { LogicError } from '../../errors';
import { Ability } from '../Ability';
import { AnswersQuestions, UsesAbilities } from '../actor';
import { Discardable } from '../Discardable';
import { Question } from '../Question';
import { Discardable } from './Discardable';

/**
* @desc
@@ -1 +1,6 @@
export * from './Discardable';
export * from './Extendable';
export * from './Extension';
export * from './ExtensionType';
export * from './Initialisable';
export * from './TakeNotes';
@@ -154,7 +154,7 @@ export class Actor implements
* @returns {Promise<void>}
*/
dismiss(): Promise<void> {
return this.findAbilitiesOfType('discard')
return this.findAbilitiesOfType<Discardable>('discard')
.reduce((previous: Promise<void>, ability: (Discardable & Ability)) =>
previous.then(() => ability.discard()),
Promise.resolve(void 0),
@@ -4,8 +4,6 @@ export * from './Activity';
export * from './abilities';
export * from './actor';
export * from './Answerable';
export * from './Discardable';
export * from './Initialisable';
export * from './Interaction';
export * from './interactions';
export * from './Question';
@@ -16,7 +16,9 @@ exports.config = {
specs: [ '**/*.spec.ts' ],

mochaOpts: {
compiler: 'ts:ts-node/register',
require: [
'ts-node/register',
],
reporter: 'dot',
},

@@ -0,0 +1,97 @@
import 'mocha';
import { expect } from '@integration/testing-tools';
import { Ensure, isTrue } from '@serenity-js/assertions';
import { Actor, actorCalled, actorInTheSpotlight, Cast, ConfigurationError, configure, Discardable, engage, Extension, Interaction, Question } from '@serenity-js/core';
import { protractor, ProtractorBrowser } from 'protractor';
import { BrowseTheWeb } from '../../../src';

describe('BrowseTheWeb', () => {

class TroubleMaker implements Extension<ProtractorBrowser> {
applyTo(subject: ProtractorBrowser): ProtractorBrowser | Promise<ProtractorBrowser> {
return subject;
}

someMethod(): void {
return void 0;
}
}

describe('when extended', () => {

class SupportForAngularEnabled implements Discardable, Extension<ProtractorBrowser>{
private browser: ProtractorBrowser;

applyTo(subject: ProtractorBrowser): ProtractorBrowser | Promise<ProtractorBrowser> {
this.browser = subject;

return Promise.resolve()
.then(() => subject.waitForAngularEnabled(true))
.then(() => this.browser);
}

synchronisationEnabled(): boolean {
return ! this.browser.ignoreSynchronization;
}

discard(): Promise<void> {
return Promise.resolve()
.then(() => {
this.browser.waitForAngularEnabled(false)
})
}
}

class Actors implements Cast {
prepare(actor: Actor): Actor {
return actor.whoCan(BrowseTheWeb.using(protractor.browser).extendedWith(new SupportForAngularEnabled()));
}
}

const AngularSupportEnabled = () =>
Question.about('Protractor ignoreSynchronization setting', actor =>
BrowseTheWeb.as(actor).extension(SupportForAngularEnabled).synchronisationEnabled()
);

const CauseTrouble = () =>
Interaction.where(`#actor tries to use an extension that hasn't been provided`, actor => {
BrowseTheWeb.as(actor).extension(TroubleMaker).someMethod()
});

beforeEach(() => engage(new Actors()));

/** @test {BrowseTheWeb} */
/** @test {BrowseTheWeb#with} */
it('initialises the extensions correctly', () =>
actorCalled('Bernie').attemptsTo(
Ensure.that(AngularSupportEnabled(), isTrue())
));

/** @test {BrowseTheWeb} */
/** @test {BrowseTheWeb#with} */
it('complains if the extension is not available', () =>
expect(actorCalled('Bernie').attemptsTo(
CauseTrouble(),
)).to.be.rejectedWith(ConfigurationError, `BrowseTheWeb doesn't have the TroubleMaker extension`));

// Note that you don't have to manually dismiss the actors in your tests
// as Serenity/JS will do this for you.
//
// In the case of this scenario, however, I have to dismiss them explicitly
// because it's executed via plain Mocha rather than
// the Serenity/JS runner.
afterEach(() =>
actorCalled('Bernie').dismiss()
);
});

/** @test {BrowseTheWeb} */
/** @test {BrowseTheWeb#with} */
it('complains if the extension is registered more than once', () => {
expect(() => {
BrowseTheWeb.using(protractor.browser)
.extendedWith(new TroubleMaker())
.extendedWith(new TroubleMaker())
}).to.throw(ConfigurationError, `BrowseTheWeb already has the TroubleMaker extension, so you don't need to assign it again`);
});
});
@@ -1,6 +1,6 @@
// tslint:disable:member-ordering

import { Ability, LogicError, UsesAbilities } from '@serenity-js/core';
import { Ability, ConfigurationError, Discardable, Extendable, Extension, ExtensionType, Initialisable, LogicError, UsesAbilities } from '@serenity-js/core';
import { ActionSequence, ElementArrayFinder, ElementFinder, Locator, protractor, ProtractorBrowser } from 'protractor';
import { AlertPromise, Capabilities, Navigation, Options, WebElement } from 'selenium-webdriver';
import { promiseOf } from '../../promiseOf';
@@ -10,31 +10,38 @@ import { promiseOf } from '../../promiseOf';
* An {@link @serenity-js/core/lib/screenplay~Ability} that enables the {@link Actor} to interact with web front-ends using {@link protractor}.
*
* @example <caption>Using the protractor.browser</caption>
* import { Actor } from '@serenity-js/core';
* import { BrowseTheWeb, Navigate, Target } from '@serenity-js/protractor'
* import { Ensure, equals } from '@serenity-js/assertions';
* import { by, protractor } from 'protractor';
* import { Actor } from '@serenity-js/core';
* import { BrowseTheWeb, Navigate, Target } from '@serenity-js/protractor'
* import { Ensure, equals } from '@serenity-js/assertions';
* import { by, protractor } from 'protractor';
*
* const actor = Actor.named('Wendy').whoCan(
* BrowseTheWeb.using(protractor.browser),
* );
* const actor = Actor.named('Wendy').whoCan(
* BrowseTheWeb.using(protractor.browser),
* );
*
* const HomePage = {
* Title: Target.the('title').located(by.css('h1')),
* };
* const HomePage = {
* Title: Target.the('title').located(by.css('h1')),
* };
*
* actor.attemptsTo(
* Navigate.to(`https://serenity-js.org`),
* Ensure.that(Text.of(HomePage.Title), equals('Serenity/JS')),
* );
* actor.attemptsTo(
* Navigate.to(`https://serenity-js.org`),
* Ensure.that(Text.of(HomePage.Title), equals('Serenity/JS')),
* );
*
* @see https://www.protractortest.org/
*
* @public
* @implements {@link @serenity-js/core/lib/screenplay~Ability}
* @see {@link @serenity-js/core/lib/screenplay/actor~Actor}
*/
export class BrowseTheWeb implements Ability {
export class BrowseTheWeb
implements Extendable<ProtractorBrowser>, Initialisable, Discardable, Ability
{
/**
* @private
*/
private readonly extensions = new Map<ExtensionType<ProtractorBrowser>, Extension<ProtractorBrowser>>();
private extensionsInitialised = false;

/**
* @private
@@ -73,7 +80,134 @@ export class BrowseTheWeb implements Ability {
* @param {ProtractorBrowser} browser
* An instance of a protractor browser
*/
constructor(private readonly browser: ProtractorBrowser) {
constructor(private browser: ProtractorBrowser) {
}

/**
* @desc
* Adds an {@link @serenity-js/core/lib/screenplay/abilities~Extension} to the list
* of extensions to be applied to {@link ProtractorBrowser} when the ability is initialised
* (see {@link @serenity-js/core/lib/screenplay/abilities~Initialisable}).
*
* If the {@link @serenity-js/core/lib/screenplay/abilities~Extension} is
* {@link @serenity-js/core/lib/screenplay/abilities~Discardable}, it will be discarded
* together with this {@link @serenity-js/core/lib/screenplay~Ability}.
*
* @experimental
*
* @param {@serenity-js/core/lib/screenplay/abilities~Extension} extension
* @returns {BrowseTheWeb}
*/
extendedWith(extension: Extension<ProtractorBrowser>): this {

const extensionType = extension.constructor as ExtensionType<ProtractorBrowser>;

const found = this.findExtension(extensionType);

if (found) {
throw new ConfigurationError(`${ this.constructor.name } already has the ${ found.constructor.name } extension, so you don't need to assign it again.`);
}

this.extensions.set(extensionType as ExtensionType<ProtractorBrowser>, extension);

return this;
}

/**
* @param requiredExtensionType
* @private
*/
private findExtension<T extends Extension<ProtractorBrowser>>(requiredExtensionType: ExtensionType<ProtractorBrowser>): Extension<ProtractorBrowser> | undefined {
for (const [extensionType, extension] of this.extensions) {
if (extension instanceof requiredExtensionType) {
return extension as T;
}
}

return undefined;
}

/**
* @desc
* Retrieves an extension by type, provided that one has already been added via {@link extendedWith}.
*
* @experimental
*
* @param {ExtensionType<ProtractorBrowser>} extensionType
* @returns {Extension<ProtractorBrowser>}
*/
extension<E extends Extension<ProtractorBrowser>>(extensionType: ExtensionType<ProtractorBrowser, E>): E {
const found = this.findExtension(extensionType);

if (! found) {
throw new ConfigurationError(`${ this.constructor.name } doesn't have the ${ extensionType.name } extension`);
}

return found as E;
}

/**
* @desc
* Initialises any {@link @serenity-js/core/lib/screenplay/abilities~Extension}s this ability has been
* configured {@link extendedWith}.
*
* @returns {Promise<void>}
*
* @see {@link @serenity-js/core/lib/screenplay/abilities~Initialisable}
*/
initialise(): Promise<void> {
return Array.from(this.extensions.values())
.reduce((previous: Promise<void>, extension: Extension<ProtractorBrowser>) =>
previous
.then(() => extension.applyTo(this.browser))
.then(browser => {
this.browser = browser
}),
Promise.resolve(void 0),
) as Promise<void>;
}

/**
* @desc
* Discards any {@link @serenity-js/core/lib/screenplay/abilities~Discardable}
* {@link @serenity-js/core/lib/screenplay/abilities~Extension}s this ability has been
* configured {@link extendedWith}.
*
* @returns {Promise<void>}
*
* @see {@link @serenity-js/core/lib/screenplay/abilities~Discardable}
*/
discard(): Promise<void> {
return this.findExtensionsOfType<Discardable>('discard')
.reduce((previous: Promise<void>, extension: (Discardable & Extension<ProtractorBrowser>)) =>
previous.then(() => extension.discard()),
Promise.resolve(void 0),
) as Promise<void>;
}

/**
* @param methodNames
* @private
*/
private findExtensionsOfType<T>(...methodNames: Array<keyof T>): Array<Extension<ProtractorBrowser> & T> {
const extensionsFrom = (map: Map<ExtensionType<ProtractorBrowser>, Extension<ProtractorBrowser>>): Array<Extension<ProtractorBrowser>> =>
Array.from(map.values());

const extensionsWithDesiredMethods = (extension: Extension<ProtractorBrowser> & T): boolean =>
methodNames.every(methodName => typeof(extension[methodName]) === 'function');

return extensionsFrom(this.extensions)
.filter(extensionsWithDesiredMethods) as Array<Extension<ProtractorBrowser> & T>;
}

/**
* @desc
* Whether or not all the extensions have been initialised and the ability is ready to be used.
*
* @returns {boolean}
*/
isInitialised(): boolean {
return this.extensionsInitialised
}

/**

0 comments on commit 3b26baa

Please sign in to comment.