Skip to content
Closed
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 29 additions & 5 deletions packages/globals/src/globals.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,28 @@ type ActionType = "interaction" | "check";

interface Globals {
readonly document: Document;
readonly wrapAction: <T>(description: string, action: () => Promise<T>, type: ActionType) => () => Promise<T>;
readonly wrapAction: <T>(
description: string,
action: () => Promise<T>,
type: ActionType,
options: InteractorOptions
) => () => Promise<T>;
readonly actionWrappers: Set<
<T>(description: string, action: () => Promise<T>, type: ActionType) => () => Promise<T>
<T>(description: string, action: () => Promise<T>, type: ActionType, options: InteractorOptions) => () => Promise<T>
>;
readonly interactorTimeout: number;
readonly reset: () => void;
}

export type InteractorOptions = {
name: string;
actionName: string;
args?: unknown[];
locator?: string;
filter?: Record<string, any>;
ancestors?: Omit<InteractorOptions, "actionName">[];
};

declare global {
// eslint-disable-next-line @typescript-eslint/no-namespace, @typescript-eslint/prefer-namespace-keyword
module globalThis {
Expand All @@ -30,10 +44,15 @@ if (!globalThis.__interactors) {
configurable: true,
},
wrapAction: {
value: <T>(description: string, action: () => Promise<T>, type: ActionType): (() => Promise<T>) => {
value: <T>(
description: string,
action: () => Promise<T>,
type: ActionType,
options: InteractorOptions
): (() => Promise<T>) => {
let wrappedAction = action;
for (let wrapper of getGlobals().actionWrappers) {
wrappedAction = wrapper(description, wrappedAction, type);
wrappedAction = wrapper(description, wrappedAction, type, options);
}
return wrappedAction;
},
Expand Down Expand Up @@ -80,7 +99,12 @@ export function setInteractorTimeout(ms: number): void {
}

export function addActionWrapper<T>(
wrapper: (description: string, action: () => Promise<T>, type: ActionType) => () => Promise<T>
wrapper: (
description: string,
action: () => Promise<T>,
type: ActionType,
options: InteractorOptions
) => () => Promise<T>
): () => boolean {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It looks like this wrapper should be a type:

export interface InteractionEvent<T> {
  description: string;
  action: () => Promise<T> ;
  type: ActionType;
  options: InteractionOptions;
}

export type InteractionHook<T> = (event: InteractionEvent<T>) => Promise<T>;

export function addActionWrapper<T>(hook: InteractionHook<T>): () => boolean {
  // add it
}

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. That was a little bit tricky to not break things

getGlobals().actionWrappers.add(wrapper as any);

Expand Down
100 changes: 63 additions & 37 deletions packages/html/src/constructor.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/* eslint-disable @typescript-eslint/no-explicit-any */

import { globals } from '@interactors/globals';
import { globals, InteractorOptions as SerializedInteractorOptions } from '@interactors/globals';
import { converge } from './converge';
import { MergeObjects } from './merge-objects';
import {
Expand Down Expand Up @@ -138,6 +138,18 @@ export function unsafeSyncResolveUnique<E extends Element>(options: InteractorOp
return resolveUnique(unsafeSyncResolveParent(options), options) as E;
}

export function serializeOptions(actionName: string, options: InteractorOptions<any, any, any>, args?: unknown[]): SerializedInteractorOptions {
let locator = options.locator?.value
return {
...options,
actionName,
args,
locator: locator == 'string' ? locator : undefined,
filter: options.filter.all,
ancestors: options.ancestors.map(ancestor => serializeOptions(actionName, ancestor)),
}
}

export function instantiateBaseInteractor<E extends Element, F extends Filters<E>, A extends Actions<E>>(
options: InteractorOptions<E, F, A>,
resolver: (options: InteractorOptions<E, F, A>) => E
Expand All @@ -150,15 +162,19 @@ export function instantiateBaseInteractor<E extends Element, F extends Filters<E
},

perform<T>(fn: (element: E) => T): Interaction<T> {
return interaction(`run perform on ${description(options)}`, () => converge(() => fn(resolver(options))))
return interaction(
`run perform on ${description(options)}`,
() => converge(() => fn(resolver(options))),
serializeOptions('perform', options)
)
},

assert(fn: (element: E) => void): ReadonlyInteraction<void> {
return check(`${description(options)} asserts`, () => {
return converge(() => {
fn(resolver(options));
});
});
return check(interaction(
`${description(options)} asserts`,
() => converge(() => void fn(resolver(options))),
serializeOptions('assert', options)
));
},

has(filters: FilterParams<E, F>): ReadonlyInteraction<void> {
Expand All @@ -167,15 +183,18 @@ export function instantiateBaseInteractor<E extends Element, F extends Filters<E

is(filters: FilterParams<E, F>): ReadonlyInteraction<void> {
let filter = new Filter(options.specification, filters);
return check(`${description(options)} matches filters: ${filter.description}`, () => {
return converge(() => {
let element = resolver({...options, filter: getLookupFilterForAssertion(options.filter, filters) });
let match = new MatchFilter(element, filter);
if (!match.matches) {
throw new FilterNotMatchingError(`${description(options)} does not match filters:\n\n${match.formatAsExpectations()}`);
}
});
});
return check(interaction(
`${description(options)} matches filters: ${filter.description}`, () => {
return converge(() => {
let element = resolver({...options, filter: getLookupFilterForAssertion(options.filter, filters) });
let match = new MatchFilter(element, filter);
if (!match.matches) {
throw new FilterNotMatchingError(`${description(options)} does not match filters:\n\n${match.formatAsExpectations()}`);
}
});
},
serializeOptions('is', options)
));
},
}

Expand All @@ -189,7 +208,8 @@ export function instantiateBaseInteractor<E extends Element, F extends Filters<E
}
return interaction(
`${actionDescription} on ${description(options)}`,
() => action(interactor as Interactor<E, FilterParams<E, F>> & ActionMethods<E, A>, ...args)
() => action(interactor as Interactor<E, FilterParams<E, F>> & ActionMethods<E, A>, ...args),
serializeOptions(actionName, options, args)
);
},
configurable: true,
Expand All @@ -203,12 +223,17 @@ export function instantiateBaseInteractor<E extends Element, F extends Filters<E
if (!interactor.hasOwnProperty(filterName)) {
Object.defineProperty(interactor, filterName, {
value: function() {
return interactionFilter(`${filterName} of ${description(options)}`, async () => {
return applyFilter(filter, resolver(options));
}, (parentElement) => {
let element = [...options.ancestors, options].reduce(resolveUnique, parentElement);
return applyFilter(filter, element);
});
return interactionFilter(
interaction(
`${filterName} of ${description(options)}`,
async () => { return applyFilter(filter, resolver(options)) },
serializeOptions(filterName, options)
),
(parentElement) => {
let element = [...options.ancestors, options].reduce(resolveUnique, parentElement);
return applyFilter(filter, element);
}
);
},
configurable: true,
writable: true,
Expand All @@ -234,23 +259,24 @@ export function instantiateInteractor<E extends Element, F extends Filters<E>, A
},

exists(): ReadonlyInteraction<void> & FilterObject<boolean, Element> {
return checkFilter(`${description(options)} exists`, () => {
return converge(() => {
resolveNonEmpty(unsafeSyncResolveParent(options), options);
});
}, (element) => {
return findMatchesMatching(element, options).length > 0;
});
return checkFilter(
interaction(
`${description(options)} exists`,
() => converge(() => { resolveNonEmpty(unsafeSyncResolveParent(options), options) }),
serializeOptions('exists', options)
),
(element) => findMatchesMatching(element, options).length > 0
);
},

absent(): ReadonlyInteraction<void> & FilterObject<boolean, Element> {
return checkFilter(`${description(options)} does not exist`, () => {
return converge(() => {
resolveEmpty(unsafeSyncResolveParent(options), options);
});
}, (element) => {
return findMatchesMatching(element, options).length === 0;
});
return checkFilter(
interaction(
`${description(options)} does not exist`,
() => converge(() => { resolveEmpty(unsafeSyncResolveParent(options), options) }),
serializeOptions('absent', options)
),
(element) => findMatchesMatching(element, options).length === 0);
}
});
}
Expand Down
20 changes: 10 additions & 10 deletions packages/html/src/interaction.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { globals } from '@interactors/globals';
import type { FilterObject } from './specification';
import { globals, InteractorOptions } from '@interactors/globals';
import type { FilterObject, } from './specification';

const interactionSymbol = Symbol.for('interaction');

Expand Down Expand Up @@ -65,18 +65,18 @@ function createInteraction<T>(description: string, action: () => Promise<T>): In
}
}

export function interaction<T>(description: string, action: () => Promise<T>): Interaction<T> {
return createInteraction(description, globals.wrapAction(description, action, 'interaction'))
export function interaction<T>(description: string, action: () => Promise<T>, options: InteractorOptions ): Interaction<T> {
return createInteraction(description, globals.wrapAction(description, action, 'interaction', options))
}

export function check<T>(description: string, check: () => Promise<T>): ReadonlyInteraction<T> {
return { check() { return this.action() }, ...createInteraction(description, globals.wrapAction(description, check, 'check')) };
export function check<T>(interaction: Interaction<T>): ReadonlyInteraction<T> {
return { check() { return this.action() }, ...interaction };
}

export function interactionFilter<T, Q>(description: string, action: () => Promise<T>, filter: (element: Element) => Q): Interaction<T> & FilterObject<Q, Element> {
return { apply: filter, ...interaction(description, action) };
export function interactionFilter<T, Q>(interaction: Interaction<T>, filter: (element: Element) => Q): Interaction<T> & FilterObject<Q, Element> {
return { apply: filter, ...interaction };
}

export function checkFilter<T, Q>(description: string, action: () => Promise<T>, filter: (element: Element) => Q): ReadonlyInteraction<T> & FilterObject<Q, Element> {
return { apply: filter , ...check(description, action) };
export function checkFilter<T, Q>(interaction: Interaction<T>, filter: (element: Element) => Q): ReadonlyInteraction<T> & FilterObject<Q, Element> {
return { apply: filter , ...check(interaction) };
}
2 changes: 1 addition & 1 deletion packages/html/src/page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ const PageInteractorInstance = Object.assign(PageInteractor(), {
reject(new Error('timed out trying to load application'));
}, bigtestGlobals.defaultAppTimeout);
});
});
}, { name: 'Page', actionName: 'visit', args: [path] });
}
});

