Skip to content

Commit

Permalink
Browse files Browse the repository at this point in the history
feat(core): abilities can be initialised and discarded automatically
Useful when your custom ability needs to perform some asynchronous setup or tear down of the
resources it uses. See examples at
https://serenity-js.org/modules/core/class/src/screenplay/Ability.ts~Ability.html

Enables #563
  • Loading branch information
jan-molak committed Nov 24, 2020
1 parent 9823ff8 commit e537ae9
Show file tree
Hide file tree
Showing 7 changed files with 207 additions and 45 deletions.
87 changes: 75 additions & 12 deletions packages/core/spec/screenplay/actor.spec.ts
@@ -1,11 +1,11 @@
import 'mocha';

import * as sinon from 'sinon';
import { ConfigurationError } from '../../src/errors';
import { ConfigurationError, TestCompromisedError } from '../../src/errors';

import { InteractionFinished, InteractionStarts } from '../../src/events';
import { CorrelationId, ExecutionSuccessful, Name, Timestamp } from '../../src/model';
import { Ability, Actor, See } from '../../src/screenplay';
import { Ability, Actor, Initialisable, See } from '../../src/screenplay';
import { Stage } from '../../src/stage';
import { expect } from '../expect';
import { AcousticGuitar, Chords, Guitar, MusicSheets, NumberOfGuitarStringsLeft, PlayAChord, PlayAGuitar, PlayASong } from './example-implementation';
Expand Down Expand Up @@ -96,19 +96,82 @@ describe('Actor', () => {
});
});

