spec/stage/crew/artifact-archiver/ArtifactArchiver.spec.ts

import 'mocha';

import * as sinon from 'sinon';

import { Actor, StageCrewMember } from '../../../../src';
import { ArtifactArchived, ArtifactGenerated, DomainEvent } from '../../../../src/events';
import { FileSystem, Path } from '../../../../src/io';
import { CorrelationId, Duration, JSONData, Name, TestReport } from '../../../../src/model';
import { ArtifactArchiver, Cast, Clock, Stage, StageManager } from '../../../../src/stage';
import { expect } from '../../../expect';
import { photo } from '../samples';

/** @test {ArtifactArchiver} */
describe('ArtifactArchiver', () => {

    const
        json = { key: 'value' },
        jsonValueShortHash = '64cdd772d3',
        photoShortHash = '6808b2e9fe',
        sceneId = new CorrelationId('a-scene');

    let stage:          Stage,
        fs:             sinon.SinonStubbedInstance<FileSystem>,
        archiver:       StageCrewMember;

    class Extras implements Cast {
        prepare(actor: Actor): Actor {
            return actor;
        }
    }

    beforeEach(() => {
        fs = sinon.createStubInstance(FileSystem);
        fs.store.callsFake((path: Path, contents: any) => Promise.resolve(path));
    });

    describe('stores the artifacts generated by other stage crew members', () => {

        beforeEach(() => {
            stage = new Stage(new Extras(), new StageManager(Duration.ofMilliseconds(250), new Clock()));

            archiver = new ArtifactArchiver(fs as any, stage);
            stage.assign(archiver);
        });

        const
            jsonArtifactName = new Name('Scenario Name'),
            expectedJsonFileName = 'scenario-name',
            pngArtifactName  = new Name('PNG Artifact name'),
            expectedPngFileName = 'png-artifact-name';

        /**
         * @test {ArtifactArchiver}
         * @test {ArtifactGenerated}
         */
        it('notifies the StageManager when an artifact is saved so that the promise of a stage cue can be fulfilled', () => {
            stage.announce(new ArtifactGenerated(
                sceneId,
                jsonArtifactName,
                JSONData.fromJSON(json),
            ));

            return expect(stage.waitForNextCue()).to.be.fulfilled;
        });

        /**
         * @test {ArtifactArchiver}
         * @test {ArtifactGenerated}
         */
        it('notifies the StageManager when an artifact cannot be saved so that the promise of a stage cue can be rejected', () => {
            fs.store.returns(Promise.reject(new Error('Something happened')));

            stage.announce(new ArtifactGenerated(
                sceneId,
                pngArtifactName,
                photo,
            ));

            return expect(stage.waitForNextCue()).to.be.rejected;
        });

        /**
         * @test {ArtifactArchiver}
         * @test {ArtifactGenerated}
         */
        it('correctly saves the test report to a unique file', () => {
            stage.announce(new ArtifactGenerated(
                sceneId,
                jsonArtifactName,
                TestReport.fromJSON(json),
            ));

            return stage.waitForNextCue().then(() => {
                expect(fs.store).to.have.been.calledWith(
                    new Path(`scenario-${ expectedJsonFileName }-${ jsonValueShortHash }.json`),
                    JSON.stringify(json),
                );
            });
        });

        /**
         * @test {ArtifactArchiver}
         * @test {ArtifactGenerated}
         */
        it('correctly saves PNG content to a file', () => {
            stage.announce(new ArtifactGenerated(
                sceneId,
                pngArtifactName,
                photo,
            ));

            return stage.waitForNextCue().then(() => {
                expect(fs.store).to.have.been.calledWith(
                    new Path(`photo-${ expectedPngFileName }-${ photoShortHash }.png`),
                    photo.base64EncodedValue,
                    'base64',
                );
            });
        });

        /**
         * @test {ArtifactArchiver}
         * @test {ArtifactGenerated}
         *
         * @see https://github.com/serenity-js/serenity-js/issues/634
         */
        it(`ensures that the generate file name doesn't contain special characters`, () => {
            const emittedEventName = 'linux-chrome-87-0.4280.88-jasmine-navigates-to-https://www.bounteous.com/#sr=g&m=o&cp=or&ct=-tmc&st=(opu%20qspwjefe)&ts=1402322447';
            const expectedFileName = `photo-linux-chrome-87-0.4280.88-jasmine-navigates-to-https-www.bount-${ photoShortHash }.png`;

            stage.announce(new ArtifactGenerated(
                sceneId,
                new Name(emittedEventName),
                photo,
            ));

            return stage.waitForNextCue().then(() => {
                expect(fs.store).to.have.been.calledWith(
                    new Path(expectedFileName),
                    photo.base64EncodedValue,
                    'base64',
                );
            });
        });
    });

    describe(`when it encounters events it's not interested in`, () => {

        class SomeEvent extends DomainEvent {
            constructor() {
                super();
            }
        }

        const someEvent = new SomeEvent();

        /**
         * @test {ArtifactArchiver}
         */
        it('ignores them', () => {
            const stageManager = sinon.createStubInstance(StageManager);

            fs           = sinon.createStubInstance(FileSystem);
            stage        = new Stage(new Extras(), stageManager as unknown as StageManager);

            archiver     = new ArtifactArchiver(fs as any, stage);
            stage.assign(archiver);

            archiver.notifyOf(
                someEvent,
            );

            expect(stageManager.notifyOf).to.not.have.been.called;
            expect(fs.store).to.not.have.been.called;
        });
    });

    /**
     * @test {ArtifactArchiver}
     * @test {ArtifactGenerated}
     * @test {ArtifactArchived}
     */
    it('notifies the StageManager when the artifact is correctly archived', () => {

        const stageManager = new StageManager(Duration.ofMilliseconds(250), new Clock());

        stage = new Stage(new Extras(), stageManager);

        archiver = new ArtifactArchiver(fs as any, stage);
        stage.assign(archiver);

        const notifyOf = sinon.spy(stageManager, 'notifyOf');

        stageManager.notifyOf(new ArtifactGenerated(
            sceneId,
            new Name('Some Report Name'),
            TestReport.fromJSON(json),
        ));

        return expect(stageManager.waitForNextCue()).to.be.fulfilled.then(() => {

            const archived: ArtifactArchived = notifyOf.getCall(2).lastArg;

            expect(archived).to.be.instanceOf(ArtifactArchived);
            expect(archived.sceneId).to.equal(sceneId);
            expect(archived.name).to.equal(new Name('Some Report Name'));
            expect(archived.type).to.equal(TestReport);
            expect(archived.path).to.equal(new Path(`scenario-some-report-name-${ jsonValueShortHash }.json`));
        });
    });

    describe('when instantiated using a factory method', () => {
        it('joins the path segments provided so that the developer doesn\'t need to worry about cross-OS compatibility of the path', () => {
            archiver = ArtifactArchiver.storingArtifactsAt(process.cwd(), 'target', 'site/serenity');

            expect((archiver as any).fileSystem.root).to.equal(new Path(process.cwd()).join(new Path('target/site/serenity')));
        });

        it('complains if the destination is not provided', () => {
            expect(() => ArtifactArchiver.storingArtifactsAt()).to.throw(Error, `Path to destination directory should have a property "length" that is greater than 0`);
        });
    });
});