intraxia/wp-gistpen

View on GitHub
commands/E2ECommand.tsx

Summary

Maintainability
A
3 hrs
Test Coverage
import path from 'path';
import { Command } from 'brookjs-cli';
import { ofType, useDelta, Delta, sampleByAction } from 'brookjs';
import { Box, Color, AppContext } from 'ink';
import Kefir, { Observable } from 'kefir';
import React, { useContext, useEffect } from 'react';
import execa from 'execa';
import {
  ActionType,
  createAction,
  createAsyncAction,
  getType,
  ActionCreator
} from 'typesafe-actions';
import jest from 'jest';
import { buildArgv } from 'jest-cli/build/cli';
import fs from './fs';

type Maybe<T> = T | null | undefined;

const unreachable = (x: never): never => {
  throw new Error('unreachable value found ' + x);
};

const isPromise = (obj: any): obj is Promise<any> =>
  !!obj &&
  (typeof obj === 'object' || typeof obj === 'function') &&
  typeof obj.then === 'function';

type State = {
  cwd: string;
  e2e: E2ERC;
  status:
    | 'idle'
    | 'starting'
    | 'started'
    | 'startup-failed'
    | 'running-tests'
    | 'tests-passed'
    | 'tests-failed';
};

const actions = {
  init: createAction('INIT'),
  startup: createAsyncAction(
    'STARTUP_REQUESTED',
    'STARTUP_SUCCEEDED',
    'STARTUP_FAILED'
  )<void, { msg: string }, Error>(),
  testRun: createAsyncAction(
    'TEST_RUN_REQUESTED',
    'TEST_RUN_SUCCEEDED',
    'TEST_RUN_FAILED'
  )<void, void, void>(),
  shutdown: createAsyncAction(
    'SHUTDOWN_REQUESTED',
    'SHUTDOWN_SUCCEEDED',
    'SHUTDOWN_FAILED'
  )<void, void, void>()
};

type Action = ActionType<typeof actions>;

type E2EExec = Maybe<Observable<string, Error> | Promise<string>>;

interface E2ERC {
  dir?: string;
  startup?(): E2EExec;
  shutdown?(): E2EExec;
}

// This is what's going to be defined in the E2E key in the rc file.
const e2e: E2ERC = {
  async startup() {
    await execa.command('wp-env start');
    return 'Started!';
  },

  async shutdown() {
    await execa.command('wp-env stop');
    return 'Stopped!';
  }
};

const toObs = (ret: E2EExec): Observable<string, Error> => {
  // Normalize other values to Observables.
  if (ret == null) {
    return Kefir.constant('');
  }

  if (isPromise(ret)) {
    return Kefir.fromPromise(ret);
  }

  return ret;
};

const startup: Delta<Action, State> = (action$, state$) =>
  Kefir.concat([
    // @TODO(mAAdhaTTah) use `constant` when useDelta is fixed.
    Kefir.later(0, actions.startup.request()),
    state$.take(1).flatMap(
      (state): Observable<Action, never> => {
        const ret = toObs(state.e2e.startup?.())
          .map(msg => actions.startup.success({ msg }))
          .flatMapErrors(err => Kefir.constant(actions.startup.failure(err)));

        return state.e2e.shutdown == null
          ? ret.takeUntilBy(action$.thru(ofType(actions.shutdown.request)))
          : ret;
      }
    )
  ]);

const setupTestsPath = (state: State, testExtension: string) =>
  path.join(state.cwd, state.e2e.dir ?? 'e2e', `setupTests.${testExtension}`);