/** @test {Actor} */
it('admits if it does not have the Ability necessary to accomplish a given Interaction', () =>
describe('when using abilities', () => {

expect(actor('Ben').attemptsTo(
PlayAChord.of(Chords.AMajor),
)).to.be.eventually.rejectedWith(ConfigurationError, `Ben can't PlayAGuitar yet. Did you give them the ability to do so?`));
/** @test {Actor} */
it('admits if it does not have the Ability necessary to accomplish a given Interaction', () =>

/** @test {Actor} */
it('complains if given the same ability twice', () => {
expect(actor('Ben').attemptsTo(
PlayAChord.of(Chords.AMajor),
)).to.be.eventually.rejectedWith(ConfigurationError, `Ben can't PlayAGuitar yet. Did you give them the ability to do so?`));

/** @test {Actor} */
it('complains if given the same ability twice', () => {

expect(() =>
actor('Ben').whoCan(PlayAGuitar.suchAs(guitar), PlayAGuitar.suchAs(guitar))
).to.throw(ConfigurationError, `Ben already has an ability to PlayAGuitar, so you don't need to give it to them again.`);
});

describe('that have to be initialised', () => {

class UseDatabase implements Initialisable, Ability {
public callsToInitialise = 0;
private connection = null;

initialise(): Promise<void> | void {
this.connection = 'some connection';

this.callsToInitialise++;
}

isInitialised(): boolean {
return !! this.connection;
}
}

expect(() =>
actor('Ben').whoCan(PlayAGuitar.suchAs(guitar), PlayAGuitar.suchAs(guitar))
).to.throw(ConfigurationError, `Ben already has an ability to PlayAGuitar, so you don't need to give it to them again.`);
class UseBrokenDatabase implements Initialisable, Ability {
initialise(): Promise<void> | void {
throw new Error('DB server is down, please cheer it up');
}

isInitialised(): boolean {
return false;
}
}

/** @test {Actor} */
it('initialises them upon the first call to attemptsTo', async () => {

const useDatabase = new UseDatabase();

await actor('Dibillo').whoCan(useDatabase).attemptsTo(/* */);

expect(useDatabase.isInitialised()).to.equal(true);
});

/** @test {Actor} */
it(`initialises them only if they haven't been initialised before`, async () => {

const useDatabase = new UseDatabase();

await actor('Dibillo').whoCan(useDatabase).attemptsTo(/* */);
await actor('Dibillo').whoCan(useDatabase).attemptsTo(/* */);
await actor('Dibillo').whoCan(useDatabase).attemptsTo(/* */);

expect(useDatabase.callsToInitialise).to.equal(1);
});

/** @test {Actor} */
it(`complains if the ability could not be initialised`, () => {

return expect(actor('Dibillo').whoCan(new UseBrokenDatabase()).attemptsTo(/* */))
.to.be.rejectedWith(TestCompromisedError, `Dibillo couldn't initialise the ability to UseBrokenDatabase`)
.then(error => {
expect(error.cause.message).to.equal('DB server is down, please cheer it up')
});
});
});
});

describe('DomainEvent handling', () => {
Expand Down
77 changes: 59 additions & 18 deletions packages/core/src/screenplay/Ability.ts
Expand Up @@ -5,30 +5,71 @@
* Technically speaking, it's a wrapper around a client of said interface.
*
* @example
* import { Ability, Actor, Interface } from '@serenity-js/core';
* import { Ability, Actor, Interaction } from '@serenity-js/core';
*
* class MakePhoneCalls implements Ability {
* static as(actor: UsesAbilities): MakesPhoneCalls {
* return actor.abilityTo(MakePhoneCalls);
* }
* class MakePhoneCalls implements Ability {
* static as(actor: UsesAbilities): MakesPhoneCalls {
* return actor.abilityTo(MakePhoneCalls);
* }
*
* static using(phone: Phone) {
* return new MakePhoneCalls(phone);
* }
* static using(phone: Phone) {
* return new MakePhoneCalls(phone);
* }
*
* constructor(private readonly phone: Phone) {}
* constructor(private readonly phone: Phone) {}
*
* // some method that allows us to interact with the external interface of the system under test
* dial(phoneNumber: string) {
* // ...
* }
* }
* // some method that allows us to interact with the external interface of the system under test
* dial(phoneNumber: string) {
* // ...
* }
* }
*
* const Connie = Actor.named('Connie').whoCan(MakePhoneCalls.using(phone));
* const Connie = Actor.named('Connie').whoCan(MakePhoneCalls.using(phone));
*
* const Call = (phoneNumber: string) => Interaction.where(`#actor calls ${ phoneNumber }`, actor =>
* MakePhoneCalls.as(actor).dial(phoneNumber);
* );
* const Call = (phoneNumber: string) => Interaction.where(`#actor calls ${ phoneNumber }`, actor =>
* MakePhoneCalls.as(actor).dial(phoneNumber);
* );
*
* @example <caption>Ability that's automatically initialised and discarded</caption>
* import { Ability, Actor, Initialisable, Discardable, Interaction } from '@serenity-js/core';
* import { Client } from 'pg';
*
* class UsePostgreSQLDatabase implements Initialisable, Discardable, Ability {
* static using(client: Client) {
* return new UsePostgreSQLDatabase(client);
* }
*
* static as(actor: UsePostgreSQLDatabase): MakesPhoneCalls {
* return actor.abilityTo(UsePostgreSQLDatabase);
* }
*
* constructor(private readonly client: Client) {}
*
* // Connect to the database automatically the first time
* // actor.attemptsTo() is called.
* // See Initialisable for details
* async initialise(): Promise<void> {
* await this.client.connect();
* }
*
* // Disconnect when the actor is dismissed.
* // See Discardable for details
* async discard(): Promise<void> {
* await this.client.end();
* }
*
* // some method that allows us to interact with the external interface of the system under test
* query(queryText: string, ...params: string[]) {
* return this.client(queryText, params);
* }
* }
*
* const ResultsFor = (queryText: string, params: string[]) =>
* Question.about(`results for ${ queryText } with params: ${ params }`,
* actor => UsePostgreSQLDatabase.as(actor).query(queryText, params));
*
* @see {@link Initialisable}
* @see {@link Discardable}
*
* @access public
*/
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/screenplay/Discardable.ts
Expand Up @@ -3,7 +3,7 @@
* An interface to be implemented by any {@link Ability} that needs to free up
* the resources it uses.
*
* This method is invoked directly by the {@link Actor}, and indirectly by {@link Stage}:
* This {@link discard} method is invoked directly by the {@link Actor}, and indirectly by {@link Stage}:
* - when {@link SceneFinishes}, for actors instantiated after {@link SceneStarts} - i.e. within a test scenario or in a "before each" hook
* - when {@link TestRunFinishes}, for actors instantiated before {@link SceneStarts} - i.e. in a "before all" hook
*
Expand Down
33 changes: 33 additions & 0 deletions packages/core/src/screenplay/Initialisable.ts
@@ -0,0 +1,33 @@
/**
* @desc
* An interface to be implemented by any {@link Ability} that needs to initialise
* the resources it uses (i.e. establish a database connection).
*
* The {@link initialise} method is invoked when {@link Actor#attemptsTo} is called,
* but only when {@link isInitialised} returns false.
*
* @public
*/
export interface Initialisable {

/**
* @desc
* Initialises the ability. Invoked when {@link Actor#attemptsTo} is called,
* but only when {@link isInitialised} returns false.
*
* Make sure to implement {@link isInitialised} so that it returns `true`
* when the ability has been successfully initialised.
*
* @returns {Promise<void> | void}
*/
initialise(): Promise<void> | void;

/**
* @desc
* Whether or not a given ability has been initialised already
* and should not be initialised again.
*
* @returns {boolean}
*/
isInitialised(): boolean;
}
50 changes: 37 additions & 13 deletions packages/core/src/screenplay/actor/Actor.ts
@@ -1,7 +1,7 @@
import { ConfigurationError } from '../../errors';
import { ConfigurationError, TestCompromisedError } from '../../errors';
import { ActivityRelatedArtifactGenerated } from '../../events';
import { Artifact, Name } from '../../model';
import { Ability, AbilityType, Answerable, Discardable } from '../../screenplay';
import { Ability, AbilityType, Answerable, Discardable, Initialisable } from '../../screenplay';
import { Stage } from '../../stage';
import { TrackedActivity } from '../activities';
import { Activity } from '../Activity';
Expand Down Expand Up @@ -68,7 +68,7 @@ export class Actor implements
/* todo: add an execution strategy */
return current.performAs(this);
});
}, Promise.resolve(void 0));
}, this.initialiseAbilities());
}

