orphic-cypress
Version:
Set of utilities and typescript transformers to cover storybook stories with cypress component tests
173 lines (163 loc) • 5.31 kB
text/typescript
/**
* Utilities to provide cypress stubs for storybook actions.
* This will mock all explicitly defined argTypes in any location.
*
* Happens automatically for `executeCyTests`, but would be executed
* manually for external test files.
*
* @module
*/
import type { ArgTypes } from "@storybook/react";
import type {
ComponentStoryCy,
ComponentStoryObjCy,
StoryFileCy,
} from "./types";
/**
* Object of function name keys to stubbed actions values.
* Might be more likely that you'd access these stubs via `cy.get("@actions")`
*/
export type WrappedActions = {
[fnName: string]: ReturnType<typeof cy.stub | typeof cy.spy>;
};
/** Quick util to stub or spy and alias, which isn't consistent in cypress API */
const mockAs = (alias: string, toSpy?: any) => {
if (toSpy) return cy.spy(toSpy).as(alias);
const stub = cy.stub();
stub.as(alias);
return stub;
};
const addAlias = (
stubOrSpy: ReturnType<typeof cy.stub | typeof cy.spy>,
alias: string
) => {
stubOrSpy.as(alias);
return stubOrSpy;
};
/**
* Wrap argTypes in cy.stubs. Unit test framework from storybook at this point doesn't do
* anything with these argTypes, nor does it add props/stubs for actions.argTypesRegex.
* As such, its recommended to manually specify crucial argTypes, or write `.cy` tests
* which provide mocks.
*
* In executeCyTests will operate on `export default { argTypes: { some: { action: 'some' } } }`
* and combine that with any action argTypes defined on the story level.
* Will be available at `cy.get("@actions")` or `this.actions` within tests.
*
* @private
*/
const stubArgTypeActions = (
args: ComponentStoryCy<any>["args"] | ComponentStoryObjCy<any>["args"],
argTypes?: Partial<ArgTypes<any>>,
seed?: WrappedActions
): WrappedActions =>
Object.entries(argTypes ?? {}).reduce((acc, [key, value]) => {
if (value && value.action) {
// alias the stub with the name given to the action. shows up really well in cypress
// as a stub with call count and in assertion names etc.
return {
...acc,
[key]: acc[key]
? addAlias(acc[key], value.action)
: mockAs(value.action, args?.[key]),
};
}
return acc;
}, seed ?? {});
/**
* Object of either component obj of function
* @private
*/
export type Stories = {
[name: string]: (ComponentStoryCy<any> | ComponentStoryObjCy<any>) & {
/** this seems to be the accurate storyName for the component */
storyName: string;
};
};
/**
* Get argTypes from both the default export and the individual story.
* Useful for a per-component beforeEach or top-of-test declaration.
* Note that you'll want to return undefined from `beforeEach`
*
* ```ts
* describe("SomeComponent", () => {
* beforeEach(() => {
* stubStoryActions(SomeComponent, stories);
* });
*
* it("should render ok and call someAction on init", () => {
* cy.mount(<SomeComponent {...this.actions} />);
* cy.get("@actions").its("someAction").should("be.calledWith", "");
* });
* });
* ```
*
* ```ts
* it("should do something", () => {
* // could just be `const actions = { someAction: cy.stub(), ... }`
* const actions = stubStoryActions(SomeStory, stories);
* cy.mount(<SomeStory {...actions} />);
* cy.dataCy("something").click().then(() => {
* expect(actions.someAction).to.have.callCount(1);
* });
* // or without the promise
* cy.dataCy("something").click();
* cy.get("@actions").its("someAction").should("have.callCount", 2);
* });
* ```
*
* Mostly an internal detail: precedence order, 3 through end will have essentially the same effect
* 1) explicitly provided args/props for story which will become spies
* 2) explicitly provided args at default export which will become spies
* 3) local argTypes action definition
* 4) global argTypes action definition
* 5) local argTypes regex definition
* 6) global argTypes regex definition
*/
export const stubStoryActions = <T extends StoryFileCy>(
composedStory: ComponentStoryCy<any> | ComponentStoryObjCy<any>,
stories: T,
seed?: WrappedActions
): WrappedActions => {
const { argTypes, storyName, parameters, args } = composedStory;
const argTypesRegex = parameters?.actions?.argTypesRegex;
const docgenInfo = (
stories.default?.component as any as {
__docgenInfo?: { props: { [key: string]: unknown } };
}
)?.__docgenInfo;
const asRegex = new RegExp(argTypesRegex);
// start with args and props, unique
const argKeys = [
...new Set([
...Object.keys(args ?? {}),
...Object.keys(docgenInfo?.props ?? {}),
]),
];
const toAutoMock = argTypesRegex
? Object.fromEntries(
argKeys.flatMap((key) =>
asRegex.test(key)
? [[key, mockAs(`argTypesRegex.${key}`, args?.[key])]]
: []
)
)
: {};
const argTypesFromStoryObj = storyName
? (stories as any as Stories)[storyName]?.argTypes
: null;
const actions = stubArgTypeActions(
{ ...(stories.default?.args ?? {}), ...composedStory.args },
{
...(stories.default?.argTypes ?? {}),
...(argTypes ?? {}),
...(argTypesFromStoryObj ?? {}),
},
{
...toAutoMock,
...(seed ?? {}),
}
);
cy.wrap(actions).as("actions");
return actions;
};