Expand Down
4 changes: 2 additions & 2 deletions packages/html/src/read.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import { converge } from "./converge";
import { interaction } from "./interaction";
import { unsafeSyncResolveUnique } from './constructor'
import { serializeOptions, unsafeSyncResolveUnique } from './constructor'
import { EmptyObject, FilterReturn, Interactor } from "./specification";

export const read = <E extends Element, F extends EmptyObject>(interactor: Interactor<E, F>, field: keyof F): Promise<FilterReturn<F>> => {
let filter = interactor.options.specification.filters?.[field]
return interaction(`get ${field} from ${interactor.description}`, async () => {
let filterFn = typeof(filter) === 'function' ? filter : filter.apply
return await converge(() => filterFn(unsafeSyncResolveUnique(interactor.options)));
});
}, serializeOptions('read', interactor.options));
}
4 changes: 2 additions & 2 deletions packages/material-ui/.storybook/main.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
module.exports = {
stories: ["../stories/**/*.stories.@(md|ts)x"],
addons: ["@storybook/addon-postcss", "@storybook/addon-essentials"],
features: { previewCsfV3: true },
addons: ["@storybook/addon-postcss", "@storybook/addon-essentials", "@storybook/addon-interactions"],
features: { previewCsfV3: true, interactionsDebugger: true },
core: { builder: "webpack5" },
};
40 changes: 40 additions & 0 deletions packages/material-ui/.storybook/preview.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { instrument } from "@storybook/instrumenter";
import { addActionWrapper, InteractorOptions } from "@interactors/globals";

