@v4fire/client
Version:
V4Fire client core library
353 lines (298 loc) • 11 kB
text/typescript
/*!
* V4Fire Client Core
* https://github.com/V4Fire/Client
*
* Released under the MIT license
* https://github.com/V4Fire/Client/blob/master/LICENSE
*/
import path from 'upath';
import type { JSHandle, Locator, Page } from 'playwright';
import { resolve } from '@pzlr/build-core';
import { Component, DOM, Utils } from 'tests/helpers';
import type ComponentObject from 'tests/helpers/component-object';
import type iBlock from 'super/i-block/i-block';
import type { BuildOptions } from 'tests/helpers/component-object/interface';
import type { ComponentInDummy } from 'tests/helpers/component/interface';
/**
* A class implementing the `ComponentObject` approach that encapsulates different
* interactions with a component from the client.
*
* This class provides a basic API for creating or selecting any component and interacting with it during tests.
*
* However, the recommended usage is to inherit from this class and implement a specific `ComponentObject`
* that encapsulates and enhances the client's interaction with component during the test.
*/
export default abstract class ComponentObjectBuilder<COMPONENT extends iBlock> {
/**
* The name of the component to be rendered.
*/
readonly componentName: string;
/**
* The props of the component.
*/
readonly props: Dictionary = {};
/**
* The children of the component.
*/
readonly children: VNodeChildren = {};
/**
* The locator for the root node of the component.
*/
readonly node: Locator;
/**
* The path to the class used to build the component.
* By default, it generates the path using `plzr.resolve.blockSync(componentName)`.
*
* This field is used for setting up various mocks and spies.
* Setting the path is optional if you're not using the `spy` API.
*/
readonly componentClassImportPath: Nullable<string>;
/**
* The page on which the component is located.
*/
readonly pwPage: Page;
/**
* The unique ID of the component generated when the constructor is called.
*/
protected id: string;
/**
* Stores a reference to the component's `JSHandle`.
*/
protected componentStore?: JSHandle<COMPONENT>;
/**
* Reference to the `b-dummy` wrapper component.
*/
protected dummy?: ComponentInDummy<COMPONENT>;
/**
* The component selector
*/
protected nodeSelector?: string;
/**
* The component styles that should be inserted into the page
*/
get componentStyles(): CanUndef<string> {
return undefined;
}
/**
* Public access to the reference of the component's `JSHandle`
* @throws {@link ReferenceError} if trying to access a component that has not been built or picked
*/
get component(): Promise<JSHandle<COMPONENT>> {
if (this.componentStore) {
return Promise.resolve(this.componentStore);
}
if (this.nodeSelector == null) {
throw new ReferenceError('Bad access to the component without "build" or "pick" call');
}
return this.node.elementHandle()
.then((node) => node!.getProperty('component'));
}
/**
* Returns `true` if the component is built or picked
*/
get isBuilt(): boolean {
return Boolean(this.componentStore);
}
/**
* @param page - the page on which the component is located
* @param componentName - the name of the component to be rendered
* @param [nodeSelector] - the component selector (it can be passed later using the pick method).
*/
constructor(page: Page, componentName: string, nodeSelector?: string) {
this.pwPage = page;
this.componentName = componentName;
this.id = `${this.componentName}_${Math.random().toString()}`;
this.props = {'data-component-object-id': this.id};
this.node = page.locator(nodeSelector ?? `[data-component-object-id="${this.id}"]`);
this.nodeSelector = nodeSelector;
this.componentClassImportPath = path.join(
path.relative(`${process.cwd()}/src`, resolve.blockSync(this.componentName)!),
`/${this.componentName}.ts`
);
}
/**
* A shorthand for generating selectors for component elements.
* {@link DOM.elNameSelectorGenerator}
*
* @example
* ```typescript
* this.elSelector('element') // .${componentName}__element
* ```
*/
elSelector(elName: string): string {
return DOM.elNameSelectorGenerator(this.componentName, elName);
}
/**
* Returns the base class of the component
*/
async getComponentClass(): Promise<JSHandle<new () => COMPONENT>> {
const {componentClassImportPath} = this;
if (componentClassImportPath == null) {
throw new Error('Missing component path');
}
const
classModule = await Utils.import<{default: new () => COMPONENT}>(this.pwPage, componentClassImportPath),
classInstance = await classModule.evaluateHandle((ctx) => ctx.default);
return classInstance;
}
/**
* Renders the component with the previously set props and children
* using the `withProps` and `withChildren` methods.
*
* @param [options]
*/
async build(options?: BuildOptions): Promise<JSHandle<COMPONENT>> {
await this.insertComponentStyles();
const
name = this.componentName,
fullComponentName = `${name}${options?.functional && !name.endsWith('-functional') ? '-functional' : ''}`;
const normalizeChildren = () => {
const result: Dictionary<RenderContent | RenderContentFn | string> = {};
const normalizeVNode = (vNode) => ({...vNode, tag: vNode.type});
Object.entries(this.children).forEach(([slotName, child]) => {
if (child == null || Object.isString(child) || Object.isFunction(child)) {
result[slotName] = Object.cast(child);
return;
}
if (Array.isArray(child)) {
result[slotName] = Object.cast(child.map(normalizeVNode));
}
result[slotName] = normalizeVNode(child);
});
return result;
};
if (options?.useDummy) {
const component = await Component.createComponentInDummy<COMPONENT>(this.pwPage, fullComponentName, {
attrs: this.props,
children: this.children
});
this.dummy = component;
this.componentStore = component;
} else {
this.componentStore = await Component.createComponent(this.pwPage, fullComponentName, {
attrs: this.props,
content: normalizeChildren()
});
}
return this.componentStore;
}
/**
* Picks the `Node` with the provided selector and extracts the `component` property,
* which will be assigned to the {@link ComponentObject.component}.
*
* After this operation, the `ComponentObject` will be marked as built
* and the {@link ComponentObject.component} property will be accessible.
*
* @param selector - the selector or locator for the component node
*/
async pick(selector: string): Promise<this>;
/**
* Extracts the `component` property from the provided locator,
* which will be assigned to the {@link ComponentObject.component}.
*
* After this operation, the `ComponentObject` will be marked as built
* and the {@link ComponentObject.component} property will be accessible.
*
* @param locator - the locator for the component node
*/
async pick(locator: Locator): Promise<this>;
/**
* Waits for promise to resolve and extracts the `component` property from the provided locator,
* which will be assigned to the {@link ComponentObject.component}.
*
* After this operation, the `ComponentObject` will be marked as built
* and the {@link ComponentObject.component} property will be accessible.
*
* @param locatorPromise - the promise that resolves to locator for the component node
*/
async pick(locatorPromise: Promise<Locator>): Promise<this>;
async pick(selectorOrLocator: string | Locator | Promise<Locator>): Promise<this> {
await this.insertComponentStyles();
// eslint-disable-next-line no-nested-ternary
const locator = Object.isString(selectorOrLocator) ?
this.pwPage.locator(selectorOrLocator) :
Object.isPromise(selectorOrLocator) ? await selectorOrLocator : selectorOrLocator;
this.componentStore = await locator.elementHandle().then(async (el) => {
await el?.evaluate((ctx, [id]) => ctx.setAttribute('data-component-object-id', id), [this.id]);
return el?.getProperty('component');
});
return this;
}
/**
* Inserts into page styles of components that are defined in the {@link ComponentObject.componentStyles} property
*/
async insertComponentStyles(): Promise<void> {
if (this.componentStyles != null) {
await this.pwPage.addStyleTag({content: this.componentStyles});
}
}
/**
* Stores the provided props.
* The stored props will be assigned when the component is created or picked.
*
* @param props - the props to set
*/
withProps(props: Dictionary): this {
if (!this.isBuilt) {
Object.assign(this.props, props);
}
return this;
}
/**
* Stores the provided children.
* The stored children will be assigned when the component is created.
*
* @param children - the children to set
*/
withChildren(children: VNodeChildren): this {
Object.assign(this.children, children);
return this;
}
/**
* Updates the component's props or children using the `b-dummy` component.
* This method will not work if the component was built without the `useDummy` option.
*
* @param props
* @param [mixInitialProps] - if true, the initially set props will be mixed with the passed props
*
* @throws {@link ReferenceError} - if the component object was not built or was built without the `useDummy` option
*/
update(props: RenderComponentsVnodeParams, mixInitialProps: boolean = true): Promise<void> {
if (!this.dummy) {
throw new ReferenceError('Failed to update component. Missing "b-dummy" component.');
}
return this.dummy.update(props, mixInitialProps);
}
/**
* Updates the component's props using the `b-dummy` component.
* This method will not work if the component was built without the `useDummy` option.
*
* By default, the passed props will be merged with the previously set props,
* but this behavior can be cancelled by specifying the second argument as false.
*
* @param props
* @param [mixInitialProps] - if true, the initially set props will be mixed with the passed props
*
* @throws {@link ReferenceError} - if the component object was not built or was built without the `useDummy` option
*/
updateProps(props: RenderComponentsVnodeParams['attrs'], mixInitialProps: boolean = true): Promise<void> {
if (!this.dummy) {
throw new ReferenceError('Failed to update props. Missing "b-dummy" component.');
}
return this.dummy.update({attrs: props}, mixInitialProps);
}
/**
* Updates the component's children using the `b-dummy` component.
* This method will not work if the component was built without the `useDummy` option.
*
* @param children
*
* @throws {@link ReferenceError} - if the component object was not built or was built without the `useDummy` option
*/
updateChildren(children: RenderComponentsVnodeParams['children']): Promise<void> {
if (!this.dummy) {
throw new ReferenceError('Failed to update children. Missing "b-dummy" component.');
}
return this.dummy.update({children});
}
}