const tests: Delta<Action, State> = (action$, state$) =>
  state$.thru(sampleByAction(action$, actions.startup.success))
    .flatMap(state =>
      Kefir.combine({
        cwd: Kefir.constant(state.cwd),
        dir: Kefir.constant(state.e2e.dir ?? 'e2e'),
        setupTests: fs
          // If tsconfig.json exists, we're going to assume typescript.
          .access(path.join(state.cwd, 'tsconfig.json'))
          .map(() => 'ts')
          .flatMapErrors(() => Kefir.constant('js'))
          .flatMap(testExtension =>
            fs
              // If setupTests.{ts,js} exists in the src dir, then we'll use it.
              .access(setupTestsPath(state, testExtension))
              .map(() => [
                `<rootDir>/${state.e2e.dir ??
                  'e2e'}/setupTests.${testExtension}`
              ])
              .flatMapErrors(() => Kefir.constant([]))
          )
      })
    )
    .map(({ cwd, dir, setupTests }) => {
      const argv = [];

      const config: any = {
        roots: [path.join('<rootDir>', dir)],
        preset: 'jest-puppeteer',
        setupFilesAfterEnv: setupTests,
        testMatch: [
          // Anything with `spec/test` is a test file
          // Don't glob `__tests__` because test utils
          `<rootDir>/${dir}/**/*.{spec,test}.{js,jsx,ts,tsx}`
        ],
        transform: {
          '^.+\\.(js|jsx|ts|tsx)$': path.join(
            'brookjs-cli',
            'jest',
            'babelTransform.js'
          ),
          '^.+\\.css$': path.join('brookjs-cli', 'jest', 'cssTransform.js'),
          '^(?!.*\\.(js|jsx|ts|tsx|css|json)$)': path.join(
            'brookjs-cli',
            'jest',
            'fileTransform.js'
          )
        },
        transformIgnorePatterns: [
          '[/\\\\]node_modules[/\\\\].+\\.(js|jsx|ts|tsx)$',
          '^.+\\.module\\.(css|sass|scss)$'
        ],
        moduleNameMapper: {
          '^react-native$': 'react-native-web',
          '^.+\\.module\\.(css|sass|scss)$': 'identity-obj-proxy'
        },
        moduleFileExtensions: ['js', 'jsx', 'ts', 'tsx', 'node']
      };

      argv.push(`--config`, JSON.stringify(config));
      argv.push('--runInBand');

      return [argv, [cwd]];
    })
    .flatMap(([argv, projects]) =>
      Kefir.stream<Action, never>(emitter => {
        process.env.NODE_ENV = 'test';
        process.env.BABEL_ENV = 'test';
        emitter.value(actions.testRun.request());
        jest.runCLI(buildArgv(argv), projects).then(({ results }) => {
          if (results.success) {
            emitter.value(actions.testRun.success());
          } else {
            emitter.value(actions.testRun.failure());
          }
          emitter.value(actions.shutdown.request());
        });
      })
    )
    .flatMapErrors(() => Kefir.constant(actions.testRun.failure()));

const reducer = (state: State, action: Action): State => {
  switch (action.type) {
    case getType(actions.startup.request):
      return {
        ...state,
        status: 'starting'
      };
    case getType(actions.startup.success):
      return {
        ...state,
        status: 'started'
      };
    case getType(actions.startup.failure):
      return {
        ...state,
        status: 'startup-failed'
      };
    case getType(actions.testRun.request):
      return {
        ...state,
        status: 'running-tests'
      };
    case getType(actions.testRun.success):
      return {
        ...state,
        status: 'tests-passed'
      };
    case getType(actions.testRun.failure):
      return {
        ...state,
        status: 'tests-failed'
      };
    default:
      return state;
  }
};

const useExit = (error?: Error) => {
  const { exit } = useContext(AppContext);

  useEffect(() => {
    exit(error);
  }, [exit]);
};

const Fail: React.FC = () => {
  useExit(new Error());

  return <Color red>Tests failed!</Color>;
};

const Status: React.FC<{ status: State['status'] }> = ({ status }) => {
  switch (status) {
    case 'idle':
      return <Color yellow>Booting...</Color>;
    case 'starting':
      return <Color yellow>Starting application...</Color>;
    case 'started':
      return <Color green>Startup successful!</Color>;
    case 'startup-failed':
      return <Color red>Startup failed!</Color>;
    case 'running-tests':
      return <Color yellow>Running tests</Color>;
    case 'tests-passed':
      return <Color green>Tests passed!</Color>;
    case 'tests-failed':
      return <Fail />;
    default:
      return unreachable(status);
  }
};

const rootDelta: Delta<Action, State> = (action$, state$) =>
  Kefir.merge([startup(action$, state$), tests(action$, state$)]);

const E2ECommand: Command<{}> = {
  cmd: 'e2e',
  describe: 'Run the application e2e tests.',
  builder(yargs) {
    return yargs;
  },
  View: ({ cwd }) => {
    const { state } = useDelta(
      reducer,
      { e2e, cwd, status: 'idle' },
      rootDelta
    );

    return (
      <Box>
        <Status status={state.status} />
      </Box>
    );
  }
};

export default E2ECommand;