// NOTE: Dummy call to initialize instrumenter. See: https://github.com/storybookjs/storybook/blob/next/lib/instrumenter/src/instrumenter.ts#L512
instrument({});

let topActionRef = null;

addActionWrapper((_description, action, _type, options) => async () => {
if (!topActionRef) topActionRef = action;
try {
if (topActionRef != action) return action();
const instrumenter = global.window.__STORYBOOK_ADDON_INTERACTIONS_INSTRUMENTER__;
const { actionName } = options;
let find = (r) => r;

for (const ancestor of options.ancestors ?? []) {
({ find } = find(
instrumenter.track(
ancestor.name,
() => ({ find: (r) => r }),
ancestor.locator ? [ancestor.locator, ancestor.filter] : [ancestor.filter],
{ intercept: () => true }
)
));
}
const { [actionName]: wrappedAction } = find(
instrumenter.track(
options.name,
() => ({ [actionName]: action }),
options.locator ? [options.locator, options.filter] : [options.filter],
{ intercept: () => true }
)
);

await wrappedAction(...options.args);
} finally {
if (topActionRef == action) topActionRef = null;
}
});
12 changes: 7 additions & 5 deletions packages/material-ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,16 +39,18 @@
"homepage": "https://frontside.com/interactors",
"devDependencies": {
"@date-io/date-fns": "^1.3.13",
"@interactors/globals": "^0.1.0",
"@material-ui/core": "^4.12.3",
"@material-ui/icons": "^4.11.2",
"@material-ui/pickers": "^3.3.10",
"@material-ui/styles": "^4.11.4",
"@storybook/addon-docs": "6.4.0-alpha.30",
"@storybook/addon-essentials": "6.4.0-alpha.30",
"@storybook/addon-docs": "6.4.0-beta.30",
"@storybook/addon-essentials": "6.4.0-beta.30",
"@storybook/addon-interactions": "^6.4.0-beta.30",
"@storybook/addon-postcss": "^2.0.0",
"@storybook/builder-webpack5": "6.4.0-alpha.30",
"@storybook/manager-webpack5": "6.4.0-alpha.30",
"@storybook/react": "6.4.0-alpha.30",
"@storybook/builder-webpack5": "6.4.0-beta.30",
"@storybook/manager-webpack5": "6.4.0-beta.30",
"@storybook/react": "6.4.0-beta.30",
"@testing-library/react": "^12.0.0",
"@types/react": "^17.0.19",
"bigtest": "^0.15.3",
Expand Down
5 changes: 5 additions & 0 deletions packages/material-ui/stories/docs.stories.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,16 @@ import {
Tabs,
TextField,
TimeField,
DeepDivs
} from "./interactors.stories";
import { InteractiveStory as Story } from "./interactive-story";

<Meta title="Interactors" />

<Canvas>
<Story story={DeepDivs} />
</Canvas>

<Canvas>
<Story story={Accordion} />
</Canvas>
Expand Down
Loading