/**
Expand Down Expand Up @@ -154,16 +154,9 @@ export class Actor implements
* @returns {Promise<void>}
*/
dismiss(): Promise<void> {
const abilitiesFrom = (map: Map<AbilityType<Ability>, Ability>): Ability[] =>
Array.from(map.values());

const discardable = (ability: Ability): boolean =>
'discard' in ability;

return abilitiesFrom(this.abilities)
.filter(discardable)
return this.findAbilitiesOfType('discard')
.reduce((previous: Promise<void>, ability: (Discardable & Ability)) =>
previous.then(() => Promise.resolve(ability.discard())),
previous.then(() => ability.discard()),
Promise.resolve(void 0),
) as Promise<void>;
}
Expand All @@ -181,7 +174,38 @@ export class Actor implements
}

/**
* @param doSomething
* @private
*/
private initialiseAbilities(): Promise<void> {
return this.findAbilitiesOfType<Initialisable>('initialise', 'isInitialised')
.filter(ability => ! ability.isInitialised())
.reduce((previous: Promise<void>, ability: (Initialisable & Ability)) =>
previous
.then(() => ability.initialise())
.catch(error => {
throw new TestCompromisedError(`${ this.name } couldn't initialise the ability to ${ ability.constructor.name }`, error);
}),
Promise.resolve(void 0),
)
}

/**
* @param {...string[]} methodNames
* @private
*/
private findAbilitiesOfType<T>(...methodNames: Array<keyof T>): Array<Ability & T> {
const abilitiesFrom = (map: Map<AbilityType<Ability>, Ability>): Ability[] =>
Array.from(map.values());

const abilitiesWithDesiredMethods = (ability: Ability & T): boolean =>
methodNames.every(methodName => typeof(ability[methodName]) === 'function');

return abilitiesFrom(this.abilities)
.filter(abilitiesWithDesiredMethods) as Array<Ability & T>;
}

/**
* @param {string} doSomething
* @private
*/
private findAbilityTo<T extends Ability>(doSomething: AbilityType<T>): T | undefined {
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/screenplay/index.ts
Expand Up @@ -5,6 +5,7 @@ export * from './abilities';
export * from './actor';
export * from './Answerable';
export * from './Discardable';
export * from './Initialisable';
export * from './Interaction';
export * from './interactions';
export * from './Question';
Expand Down
@@ -1,6 +1,6 @@
// tslint:disable:member-ordering

import { Ability, LogicError, TestCompromisedError, UsesAbilities } from '@serenity-js/core';
import { Ability, 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';
Expand Down

0 comments on commit e537ae9

Please sign in to comment.