All files / src/storybook UnitTest.tsx

83.87% Statements 52/62
75.22% Branches 82/109
87.5% Functions 7/8
86.2% Lines 50/58

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200                                                            113x 165x     113x 373x 373x 373x 373x                     113x                                             113x   373x 373x 373x 373x   373x 373x 157x           157x 157x     373x   373x   373x 373x 373x                   373x     113x           113x     276x 276x   276x   276x 2305x 613x 613x   613x   1692x       276x 276x               113x         276x 276x 9613x 1826x 1826x 276x     276x                                 113x 1516x 1516x 102x           1516x                                
import { DocsContext, getStoryId } from "@storybook/addon-docs";
import { Source } from "@storybook/components";
import React, { createContext, useContext } from "react";
import dedent from "ts-dedent";
 
import type { WithCy } from "../types";
 
/** Props for the UnitTest component, mostly to link to story */
export type UnitTestProps = {
  /** name matching the story's name */
  name?: string;
  /** id matching the story's name. either name or id must be provided */
  id?: string;
  /**
   * description for tests which have cy function or cyTest formats.
   * Provides a reasonable default but can opt out with `false`
   */
  description?: string | false | null;
  /**
   * Parameters, probably passed in from parent in unitTestDecorator,
   * though I suppose passing in directly could be useful somehow
   */
  parameters?: WithCy<any>;
  /**
   * Potentially internal only: if the literate test is from code block. Used to
   * deduplicate display of code block in docs view
   */
  isCodeBlock?: boolean;
};
 
const getDefaultDescription = (cyTest: unknown) =>
  `should pass the following ${cyTest ? "cyTest" : "cy"} expectation`;
 
/** Get entries to map over to then display in each Preview component */
const getNormalizedCyTestEntries = (parameters: any, props: UnitTestProps) => {
  Iif (!parameters) return [];
  const { cy, cyTest } = parameters;
  if (cy) {
    return typeof cy === "object"
      ? Object.entries(cy)
      : [[props.description ?? getDefaultDescription(false), cy]];
  }
  Iif (cyTest) {
    return [[props.description ?? getDefaultDescription(true), cyTest]];
  }
  return [];
};
 
/** Context for parameters used in unitTestDecorator */
export const ParametersContext = createContext({});
 
/**
 * Display test information for pure unit test stories.
 * This is likey used in mdx files and must have a 'name' or 'id' which would appropriately
 * match to the proper story.
 * See [the task story](https://quotapath.github.io/orphic-cypress/storybook/?path=/docs/cypressutils-tasks--arbitrary-task#literate-testing)
 * for detailed use.
 * ```ts
 * <Story
 *   name="ArbitraryTask"
 *   parameters={{
 *     cy: () =>
 *       cy.arbitraryTask(2).then(($num) => expect($num).to.equal(2)),
 *   }}
 * >
 *   <UnitTest name="ArbitraryTask" />
 * </Story>
 * ```
 *
 * Or by providing the `unitTestDecorator` decorator and setting a `cyUnitTest`
 * parameter to `true`
 */
export const UnitTest = (props: UnitTestProps) => {
  // lots of different possible ways of getting parameters in here
  const docsContext = useContext(DocsContext);
  const parametersContext = useContext(ParametersContext);
  let parameters = props.parameters;
  const hasDocsContext = docsContext && Object.keys(docsContext).length > 0;
  const hasParametersContext =
    parametersContext && Object.keys(parametersContext).length > 0;
  if (!parameters) {
    Iif (hasDocsContext) {
      const storyId = getStoryId(props, docsContext);
      const story = docsContext
        .componentStories()
        .find(({ id }) => id === storyId);
      parameters = story?.parameters;
    } else if (hasParametersContext) {
      parameters = parametersContext;
    }
  }
  Iif (!parameters) return null;
 
  const cyMap = getNormalizedCyTestEntries(parameters, props);
 
  const previews = cyMap.map(([key, orgCode], i) => {
    const code = dedent((orgCode as () => void).toString());
    return (
      <div key={String(key) || i}>
        {key && <div className="orphic-cypress-unit-test">{key}</div>}
        {!(props.isCodeBlock && hasDocsContext) && (
          <Source language="tsx" dark format={false} code={code} />
        )}
      </div>
    );
  });
 
  return <>{previews}</>;
};
 
const regex = {
  multilineInit: /^\s?\/\*/,
  multilineClose: /\*\//,
  comment: /^\s?(\/\/|\/\*\*|\*\/|\*)\s?/,
};
 
const partitionCommentsAndCode = (
  fnToParse: string
): [comments: string, code: string] => {
  const [comments, code]: [string[], string[]] = [[], []];
  const fnToParseSplit = fnToParse.split("\n");
  let isMultiLine =
    regex.multilineInit.test(fnToParseSplit[0]) &&
    !regex.multilineClose.test(fnToParseSplit[0]);
  for (const line of fnToParse.split("\n")) {
    if (code.length === 0 && (regex.comment.test(line) || isMultiLine)) {
      if (regex.multilineClose.test(line)) isMultiLine = false;
      const newComment = line.replace(regex.comment, "");
      // strip empty lines
      if (newComment.length) comments.push(newComment);
    } else {
      code.push(line);
    }
  }
  // for now at least,
  const joinedDescription = comments.length > 0 ? comments.join(" ") : "";
  return [joinedDescription, code.join("\n")];
};
 
/**
 * Gets the code block matching a story from the mdx page.
 * Just a tad hacky with how its getting to the MDXContent
 * @private
 */
export const getStoryCyFromMdxCodeBlock = (
  parameters: any,
  storyName: string,
  functionize?: boolean
): { [description: string]: string } => {
  const mdxContent = parameters?.docs?.page?.()?.props?.children?.type?.({});
  for (const child of mdxContent?.props?.children ?? []) {
    if (child?.props?.mdxType === "pre") {
      const childrenProps = child.props.children?.props;
      if (childrenProps.metastring === storyName) {
        const [description, code] = partitionCommentsAndCode(
          childrenProps.children
        );
        return {
          [description.length ? description : getDefaultDescription(false)]:
            functionize ? `() => { ${code} }` : code,
        };
      }
    }
  }
  return {};
};
 
/**
 * A storybook decorator that provides a parameter context for the sake
 * of showing UnitTest components in cypress and storybook canvas as opposed
 * to just docs, and allows display without manually adding a UnitTest component
 * via `cyUnitTest` parameter.
 * TODO: types would be nice, but have been annoying
 */
export const unitTestDecorator = (Story: any, context: any) => {
  const parameters = { ...context.originalStoryFn, ...context.parameters };
  if (parameters.cyCodeBlock) {
    parameters.cy = getStoryCyFromMdxCodeBlock(
      context.parameters,
      context.originalStoryFn.storyName
    );
  }
 
  return (
    <ParametersContext.Provider value={parameters}>
      <Story />
      {(parameters.cyUnitTest || parameters.cyCodeBlock) && (
        <span data-cy="cy-unit-test">
          <br />
          <UnitTest
            name={context.name}
            parameters={parameters}
            isCodeBlock={parameters.cyCodeBlock}
          />
        </span>
      )}
    </ParametersContext.Provider>
  );
};