jsGiven/jsGiven

View on GitHub
js-given/src/reports.js

Summary

Maintainability
A
3 hrs
Test Coverage
// @flow
import fs from 'fs';
import crypto from 'crypto';

import _ from 'lodash';
import humanize from 'string-humanize';

import { type DecodedParameter } from './parametrized-scenarios';
import { formatParameter } from './parameter-formatting';

export type ScenarioPartKind = 'GIVEN' | 'WHEN' | 'THEN';

export const REPORTS_DESTINATION = '.jsGiven-reports';

const INTRO_WORD_METHODS = ['given', 'when', 'then', 'and', 'but', 'with'];

export class ScenarioPart {
  kind: ScenarioPartKind;
  steps: Step[];
  introWord: string | null;

  constructor(kind: ScenarioPartKind, steps?: Step[] = []) {
    this.kind = kind;
    this.steps = steps;
    this.introWord = null;
  }

  stageMethodCalled(
    methodName: string,
    parameters: DecodedParameter[],
    stepStatus: StepStatus,
    executionTimeInNanos: number
  ) {
    if (INTRO_WORD_METHODS.find(introWord => introWord === methodName)) {
      this.introWord = methodName;
    } else {
      const isFirstStep = this.steps.length === 0;
      this.steps.push(
        new Step(
          methodName,
          parameters,
          isFirstStep,
          this.introWord,
          stepStatus,
          executionTimeInNanos
        )
      );
      this.introWord = null;
    }
  }
}

type StepStatus = 'PASSED' | 'FAILED' | 'SKIPPED';

export class Step {
  name: string;
  methodName: string;
  words: Word[];
  status: StepStatus;
  durationInNanos: number;

  constructor(
    methodName: string,
    parameters: DecodedParameter[],
    isFirstStep: boolean,
    introWord: string | null,
    stepStatus: StepStatus,
    durationInNanos: number
  ) {
    const TWO_DOLLAR_PLACEHOLDER = 'zzblablaescapedollarsignplaceholdertpolm';

    const parametersCopy = [...parameters];

    let words: Word[] = [
      ...methodName // 'a_bill_of_$_$$'
        .replace('$$', TWO_DOLLAR_PLACEHOLDER) // 'a_bill_of_$_TWO_DOLLAR_PLACEHOLDER'
        .split('$') // ['a_bill_of', 'TWO_DOLLAR_PLACEHOLDER']
        .map(word => _.lowerCase(humanize(word))) //  ['a bill of', 'TWO_DOLLAR_PLACEHOLDER']
        .reduce((previous, newString, index) => {
          if (index === 0) {
            return [{ word: newString, scenarioParameterName: null }];
          }

          let formattedParameters: WordDescription[];
          if (parametersCopy.length > 0) {
            const [parameter] = parametersCopy.splice(0, 1);
            const word = formatParameter(parameter.value, parameter.formatters);
            formattedParameters = [
              {
                word,
                scenarioParameterName: parameter.scenarioParameterName,
              },
            ];
          } else {
            formattedParameters = [];
          }

          return [
            ...previous,
            ...formattedParameters,
            { word: newString, scenarioParameterName: null },
          ];
        }, []) //  ['a bill of', '500', 'TWO_DOLLAR_PLACEHOLDER']
        .filter(({ word }) => word !== '') // If one puts a $ at the end of the method, this adds a useless '' at the end
        .map(({ word, scenarioParameterName }) => ({
          word: word.replace(TWO_DOLLAR_PLACEHOLDER, '$'),
          scenarioParameterName,
        })) //  ['a bill of', '500', '$']
        .map(toWord), // [Word, Word, Word]
      ...parametersCopy
        .map(parameter => ({
          word: formatParameter(parameter.value, parameter.formatters),
          scenarioParameterName: parameter.scenarioParameterName,
        }))
        .map(toWord),
    ];

    if (introWord !== null) {
      words = [toIntroWord(introWord), ...words];
    }

    if (isFirstStep) {
      const [{ value, isIntroWord }, ...rest] = words;
      words = [new Word(_.upperFirst(value), isIntroWord), ...rest];
    }
    this.words = words;
    this.name = words.map(({ value }) => value).join(' ');
    this.status = stepStatus;
    this.durationInNanos = durationInNanos;

    function toWord({ word, scenarioParameterName }: WordDescription): Word {
      return new Word(word, false, scenarioParameterName);
    }

    function toIntroWord(value: string): Word {
      return new Word(value, true, null);
    }

    type WordDescription = {
      word: string,
      scenarioParameterName: string | null,
    };
  }
}

export class Word {
  value: string;
  isIntroWord: boolean;
  scenarioParameterName: string | null;

  constructor(
    value: string,
    isIntroWord: boolean,
    scenarioParameterName: string | null = null
  ) {
    this.value = value;
    this.isIntroWord = isIntroWord;
    this.scenarioParameterName = scenarioParameterName;
  }
}

export class ScenarioCase {
  args: string[];
  parts: ScenarioPart[];
  successful: boolean;
  durationInNanos: number;
  errorMessage: string | null;
  stackTrace: string[] | null;

  constructor(args: string[] = [], parts: ScenarioPart[] = []) {
    this.args = args;
    this.parts = parts;
  }
}

export type ScenarioExecutionStatus = 'SUCCESS' | 'FAILED';

export class ScenarioReport {
  groupReport: GroupReport;
  name: string;
  cases: ScenarioCase[];
  argumentNames: string[];
  executionStatus: ScenarioExecutionStatus;

  constructor(
    groupReport: GroupReport,
    name: string,
    cases: ScenarioCase[],
    argumentNames: string[]
  ) {
    this.groupReport = groupReport;
    this.name = name;
    this.cases = cases;
    this.argumentNames = argumentNames;
  }

  dumpToFile(reportsDestination: string) {
    createDirOrDoNothingIfExists(reportsDestination);
    const fileName = computeScenarioFileName(this.groupReport.name, this.name);
    fs.writeFileSync(
      `${reportsDestination}/${fileName}`,
      JSON.stringify(this),
      'utf-8'
    );
  }
}

export class GroupReport {
  name: string;

  constructor(name: string) {
    this.name = name;
  }
}

function createDirOrDoNothingIfExists(path: string) {
  try {
    fs.mkdirSync(path);
  } catch (error) {
    if (error.code !== 'EEXIST') {
      throw error;
    } else {
      // do nothing
    }
  }
}

export function computeScenarioFileName(
  groupName: string,
  scenarioName: string
): string {
  const hash = crypto.createHash('sha256');
  hash.update(groupName + '\n' + scenarioName);
  return hash.digest('hex');
}