UNPKG

@v4fire/client

Version:

V4Fire client core library

353 lines (298 loc) • 11 kB
/*! * 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}); } }