diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d2d516902..7e23901e46 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,24 @@ Changelog _Note: Gaps between patch versions are faulty, broken or test releases._ +## v4.0.0-beta.56 (2024-02-09) + +#### :rocket: New Feature + +* New test APIs: + * Request interceptor `tests/helpers/network/interceptor`; + * ComponentObject `tests/helpers/component-object`; + * Spy and mock `tests/helpers/mock`; + * Component.createComponentInDummy `tests/helpers/component`. + +#### :bug: Bug Fix + +* Fixed the problem that the `lifecycleDone` event could fire before `renderDone` `components/base/b-virtual-scroll-new` + +#### :house: Internal + +* Added tests for `b-virtual-scroll-new` `components/base/b-virtual-scroll-new` + ## v4.0.0-beta.55 (2024-02-08) #### :boom: Breaking Change diff --git a/build/webpack/resolve/alias.js b/build/webpack/resolve/alias.js index 51e0d6b5bc..e21ee17d3b 100644 --- a/build/webpack/resolve/alias.js +++ b/build/webpack/resolve/alias.js @@ -21,6 +21,14 @@ const */ const aliases = { '@super': resolve.rootDependencies[0], + + // This is required for using jest-mock, + // otherwise jest-mock pulls various Node.js modules into the browser environment. + 'graceful-fs': false, + path: false, + picomatch: false, + url: false, + ...$C(pzlr.dependencies).to({}).reduce((map, el, i) => { const asset = resolve.depMap[el].config.assets; diff --git a/components-lock.json b/components-lock.json index 9a323d9924..10dbc7ddf5 100644 --- a/components-lock.json +++ b/components-lock.json @@ -1,5 +1,5 @@ { - "hash": "865280bfec97ef8005ed118094d96b33afcfece456c2a8b26c419ca2093656e6", + "hash": "098211bcc31aa8c403e2f0f126046ef09ad2fb212477edeb5a60735ae3e661f0", "data": { "%data": "%data:Map", "%data:Map": [ @@ -2261,8 +2261,9 @@ "b-remote-provider", "b-list", "b-tree", - "b-virtual-scroll", "b-window", + "b-virtual-scroll", + "b-virtual-scroll-new", "b-bottom-slide", "b-slider", "b-sidebar", @@ -2303,8 +2304,9 @@ "b-remote-provider", "b-list", "b-tree", - "b-virtual-scroll", "b-window", + "b-virtual-scroll", + "b-virtual-scroll-new", "b-bottom-slide", "b-slider", "b-sidebar", diff --git a/index.d.ts b/index.d.ts index 3b85ddb645..fc648124e7 100644 --- a/index.d.ts +++ b/index.d.ts @@ -148,7 +148,32 @@ declare var * Requires a module by the specified path. * This function should only be used when writing tests. */ - importModule: (path: string) => any; + importModule: (path: string) => any, + + /** + * Jest mock API for test environment. + */ + jestMock: { + /** + * Wrapper for jest `spyOn` function. + * @see https://jestjs.io/docs/mock-functions + */ + spy: import('jest-mock').ModuleMocker['spyOn']; + + /** + * Wrapper for jest `fn` function. + * @see https://jestjs.io/docs/mock-functions + */ + mock: import('jest-mock').ModuleMocker['fn']; + }; + +/** + * The results returned by a mock or spy function from `jestMock`. + */ +interface JestMockResult { + type: 'throw' | 'return'; + value: VAL; +} interface TouchGesturesCreateOptions { /** diff --git a/package.json b/package.json index ec3e128ec0..d39f9bf600 100644 --- a/package.json +++ b/package.json @@ -88,6 +88,7 @@ "cssnano": "5.0.17", "del": "6.0.0", "delay": "5.0.0", + "doctoc": "2.2.1", "extract-loader": "5.1.0", "fast-glob": "3.2.12", "favicons": "7.1.0", diff --git a/src/components/base/b-virtual-scroll-new/b-virtual-scroll-new.ts b/src/components/base/b-virtual-scroll-new/b-virtual-scroll-new.ts index 37a5dbca56..0db29fb523 100644 --- a/src/components/base/b-virtual-scroll-new/b-virtual-scroll-new.ts +++ b/src/components/base/b-virtual-scroll-new/b-virtual-scroll-new.ts @@ -401,8 +401,11 @@ export default class bVirtualScrollNew extends iVirtualScrollHandlers implements }); }); + this.componentInternalState.setIsDomInsertInProgress(true); + this.async.requestAnimationFrame(() => { this.$refs.container.appendChild(fragment); + this.componentInternalState.setIsDomInsertInProgress(false); this.onDomInsertDone(); this.onRenderDone(); diff --git a/src/components/base/b-virtual-scroll-new/const.ts b/src/components/base/b-virtual-scroll-new/const.ts index e2df9b7e69..d7e915a8cc 100644 --- a/src/components/base/b-virtual-scroll-new/const.ts +++ b/src/components/base/b-virtual-scroll-new/const.ts @@ -137,6 +137,16 @@ export const componentEvents = { ...componentObserverLocalEvents }; +/** + * Internal component events (emitted in localEmitter) + */ +export const componentLocalEvents = { + /** + * The rendering cycle of components has completed (the path from renderStart to renderDone has been traversed) + */ + renderCycleDone: 'renderCycleDone' +}; + /** * Reasons for rejecting a render operation. */ diff --git a/src/components/base/b-virtual-scroll-new/handlers.ts b/src/components/base/b-virtual-scroll-new/handlers.ts index 87c1a1bc92..c58cff8201 100644 --- a/src/components/base/b-virtual-scroll-new/handlers.ts +++ b/src/components/base/b-virtual-scroll-new/handlers.ts @@ -6,16 +6,20 @@ * https://github.com/V4Fire/Client/blob/master/LICENSE */ +import symbolGenerator from 'core/symbol'; + import iVirtualScrollProps from 'components/base/b-virtual-scroll-new/props'; import type bVirtualScrollNew from 'components/base/b-virtual-scroll-new/b-virtual-scroll-new'; import type { MountedChild } from 'components/base/b-virtual-scroll-new/interface'; -import { bVirtualScrollNewAsyncGroup, componentEvents } from 'components/base/b-virtual-scroll-new/const'; +import { bVirtualScrollNewAsyncGroup, componentEvents, componentLocalEvents } from 'components/base/b-virtual-scroll-new/const'; import { isAsyncReplaceError } from 'components/base/b-virtual-scroll-new/modules/helpers'; import iData, { component } from 'components/super/i-data/i-data'; +const $$ = symbolGenerator(); + /** * A class that provides an API to handle events emitted by the {@link bVirtualScrollNew} component. * This class is designed to work in conjunction with {@link bVirtualScrollNew}. @@ -89,6 +93,7 @@ export abstract class iVirtualScrollHandlers extends iVirtualScrollProps { */ protected onRenderDone(this: bVirtualScrollNew): void { this.componentEmitter.emit(componentEvents.renderDone); + this.localEmitter.emit(componentLocalEvents.renderCycleDone); } /** @@ -97,15 +102,29 @@ export abstract class iVirtualScrollHandlers extends iVirtualScrollProps { */ protected onLifecycleDone(this: bVirtualScrollNew): void { const - state = this.getVirtualScrollState(); + state = this.getVirtualScrollState(), + isDomInsertInProgress = this.componentInternalState.getIsDomInsertInProgress(); if (state.isLifecycleDone) { return; } - this.slotsStateController.doneState(); - this.componentInternalState.setIsLifecycleDone(true); - this.componentEmitter.emit(componentEvents.lifecycleDone); + const handler = () => { + this.slotsStateController.doneState(); + this.componentInternalState.setIsLifecycleDone(true); + this.componentEmitter.emit(componentEvents.lifecycleDone); + }; + + if (isDomInsertInProgress) { + this.localEmitter.once(componentLocalEvents.renderCycleDone, handler, { + group: bVirtualScrollNewAsyncGroup, + label: $$.waitUntilRenderDone + }); + + return; + } + + return handler(); } /** diff --git a/src/components/base/b-virtual-scroll-new/interface/component.ts b/src/components/base/b-virtual-scroll-new/interface/component.ts index 863eb08eb2..8b4bf66eed 100644 --- a/src/components/base/b-virtual-scroll-new/interface/component.ts +++ b/src/components/base/b-virtual-scroll-new/interface/component.ts @@ -132,6 +132,11 @@ export interface PrivateComponentState { * Pointer to the index of the data element that was last rendered. */ dataOffset: number; + + /** + * If true, it means that the process of inserting components into the DOM tree is currently in progress. + */ + isDomInsertInProgress: boolean; } /** diff --git a/src/components/base/b-virtual-scroll-new/modules/state/helpers.ts b/src/components/base/b-virtual-scroll-new/modules/state/helpers.ts index 8274bdbaef..8530482b89 100644 --- a/src/components/base/b-virtual-scroll-new/modules/state/helpers.ts +++ b/src/components/base/b-virtual-scroll-new/modules/state/helpers.ts @@ -40,6 +40,7 @@ export function createInitialState(): VirtualScrollState { */ export function createPrivateInitialState(): PrivateComponentState { return { - dataOffset: 0 + dataOffset: 0, + isDomInsertInProgress: false }; } diff --git a/src/components/base/b-virtual-scroll-new/modules/state/index.ts b/src/components/base/b-virtual-scroll-new/modules/state/index.ts index 62fc98e086..f0d2b2a162 100644 --- a/src/components/base/b-virtual-scroll-new/modules/state/index.ts +++ b/src/components/base/b-virtual-scroll-new/modules/state/index.ts @@ -127,6 +127,14 @@ export class ComponentInternalState extends Friend { this.state.isInitialRender = value; } + /** + * Sets the flag indicating that the process of inserting components into the DOM tree is currently in progress + * @param value + */ + setIsDomInsertInProgress(value: boolean): void { + this.privateState.isDomInsertInProgress = value; + } + /** * Sets the flag indicating if requests are stopped and the component won't make any more requests * until the lifecycle is refreshed. @@ -190,6 +198,14 @@ export class ComponentInternalState extends Friend { return this.privateState.dataOffset; } + /** + * Returns the value of the flag indicating whether the process + * of inserting components into the DOM tree is currently in progress + */ + getIsDomInsertInProgress(): boolean { + return this.privateState.isDomInsertInProgress; + } + /** * Updates the cursor indicating the last index of the last rendered data element */ diff --git a/src/components/base/b-virtual-scroll-new/test/api/component-object/index.ts b/src/components/base/b-virtual-scroll-new/test/api/component-object/index.ts new file mode 100644 index 0000000000..ec8367ecec --- /dev/null +++ b/src/components/base/b-virtual-scroll-new/test/api/component-object/index.ts @@ -0,0 +1,238 @@ +/*! + * V4Fire Client Core + * https://github.com/V4Fire/Client + * + * Released under the MIT license + * https://github.com/V4Fire/Client/blob/master/LICENSE + */ + +import type { Locator, Page } from 'playwright'; + +import { ComponentObject, Scroll } from 'tests/helpers'; + +import type bVirtualScrollNew from 'components/base/b-virtual-scroll-new/b-virtual-scroll-new'; +import type { ComponentRefs, VirtualScrollState } from 'components/base/b-virtual-scroll-new/interface'; +import type { SlotsStateObj } from 'components/base/b-virtual-scroll-new/modules/slots'; + +import { testStyles } from 'components/base/b-virtual-scroll-new/test/api/component-object/styles'; + +/** + * The component object API for testing the {@link bVirtualScrollNew} component. + */ +export class VirtualScrollComponentObject extends ComponentObject { + /** + * The locator for the container ref. + */ + readonly container: Locator; + + /** + * The locator to select all children in the container ref. + */ + readonly childList: Locator; + + override get componentStyles(): string { + return testStyles; + } + + /** + * @param page - the Playwright page instance. + */ + constructor(page: Page) { + super(page, 'b-virtual-scroll-new'); + + this.container = this.node.locator(this.elSelector('container')); + this.childList = this.container.locator('> *'); + } + + /** + * Calls the reload method of the component + * {@link bVirtualScrollNew.reload} + */ + reload(): Promise { + return this.component.evaluate((ctx) => ctx.reload()); + } + + /** + * Returns the internal component state + * {@link bVirtualScrollNew.getVirtualScrollState} + */ + getVirtualScrollState(): Promise { + return this.component.evaluate((ctx) => ctx.getVirtualScrollState()); + } + + /** + * Returns the count of children in the container ref + */ + getChildCount(): Promise { + return this.childList.count(); + } + + /** + * Waits for the container child count to be equal to N. + * Throws an error if there are more items in the child list than expected. + * + * @param count - the expected child count. + */ + async waitForChildCountEqualsTo(count: number): Promise { + await this.childList.nth(count - 1).waitFor({state: 'attached'}); + + const realCount = await this.childList.count(); + + if (realCount > count) { + throw new Error(`Expected container to have exactly ${count} items, but got ${realCount}`); + } + } + + /** + * Returns a promise that resolves when an element matching the given selector is inserted into the container. + * + * @param selector - the selector to match the element. + * @returns A promise that resolves when the element is attached. + */ + async waitForChild(selector: string): Promise { + await this.container.locator(selector).waitFor({state: 'attached'}); + } + + /** + * Returns a promise that resolves when an element with the attribute data-index="n" is inserted into the container. + * + * @param index - the index value for the data-index attribute. + * @returns A promise that resolves when the element is attached. + */ + async waitForDataIndexChild(index: number): Promise { + return this.waitForChild(`[data-index="${index}"]`); + } + + /** + * Returns a promise that resolves when the specified event occurs. + * + * @param eventName - the name of the event to wait for. + * @returns A promise that resolves to the payload of the event, or `undefined`. + */ + async waitForEvent(eventName: string): Promise> { + return this.component.evaluate((ctx, [eventName]) => ctx.promisifyOnce(eventName), [eventName]); + } + + /** + * Waits for the component lifecycle to be done + */ + async waitForLifecycleDone(): Promise { + await this.component.evaluate((ctx) => { + const state = ctx.getVirtualScrollState(); + + if (state.isLifecycleDone) { + return; + } + + return ctx.unsafe.componentEmitter.promisifyOnce('lifecycleDone'); + }); + } + + /** + * Waits for the provided slot to reach the specified visibility state. + * + * @param slotName - the name of the slot. + * @param isVisible - the expected visibility state. + * @param timeout - the timeout for waiting (optional). + */ + async waitForSlotState(slotName: keyof ComponentRefs, isVisible: boolean, timeout?: number): Promise { + const slot = this.node.locator(this.elSelector(slotName.dasherize())); + await slot.waitFor({state: isVisible ? 'visible' : 'hidden', timeout}); + } + + /** + * Returns an object representing the state of all slots. + * Each slot is represented as [slotName: slotState], where `slotState=true` means the slot is visible. + */ + async getSlotsState(): Promise> { + const + container = this.node.locator(this.elSelector('container')), + loader = this.node.locator(this.elSelector('loader')), + tombstones = this.node.locator(this.elSelector('tombstones')), + empty = this.node.locator(this.elSelector('empty')), + retry = this.node.locator(this.elSelector('retry')), + done = this.node.locator(this.elSelector('done')), + renderNext = this.node.locator(this.elSelector('render-next')); + + return { + container: await container.isVisible(), + loader: await loader.isVisible(), + tombstones: await tombstones.isVisible(), + empty: await empty.isVisible(), + retry: await retry.isVisible(), + done: await done.isVisible(), + renderNext: await renderNext.isVisible() + }; + } + + /** + * Scrolls the page to the bottom + */ + async scrollToBottom(): Promise { + await Scroll.scrollToBottom(this.pwPage); + return this; + } + + /** + * Scrolls the page to the top + */ + async scrollToTop(): Promise { + await Scroll.scrollToTop(this.pwPage); + return this; + } + + /** + * Adds default `itemProps` for pagination + */ + withPaginationItemProps(): this { + this.withProps({ + item: 'section', + itemProps: (item) => ({'data-index': item.i}) + }); + + return this; + } + + /** + * Adds a `requestProp` for pagination. + * + * @param requestParams - the request parameters. + */ + withRequestPaginationProps(requestParams: Dictionary = {}): this { + this.withProps({ + request: { + get: { + chunkSize: 10, + id: Math.random(), + ...requestParams + } + } + }); + + return this; + } + + /** + * Adds a `Provider` into the provider prop for pagination + */ + withPaginationProvider(): this { + this.withProps({dataProvider: 'Provider'}); + return this; + } + + /** + * Calls all default pagination prop setters: + * - `withPaginationProvider` + * - `withPaginationItemProps` + * - `withRequestProp` + * + * @param requestParams - the request parameters. + */ + withDefaultPaginationProviderProps(requestParams: Dictionary = {}): this { + this.withPaginationProvider(); + this.withPaginationItemProps(); + this.withRequestPaginationProps(requestParams); + + return this; + } +} diff --git a/src/components/base/b-virtual-scroll-new/test/api/component-object/styles.ts b/src/components/base/b-virtual-scroll-new/test/api/component-object/styles.ts new file mode 100644 index 0000000000..aa44ed81ef --- /dev/null +++ b/src/components/base/b-virtual-scroll-new/test/api/component-object/styles.ts @@ -0,0 +1,58 @@ +/*! + * V4Fire Client Core + * https://github.com/V4Fire/Client + * + * Released under the MIT license + * https://github.com/V4Fire/Client/blob/master/LICENSE + */ + +export const testStyles = ` +[data-index] { + width: 200px; + height: 200px; + margin: 16px; + background-color: red; +} + +[data-index]:after { + content: attr(data-index); +} + +.b-virtual-scroll-new__container { + min-width: 20px; + min-height: 20px; +} + +#done { + width: 200px; + height: 200px; + display: flex; + justify-content: center; + align-items: center; + background-color: green; +} + +#done:after { + content: "done"; +} + +#empty:after { + content: "empty"; +} + +#retry:after { + content: "retry" +} + +#renderNext:after { + content: "render next" +} + +#loader, +#tombstone { + display: block; + height: 120px; + width: 200px; + background-color: grey; +} +`; diff --git a/src/components/base/b-virtual-scroll-new/test/api/helpers/index.ts b/src/components/base/b-virtual-scroll-new/test/api/helpers/index.ts new file mode 100644 index 0000000000..beac49bd21 --- /dev/null +++ b/src/components/base/b-virtual-scroll-new/test/api/helpers/index.ts @@ -0,0 +1,399 @@ +/*! + * V4Fire Client Core + * https://github.com/V4Fire/Client + * + * Released under the MIT license + * https://github.com/V4Fire/Client/blob/master/LICENSE + */ + +import type { Page } from 'playwright'; + +import test from 'tests/config/unit/test'; + +import { createInitialState as createInitialStateObj } from 'components/base/b-virtual-scroll-new/modules/state/helpers'; +import type { MountedChild, ComponentItem, VirtualScrollState, MountedItem } from 'components/base/b-virtual-scroll-new/interface'; +import { componentEvents, componentObserverLocalEvents } from 'components/base/b-virtual-scroll-new/const'; + +import { paginationHandler } from 'tests/network-interceptors/pagination'; +import { RequestInterceptor } from 'tests/helpers/network/interceptor'; + +import { VirtualScrollComponentObject } from 'components/base/b-virtual-scroll-new/test/api/component-object'; +import type { DataConveyor, DataItemCtor, MountedItemCtor, StateApi, VirtualScrollTestHelpers, MountedSeparatorCtor, IndexedObj } from 'components/base/b-virtual-scroll-new/test/api/helpers/interface'; + +export * from 'components/base/b-virtual-scroll-new/test/api/component-object'; + +/** + * Creates a helper API for convenient testing of the `b-virtual-scroll-new` component + * @param page - the page object representing the testing page. + */ +export async function createTestHelpers(page: Page): Promise { + const + component = new VirtualScrollComponentObject(page), + initLoadSpy = await component.spyOn('initLoad', {proto: true}), + provider = new RequestInterceptor(page, /api/), + state = createStateApi({}, createDataConveyor( + createIndexedObj, + createMountedSeparator, + createMountedItem + )); + + provider.response(paginationHandler); + + return { + component, + initLoadSpy, + provider, + state + }; +} + +/** + * Creates a data conveyor that accumulates added data and can return it. + * + * For example, the `extractStateFromDataConveyor` function can be used to generate the component's data state based on + * the provided data conveyor. + * + * @param itemsCtor - the constructor function for data items. + * @param separatorCtor - the constructor function for mounted separators. + * @param mountedCtor - the constructor function for mounted items. + */ +export function createDataConveyor( + itemsCtor: DataItemCtor, + separatorCtor: MountedSeparatorCtor, + mountedCtor: MountedItemCtor +): DataConveyor { + let + data = [], + items = [], + childList = [], + dataChunks = []; + + let + dataI = 0, + itemsI = 0, + childI = 0, + page = 0, + total: CanUndef = undefined; + + const obj: DataConveyor = { + addData(count: number) { + const newData = createChunk(count, itemsCtor, dataI); + + data.push(...newData); + dataChunks.push(newData); + + dataI = data.length; + page++; + + return newData; + }, + + addItems(count: number) { + const + newData = createChunk(count, itemsCtor, itemsI), + itemsData = createFromData(newData, mountedCtor, itemsI); + + items.push(...itemsData); + childList.push(...itemsData); + + itemsI = itemsData.length; + childI = childList.length; + + return itemsData; + }, + + addSeparators(count: number) { + const + newData = createChunk(count, itemsCtor, childI), + separatorsData = createFromData(newData, separatorCtor, childI); + + childList.push(...separatorsData); + childI = childList.length; + + return separatorsData; + }, + + addChild(list: ComponentItem[]) { + let itemsCounter = 0; + + const newChild = list.map((child, i) => { + const v = { + childIndex: childI + i, + node: test.expect.any(String), + ...child + }; + + if (child.type === 'item') { + Object.assign(v, { + itemIndex: items.length + itemsCounter + }); + + itemsCounter++; + } + + return v; + }); + + items.push(...newChild.filter((item) => item.type === 'item')); + childList.push(...newChild); + childI = childList.length; + itemsI = items.length; + + return childList; + }, + + getDataChunk(index: number) { + return dataChunks[index]; + }, + + setTotal(newTotal: number) { + total = newTotal; + return total; + }, + + reset() { + dataI = 0; + itemsI = 0; + childI = 0; + page = 0; + total = undefined; + childList = []; + items = []; + data = []; + dataChunks = []; + }, + + get items() { + return items; + }, + + get total() { + return total; + }, + + get childList() { + return childList; + }, + + get data() { + return data; + }, + + get page() { + return page; + }, + + get lastLoadedData() { + return dataChunks[dataChunks.length - 1] ?? []; + } + }; + + return obj; +} + +/** + * Creates an API for convenient manipulation of a component's state fork. + * + * @param initial - the initial partial state of the component. + * @param dataConveyor - the data conveyor used for managing data within the component. + */ +export function createStateApi( + initial: Partial, + dataConveyor: DataConveyor +): StateApi { + let + state = createInitialState(initial), + settled = {}; + + const obj: StateApi = { + compile(override?: Partial): VirtualScrollState { + const compiled = { + ...state, + ...extractStateFromDataConveyor(dataConveyor) + }; + + Object.keys(settled).forEach((key) => { + compiled[key] = settled[key]; + }); + + if (override) { + Object.keys(override).forEach((key) => { + compiled[key] = override[key]; + }); + } + + return compiled; + }, + + set(props: Partial): StateApi { + Object.keys(props).forEach((key) => { + settled[key] = props[key]; + state[key] = props[key]; + }); + + return obj; + }, + + reset(): void { + state = createInitialState(initial); + settled = {}; + dataConveyor.reset(); + }, + + data: dataConveyor + }; + + return obj; +} + +/** + * Creates the "initial" component state and returns it. + * Since this state is intended for comparison in tests, some fields use `expect.any` since they are not "stable". + * + * @param state - the partial component state to override the default values. + */ +export function createInitialState(state: Partial): VirtualScrollState { + return { + ...createInitialStateObj(), + maxViewedItem: Object.cast(test.expect.any(Number)), + maxViewedChild: Object.cast(test.expect.any(Number)), + remainingItems: Object.cast(test.expect.any(Number)), + remainingChildren: Object.cast(test.expect.any(Number)), + isLoadingInProgress: Object.cast(test.expect.any(Boolean)), + ...state + }; +} + +/** + * Extracts state data from the data conveyor and returns it + * @param conveyor - the data conveyor to extract state data from. + */ +export function extractStateFromDataConveyor(conveyor: DataConveyor): Pick { + return { + data: [...conveyor.data], + lastLoadedData: [...conveyor.lastLoadedData], + lastLoadedRawData: conveyor.page === 0 ? + undefined : + { + data: [...conveyor.lastLoadedData], + ...(conveyor.total != null ? {total: conveyor.total} : undefined) + }, + items: [...conveyor.items], + childList: [...conveyor.childList] + }; +} + +/** + * Calls `objCtor` on each element of the `data` array and returns a new array with the results. + * + * @param data - the array of data elements. + * @param objCtor - the constructor function to create new objects from the data elements. + * @param start - the starting index for creating objects (default: 0). + */ +export function createFromData( + data: DATA[], + objCtor: (data: DATA, i: number) => ITEM, + start: number = 0 +): ITEM[] { + return data.map((item, i) => objCtor(item, start + i)); +} + +/** + * Creates a simple object that matches the {@link MountedItem} interface + * @param data - the object with index of the mounted item. + */ +export function createMountedItem(data: IndexedObj): MountedItem { + return { + itemIndex: data.i, + childIndex: data.i, + props: { + 'data-index': data.i + }, + key: Object.cast(undefined), + item: 'section', + type: 'item', + node: test.expect.anything(), + meta: { + data: test.expect.any(Object) + } + }; +} + +/** + * Creates a simple object that matches the {@link MountedChild}` interface + * @param data - the object with index of the mounted child. + */ +export function createMountedSeparator(data: IndexedObj): MountedChild { + return { + childIndex: data.i, + props: { + 'data-index': data.i + }, + key: Object.cast(undefined), + item: 'section', + type: 'separator', + node: test.expect.anything() + }; +} + +/** + * Creates an array of data with the specified length and uses the `itemCtor` function to build items within the array. + * The `start` parameter can be used to specify the starting index that will be passed to the `itemCtor` function. + * + * @param count - the number of items to create. + * @param itemCtor - the constructor function to create items. + * @param [start] - the starting index (default: 0). + */ +export function createChunk( + count: number, + itemCtor: (i: number) => DATA, + start: number = 0 +): DATA[] { + return Array.from(new Array(count), (_, i) => itemCtor(start + i)); +} + +/** + * Creates a simple indexed object + * @param i - the index of the object. + */ +export function createIndexedObj(i: number): IndexedObj { + return {i}; +} + +/** + * Filters emitter emit calls and removes unnecessary events. + * It only keeps component events. + * + * @param emitCalls - the array of emit calls. + * @param [filterObserverEvents] - whether to filter out observer events (default: true). + * @param [allowedEvents] + */ +export function filterEmitterCalls( + emitCalls: unknown[][], + filterObserverEvents: boolean = true, + allowedEvents: string[] = [] +): unknown[][] { + return emitCalls.filter(([event]) => Object.isString(event) && + (Boolean(componentEvents[event]) || allowedEvents.includes(event)) && + (filterObserverEvents ? !(event in componentObserverLocalEvents) : true)); +} + +/** + * Filters emitter emit results and removes unnecessary events. + * It only keeps component events. + * + * @param results - the array of emit results. + * @param [filterObserverEvents] - whether to filter out observer events (default: true). + * @param [allowedEvents] + */ +export function filterEmitterResults( + results: Array>, + filterObserverEvents: boolean = true, + allowedEvents: string[] = [] +): VAL[] { + const filtered = results.filter(({value: [event]}) => Object.isString(event) && + (Boolean(componentEvents[event]) || allowedEvents.includes(event)) && + (filterObserverEvents ? !(event in componentObserverLocalEvents) : true)); + + return filtered.map(({value}) => value); +} diff --git a/src/components/base/b-virtual-scroll-new/test/api/helpers/interface.ts b/src/components/base/b-virtual-scroll-new/test/api/helpers/interface.ts new file mode 100644 index 0000000000..d8420ab72c --- /dev/null +++ b/src/components/base/b-virtual-scroll-new/test/api/helpers/interface.ts @@ -0,0 +1,166 @@ +/*! + * V4Fire Client Core + * https://github.com/V4Fire/Client + * + * Released under the MIT license + * https://github.com/V4Fire/Client/blob/master/LICENSE + */ + +import type { ComponentItem, VirtualScrollState, MountedChild, MountedItem } from 'components/base/b-virtual-scroll-new/interface'; + +import type { SpyObject } from 'tests/helpers/mock/interface'; +import type { RequestInterceptor } from 'tests/helpers/network/interceptor'; +import type { VirtualScrollComponentObject } from 'components/base/b-virtual-scroll-new/test/api/component-object'; + +/** + * The interface defining the data conveyor for convenient data manipulation. + */ +export interface DataConveyor { + /** + * Adds a specified number of data items to the conveyor. + * + * @param count - the number of data items to add. + * @returns An array containing the newly added data items. + */ + addData(count: number): DATA[]; + + /** + * Adds a specified number of mounted items to the conveyor. + * + * @param count - the number of mounted items to add. + * @returns An array containing the newly added mounted items. + */ + addItems(count: number): MountedItem[]; + + /** + * Adds a specified number of mounted child items (separators) to the conveyor. + * + * @param count - the number of mounted child items to add. + * @returns An array containing the newly added mounted child items. + */ + addSeparators(count: number): MountedChild[]; + + /** + * Adds an array of component items as mounted child items to the conveyor. + * + * @param child - the array of component items to add as mounted child items. + * @returns The updated array of mounted child items. + */ + addChild(child: ComponentItem[]): MountedChild[]; + + /** + * Returns an array of data for the given index added using the `addData` method. + * + * @param index - the index of the data chunk. + * @returns An array of data. + */ + getDataChunk(index: number): DATA[]; + + /** + * Sets the value of total data + * @param newTotal + */ + setTotal(newTotal: number): number; + + /** + * Resets the data conveyor, clearing all data and items. + */ + reset(): void; + + /** + * Retrieves the array of data items in the conveyor. + */ + get data(): DATA[]; + + /** + * Returns the data amount + * @param newTotal + */ + get total(): CanUndef; + + /** + * Returns a data page. + */ + get page(): number; + + /** + * Retrieves the array of mounted child items in the conveyor. + */ + get childList(): MountedChild[]; + + /** + * Retrieves the array of last loaded data items in the conveyor. + */ + get lastLoadedData(): DATA[]; + + /** + * Retrieves the array of mounted items in the conveyor. + */ + get items(): MountedItem[]; +} + +/** + * The interface defining the API for manipulating the component state. + */ +export interface StateApi { + /** + * Compiles and returns the assembled component state object. + * + * @param override - An object for overriding the current fields of the component state. + * @returns The compiled component state. + */ + compile(override?: Partial): VirtualScrollState; + + /** + * Resets the component state to its initial values. + */ + reset(): void; + + /** + * Sets the values from an object as the current state. Fields set using this method are not automatically reset, + * and they can only be reset by overriding them or using the `reset` method. + * + * @param props - An object containing the new state values. + * @returns The updated StateApi object. + */ + set(props: Partial): StateApi; + + /** + * The data conveyor used for managing data within the component state. + */ + data: DataConveyor; +} + +/** + * Helpers returned by the `createTestHelpers` function. + */ +export interface VirtualScrollTestHelpers { + /** + * The component object representing the `bVirtualScrollNew` component. + */ + component: VirtualScrollComponentObject; + + /** + * The spy object for the `initLoad` function. + */ + initLoadSpy: SpyObject; + + /** + * The request interceptor provider. + */ + provider: RequestInterceptor; + + /** + * The state API object for convenient manipulation of the component's state fork. + */ + state: StateApi; +} + +export interface IndexedObj { + i: number; +} + +export type DataItemCtor = (i: number) => COMPILED; +export type MountedItemCtor = (data: DATA, i: number) => MountedItem; +export type MountedSeparatorCtor = (data: DATA, i: number) => MountedChild; + diff --git a/src/components/base/b-virtual-scroll-new/test/unit/functional/emitter/payload.ts b/src/components/base/b-virtual-scroll-new/test/unit/functional/emitter/payload.ts new file mode 100644 index 0000000000..3885b84083 --- /dev/null +++ b/src/components/base/b-virtual-scroll-new/test/unit/functional/emitter/payload.ts @@ -0,0 +1,225 @@ +/*! + * V4Fire Client Core + * https://github.com/V4Fire/Client + * + * Released under the MIT license + * https://github.com/V4Fire/Client/blob/master/LICENSE + */ + +/** + * @file This file contains test cases to verify the functionality of events emitted by the component. + */ + +import test from 'tests/config/unit/test'; + +import { createTestHelpers, filterEmitterCalls } from 'components/base/b-virtual-scroll-new/test/api/helpers'; +import type { VirtualScrollTestHelpers } from 'components/base/b-virtual-scroll-new/test/api/helpers/interface'; + +test.describe('', () => { + let + component: VirtualScrollTestHelpers['component'], + provider: VirtualScrollTestHelpers['provider'], + state: VirtualScrollTestHelpers['state']; + + test.beforeEach(async ({demoPage, page}) => { + await demoPage.goto(); + + ({component, provider, state} = await createTestHelpers(page)); + await provider.start(); + }); + + test.describe('all data has been loaded after the initial load', () => { + test('should emit the correct set of events with the correct set of arguments', async () => { + const chunkSize = 12; + + provider + .responseOnce(200, {data: state.data.addData(chunkSize)}) + .response(200, {data: []}); + + await component + .withDefaultPaginationProviderProps({chunkSize}) + .withProps({ + chunkSize, + shouldStopRequestingData: () => true, + '@hook:beforeDataCreate': (ctx) => jestMock.spy(ctx, 'emit') + }) + .build(); + + await component.waitForLifecycleDone(); + + const + spy = await component.getSpy((ctx) => ctx.emit), + calls = filterEmitterCalls(await spy.calls); + + test.expect(calls).toEqual([ + ['dataLoadStart', true], + ['convertDataToDB', {data: state.data.data}], + ['dataLoadSuccess', state.data.data, true], + ['renderStart'], + ['renderEngineStart'], + ['renderEngineDone'], + ['domInsertStart'], + ['domInsertDone'], + ['renderDone'], + ['lifecycleDone'] + ]); + }); + }); + + test.describe('all data has been loaded after the second load', () => { + test('should emit the correct set of events with the correct set of arguments', async () => { + const + chunkSize = 12, + providerChunkSize = chunkSize / 2; + + const + firstDataChunk = state.data.addData(providerChunkSize), + secondDataChunk = state.data.addData(providerChunkSize); + + provider + .responseOnce(200, {data: firstDataChunk}) + .responseOnce(200, {data: secondDataChunk}) + .response(200, {data: []}); + + await component + .withDefaultPaginationProviderProps({chunkSize: providerChunkSize}) + .withProps({ + chunkSize, + shouldPerformDataRequest: () => true, + shouldStopRequestingData: ({lastLoadedData}) => lastLoadedData.length === 0, + '@hook:beforeDataCreate': (ctx) => jestMock.spy(ctx, 'emit') + }) + .build(); + + await component.waitForChildCountEqualsTo(chunkSize); + + const + spy = await component.getSpy((ctx) => ctx.emit), + calls = filterEmitterCalls(await spy.calls); + + test.expect(calls).toEqual([ + ['dataLoadStart', true], + ['convertDataToDB', {data: firstDataChunk}], + ['dataLoadSuccess', firstDataChunk, true], + ['dataLoadStart', false], + ['convertDataToDB', {data: secondDataChunk}], + ['dataLoadSuccess', secondDataChunk, false], + ['renderStart'], + ['renderEngineStart'], + ['renderEngineDone'], + ['domInsertStart'], + ['domInsertDone'], + ['renderDone'], + ['dataLoadStart', false], + ['convertDataToDB', {data: []}], + ['dataLoadSuccess', [], false], + ['renderStart'], + ['renderDone'], + ['lifecycleDone'] + ]); + }); + }); + + test.describe('data loading is completed but data is less than chunkSize', () => { + test('should emit the correct set of events with the correct set of arguments', async () => { + const + chunkSize = 12, + providerChunkSize = chunkSize / 2; + + const + firstDataChunk = state.data.addData(providerChunkSize); + + provider + .responseOnce(200, {data: firstDataChunk}) + .response(200, {data: []}); + + await component + .withDefaultPaginationProviderProps({chunkSize: providerChunkSize}) + .withProps({ + chunkSize, + shouldPerformDataRequest: () => true, + shouldStopRequestingData: ({lastLoadedData}) => lastLoadedData.length === 0, + '@hook:beforeDataCreate': (ctx) => jestMock.spy(ctx, 'emit') + }) + .build(); + + await component.waitForChildCountEqualsTo(providerChunkSize); + await component.waitForLifecycleDone(); + + const + spy = await component.getSpy((ctx) => ctx.emit), + calls = filterEmitterCalls(await spy.calls); + + test.expect(calls).toEqual([ + ['dataLoadStart', true], + ['convertDataToDB', {data: firstDataChunk}], + ['dataLoadSuccess', firstDataChunk, true], + ['dataLoadStart', false], + ['convertDataToDB', {data: []}], + ['dataLoadSuccess', [], false], + ['renderStart'], + ['renderEngineStart'], + ['renderEngineDone'], + ['domInsertStart'], + ['domInsertDone'], + ['renderDone'], + ['lifecycleDone'] + ]); + }); + }); + + test.describe('reload was called after data was rendered', () => { + test('should emit the correct set of events with the correct set of arguments', async () => { + const chunkSize = 12; + + provider + .responseOnce(200, {data: state.data.addData(chunkSize)}) + .response(200, {data: []}); + + await component + .withDefaultPaginationProviderProps({chunkSize}) + .withProps({ + chunkSize, + shouldStopRequestingData: () => true, + '@hook:beforeDataCreate': (ctx) => jestMock.spy(ctx, 'emit') + }) + .build(); + + await component.waitForChildCountEqualsTo(chunkSize); + + state.reset(); + provider.responseOnce(200, {data: state.data.addData(chunkSize)}); + + await component.reload(); + await component.waitForChildCountEqualsTo(chunkSize); + + const + spy = await component.getSpy((ctx) => ctx.emit), + calls = filterEmitterCalls(await spy.calls); + + test.expect(calls).toEqual([ + ['dataLoadStart', true], + ['convertDataToDB', {data: state.data.data}], + ['dataLoadSuccess', state.data.data, true], + ['renderStart'], + ['renderEngineStart'], + ['renderEngineDone'], + ['domInsertStart'], + ['domInsertDone'], + ['renderDone'], + ['lifecycleDone'], + ['resetState'], + ['dataLoadStart', true], + ['convertDataToDB', {data: state.data.data}], + ['dataLoadSuccess', state.data.data, true], + ['renderStart'], + ['renderEngineStart'], + ['renderEngineDone'], + ['domInsertStart'], + ['domInsertDone'], + ['renderDone'], + ['lifecycleDone'] + ]); + }); + }); +}); diff --git a/src/components/base/b-virtual-scroll-new/test/unit/functional/props/props.ts b/src/components/base/b-virtual-scroll-new/test/unit/functional/props/props.ts new file mode 100644 index 0000000000..75702fb07f --- /dev/null +++ b/src/components/base/b-virtual-scroll-new/test/unit/functional/props/props.ts @@ -0,0 +1,166 @@ +/*! + * V4Fire Client Core + * https://github.com/V4Fire/Client + * + * Released under the MIT license + * https://github.com/V4Fire/Client/blob/master/LICENSE + */ + +/** + * @file This file contains test cases to verify the functionality of props in the component. + */ + +import type { Route } from 'playwright'; + +import test from 'tests/config/unit/test'; + +import { fromQueryString } from 'core/url'; + +import type bVirtualScrollNew from 'components/base/b-virtual-scroll-new/b-virtual-scroll-new'; +import { createTestHelpers } from 'components/base/b-virtual-scroll-new/test/api/helpers'; +import type { VirtualScrollTestHelpers } from 'components/base/b-virtual-scroll-new/test/api/helpers/interface'; + +test.describe('', () => { + let + component: VirtualScrollTestHelpers['component'], + provider: VirtualScrollTestHelpers['provider'], + state: VirtualScrollTestHelpers['state']; + + test.beforeEach(async ({demoPage, page}) => { + await demoPage.goto(); + + ({component, provider, state} = await createTestHelpers(page)); + await provider.start(); + }); + + test.describe('`chunkSize` prop changes after the first chunk has been rendered', () => { + test('Should render the second chunk with the new chunk size', async () => { + const + chunkSize = 12; + + provider.response(200, () => ({data: state.data.addData(chunkSize)})); + + await component + .withDefaultPaginationProviderProps({chunkSize}) + .withProps({ + chunkSize, + '@hook:beforeDataCreate': (ctx: bVirtualScrollNew['unsafe']) => jestMock.spy(ctx.componentFactory, 'produceComponentItems') + }) + .build({useDummy: true}); + + await component.waitForChildCountEqualsTo(chunkSize); + await component.updateProps({chunkSize: chunkSize * 2}); + await component.scrollToBottom(); + await component.waitForChildCountEqualsTo(chunkSize * 3); + + const + produceSpy = await component.getSpy((ctx) => ctx.componentFactory.produceComponentItems); + + test.expect(provider.mock.mock.calls.length).toBe(3); + await test.expect(produceSpy.calls).resolves.toHaveLength(2); + await test.expect(component.waitForChildCountEqualsTo(chunkSize * 3)).resolves.toBeUndefined(); + await test.expect(component.waitForDataIndexChild(chunkSize * 3 - 1)).resolves.toBeUndefined(); + }); + }); + + test.describe('`requestQuery`', () => { + test('Should pass the parameters to the GET parameters of the request', async () => { + const + chunkSize = 12; + + provider.response(200, () => ({data: state.data.addData(chunkSize)})); + + await component + .withDefaultPaginationProviderProps({chunkSize}) + .withProps({ + chunkSize, + requestQuery: () => ({get: {param1: 'param1'}}), + shouldPerformDataRequest: () => false, + '@hook:beforeDataCreate': (ctx: bVirtualScrollNew['unsafe']) => jestMock.spy(ctx.componentFactory, 'produceComponentItems') + }) + .build(); + + await component.waitForChildCountEqualsTo(chunkSize); + + const + providerCalls = provider.mock.mock.calls, + query = fromQueryString(new URL((providerCalls[0][0]).request().url()).search); + + test.expect(providerCalls).toHaveLength(1); + test.expect(query).toEqual({ + param1: 'param1', + chunkSize: 12, + id: test.expect.anything() + }); + }); + }); + + test.describe('`dbConverter`', () => { + test('Should convert data to the component', async () => { + const + chunkSize = 12; + + provider.response(200, () => ({data: {nestedData: state.data.addData(chunkSize)}})); + + await component + .withDefaultPaginationProviderProps({chunkSize}) + .withProps({ + chunkSize, + shouldPerformDataRequest: () => false, + dbConverter: ({data: {nestedData}}) => ({data: nestedData}) + }) + .build(); + + await test.expect(component.waitForChildCountEqualsTo(chunkSize)).resolves.toBeUndefined(); + }); + + test('Should convert second data chunk to the component', async () => { + const + chunkSize = 12; + + provider.response(200, () => ({data: {nestedData: state.data.addData(chunkSize)}})); + + await component + .withDefaultPaginationProviderProps({chunkSize}) + .withProps({ + chunkSize, + shouldPerformDataRequest: ({remainingItems}) => remainingItems === 0, + dbConverter: ({data: {nestedData}}) => ({data: nestedData}) + }) + .build(); + + await component.waitForChildCountEqualsTo(chunkSize); + await component.scrollToBottom(); + + await test.expect(component.waitForChildCountEqualsTo(chunkSize * 2)).resolves.toBeUndefined(); + }); + }); + + test.describe('`itemsProcessors`', () => { + test('Should modify components before rendering', async () => { + const + chunkSize = 12; + + provider.response(200, () => ({data: state.data.addData(chunkSize)})); + + await component + .withDefaultPaginationProviderProps({chunkSize}) + .withProps({ + chunkSize, + shouldPerformDataRequest: () => false, + itemsProcessors: (items) => items.concat([ + { + item: 'b-dummy', + type: 'separator', + props: {}, + key: 'uniq' + } + ]) + }) + .build(); + + await test.expect(component.waitForChildCountEqualsTo(chunkSize + 1)).resolves.toBeUndefined(); + await test.expect(component.container.locator('.b-dummy')).toHaveCount(1); + }); + }); +}); diff --git a/src/components/base/b-virtual-scroll-new/test/unit/functional/rendering/default.ts b/src/components/base/b-virtual-scroll-new/test/unit/functional/rendering/default.ts new file mode 100644 index 0000000000..fc0950a8d5 --- /dev/null +++ b/src/components/base/b-virtual-scroll-new/test/unit/functional/rendering/default.ts @@ -0,0 +1,170 @@ +/*! + * V4Fire Client Core + * https://github.com/V4Fire/Client + * + * Released under the MIT license + * https://github.com/V4Fire/Client/blob/master/LICENSE + */ + +/** + * @file Basic test cases for component rendering functionality. + */ + +import test from 'tests/config/unit/test'; + +import { Scroll } from 'tests/helpers'; + +import type { VirtualScrollState, ShouldPerform } from 'components/base/b-virtual-scroll-new/interface'; +import { createTestHelpers } from 'components/base/b-virtual-scroll-new/test/api/helpers'; +import type { VirtualScrollTestHelpers } from 'components/base/b-virtual-scroll-new/test/api/helpers/interface'; +import type bVirtualScrollNew from 'components/base/b-virtual-scroll-new/b-virtual-scroll-new'; + +test.describe('', () => { + let + component: VirtualScrollTestHelpers['component'], + provider: VirtualScrollTestHelpers['provider'], + state: VirtualScrollTestHelpers['state']; + + test.beforeEach(async ({demoPage, page}) => { + await demoPage.goto(); + + ({component, provider, state} = await createTestHelpers(page)); + await provider.start(); + + await page.setViewportSize({height: 640, width: 360}); + }); + + test.describe('`chunkSize` is 12', () => { + test.describe('provider can provide 3 data chunks', () => { + test('Should render 36 items', async () => { + const + chunkSize = 12; + + provider + .responseOnce(200, {data: state.data.addData(chunkSize)}) + .responseOnce(200, {data: state.data.addData(chunkSize)}) + .responseOnce(200, {data: state.data.addData(chunkSize)}) + .response(200, {data: state.data.addData(0)}); + + const shouldPerformDataRender = await component.mockFn( + ({isInitialRender, remainingItems: remainingItems}) => isInitialRender || remainingItems === 0 + ); + + await component + .withDefaultPaginationProviderProps({chunkSize}) + .withProps({ + shouldPerformDataRender, + chunkSize + }); + + await component.build(); + await component.waitForChildCountEqualsTo(chunkSize); + await component.scrollToBottom(); + await component.waitForChildCountEqualsTo(chunkSize * 2); + await component.scrollToBottom(); + await component.waitForChildCountEqualsTo(chunkSize * 3); + await component.scrollToBottom(); + await component.waitForChildCountEqualsTo(chunkSize * 3); + + await test.expect(component.childList).toHaveCount(chunkSize * 3); + }); + }); + }); + + test.describe('with a different chunk size for each render cycle', () => { + test('Should render 6 components first, then 12, then 18', async () => { + const chunkSize = [6, 12, 18]; + + provider + .responseOnce(200, {data: state.data.addData(chunkSize[0])}) + .responseOnce(200, {data: state.data.addData(chunkSize[1])}) + .responseOnce(200, {data: state.data.addData(chunkSize[2])}) + .response(200, {data: []}); + + await component + .withDefaultPaginationProviderProps() + .withProps({ + chunkSize: (state: VirtualScrollState) => [6, 12, 18][state.renderPage] ?? 18 + }); + + await component.build(); + + await test.step('First chunk', async () => { + const + expectedIndex = chunkSize[0]; + + await test.expect(component.waitForChildCountEqualsTo(expectedIndex)).resolves.toBeUndefined(); + await test.expect(component.waitForDataIndexChild(expectedIndex - 1)).resolves.toBeUndefined(); + }); + + await test.step('Second chunk', async () => { + const + expectedIndex = chunkSize[0] + chunkSize[1]; + + await component.scrollToBottom(); + + await test.expect(component.waitForChildCountEqualsTo(expectedIndex)).resolves.toBeUndefined(); + await test.expect(component.waitForDataIndexChild(expectedIndex - 1)).resolves.toBeUndefined(); + }); + + await test.step('Third chunk', async () => { + const + expectedIndex = chunkSize[0] + chunkSize[1] + chunkSize[2]; + + await component.scrollToBottom(); + + await test.expect(component.waitForChildCountEqualsTo(expectedIndex)).resolves.toBeUndefined(); + await test.expect(component.waitForDataIndexChild(expectedIndex - 1)).resolves.toBeUndefined(); + }); + + await test.step('Lifecycle is done', async () => { + await component.scrollToBottom(); + + await test.expect(component.waitForLifecycleDone()).resolves.toBeUndefined(); + }); + }); + }); + + test.describe('`chunkSize` is 6', () => { + test.describe('provider responded once, returning 45 elements', () => { + test.describe('`shouldStopRequestingData` returns true after first request', () => { + test('should render all 45 elements within 8 rendering cycles', async () => { + const + chunkSize = 6, + providerChunkSize = 45; + + provider + .responseOnce(200, {data: state.data.addData(providerChunkSize)}); + + const shouldPerformDataRender = await component.mockFn( + ({isInitialRender, remainingItems: remainingItems}) => isInitialRender || remainingItems === 0 + ); + + await component + .withDefaultPaginationProviderProps({chunkSize}) + .withProps({ + shouldPerformDataRender, + shouldStopRequestingData: () => true, + chunkSize, + '@hook:beforeDataCreate': (ctx: bVirtualScrollNew) => jestMock.spy(ctx.unsafe.componentFactory, 'produceNodes') + }); + + await component.build(); + + await Scroll.scrollToBottomWhile(component.pwPage, async () => { + const + isEqual = await component.getChildCount() === providerChunkSize; + + return isEqual; + }); + + const + spy = await component.getSpy((ctx) => ctx.unsafe.componentFactory.produceNodes); + + await test.expect(spy.callsCount).resolves.toBe(8); + await test.expect(component.childList).toHaveCount(providerChunkSize); + }); + }); + }); + }); +}); diff --git a/src/components/base/b-virtual-scroll-new/test/unit/functional/rendering/items-factory.ts b/src/components/base/b-virtual-scroll-new/test/unit/functional/rendering/items-factory.ts new file mode 100644 index 0000000000..e43b8790d9 --- /dev/null +++ b/src/components/base/b-virtual-scroll-new/test/unit/functional/rendering/items-factory.ts @@ -0,0 +1,301 @@ +/*! + * V4Fire Client Core + * https://github.com/V4Fire/Client + * + * Released under the MIT license + * https://github.com/V4Fire/Client/blob/master/LICENSE + */ + +/** + * @file Test cases of the component `itemsFactory` prop. + */ + +import test from 'tests/config/unit/test'; + +import type { ComponentItemFactory, ComponentItem, ShouldPerform, VirtualScrollState } from 'components/base/b-virtual-scroll-new/interface'; + +import { createTestHelpers } from 'components/base/b-virtual-scroll-new/test/api/helpers'; +import type { VirtualScrollTestHelpers } from 'components/base/b-virtual-scroll-new/test/api/helpers/interface'; + +test.describe(' rendering via itemsFactory', () => { + let + component: VirtualScrollTestHelpers['component'], + provider: VirtualScrollTestHelpers['provider'], + state: VirtualScrollTestHelpers['state']; + + test.beforeEach(async ({demoPage, page}) => { + await demoPage.goto(); + + ({component, provider, state} = await createTestHelpers(page)); + await provider.start(); + }); + + test.describe('returned items with type `item` is equal to the provided data', () => { + test('should render all of the items that were returned from `itemsFactory`', async () => { + const + chunkSize = 12; + + provider + .responseOnce(200, {data: state.data.addData(chunkSize)}) + .response(200, {data: state.data.addData(0)}); + + const itemsFactory = await component.mockFn>((state) => { + const data = state.lastLoadedData; + + return data.map((item) => ({ + item: 'section', + key: Object.cast(undefined), + type: 'item', + children: [], + props: { + 'data-index': item.i + } + })); + }); + + await component + .withDefaultPaginationProviderProps({chunkSize}) + .withProps({ + itemsFactory, + shouldPerformDataRender: () => true, + chunkSize + }) + .build(); + + await component.waitForChildCountEqualsTo(chunkSize); + + await test.expect(component.childList).toHaveCount(chunkSize); + }); + }); + + test.describe('In additional `item`, `separator` was also returned', () => { + test('should render both', async () => { + const + chunkSize = 12; + + provider + .responseOnce(200, {data: state.data.addData(chunkSize)}) + .response(200, {data: state.data.addData(0)}); + + const separator = { + item: 'b-button', + key: '', + children: { + default: 'ima button' + }, + props: { + id: 'button' + }, + type: 'separator' + }; + + const itemsFactory = await component.mockFn((state: VirtualScrollState, ctx, separator) => { + const + data = >state.lastLoadedData; + + const items = data.map((item) => ({ + item: 'section', + key: Object.cast(undefined), + type: 'item', + children: [], + props: { + 'data-index': item.i + } + })); + + if (items.length > 0) { + items.push(separator); + } + + return items; + }, separator); + + await component + .withDefaultPaginationProviderProps({chunkSize}) + .withProps({ + itemsFactory, + shouldPerformDataRender: () => true, + chunkSize + }) + .build(); + + await component.waitForChildCountEqualsTo(chunkSize + 1); + + await test.expect(component.container.locator('#button')).toBeVisible(); + await test.expect(component.childList).toHaveCount(chunkSize + 1); + }); + }); + + test.describe('returned items with type `item` is less than the provided data', () => { + test('should render items that were returned from `itemsFactory`', async () => { + const + chunkSize = 12, + renderedChunkSize = chunkSize - 2; + + provider + .responseOnce(200, {data: state.data.addData(chunkSize)}) + .response(200, {data: state.data.addData(0)}); + + const itemsFactory = await component.mockFn>((state) => { + const data = state.lastLoadedData; + + const items = data.map((item) => ({ + item: 'section', + key: Object.cast(undefined), + type: 'item', + children: [], + props: { + 'data-index': item.i + } + })); + + items.length -= 2; + return items; + }); + + await component + .withDefaultPaginationProviderProps({chunkSize}) + .withProps({ + itemsFactory, + shouldPerformDataRender: () => true, + chunkSize + }) + .build(); + + await component.waitForChildCountEqualsTo(renderedChunkSize); + + await test.expect(component.childList).toHaveCount(renderedChunkSize); + }); + }); + + test.describe('returned item with type `item` is more than the provided data', () => { + test('should render items that were returned from `itemsFactory`', async () => { + const + chunkSize = 12, + renderedChunkSize = chunkSize * 2; + + provider + .responseOnce(200, {data: state.data.addData(chunkSize)}) + .response(200, {data: state.data.addData(0)}); + + const itemsFactory = await component.mockFn>((state) => { + const data = state.lastLoadedData; + + const items = data.map((item) => ({ + item: 'section', + key: Object.cast(undefined), + type: 'item', + children: [], + props: { + 'data-index': item.i + } + })); + + return [...items, ...items]; + }); + + await component + .withDefaultPaginationProviderProps({chunkSize}) + .withProps({ + itemsFactory, + shouldPerformDataRender: () => true, + chunkSize + }) + .build(); + + await component.waitForChildCountEqualsTo(renderedChunkSize); + + await test.expect(component.childList).toHaveCount(renderedChunkSize); + }); + }); + + test.describe('The items of type `item` were not returned, but the items of type `separator` were returned in the same quantity as the loaded data.', () => { + test('should render separators that were returned from `itemsFactory`', async () => { + const + chunkSize = 12; + + provider + .responseOnce(200, {data: state.data.addData(chunkSize)}) + .response(200, {data: state.data.addData(0)}); + + const itemsFactory = await component.mockFn>((state) => { + const data = state.lastLoadedData; + + return data.map((item) => ({ + item: 'section', + key: Object.cast(undefined), + type: 'separator', + children: [], + props: { + 'data-index': item.i + } + })); + }); + + await component + .withDefaultPaginationProviderProps({chunkSize}) + .withProps({ + itemsFactory, + shouldPerformDataRender: () => true, + chunkSize + }) + .build(); + + await component.waitForChildCountEqualsTo(chunkSize); + + await test.expect(component.childList).toHaveCount(chunkSize); + }); + }); + + test.describe('`itemsFactory` returns twice as much data as the `chunkSize`', () => { + test('should render twice as much items as the `chunkSize`', async () => { + const + chunkSize = 12; + + provider + .responseOnce(200, {data: state.data.addData(chunkSize)}) + .responseOnce(200, {data: state.data.addData(chunkSize)}) + .responseOnce(200, {data: state.data.addData(chunkSize)}) + .response(200, {data: state.data.addData(0)}); + + const shouldPerformDataRender = await component.mockFn( + ({isInitialRender, remainingItems: remainingItems}) => isInitialRender || remainingItems === 0 + ); + + const itemsFactory = await component.mockFn>((state) => { + const data = state.lastLoadedData; + + const items = data.map((item) => ({ + item: 'section', + key: Object.cast(undefined), + type: 'item', + children: [], + props: { + 'data-index': item.i + } + })); + + return [...items, ...items]; + }); + + await component + .withDefaultPaginationProviderProps({chunkSize}) + .withProps({ + itemsFactory, + shouldPerformDataRender, + chunkSize + }) + .build(); + + await component.waitForChildCountEqualsTo(chunkSize * 2); + await component.scrollToBottom(); + await component.waitForChildCountEqualsTo(chunkSize * 2 * 2); + await component.scrollToBottom(); + await component.waitForChildCountEqualsTo(chunkSize * 3 * 2); + await component.scrollToBottom(); + await component.waitForChildCountEqualsTo(chunkSize * 3 * 2); + + await test.expect(component.childList).toHaveCount(chunkSize * 3 * 2); + }); + }); +}); diff --git a/src/components/base/b-virtual-scroll-new/test/unit/functional/rendering/items-mode.ts b/src/components/base/b-virtual-scroll-new/test/unit/functional/rendering/items-mode.ts new file mode 100644 index 0000000000..dd48e058ce --- /dev/null +++ b/src/components/base/b-virtual-scroll-new/test/unit/functional/rendering/items-mode.ts @@ -0,0 +1,71 @@ +/*! + * V4Fire Client Core + * https://github.com/V4Fire/Client + * + * Released under the MIT license + * https://github.com/V4Fire/Client/blob/master/LICENSE + */ + +/** + * @file Basic test cases for component rendering data provided in items prop. + */ + +import test from 'tests/config/unit/test'; + +import type { ShouldPerform } from 'components/base/b-virtual-scroll-new/interface'; +import { createTestHelpers } from 'components/base/b-virtual-scroll-new/test/api/helpers'; +import type { VirtualScrollTestHelpers } from 'components/base/b-virtual-scroll-new/test/api/helpers/interface'; + +test.describe('', () => { + let + component: VirtualScrollTestHelpers['component'], + provider: VirtualScrollTestHelpers['provider'], + state: VirtualScrollTestHelpers['state']; + + test.beforeEach(async ({demoPage, page}) => { + await demoPage.goto(); + + ({component, provider, state} = await createTestHelpers(page)); + await provider.start(); + + await page.setViewportSize({height: 640, width: 360}); + }); + + test.describe('`chunkSize` is 12', () => { + test.describe('provided 36 elements in the items prop', () => { + test('should render 36 items', async () => { + const + chunkSize = 12; + + const items = [ + ...state.data.addData(chunkSize), + ...state.data.addData(chunkSize), + ...state.data.addData(chunkSize) + ]; + + const shouldPerformDataRender = await component.mockFn( + ({isInitialRender, remainingItems: remainingItems}) => isInitialRender || remainingItems === 0 + ); + + await component + .withPaginationItemProps() + .withProps({ + shouldPerformDataRender, + chunkSize, + items + }); + + await component.build(); + await component.waitForChildCountEqualsTo(chunkSize); + await component.scrollToBottom(); + await component.waitForChildCountEqualsTo(chunkSize * 2); + await component.scrollToBottom(); + await component.waitForChildCountEqualsTo(chunkSize * 3); + await component.scrollToBottom(); + await component.waitForChildCountEqualsTo(chunkSize * 3); + + await test.expect(component.childList).toHaveCount(chunkSize * 3); + }); + }); + }); +}); diff --git a/src/components/base/b-virtual-scroll-new/test/unit/functional/state/default.ts b/src/components/base/b-virtual-scroll-new/test/unit/functional/state/default.ts new file mode 100644 index 0000000000..a1577bbfae --- /dev/null +++ b/src/components/base/b-virtual-scroll-new/test/unit/functional/state/default.ts @@ -0,0 +1,341 @@ +/*! + * V4Fire Client Core + * https://github.com/V4Fire/Client + * + * Released under the MIT license + * https://github.com/V4Fire/Client/blob/master/LICENSE + */ + +/** + * @file This file contains test cases that verify the correctness of the internal component state module. + */ + +import test from 'tests/config/unit/test'; + +import type bVirtualScrollNew from 'components/base/b-virtual-scroll-new/b-virtual-scroll-new'; +import { defaultShouldProps } from 'components/base/b-virtual-scroll-new/const'; +import type { ComponentItem, ShouldPerform } from 'components/base/b-virtual-scroll-new/interface'; + +import { createTestHelpers } from 'components/base/b-virtual-scroll-new/test/api/helpers'; +import type { VirtualScrollTestHelpers } from 'components/base/b-virtual-scroll-new/test/api/helpers/interface'; + +test.describe('', () => { + let + component: VirtualScrollTestHelpers['component'], + provider: VirtualScrollTestHelpers['provider'], + state: VirtualScrollTestHelpers['state']; + + test.beforeEach(async ({demoPage, page}) => { + await demoPage.goto(); + + ({component, provider, state} = await createTestHelpers(page)); + await provider.start(); + }); + + test('Initial state', async () => { + const + chunkSize = 12, + mockFn = await component.mockFn((ctx: bVirtualScrollNew) => ctx.getVirtualScrollState()); + + provider.response(200, {data: []}, {delay: (10).seconds()}); + + const expectedState = state.compile({ + lastLoadedRawData: undefined, + remainingItems: undefined, + remainingChildren: undefined, + maxViewedItem: undefined, + maxViewedChild: undefined, + areRequestsStopped: false, + isLoadingInProgress: true, + lastLoadedData: [], + loadPage: 0 + }); + + await component + .withDefaultPaginationProviderProps({chunkSize}) + .withProps({ + '@hook:created': mockFn + }) + .build(); + + await test.expect(mockFn.results).resolves.toEqual([{type: 'return', value: expectedState}]); + }); + + test('State after loading first and second data chunks', async () => { + const + chunkSize = 12, + providerChunkSize = chunkSize / 2; + + const + shouldStopRequestingData = (defaultShouldProps.shouldStopRequestingData), + shouldPerformDataRequest = (({isInitialLoading, remainingItems: remainingItems, isLastEmpty}) => + isInitialLoading || (remainingItems === 0 && !isLastEmpty)), + shouldPerformDataRender = (({isInitialRender, remainingItems: remainingItems}) => + isInitialRender || remainingItems === 0); + + await test.step('After rendering first data chunk', async () => { + provider + .responseOnce(200, {data: state.data.addData(providerChunkSize)}) + .responseOnce(200, {data: state.data.addData(providerChunkSize)}); + + state.data.addItems(chunkSize); + + await component + .withDefaultPaginationProviderProps({chunkSize: providerChunkSize}) + .withProps({ + chunkSize, + shouldStopRequestingData, + shouldPerformDataRequest, + shouldPerformDataRender + }) + .build(); + + await component.waitForChildCountEqualsTo(chunkSize); + + const + currentState = await component.getVirtualScrollState(); + + test.expect(currentState).toEqual(state.compile({ + isInitialLoading: false, + isInitialRender: false, + areRequestsStopped: false, + isLoadingInProgress: false, + loadPage: 2, + renderPage: 1 + })); + }); + + await test.step('After rendering second data chunk', async () => { + provider + .responseOnce(200, {data: state.data.addData(providerChunkSize)}) + .responseOnce(200, {data: state.data.addData(providerChunkSize)}) + .response(200, {data: state.data.addData(0)}); + + state.data.addItems(chunkSize); + + await component.scrollToBottom(); + await component.waitForChildCountEqualsTo(chunkSize * 2); + await component.scrollToBottom(); + await component.waitForLifecycleDone(); + + const + currentState = await component.getVirtualScrollState(); + + test.expect(currentState).toEqual(state.compile({ + isInitialLoading: false, + isInitialRender: false, + areRequestsStopped: true, + isLoadingInProgress: false, + isLastEmpty: true, + isLifecycleDone: true, + isLastRender: true, + loadPage: 5, + renderPage: 2 + })); + }); + }); + + test.describe('state after rendering via `itemsFactory`', () => { + test('`itemsFactory` returns mixed items with `item` and `separator` type', async () => { + const chunkSize = 12; + + const separator: ComponentItem = { + item: 'b-button', + key: Object.cast(undefined), + children: { + default: 'ima button' + }, + props: { + id: 'button' + }, + type: 'separator' + }; + + const item = (data): ComponentItem => ({ + item: 'section', + key: Object.cast(undefined), + type: 'item', + props: { + 'data-index': data.i + }, + meta: { + data + } + }); + + const compileItemsFn = (state, ctx, separator, item) => { + const + data = state.lastLoadedData, + result: ComponentItem[] = []; + + data.forEach((data) => { + result.push(separator, item(data)); + }); + + return result; + }; + + const itemsFactory = await component.mockFn(compileItemsFn, separator, item); + + provider + .responseOnce(200, {data: state.data.addData(chunkSize)}) + .response(200, {data: state.data.addData(0)}); + + state.data.addChild(compileItemsFn({lastLoadedData: state.data.data}, null, separator, item)); + + await component + .withDefaultPaginationProviderProps({chunkSize}) + .withProps({ + itemsFactory, + shouldPerformDataRender: () => true, + chunkSize + }) + .build(); + + await component.waitForChildCountEqualsTo(chunkSize * 2); + await component.waitForLifecycleDone(); + + const + currentState = await component.getVirtualScrollState(); + + test.expect(currentState).toEqual(state.compile({ + isInitialLoading: false, + isInitialRender: false, + areRequestsStopped: true, + isLoadingInProgress: false, + isLastEmpty: true, + isLifecycleDone: true, + isLastRender: true, + loadPage: 2, + renderPage: 1 + })); + }); + + test('`itemsFactory` returns items with `item` and last item with `separator` type', async () => { + const chunkSize = 12; + + const separator: ComponentItem = { + item: 'b-button', + key: Object.cast(undefined), + children: { + default: 'ima button' + }, + props: { + id: 'button' + }, + type: 'separator' + }; + + const itemsFactory = await component.mockFn((state, ctx, separator) => { + const + data = state.lastLoadedData; + + const items = data.map((item) => ({ + item: 'section', + key: Object.cast(undefined), + type: 'item', + props: { + 'data-index': item.i + }, + meta: { + data: item + } + })); + + if (data.length > 0) { + items.push(separator); + } + + return items; + }, separator); + + provider + .responseOnce(200, {data: state.data.addData(chunkSize)}) + .response(200, {data: state.data.addData(0)}); + + state.data.addItems(chunkSize); + state.data.addChild([separator]); + + await component + .withDefaultPaginationProviderProps({chunkSize}) + .withProps({ + itemsFactory, + shouldPerformDataRender: () => true, + chunkSize + }) + .build(); + + await component.waitForChildCountEqualsTo(chunkSize + 1); + await component.waitForLifecycleDone(); + + const + currentState = await component.getVirtualScrollState(); + + test.expect(currentState).toEqual(state.compile({ + isInitialLoading: false, + isInitialRender: false, + areRequestsStopped: true, + isLoadingInProgress: false, + isLastEmpty: true, + isLifecycleDone: true, + isLastRender: true, + loadPage: 2, + renderPage: 1 + })); + }); + + test('`itemsFactory` does not returns items with `item` type', async () => { + const chunkSize = 12; + + const itemsFactory = await component.mockFn((state) => { + const + data = state.lastLoadedData; + + const items = data.map((item) => ({ + item: 'section', + key: Object.cast(undefined), + type: 'separator', + props: { + 'data-index': item.i + } + })); + + return items; + }); + + provider + .responseOnce(200, {data: state.data.addData(chunkSize)}) + .response(200, {data: state.data.addData(0)}); + + state.data.addSeparators(chunkSize); + + await component + .withDefaultPaginationProviderProps({chunkSize}) + .withProps({ + itemsFactory, + shouldPerformDataRender: () => true, + chunkSize + }) + .build(); + + await component.waitForChildCountEqualsTo(chunkSize); + await component.waitForLifecycleDone(); + + const + currentState = await component.getVirtualScrollState(); + + test.expect(currentState).toEqual(state.compile({ + isInitialLoading: false, + isInitialRender: false, + areRequestsStopped: true, + isLoadingInProgress: false, + isLastEmpty: true, + isLifecycleDone: true, + isLastRender: true, + maxViewedItem: undefined, + loadPage: 2, + renderPage: 1 + })); + }); + }); +}); diff --git a/src/components/base/b-virtual-scroll-new/test/unit/functional/state/emitter.ts b/src/components/base/b-virtual-scroll-new/test/unit/functional/state/emitter.ts new file mode 100644 index 0000000000..09e6e4144b --- /dev/null +++ b/src/components/base/b-virtual-scroll-new/test/unit/functional/state/emitter.ts @@ -0,0 +1,524 @@ +/*! + * V4Fire Client Core + * https://github.com/V4Fire/Client + * + * Released under the MIT license + * https://github.com/V4Fire/Client/blob/master/LICENSE + */ + +/* eslint-disable max-lines-per-function */ + +/** + * @file This file contains test cases that verify the correctness of the component state during event emission. + */ + +import test from 'tests/config/unit/test'; + +import { createTestHelpers, filterEmitterResults } from 'components/base/b-virtual-scroll-new/test/api/helpers'; +import type { VirtualScrollTestHelpers } from 'components/base/b-virtual-scroll-new/test/api/helpers/interface'; +import type { VirtualScrollState } from 'components/base/b-virtual-scroll-new/interface'; + +test.describe('', () => { + let + component: VirtualScrollTestHelpers['component'], + provider: VirtualScrollTestHelpers['provider'], + state: VirtualScrollTestHelpers['state']; + + const observerInitialStateFields = { + remainingItems: undefined, + remainingChildren: undefined, + maxViewedChild: undefined, + maxViewedItem: undefined + }; + + const observerLoadedStateFields = { + maxViewedChild: undefined, + maxViewedItem: undefined + }; + + test.beforeEach(async ({demoPage, page}) => { + await demoPage.goto(); + + ({component, provider, state} = await createTestHelpers(page)); + await provider.start(); + }); + + test.describe('all data has been loaded after the initial load', () => { + test('state at the time of emitting events must be correct', async () => { + const chunkSize = 12; + + const states = [ + state.compile(observerInitialStateFields), + ( + state.data.addData(chunkSize), + state.set({loadPage: 1, areRequestsStopped: true}).compile(observerInitialStateFields) + ), + ( + state.set({isLastRender: true}).compile(observerInitialStateFields) + ), + ( + state.data.addItems(chunkSize), + state.set({isInitialRender: false, renderPage: 1}).compile(observerLoadedStateFields) + ), + ( + state.compile(observerLoadedStateFields) + ), + ( + state.set({isLifecycleDone: true}).compile() + ) + ]; + + provider + .responseOnce(200, {data: state.data.getDataChunk(0)}) + .response(200, {data: []}); + + await component + .withDefaultPaginationProviderProps({chunkSize}) + .withProps({ + chunkSize, + shouldStopRequestingData: () => true, + '@hook:beforeDataCreate': (ctx) => { + const original = ctx.emit; + + ctx.emit = jestMock.mock((...args) => { + original(...args); + return [args[0], Object.fastClone(ctx.getVirtualScrollState())]; + }); + } + }) + .build(); + + await component.waitForLifecycleDone(); + + const + spy = await component.getSpy((ctx) => ctx.emit), + results = filterEmitterResults(await spy.results, true, ['initLoadStart', 'initLoad']); + + test.expect(results).toEqual([ + ['initLoadStart', {...states[0], isLoadingInProgress: true}], + ['dataLoadStart', {...states[0], isLoadingInProgress: true}], + ['convertDataToDB', {...states[0], lastLoadedRawData: states[1].lastLoadedRawData}], + ['initLoad', {...states[0], lastLoadedRawData: states[1].lastLoadedRawData}], + ['dataLoadSuccess', states[1]], + ['renderStart', states[2]], + ['renderEngineStart', states[2]], + ['renderEngineDone', states[2]], + ['domInsertStart', states[3]], + ['domInsertDone', states[4]], + ['renderDone', states[4]], + ['lifecycleDone', states[5]] + ]); + }); + }); + + test.describe('all data has been loaded after the second load and reload was called', () => { + test('state at the time of emitting events must be correct', async () => { + const + chunkSize = 12, + providerChunkSize = chunkSize / 2; + + const states = [ + state.compile(observerInitialStateFields), + ( + state.data.addData(providerChunkSize), + state.set({loadPage: 1}).compile(observerInitialStateFields) + ), + ( + state.data.addData(providerChunkSize), + state.set({loadPage: 2, isInitialLoading: false}).compile(observerInitialStateFields) + ), + ( + state.data.addItems(chunkSize), + state.set({renderPage: 1, isInitialRender: false}).compile(observerLoadedStateFields) + ), + ( + state.compile(observerLoadedStateFields) + ), + ( + state.compile() + ), + ( + state.data.addData(0), + state.set({loadPage: 3, areRequestsStopped: true, isLastEmpty: true}).compile() + ), + ( + state.set({isLastRender: true}).compile() + ), + ( + state.set({isLifecycleDone: true}).compile() + ) + ]; + + provider + .responseOnce(200, {data: state.data.getDataChunk(0)}) + .responseOnce(200, {data: state.data.getDataChunk(1)}) + .responseOnce(200, {data: state.data.getDataChunk(2)}) + .responseOnce(200, {data: state.data.getDataChunk(0)}) + .responseOnce(200, {data: state.data.getDataChunk(1)}) + .response(200, {data: state.data.getDataChunk(2)}); + + await component + .withDefaultPaginationProviderProps({chunkSize: providerChunkSize}) + .withProps({ + chunkSize, + '@hook:beforeDataCreate': (ctx) => { + const original = ctx.emit; + + ctx.emit = jestMock.mock((...args) => { + original(...args); + return [args[0], Object.fastClone(ctx.getVirtualScrollState())]; + }); + } + }) + .build(); + + await component.waitForLifecycleDone(); + await component.reload(); + await component.waitForLifecycleDone(); + + const + spy = await component.getSpy((ctx) => ctx.emit), + results = filterEmitterResults(await spy.results, true, ['initLoadStart', 'initLoad']); + + test.expect(results).toEqual([ + ['initLoadStart', {...states[0], isLoadingInProgress: true}], + ['dataLoadStart', {...states[0], isLoadingInProgress: true}], + ['convertDataToDB', {...states[0], lastLoadedRawData: states[1].lastLoadedRawData}], + ['initLoad', {...states[0], lastLoadedRawData: states[1].lastLoadedRawData}], + ['dataLoadSuccess', states[1]], + ['dataLoadStart', states[1]], + ['convertDataToDB', {...states[1], lastLoadedRawData: states[2].lastLoadedRawData}], + ['dataLoadSuccess', states[2]], + ['renderStart', states[2]], + ['renderEngineStart', states[2]], + ['renderEngineDone', states[2]], + ['domInsertStart', states[3]], + ['domInsertDone', states[4]], + ['renderDone', states[4]], + ['dataLoadStart', states[5]], + ['convertDataToDB', {...states[5], lastLoadedRawData: states[6].lastLoadedRawData}], + ['dataLoadSuccess', states[6]], + ['renderStart', states[7]], + ['renderDone', states[7]], + ['lifecycleDone', states[8]], + ['resetState', states[0]], + ['initLoadStart', {...states[0], isLoadingInProgress: true}], + ['dataLoadStart', {...states[0], isLoadingInProgress: true}], + ['convertDataToDB', {...states[0], lastLoadedRawData: states[1].lastLoadedRawData}], + ['initLoad', {...states[0], lastLoadedRawData: states[1].lastLoadedRawData}], + ['dataLoadSuccess', states[1]], + ['dataLoadStart', states[1]], + ['convertDataToDB', {...states[1], lastLoadedRawData: states[2].lastLoadedRawData}], + ['dataLoadSuccess', states[2]], + ['renderStart', states[2]], + ['renderEngineStart', states[2]], + ['renderEngineDone', states[2]], + ['domInsertStart', states[3]], + ['domInsertDone', states[4]], + ['renderDone', states[4]], + ['dataLoadStart', states[5]], + ['convertDataToDB', {...states[5], lastLoadedRawData: states[6].lastLoadedRawData}], + ['dataLoadSuccess', states[6]], + ['renderStart', states[7]], + ['renderDone', states[7]], + ['lifecycleDone', states[8]] + ]); + }); + }); + + test.describe('all data has been loaded after few scrolls', () => { + test('state at the time of emitting events must be correct', async () => { + const + chunkSize = 12, + providerChunkSize = chunkSize, + total = chunkSize * 2; + + state.data.setTotal(total); + + const states = [ + state.compile(observerInitialStateFields), + ( + // 1 + state.data.addData(providerChunkSize), + state.set({loadPage: 1}).compile(observerInitialStateFields) + ), + ( + // 2 + state.data.addItems(chunkSize), + state.set({renderPage: 1, isInitialRender: false}).compile(observerLoadedStateFields) + ), + ( + // 3 + state.compile() + ), + ( + // 4 + state.data.addData(providerChunkSize), + state.set({ + loadPage: 2, + areRequestsStopped: true, + isLastEmpty: false, + isInitialLoading: false + }).compile() + ), + ( + // 5 + state.set({isLastRender: true}).compile() + ), + ( + // 6 + state.data.addItems(chunkSize), + state.set({renderPage: 2}).compile() + ), + ( + // 7 + state.set({isLifecycleDone: true}).compile() + ) + ]; + + provider + .responseOnce(200, {data: state.data.getDataChunk(0), total}) + .responseOnce(200, {data: state.data.getDataChunk(1), total}); + + await component + .withDefaultPaginationProviderProps({chunkSize: providerChunkSize}) + .withProps({ + chunkSize, + shouldStopRequestingData: (state: VirtualScrollState): boolean => + Object.get(state, 'lastLoadedRawData.total') === state.data.length, + + '@hook:beforeDataCreate': (ctx) => { + const original = ctx.emit; + + ctx.emit = jestMock.mock((...args) => { + original(...args); + return [args[0], Object.fastClone(ctx.getVirtualScrollState())]; + }); + } + }) + .build(); + + await component.waitForChildCountEqualsTo(chunkSize); + await component.scrollToBottom(); + await component.waitForChildCountEqualsTo(chunkSize * 2); + await component.scrollToBottom(); + await component.waitForLifecycleDone(); + + const + spy = await component.getSpy((ctx) => ctx.emit), + results = filterEmitterResults(await spy.results, true, ['initLoadStart', 'initLoad']); + + test.expect(results).toEqual([ + ['initLoadStart', {...states[0], isLoadingInProgress: true}], + ['dataLoadStart', {...states[0], isLoadingInProgress: true}], + ['convertDataToDB', {...states[0], lastLoadedRawData: states[1].lastLoadedRawData}], + ['initLoad', {...states[0], lastLoadedRawData: states[1].lastLoadedRawData}], + ['dataLoadSuccess', states[1]], + ['renderStart', states[1]], + ['renderEngineStart', states[1]], + ['renderEngineDone', states[1]], + ['domInsertStart', states[2]], + ['domInsertDone', states[2]], + ['renderDone', states[2]], + ['dataLoadStart', {...states[3], isLoadingInProgress: true}], + ['convertDataToDB', {...states[3], lastLoadedRawData: states[4].lastLoadedRawData}], + ['dataLoadSuccess', states[4]], + ['renderStart', states[5]], + ['renderEngineStart', states[5]], + ['renderEngineDone', states[5]], + ['domInsertStart', states[6]], + ['domInsertDone', states[6]], + ['renderDone', states[6]], + ['lifecycleDone', states[7]] + ]); + }); + }); + + test.describe('24 elements was provided in items prop', () => { + test('state at the time of emitting events must be correct', async () => { + const + chunkSize = 12, + total = chunkSize * 2; + + const states = [ + state.compile(observerInitialStateFields), + ( + state.data.addData(chunkSize * 2), + state.data.setTotal(total), + + state.set({ + loadPage: 1, + lastLoadedRawData: undefined, + areRequestsStopped: true + }).compile(observerInitialStateFields) + ), + ( + state.data.addItems(chunkSize), + state.set({renderPage: 1, isInitialRender: false}).compile(observerLoadedStateFields) + ), + ( + state.set({isLastRender: true}).compile() + ), + ( + state.data.addItems(chunkSize), + state.set({renderPage: 2}).compile() + ), + ( + state.set({isLifecycleDone: true}).compile() + ) + ]; + + await component + .withPaginationItemProps() + .withProps({ + chunkSize, + items: state.data.getDataChunk(0), + + '@hook:beforeDataCreate': (ctx) => { + const original = ctx.emit; + + ctx.emit = jestMock.mock((...args) => { + original(...args); + return [args[0], Object.fastClone(ctx.getVirtualScrollState())]; + }); + } + }) + .build(); + + await component.waitForChildCountEqualsTo(chunkSize); + await component.scrollToBottom(); + await component.waitForChildCountEqualsTo(chunkSize * 2); + await component.scrollToBottom(); + await component.waitForLifecycleDone(); + + const + spy = await component.getSpy((ctx) => ctx.emit), + results = filterEmitterResults(await spy.results, true, ['initLoadStart', 'initLoad']); + + test.expect(results).toEqual([ + ['initLoadStart', {...states[0], isLoadingInProgress: true}], + ['initLoad', {...states[0], isLoadingInProgress: true}], + ['dataLoadSuccess', states[1]], + ['renderStart', states[1]], + ['renderEngineStart', states[1]], + ['renderEngineDone', states[1]], + ['domInsertStart', states[2]], + ['domInsertDone', states[2]], + ['renderDone', states[2]], + ['renderStart', states[3]], + ['renderEngineStart', states[3]], + ['renderEngineDone', states[3]], + ['domInsertStart', states[4]], + ['domInsertDone', states[4]], + ['renderDone', states[4]], + ['lifecycleDone', states[5]] + ]); + }); + }); + + test.describe('24 elements was provided in items prop', () => { + const + chunkSize = 12, + total = chunkSize * 2; + + test.beforeEach(async () => { + state.data.addData(chunkSize * 2); + state.data.setTotal(total); + + await component + .withPaginationItemProps() + .withProps({ + chunkSize, + items: state.data.getDataChunk(0) + }) + .build({useDummy: true}); + + await component.waitForChildCountEqualsTo(chunkSize); + await component.scrollToBottom(); + await component.waitForChildCountEqualsTo(chunkSize * 2); + await component.scrollToBottom(); + await component.waitForLifecycleDone(); + }); + + test.describe('items prop has been updated', () => { + test('state at the time of emitting events must be correct', async () => { + state.reset(); + + await component.component.evaluate((ctx) => { + const original = Object.cast(ctx.emit); + + ctx.emit = jestMock.mock((...args) => { + original(...args); + return [args[0], Object.fastClone(ctx.getVirtualScrollState())]; + }); + }); + + const states = [ + state.compile(observerInitialStateFields), + ( + state.data.addData(chunkSize * 2), + state.data.setTotal(total), + + state.set({ + loadPage: 1, + lastLoadedRawData: undefined, + areRequestsStopped: true + }).compile(observerInitialStateFields) + ), + ( + state.data.addItems(chunkSize), + state.set({renderPage: 1, isInitialRender: false}).compile(observerLoadedStateFields) + ), + ( + state.set({isLastRender: true}).compile() + ), + ( + state.data.addItems(chunkSize), + state.set({renderPage: 2}).compile() + ), + ( + state.set({isLifecycleDone: true}).compile() + ) + ]; + + const resetEvent = component.waitForEvent('resetState'); + + await component.scrollToTop(); + await component.updateProps({ + items: state.data.getDataChunk(0) + }); + + await resetEvent; + await component.waitForChildCountEqualsTo(chunkSize); + await component.scrollToBottom(); + await component.waitForChildCountEqualsTo(chunkSize * 2); + await component.scrollToBottom(); + await component.waitForLifecycleDone(); + + const + spy = await component.getSpy((ctx) => ctx.emit), + results = filterEmitterResults(await spy.results, true, ['initLoadStart', 'initLoad']); + + test.expect(results).toEqual([ + ['resetState', states[0]], + ['initLoadStart', {...states[0], isLoadingInProgress: true}], + ['initLoad', {...states[0], isLoadingInProgress: true}], + ['dataLoadSuccess', states[1]], + ['renderStart', states[1]], + ['renderEngineStart', states[1]], + ['renderEngineDone', states[1]], + ['domInsertStart', states[2]], + ['domInsertDone', states[2]], + ['renderDone', states[2]], + ['renderStart', states[3]], + ['renderEngineStart', states[3]], + ['renderEngineDone', states[3]], + ['domInsertStart', states[4]], + ['domInsertDone', states[4]], + ['renderDone', states[4]], + ['lifecycleDone', states[5]] + ]); + }); + }); + }); +}); diff --git a/src/components/base/b-virtual-scroll-new/test/unit/lifecycle/initialization/initialization.ts b/src/components/base/b-virtual-scroll-new/test/unit/lifecycle/initialization/initialization.ts new file mode 100644 index 0000000000..758e6d1a61 --- /dev/null +++ b/src/components/base/b-virtual-scroll-new/test/unit/lifecycle/initialization/initialization.ts @@ -0,0 +1,296 @@ +/*! + * V4Fire Client Core + * https://github.com/V4Fire/Client + * + * Released under the MIT license + * https://github.com/V4Fire/Client/blob/master/LICENSE + */ + +/** + * @file Test cases of the component lifecycle initialization. + */ + +import test from 'tests/config/unit/test'; + +import type bVirtualScrollNew from 'components/base/b-virtual-scroll-new/b-virtual-scroll-new'; +import { defaultShouldProps } from 'components/base/b-virtual-scroll-new/const'; + +import { createTestHelpers } from 'components/base/b-virtual-scroll-new/test/api/helpers'; +import type { VirtualScrollTestHelpers } from 'components/base/b-virtual-scroll-new/test/api/helpers/interface'; + +test.describe('', () => { + let + component: VirtualScrollTestHelpers['component'], + initLoadSpy: VirtualScrollTestHelpers['initLoadSpy'], + provider: VirtualScrollTestHelpers['provider'], + state: VirtualScrollTestHelpers['state']; + + const hookProp = { + '@hook:beforeDataCreate': (ctx: bVirtualScrollNew['unsafe']) => { + const + original = ctx.componentInternalState.compile.bind(ctx.componentInternalState); + + ctx.componentInternalState.compile = () => ({...original()}); + jestMock.spy(ctx, 'initLoadNext'); + } + }; + + test.beforeEach(async ({demoPage, page}) => { + await demoPage.goto(); + + ({component, initLoadSpy, provider, state} = await createTestHelpers(page)); + await provider.start(); + }); + + test.describe('property `chunkSize` is set to 12', () => { + test.describe('loaded data array is half length of the `chunkSize` prop', () => { + test.describe('`shouldPerformDataRequest` returns true after the initial loading', () => { + let + shouldStopRequestingData, + shouldPerformDataRequest; + + let + firstDataChunk, + secondDataChunk; + + const + chunkSize = 12; + + test.beforeEach(async () => { + const providerChunkSize = chunkSize / 2; + + shouldStopRequestingData = await component.mockFn(() => false); + shouldPerformDataRequest = await component.mockFn(defaultShouldProps.shouldPerformDataRequest); + + firstDataChunk = state.data.addData(providerChunkSize); + secondDataChunk = state.data.addData(providerChunkSize); + + state.data.addItems(chunkSize); + + await component + .withDefaultPaginationProviderProps({chunkSize: providerChunkSize}) + .withProps({ + chunkSize, + shouldStopRequestingData, + shouldPerformDataRequest, + disableObserver: true, + ...hookProp + }) + .build(); + + await component.waitForChildCountEqualsTo(chunkSize); + }); + + test('should render 12 items', async () => { + await test.expect(component.getChildCount()).resolves.toBe(chunkSize); + }); + + test('should call `shouldStopRequestingData` twice', async () => { + await test.expect(shouldStopRequestingData.calls).resolves.toEqual([ + [ + state.compile({ + remainingItems: undefined, + remainingChildren: undefined, + maxViewedItem: undefined, + maxViewedChild: undefined, + areRequestsStopped: false, + lastLoadedData: firstDataChunk, + lastLoadedRawData: {data: firstDataChunk}, + data: firstDataChunk, + loadPage: 1 + }), + test.expect.any(Object) + ], + [ + state.compile({ + remainingItems: undefined, + remainingChildren: undefined, + maxViewedItem: undefined, + maxViewedChild: undefined, + areRequestsStopped: false, + isInitialLoading: false, + lastLoadedData: secondDataChunk, + lastLoadedRawData: {data: secondDataChunk}, + data: state.data.data, + loadPage: 2 + }), + test.expect.any(Object) + ] + ]); + }); + + test('should call `shouldPerformDataRequest` once', async () => { + await test.expect(shouldPerformDataRequest.calls).resolves.toEqual([ + [ + state.compile({ + remainingItems: undefined, + remainingChildren: undefined, + maxViewedItem: undefined, + maxViewedChild: undefined, + areRequestsStopped: false, + lastLoadedData: firstDataChunk, + lastLoadedRawData: {data: firstDataChunk}, + data: firstDataChunk, + loadPage: 1 + }), + test.expect.any(Object) + ] + ]); + }); + + test('should call `initLoad` once', async () => { + await test.expect(initLoadSpy.calls).resolves.toEqual([[]]); + }); + + test('should call `initLoadNext` once', async () => { + const + spy = await component.getSpy((ctx) => ctx.initLoadNext); + + await test.expect(spy.calls).resolves.toEqual([[]]); + }); + }); + }); + }); + + test.describe('property `chunkSize` is set to 12', () => { + test.describe('loaded data array is half length of the `chunkSize` prop', () => { + test.describe('`shouldPerformDataRequest` returns false after the initial loading', () => { + let + shouldStopRequestingData, + shouldPerformDataRequest; + + const + chunkSize = 12, + providerChunkSize = chunkSize / 2; + + test.beforeEach(async () => { + shouldStopRequestingData = await component.mockFn(() => false); + shouldPerformDataRequest = await component.mockFn(() => false); + + state.data.addData(providerChunkSize); + state.data.addItems(providerChunkSize); + + await component + .withDefaultPaginationProviderProps({chunkSize: providerChunkSize}) + .withProps({ + chunkSize, + shouldStopRequestingData, + shouldPerformDataRequest, + disableObserver: true, + ...hookProp + }) + .build(); + + await component.waitForChildCountEqualsTo(providerChunkSize); + }); + + test('should render 6 items', async () => { + await test.expect(component.getChildCount()).resolves.toBe(providerChunkSize); + }); + + test('should call `shouldStopRequestingData` once', async () => { + await test.expect(shouldStopRequestingData.calls).resolves.toEqual([ + [ + state.compile({ + remainingItems: undefined, + remainingChildren: undefined, + maxViewedItem: undefined, + maxViewedChild: undefined, + areRequestsStopped: false, + loadPage: 1 + }), + test.expect.any(Object) + ] + ]); + }); + + test('should call `shouldPerformDataRequest` once', async () => { + await test.expect(shouldPerformDataRequest.calls).resolves.toEqual([ + [ + state.compile({ + remainingItems: undefined, + remainingChildren: undefined, + maxViewedItem: undefined, + maxViewedChild: undefined, + areRequestsStopped: false, + loadPage: 1 + }), + test.expect.any(Object) + ] + ]); + }); + + test('should call `initLoad` once', async () => { + await test.expect(initLoadSpy.calls).resolves.toEqual([[]]); + }); + }); + }); + }); + + test.describe('property `chunkSize` is set to 12', () => { + test.describe('loaded data array is half length of the `chunkSize` prop', () => { + test.describe('`shouldStopRequestingData` returns true after the initial loading', () => { + const + chunkSize = 12, + providerChunkSize = chunkSize / 2; + + let + shouldStopRequestingData, + shouldPerformDataRequest; + + test.beforeEach(async () => { + shouldStopRequestingData = await component.mockFn(() => true); + shouldPerformDataRequest = await component.mockFn(() => false); + + state.data.addData(providerChunkSize); + state.data.addItems(providerChunkSize); + + await component + .withDefaultPaginationProviderProps({chunkSize: providerChunkSize}) + .withProps({ + chunkSize, + shouldStopRequestingData, + shouldPerformDataRequest, + disableObserver: true, + ...hookProp + }) + .build(); + + await component.waitForChildCountEqualsTo(providerChunkSize); + }); + + test('should render 6 items', async () => { + await test.expect(component.getChildCount()).resolves.toBe(providerChunkSize); + }); + + test('should call `shouldStopRequestingData` once', async () => { + await test.expect(shouldStopRequestingData.calls).resolves.toEqual([ + [ + state.compile({ + remainingItems: undefined, + remainingChildren: undefined, + maxViewedItem: undefined, + maxViewedChild: undefined, + areRequestsStopped: false, + loadPage: 1 + }), + test.expect.any(Object) + ] + ]); + }); + + test('should call `shouldPerformDataRequest` once', async () => { + await test.expect(shouldPerformDataRequest.calls).resolves.toEqual([]); + }); + + test('should call `initLoad` once', async () => { + await test.expect(initLoadSpy.calls).resolves.toEqual([[]]); + }); + + test('should end the component lifecycle', async () => { + await test.expect(component.waitForLifecycleDone()).resolves.toBeUndefined(); + }); + }); + }); + }); +}); diff --git a/src/components/base/b-virtual-scroll-new/test/unit/lifecycle/slots/slots.ts b/src/components/base/b-virtual-scroll-new/test/unit/lifecycle/slots/slots.ts new file mode 100644 index 0000000000..44b73fc89d --- /dev/null +++ b/src/components/base/b-virtual-scroll-new/test/unit/lifecycle/slots/slots.ts @@ -0,0 +1,514 @@ +/*! + * V4Fire Client Core + * https://github.com/V4Fire/Client + * + * Released under the MIT license + * https://github.com/V4Fire/Client/blob/master/LICENSE + */ + +/** + * @file This file describes test cases for checking the correctness of displaying component slots in different states. + */ + +import delay from 'delay'; + +import test from 'tests/config/unit/test'; + +import { BOM } from 'tests/helpers'; + +import type { ShouldPerform } from 'components/base/b-virtual-scroll-new/b-virtual-scroll-new'; +import type { SlotsStateObj } from 'components/base/b-virtual-scroll-new/modules/slots'; + +import { createTestHelpers } from 'components/base/b-virtual-scroll-new/test/api/helpers'; + +// eslint-disable-next-line max-lines-per-function +test.describe('', () => { + let + component: Awaited>['component'], + provider: Awaited>['provider'], + state: Awaited>['state']; + + test.beforeEach(async ({demoPage, page}) => { + await demoPage.goto(); + + ({component, provider, state} = await createTestHelpers(page)); + await provider.start(); + + await component.withChildren({ + done: { + type: 'div', + attrs: { + id: 'done' + } + }, + + empty: { + type: 'div', + attrs: { + id: 'empty' + } + }, + + retry: { + type: 'div', + attrs: { + id: 'retry' + } + }, + + tombstone: { + type: 'div', + attrs: { + id: 'tombstone' + } + }, + + loader: { + type: 'div', + attrs: { + id: 'loader' + } + } + }); + + await component.withProps({ + tombstoneCount: 1 + }); + }); + + test.describe('`done`', () => { + test('activates when all data has been loaded after the initial load', async () => { + const chunkSize = 12; + + provider + .responseOnce(200, {data: state.data.addData(chunkSize)}) + .response(200, {data: []}); + + await component + .withDefaultPaginationProviderProps({chunkSize}) + .withProps({chunkSize}) + .build(); + + await component.waitForSlotState('done', true); + + const + slots = await component.getSlotsState(); + + test.expect(slots).toEqual(>{ + container: true, + done: true, + empty: false, + loader: false, + renderNext: false, + retry: false, + tombstones: false + }); + }); + + test('activates when all data has been loaded after the second load', async () => { + const chunkSize = 12; + + provider + .responseOnce(200, {data: state.data.addData(chunkSize)}) + .responseOnce(200, {data: state.data.addData(chunkSize)}) + .response(200, {data: []}); + + await component + .withDefaultPaginationProviderProps({chunkSize}) + .withProps({ + chunkSize, + shouldPerformDataRender: () => true + }) + .build(); + + await component.scrollToBottom(); + await component.waitForSlotState('done', true); + + const + slots = await component.getSlotsState(); + + test.expect(slots).toEqual(>{ + container: true, + done: true, + empty: false, + loader: false, + renderNext: false, + retry: false, + tombstones: false + }); + }); + + test('activates when data loading is completed but data is less than chunkSize', async () => { + const chunkSize = 12; + + provider + .responseOnce(200, {data: state.data.addData(chunkSize / 2)}) + .response(200, {data: []}); + + await component + .withDefaultPaginationProviderProps({chunkSize}) + .withProps({ + chunkSize, + shouldPerformDataRender: () => true + }) + .build(); + + await component.waitForSlotState('done', true); + + const + slots = await component.getSlotsState(); + + test.expect(slots).toEqual(>{ + container: true, + done: true, + empty: false, + loader: false, + renderNext: false, + retry: false, + tombstones: false + }); + }); + + test('does not activates if there is more data to download', async () => { + const chunkSize = 12; + + provider + .responseOnce(200, {data: state.data.addData(chunkSize)}) + .responseOnce(200, {data: state.data.addData(chunkSize)}) + .responseOnce(200, {data: state.data.addData(chunkSize)}) + .response(200, {data: []}); + + const shouldPerformDataRequest = + (({isInitialLoading, remainingItems}) => isInitialLoading || remainingItems === 0); + + const shouldPerformDataRender = + (({isInitialRender, remainingItems}) => isInitialRender || remainingItems === 0); + + await component + .withDefaultPaginationProviderProps({chunkSize}) + .withProps({ + chunkSize, + shouldPerformDataRequest, + shouldPerformDataRender + }) + .build(); + + await component.waitForChildCountEqualsTo(chunkSize); + await component.waitForSlotState('loader', false); + + const + slots = await component.getSlotsState(); + + test.expect(slots).toEqual(>{ + container: true, + done: false, + empty: false, + loader: false, + renderNext: false, + retry: false, + tombstones: false + }); + }); + }); + + test.describe('empty', () => { + test('activates when no data has been loaded after the initial load', async () => { + const chunkSize = 12; + + provider.response(200, {data: []}); + + await component + .withDefaultPaginationProviderProps({chunkSize}) + .withProps({ + chunkSize, + shouldPerformDataRender: () => true + }) + .build(); + + await component.waitForSlotState('empty', true); + + const + slots = await component.getSlotsState(); + + test.expect(slots).toEqual(>{ + container: true, + done: true, + empty: true, + loader: false, + renderNext: false, + retry: false, + tombstones: false + }); + }); + }); + + test.describe('tombstone & loader', () => { + test('activates while initial data loading in progress', async () => { + const chunkSize = 12; + + provider + .response(200, {data: state.data.addData(chunkSize)}, {delay: (10).seconds()}); + + await component + .withDefaultPaginationProviderProps({chunkSize}) + .withProps({chunkSize}) + .build(); + + await component.waitForSlotState('loader', true); + + const + slots = await component.getSlotsState(); + + test.expect(slots).toEqual(>{ + container: true, + done: false, + empty: false, + loader: true, + renderNext: false, + retry: false, + tombstones: true + }); + }); + + test('active while initial load loads all data', async () => { + const + chunkSize = 12, + providerChunkSize = chunkSize / 2; + + provider + .response(200, {data: state.data.addData(providerChunkSize)}, {delay: (4).seconds()}); + + await component + .withDefaultPaginationProviderProps({chunkSize}) + .withProps({chunkSize}) + .build(); + + let i = 0; + + while (i < 4) { + await component.waitForSlotState('loader', true); + + const + slots = await component.getSlotsState(); + + test.expect(slots).toEqual(>{ + container: true, + done: false, + empty: false, + loader: true, + renderNext: false, + retry: false, + tombstones: true + }); + + await delay(700); + i++; + } + }); + }); + + test.describe('retry', () => { + test('activates when a data load error occurred during initial loading', async () => { + const chunkSize = 12; + + provider.response(500, {}); + + await component + .withDefaultPaginationProviderProps({chunkSize}) + .withProps({ + chunkSize, + shouldPerformDataRender: () => true + }) + .build(); + + await component.waitForSlotState('retry', true); + + const + slots = await component.getSlotsState(); + + test.expect(slots).toEqual(>{ + container: true, + done: false, + empty: false, + loader: false, + renderNext: false, + retry: true, + tombstones: false + }); + }); + + test('activates when a data load error occurred during initial loading of the second data chunk', async () => { + const + chunkSize = 12, + providerChunkSize = chunkSize / 2; + + provider + .responseOnce(200, {data: state.data.addData(providerChunkSize)}) + .response(500, {}); + + await component + .withDefaultPaginationProviderProps({chunkSize}) + .withProps({ + chunkSize, + shouldPerformDataRender: () => true + }) + .build(); + + await component.waitForSlotState('retry', true); + + const + slots = await component.getSlotsState(); + + test.expect(slots).toEqual(>{ + container: true, + done: false, + empty: false, + loader: false, + renderNext: false, + retry: true, + tombstones: false + }); + }); + + test('activates when a data load error ocurred during loading of second data chunk', async () => { + const chunkSize = 12; + + provider + .responseOnce(200, {data: state.data.addData(chunkSize)}) + .response(500, {}); + + await component + .withDefaultPaginationProviderProps({chunkSize}) + .withProps({ + chunkSize + }) + .build(); + + await component.waitForChildCountEqualsTo(chunkSize); + await component.scrollToBottom(); + await component.waitForSlotState('retry', true); + + const + slots = await component.getSlotsState(); + + test.expect(slots).toEqual(>{ + container: true, + done: false, + empty: false, + loader: false, + renderNext: false, + retry: true, + tombstones: false + }); + }); + }); + + test.describe('renderNext', () => { + test.beforeEach(async () => { + await component.withChildren({ + renderNext: { + type: 'div', + attrs: { + id: 'renderNext' + } + } + }); + }); + + test('activates when data is loaded', async () => { + const chunkSize = 12; + + provider + .responseOnce(200, {data: state.data.addData(chunkSize)}) + .response(200, {data: []}); + + await component + .withDefaultPaginationProviderProps({chunkSize}) + .withProps({ + chunkSize, + disableObserver: true, + shouldPerformDataRender: () => true + }) + .build(); + + await component.waitForSlotState('renderNext', true); + + const + slots = await component.getSlotsState(); + + test.expect(slots).toEqual(>{ + container: true, + done: false, + empty: false, + loader: false, + renderNext: true, + retry: false, + tombstones: false + }); + }); + + test('doesn\'t activates while data is loading', async ({page}) => { + const chunkSize = 12; + + provider + .responseOnce(200, {data: state.data.addData(chunkSize)}, {delay: (10).seconds()}) + .response(200, {data: []}); + + await component + .withDefaultPaginationProviderProps({chunkSize}) + .withProps({ + chunkSize, + disableObserver: true, + shouldPerformDataRender: () => true + }) + .build(); + + await BOM.waitForIdleCallback(page); + await component.waitForSlotState('renderNext', false); + + const + slots = await component.getSlotsState(); + + test.expect(slots).toEqual(>{ + container: true, + done: false, + empty: false, + loader: true, + renderNext: false, + retry: false, + tombstones: true + }); + }); + + test('doesn\'t activates if there\'s a data loading error', async () => { + const chunkSize = 12; + + provider.response(500, {data: []}); + + await component + .withDefaultPaginationProviderProps({chunkSize}) + .withProps({ + chunkSize, + disableObserver: true, + shouldPerformDataRender: () => true + }) + .build(); + + await component.waitForSlotState('renderNext', false); + await component.waitForSlotState('tombstones', false); + + const + slots = await component.getSlotsState(); + + test.expect(slots).toEqual(>{ + container: true, + done: false, + empty: false, + loader: false, + renderNext: false, + retry: true, + tombstones: false + }); + }); + }); +}); diff --git a/src/components/base/b-virtual-scroll-new/test/unit/scenario/last-render.ts b/src/components/base/b-virtual-scroll-new/test/unit/scenario/last-render.ts new file mode 100644 index 0000000000..0267962350 --- /dev/null +++ b/src/components/base/b-virtual-scroll-new/test/unit/scenario/last-render.ts @@ -0,0 +1,202 @@ +/*! + * V4Fire Client Core + * https://github.com/V4Fire/Client + * + * Released under the MIT license + * https://github.com/V4Fire/Client/blob/master/LICENSE + */ + +/** + * @file This test file contains scenarios for checking the functionality of calling the last render in the lifecycle. + */ + +import test from 'tests/config/unit/test'; + +import { createTestHelpers, filterEmitterResults } from 'components/base/b-virtual-scroll-new/test/api/helpers'; +import type { VirtualScrollTestHelpers } from 'components/base/b-virtual-scroll-new/test/api/helpers/interface'; +import type { ComponentItem, VirtualScrollState } from 'components/base/b-virtual-scroll-new/interface'; + +const j = (...str: string[]) => str.join(', '); + +test.describe('', () => { + let + component: VirtualScrollTestHelpers['component'], + provider: VirtualScrollTestHelpers['provider'], + state: VirtualScrollTestHelpers['state']; + + const observerInitialStateFields = { + remainingItems: undefined, + remainingChildren: undefined, + maxViewedChild: undefined, + maxViewedItem: undefined + }; + + const observerLoadedStateFields = { + maxViewedChild: undefined, + maxViewedItem: undefined + }; + + test.beforeEach(async ({demoPage, page}) => { + await demoPage.goto(); + + ({component, provider, state} = await createTestHelpers(page)); + await provider.start(); + }); + + test.describe(j( + 'chunkSize set to 10', + 'provider responds with 10 elements for the first time', + 'next time provider responds with 0 elements', + 'client says that the requests have completed' + ), () => { + test('should fire render events 2 times with correct state', async () => { + const + chunkSize = 10, + providerChunkSize = 10; + + const states = [ + state.compile(observerInitialStateFields), + ( + // 1 + state.data.addData(providerChunkSize), + state.set({loadPage: 1}).compile(observerInitialStateFields) + ), + ( + // 2 + state.data.addItems(chunkSize), + state.set({renderPage: 1, isInitialRender: false}).compile(observerLoadedStateFields) + ), + ( + // 3 + state.compile() + ), + ( + // 4 + state.data.addData(0), + state.set({ + loadPage: 2, + areRequestsStopped: true, + isLastEmpty: true, + isInitialLoading: false + }).compile() + ), + ( + // 5 + state.set({isLastRender: true}).compile() + ), + ( + // 6 + state.set({isLifecycleDone: true}).compile() + ) + ]; + + provider + .responseOnce(200, {data: state.data.getDataChunk(0)}) + .responseOnce(200, {data: state.data.getDataChunk(1)}); + + await component + .withDefaultPaginationProviderProps() + .withProps({ + chunkSize, + shouldStopRequestingData: (state: VirtualScrollState): boolean => state.lastLoadedData.length === 0, + + '@hook:beforeDataCreate': (ctx) => { + const original = ctx.emit; + + ctx.emit = jestMock.mock((...args) => { + original(...args); + return [args[0], Object.fastClone(ctx.getVirtualScrollState())]; + }); + } + }) + .build(); + + await component.waitForChildCountEqualsTo(chunkSize); + await component.scrollToBottom(); + await component.waitForLifecycleDone(); + + const + spy = await component.getSpy((ctx) => ctx.emit), + results = filterEmitterResults(await spy.results, true, ['initLoadStart', 'initLoad']); + + test.expect(results).toEqual([ + ['initLoadStart', {...states[0], isLoadingInProgress: true}], + ['dataLoadStart', {...states[0], isLoadingInProgress: true}], + ['convertDataToDB', {...states[0], lastLoadedRawData: states[1].lastLoadedRawData}], + ['initLoad', {...states[0], lastLoadedRawData: states[1].lastLoadedRawData}], + ['dataLoadSuccess', states[1]], + ['renderStart', states[1]], + ['renderEngineStart', states[1]], + ['renderEngineDone', states[1]], + ['domInsertStart', states[2]], + ['domInsertDone', states[2]], + ['renderDone', states[2]], + ['dataLoadStart', {...states[3], isLoadingInProgress: true}], + ['convertDataToDB', {...states[3], lastLoadedRawData: states[4].lastLoadedRawData}], + ['dataLoadSuccess', states[4]], + ['renderStart', states[5]], + ['renderDone', states[5]], + ['lifecycleDone', states[6]] + ]); + }); + }); + + test.describe(j( + 'itemsFactory creates a component for each data element and always adds another separator to this array', + 'the provider returned 10 elements on the first load', + 'in the second load the provider returned 0 elements' + ), () => { + test('12 elements should be rendered', async () => { + const + chunkSize = 10; + + provider + .responseOnce(200, {data: state.data.addData(chunkSize)}) + .response(200, {data: state.data.addData(0)}); + + const separator: ComponentItem = { + item: 'button', + key: Object.cast(undefined), + children: [], + props: {}, + type: 'separator' + }; + + const itemsFactory = await component.mockFn((state, ctx, separator) => { + const data = state.lastLoadedData; + + const result: ComponentItem[] = data.map((item) => ({ + item: 'section', + key: Object.cast(undefined), + type: 'item', + children: [], + props: { + 'data-index': item.i + } + })); + + result.push(separator); + + return result; + }, separator); + + await component + .withDefaultPaginationProviderProps({chunkSize}) + .withProps({ + itemsFactory, + shouldPerformDataRender: () => true, + chunkSize + }) + .build(); + + await component.waitForDataIndexChild(1); + await component.scrollToBottom(); + await component.waitForLifecycleDone(); + + await test.expect(component.container.locator('section')).toHaveCount(10); + await test.expect(component.container.locator('button')).toHaveCount(2); + await test.expect(component.childList).toHaveCount(12); + }); + }); +}); + diff --git a/src/components/base/b-virtual-scroll-new/test/unit/scenario/manual-rendering.ts b/src/components/base/b-virtual-scroll-new/test/unit/scenario/manual-rendering.ts new file mode 100644 index 0000000000..1f54457352 --- /dev/null +++ b/src/components/base/b-virtual-scroll-new/test/unit/scenario/manual-rendering.ts @@ -0,0 +1,130 @@ +/*! + * V4Fire Client Core + * https://github.com/V4Fire/Client + * + * Released under the MIT license + * https://github.com/V4Fire/Client/blob/master/LICENSE + */ + +/** + * @file This file contains test cases for verifying the functionality of loading data + * using methods instead of observers. + */ + +import test from 'tests/config/unit/test'; + +import type { ComponentElement } from 'core/component'; + +import type bVirtualScrollNew from 'components/base/b-virtual-scroll-new/b-virtual-scroll-new'; +import { createTestHelpers } from 'components/base/b-virtual-scroll-new/test/api/helpers'; +import type { VirtualScrollTestHelpers } from 'components/base/b-virtual-scroll-new/test/api/helpers/interface'; + +test.describe('', () => { + let + component: VirtualScrollTestHelpers['component'], + provider: VirtualScrollTestHelpers['provider'], + state: VirtualScrollTestHelpers['state']; + + test.beforeEach(async ({demoPage, page}) => { + await demoPage.goto(); + + ({component, provider, state} = await createTestHelpers(page)); + await provider.start(); + + await component.withChildren({ + renderNext: { + type: 'div', + attrs: { + id: 'renderNext', + '@click': () => (>document.querySelector('.b-virtual-scroll-new')).component?.initLoadNext() + } + }, + + retry: { + type: 'div', + attrs: { + id: 'retry', + '@click': () => (>document.querySelector('.b-virtual-scroll-new')).component?.initLoadNext() + } + } + }); + }); + + test.describe('the first chunk of data is loaded and rendered', () => { + const chunkSize = 12; + + test.beforeEach(async () => { + provider.response(200, () => ({data: state.data.addData(chunkSize)})); + + await component + .withDefaultPaginationProviderProps({chunkSize}) + .withProps({ + chunkSize, + disableObserver: true, + shouldPerformDataRender: () => true + }) + .build(); + + await component.waitForChildCountEqualsTo(chunkSize); + }); + + test('should load and render the next chunk after calling initLoadNext', async () => { + await component.node.locator('#renderNext').click(); + + test.expect(provider.mock.mock.calls.length).toBe(2); + await test.expect(component.waitForDataIndexChild(chunkSize * 2 - 1)).resolves.toBeUndefined(); + await test.expect(component.waitForChildCountEqualsTo(chunkSize * 2)).resolves.toBeUndefined(); + }); + + test('should complete the component lifecycle after all data is loaded', async () => { + provider.response(200, {data: []}); + + await component.node.locator('#renderNext').click(); + + await test.expect(component.waitForLifecycleDone()).resolves.toBeUndefined(); + await test.expect(component.waitForSlotState('renderNext', false)).resolves.toBeUndefined(); + await test.expect(component.waitForDataIndexChild(chunkSize - 1)).resolves.toBeUndefined(); + await test.expect(component.waitForChildCountEqualsTo(chunkSize)).resolves.toBeUndefined(); + }); + + test.describe('an error occurred while loading the second chunk of data', () => { + test.beforeEach(async () => { + provider.responseOnce(500, {data: []}); + await component.node.locator('#renderNext').click(); + }); + + test('should not display the renderNext slot', async () => { + await test.expect(component.waitForSlotState('renderNext', false)).resolves.toBeUndefined(); + }); + + test('should display the retry slot', async () => { + await test.expect(component.waitForSlotState('retry', true)).resolves.toBeUndefined(); + }); + + test.describe('data reload occurred', () => { + test.beforeEach(async () => { + await component.node.locator('#retry').click(); + }); + + test('should display the loaded data', async () => { + await test.expect(component.waitForDataIndexChild(chunkSize * 2 - 1)).resolves.toBeUndefined(); + await test.expect(component.waitForChildCountEqualsTo(chunkSize * 2)).resolves.toBeUndefined(); + }); + + test.describe('no more data to display', () => { + test.beforeEach(async () => { + provider.response(200, {data: []}); + await component.node.locator('#renderNext').click(); + }); + + test('should complete the component lifecycle after all data is loaded', async () => { + await test.expect(component.waitForLifecycleDone()).resolves.toBeUndefined(); + await test.expect(component.waitForSlotState('renderNext', false)).resolves.toBeUndefined(); + await test.expect(component.waitForDataIndexChild(chunkSize * 2 - 1)).resolves.toBeUndefined(); + await test.expect(component.waitForChildCountEqualsTo(chunkSize * 2)).resolves.toBeUndefined(); + }); + }); + }); + }); + }); +}); diff --git a/src/components/base/b-virtual-scroll-new/test/unit/scenario/reload.ts b/src/components/base/b-virtual-scroll-new/test/unit/scenario/reload.ts new file mode 100644 index 0000000000..3f4223282a --- /dev/null +++ b/src/components/base/b-virtual-scroll-new/test/unit/scenario/reload.ts @@ -0,0 +1,168 @@ +/*! + * V4Fire Client Core + * https://github.com/V4Fire/Client + * + * Released under the MIT license + * https://github.com/V4Fire/Client/blob/master/LICENSE + */ + +/** + * @file This file contains a set of test cases to verify the functionality of component reloading. + */ + +import test from 'tests/config/unit/test'; + +import { createTestHelpers, filterEmitterCalls } from 'components/base/b-virtual-scroll-new/test/api/helpers'; +import type { VirtualScrollTestHelpers } from 'components/base/b-virtual-scroll-new/test/api/helpers/interface'; + +test.describe('', () => { + let + component: VirtualScrollTestHelpers['component'], + provider: VirtualScrollTestHelpers['provider'], + state: VirtualScrollTestHelpers['state'], + initLoadSpy: VirtualScrollTestHelpers['initLoadSpy']; + + test.beforeEach(async ({demoPage, page}) => { + await demoPage.goto(); + + ({component, provider, state, initLoadSpy} = await createTestHelpers(page)); + await provider.start(); + }); + + test.describe('`request` prop was changed', () => { + test('Should reset state and reload the component data', async () => { + const + chunkSize = [12, 20]; + + provider + .responseOnce(200, {data: state.data.addData(chunkSize[0])}) + .responseOnce(200, {data: state.data.addData(chunkSize[1])}) + .response(200, {data: []}); + + await component + .withDefaultPaginationProviderProps({chunkSize: chunkSize[0]}) + .withProps({ + chunkSize: chunkSize[0], + shouldPerformDataRequest: ({remainingItems}) => remainingItems === 0, + '@hook:beforeDataCreate': (ctx) => jestMock.spy(ctx, 'emit') + }) + .build({useDummy: true}); + + await component.waitForChildCountEqualsTo(chunkSize[0]); + + await component.update({ + attrs: { + request: { + get: { + chunkSize: chunkSize[1] + } + }, + chunkSize: chunkSize[1] + } + }); + + await component.waitForDataIndexChild(chunkSize[1] - 1); + + const + spy = await component.getSpy((ctx) => ctx.emit), + calls = filterEmitterCalls(await spy.calls, true, ['initLoadStart', 'initLoad']).map(([event]) => event); + + test.expect(calls).toEqual([ + 'initLoadStart', + 'dataLoadStart', + 'convertDataToDB', + 'initLoad', + 'dataLoadSuccess', + 'renderStart', + 'renderEngineStart', + 'renderEngineDone', + 'domInsertStart', + 'domInsertDone', + 'renderDone', + 'resetState', + 'initLoadStart', + 'dataLoadStart', + 'convertDataToDB', + 'initLoad', + 'dataLoadSuccess', + 'renderStart', + 'renderEngineStart', + 'renderEngineDone', + 'domInsertStart', + 'domInsertDone', + 'renderDone' + ]); + + await test.expect(initLoadSpy.calls).resolves.toEqual([[], []]); + await test.expect(component.waitForChildCountEqualsTo(chunkSize[1])).resolves.toBeUndefined(); + }); + }); + + ['reset', 'reset.silence', 'reset.load', 'reset.load.silence'].forEach((event, i) => { + test.describe(`${event} fired`, () => { + test('Should reset state and reload the component data', async () => { + const + chunkSize = 12; + + provider + .responseOnce(200, {data: state.data.addData(chunkSize)}) + .responseOnce(200, {data: state.data.addData(chunkSize)}) + .response(200, {data: []}); + + await component + .withDefaultPaginationProviderProps({chunkSize}) + .withProps({ + chunkSize, + shouldPerformDataRequest: ({remainingItems}) => remainingItems === 0, + '@hook:beforeDataCreate': (ctx) => jestMock.spy(ctx, 'emit') + }) + .build(); + + await component.waitForDataIndexChild(chunkSize - 1); + await component.component.evaluate((ctx, [event]) => ctx.unsafe.globalEmitter.emit(event), [event]); + await component.waitForDataIndexChild(chunkSize * 2 - 1); + + const + spy = await component.getSpy((ctx) => ctx.emit), + calls = filterEmitterCalls(await spy.calls, true, ['initLoadStart', 'initLoad']).map(([event]) => event); + + test.expect(calls).toEqual([ + 'initLoadStart', + 'dataLoadStart', + 'convertDataToDB', + 'initLoad', + 'dataLoadSuccess', + 'renderStart', + 'renderEngineStart', + 'renderEngineDone', + 'domInsertStart', + 'domInsertDone', + 'renderDone', + 'resetState', + 'initLoadStart', + 'dataLoadStart', + 'convertDataToDB', + 'initLoad', + 'dataLoadSuccess', + 'renderStart', + 'renderEngineStart', + 'renderEngineDone', + 'domInsertStart', + 'domInsertDone', + 'renderDone' + ]); + + const initLoadArgs = [ + [[], []], + [[], [undefined, {silent: true}]], + [[], []], + [[], [undefined, {silent: true}]] + ]; + + await test.expect(initLoadSpy.calls).resolves.toEqual(initLoadArgs[i]); + await test.expect(component.waitForChildCountEqualsTo(chunkSize)).resolves.toBeUndefined(); + }); + }); + }); + +}); diff --git a/src/components/base/b-virtual-scroll-new/test/unit/scenario/retry.ts b/src/components/base/b-virtual-scroll-new/test/unit/scenario/retry.ts new file mode 100644 index 0000000000..e1f24542e4 --- /dev/null +++ b/src/components/base/b-virtual-scroll-new/test/unit/scenario/retry.ts @@ -0,0 +1,178 @@ +/*! + * V4Fire Client Core + * https://github.com/V4Fire/Client + * + * Released under the MIT license + * https://github.com/V4Fire/Client/blob/master/LICENSE + */ + +/** + * @file This file contains test cases to verify the functionality of reloading data after an error. + */ + +import test from 'tests/config/unit/test'; + +import type { ComponentElement } from 'core/component'; + +import type bVirtualScrollNew from 'components/base/b-virtual-scroll-new/b-virtual-scroll-new'; +import { createTestHelpers } from 'components/base/b-virtual-scroll-new/test/api/helpers'; +import type { VirtualScrollTestHelpers } from 'components/base/b-virtual-scroll-new/test/api/helpers/interface'; + +test.describe('', () => { + let + component: VirtualScrollTestHelpers['component'], + provider: VirtualScrollTestHelpers['provider'], + state: VirtualScrollTestHelpers['state']; + + test.beforeEach(async ({demoPage, page}) => { + await demoPage.goto(); + + ({component, provider, state} = await createTestHelpers(page)); + await provider.start(); + + await component.withChildren({ + retry: { + type: 'div', + attrs: { + id: 'retry', + '@click': () => (>document.querySelector('.b-virtual-scroll-new')).component?.initLoadNext() + } + } + }); + }); + + test.describe('data loading error ocurred on initial loading', () => { + test('should reload data after initLoad call', async () => { + const chunkSize = 12; + + provider + .responseOnce(500, {}) + .responseOnce(200, {data: state.data.addData(chunkSize)}) + .response(200, {data: []}); + + await component + .withProps({chunkSize}) + .withDefaultPaginationProviderProps({chunkSize}) + .build(); + + await component.node.locator('#retry').click(); + + await test.expect(component.waitForChildCountEqualsTo(chunkSize)).resolves.toBeUndefined(); + }); + + test('should reload data after invoking retry function from `onRequestError` handler', async () => { + const chunkSize = 12; + + provider + .responseOnce(500, {}) + .responseOnce(200, {data: state.data.addData(chunkSize)}) + .response(200, {data: []}); + + await component + .withDefaultPaginationProviderProps({chunkSize}) + .withProps({ + chunkSize, + '@onRequestError': (_, retryFn) => setTimeout(retryFn, 0) + }) + .build(); + + await test.expect(component.waitForChildCountEqualsTo(chunkSize)).resolves.toBeUndefined(); + }); + + test('should goes to retry state after failing to load data twice', async () => { + const chunkSize = 12; + + provider + .responseOnce(500, {}) + .responseOnce(500, {}) + .responseOnce(200, {data: state.data.addData(chunkSize)}) + .response(200, {data: []}); + + await component + .withProps({chunkSize}) + .withDefaultPaginationProviderProps({chunkSize}) + .build(); + + const event = component.waitForEvent('dataLoadError'); + await component.node.locator('#retry').click(); + await event; + await component.node.locator('#retry').click(); + + await test.expect(component.waitForChildCountEqualsTo(chunkSize)).resolves.toBeUndefined(); + }); + }); + + test.describe('data loading error ocurred on second data chunk loading', () => { + test('should reload second data chunk after initLoad call', async () => { + const chunkSize = 12; + + provider + .responseOnce(200, {data: state.data.addData(chunkSize)}) + .responseOnce(500, {}) + .responseOnce(200, {data: state.data.addData(chunkSize)}) + .response(200, {data: []}); + + await component + .withDefaultPaginationProviderProps({chunkSize}) + .withProps({chunkSize}) + .build(); + + await component.waitForChildCountEqualsTo(chunkSize); + await component.scrollToBottom(); + + await component.node.locator('#retry').click(); + await component.waitForDataIndexChild(chunkSize * 2 - 1); + + await test.expect(component.waitForChildCountEqualsTo(chunkSize * 2)).resolves.toBeUndefined(); + }); + }); + + test.describe('an error occurred while loading the second chunk of data for rendering the first chunk of elements', () => { + test('should reload second data chunk and perform a render', async () => { + const + chunkSize = 12, + providerChunkSize = chunkSize / 2; + + provider + .responseOnce(200, {data: state.data.addData(providerChunkSize)}) + .responseOnce(500, {}) + .responseOnce(200, {data: state.data.addData(providerChunkSize)}) + .response(200, {data: []}); + + await component + .withDefaultPaginationProviderProps({chunkSize: providerChunkSize}) + .withProps({chunkSize}) + .build(); + + await component.node.locator('#retry').click(); + + await component.waitForChildCountEqualsTo(chunkSize); + await component.waitForDataIndexChild(chunkSize - 1); + await component.waitForLifecycleDone(); + + await test.expect(component.waitForChildCountEqualsTo(chunkSize)).resolves.toBeUndefined(); + }); + }); + + test.describe('an error occurred while loading the last chunk of data', () => { + test('after a successful load, the component lifecycle should complete', async () => { + const chunkSize = 12; + + provider + .responseOnce(200, {data: state.data.addData(chunkSize)}) + .responseOnce(500, {}) + .response(200, {data: []}); + + await component + .withProps({chunkSize}) + .withDefaultPaginationProviderProps({chunkSize}) + .build(); + + await component.node.locator('#retry').click(); + + test.expect(provider.mock.mock.calls.length).toBe(3); + await test.expect(component.waitForLifecycleDone()).resolves.toBeUndefined(); + await test.expect(component.waitForChildCountEqualsTo(chunkSize)).resolves.toBeUndefined(); + }); + }); +}); diff --git a/src/components/base/b-virtual-scroll/test/unit/render.ts b/src/components/base/b-virtual-scroll/test/unit/render.ts index 86f2eb133c..d8e096d4be 100644 --- a/src/components/base/b-virtual-scroll/test/unit/render.ts +++ b/src/components/base/b-virtual-scroll/test/unit/render.ts @@ -13,7 +13,7 @@ import type bVirtualScroll from 'components/base/b-virtual-scroll/b-virtual-scro import Component from 'tests/helpers/component'; import Scroll from 'tests/helpers/scroll'; import BOM from 'tests/helpers/bom'; -import { interceptPaginationRequest } from 'tests/helpers/providers/pagination'; +import { interceptPaginationRequest } from 'tests/network-interceptors/pagination'; test.describe('b-virtual-scroll render', () => { diff --git a/src/components/dummies/b-dummy/b-dummy.ss b/src/components/dummies/b-dummy/b-dummy.ss index c3f8fe180b..1a0f72333a 100644 --- a/src/components/dummies/b-dummy/b-dummy.ss +++ b/src/components/dummies/b-dummy/b-dummy.ss @@ -12,4 +12,13 @@ - template index() extends ['i-data'].index - block body - += self.slot() + < template v-if = testComponent + < component & + ref = testComponent | + :is = testComponent | + :v-attrs = testComponentAttrs | + v-render = testComponentSlots + . + + < template v-else + += self.slot() diff --git a/src/components/dummies/b-dummy/b-dummy.ts b/src/components/dummies/b-dummy/b-dummy.ts index 76678aa9b5..07e90dd434 100644 --- a/src/components/dummies/b-dummy/b-dummy.ts +++ b/src/components/dummies/b-dummy/b-dummy.ts @@ -11,7 +11,10 @@ * @packageDocumentation */ -import iData, { component } from 'components/super/i-data/i-data'; +import type { VNode } from 'core/component/engines'; + +import type iBlock from 'components/super/i-block/i-block'; +import iData, { component, field } from 'components/super/i-data/i-data'; export * from 'components/super/i-data/i-data'; @@ -22,7 +25,27 @@ export * from 'components/super/i-data/i-data'; }) class bDummy extends iData { + /** + * Name of the test component + */ + @field() + testComponent?: string; + + /** + * Attributes for the test component + */ + @field() + testComponentAttrs: Dictionary = {}; + + /** + * Slots for the test component + */ + @field() + testComponentSlots?: CanArray; + protected override readonly $refs!: iData['$refs'] & { + testComponent?: iBlock; + }; } export default bDummy; diff --git a/src/components/pages/p-v4-components-demo/index.js b/src/components/pages/p-v4-components-demo/index.js index 969b482502..2aa7c0573c 100644 --- a/src/components/pages/p-v4-components-demo/index.js +++ b/src/components/pages/p-v4-components-demo/index.js @@ -17,8 +17,9 @@ package('p-v4-components-demo') 'b-list', 'b-tree', - 'b-virtual-scroll', 'b-window', + 'b-virtual-scroll', + 'b-virtual-scroll-new', 'b-bottom-slide', 'b-slider', 'b-sidebar', diff --git a/src/components/pages/p-v4-components-demo/test/api/page.ts b/src/components/pages/p-v4-components-demo/test/api/page.ts index b855094253..5195f4b72e 100644 --- a/src/components/pages/p-v4-components-demo/test/api/page.ts +++ b/src/components/pages/p-v4-components-demo/test/api/page.ts @@ -13,22 +13,27 @@ import { concatURLs } from 'core/url'; import Component from 'tests/helpers/component'; import type bDummy from 'components/dummies/b-dummy/b-dummy'; +import type pV4ComponentsDemo from 'components/pages/p-v4-components-demo/p-v4-components-demo'; /** * Page object: provides an API to work with `DemoPage` */ export default class DemoPage { - /** {@link Page} */ readonly page: Page; /** - * Server base url + * Server base URL */ readonly baseUrl: string; /** - * Returns an initial page name + * Page component reference. + */ + component?: JSHandle; + + /** + * Returns the initial page name */ get pageName(): string { return ''; @@ -46,8 +51,13 @@ export default class DemoPage { * Opens a demo page */ async goto(): Promise { + const + root = this.page.locator('#root-component'); + await this.page.goto(concatURLs(this.baseUrl, `${build.demoPage()}.html`), {waitUntil: 'networkidle'}); - await this.page.waitForSelector('#root-component', {state: 'attached'}); + await root.waitFor({state: 'attached'}); + + this.component = await root.evaluateHandle((ctx) => ctx.component); return this; } diff --git a/src/core/prelude/test-env/components/json.ts b/src/core/prelude/test-env/components/json.ts index 2d70a55d32..23d80803ed 100644 --- a/src/core/prelude/test-env/components/json.ts +++ b/src/core/prelude/test-env/components/json.ts @@ -11,6 +11,7 @@ const fnEvalSymbol = Symbol('Function for eval'); export const fnAlias = 'FN__', fnEvalAlias = 'FNEVAL__', + fnMockAlias = 'FNMOCK__', regExpAlias = 'REGEX__'; export function evalFn(func: T): T { @@ -18,6 +19,43 @@ export function evalFn(func: T): T { return func; } +/** + * Overrides the `toJSON` method of the provided object to return the identifier of a mock function + * within the page context. + * + * @example + * ``` + * const val1 = JSON.stringify({val: 1}); // '{"val": 1}'; + * const val2 = JSON.stringify(setSerializerAsMockFn({val: 1}, 'id')); // '"id"' + * ``` + * + * This function is needed in order to extract a previously inserted mock function + * into the context of a browser page by its ID. + * + * @param obj - the object to override the `toJSON` method for. + * @param id - the identifier of the mock function. + * @returns The modified object with the overridden `toJSON` method. + */ +export function setSerializerAsMockFn(obj: T, id: string): T { + Object.assign(obj, { + toJSON: () => `${fnMockAlias}${id}` + }); + + return obj; +} + +export function stringifyFunction(val: Function): string { + if (val[fnEvalSymbol] != null) { + return `${fnEvalAlias}${val.toString()}`; + } + + return `${fnAlias}${val.toString()}`; +} + +export function stringifyRegExp(regExp: RegExp): string { + return `${regExpAlias}${JSON.stringify({source: regExp.source, flags: regExp.flags})}`; +} + /** * Stringifies the passed object to a JSON string and returns it. * The function also supports serialization of functions and regular expressions. @@ -27,15 +65,11 @@ export function evalFn(func: T): T { export function expandedStringify(obj: object): string { return JSON.stringify(obj, (_, val) => { if (Object.isFunction(val)) { - if (val[fnEvalSymbol] != null) { - return `${fnEvalAlias}${val.toString()}`; - } - - return `${fnAlias}${val.toString()}`; + return stringifyFunction(val); } if (Object.isRegExp(val)) { - return `${regExpAlias}${JSON.stringify({source: val.source, flags: val.flags})}`; + return stringifyRegExp(val); } return val; @@ -61,6 +95,11 @@ export function expandedParse(str: string): T { return Function(`return ${val.replace(fnEvalAlias, '')}`)()(); } + if (val.startsWith(fnMockAlias)) { + const mockId = val.replace(fnMockAlias, ''); + return globalThis[mockId]; + } + if (val.startsWith(regExpAlias)) { const obj = JSON.parse(val.replace(regExpAlias, '')); return new RegExp(obj.source, obj.flags); @@ -70,3 +109,5 @@ export function expandedParse(str: string): T { return val; }); } + +globalThis.expandedParse = expandedParse; diff --git a/src/core/prelude/test-env/index.ts b/src/core/prelude/test-env/index.ts index e14cf6ceca..8800be4829 100644 --- a/src/core/prelude/test-env/index.ts +++ b/src/core/prelude/test-env/index.ts @@ -14,3 +14,4 @@ import 'core/prelude/test-env/import'; import 'core/prelude/test-env/components'; import 'core/prelude/test-env/gestures'; +import 'core/prelude/test-env/mock'; diff --git a/src/core/prelude/test-env/mock/README.md b/src/core/prelude/test-env/mock/README.md new file mode 100644 index 0000000000..670c53c6c0 --- /dev/null +++ b/src/core/prelude/test-env/mock/README.md @@ -0,0 +1,3 @@ +# core/prelude/test-env/mock + +This module provides an API for accessing `jest-mock`, in fact it is just a proxy between the client and the `jest-mock` API which allows accessing `jest-mock` through the global scope. diff --git a/src/core/prelude/test-env/mock/index.ts b/src/core/prelude/test-env/mock/index.ts new file mode 100644 index 0000000000..30223acdd7 --- /dev/null +++ b/src/core/prelude/test-env/mock/index.ts @@ -0,0 +1,45 @@ +/*! + * V4Fire Client Core + * https://github.com/V4Fire/Client + * + * Released under the MIT license + * https://github.com/V4Fire/Client/blob/master/LICENSE + */ + +import { ModuleMocker } from 'jest-mock'; + +let + globalApi: CanUndef; + +globalThis.jestMock = { + /** + * {@link ModuleMocker.spyOn} + * + * @see https://jestjs.io/docs/mock-functions + * + * @param args + */ + spy: (...args: Parameters): any => { + globalApi ??= mockerFactory(); + return globalApi.spyOn(...args); + }, + + /** + * {@link ModuleMocker.fn} + * + * @see https://jestjs.io/docs/mock-functions + * + * @param args + */ + mock: (...args: any[]): any => { + globalApi ??= mockerFactory(); + return globalApi.fn(...args); + } +}; + +/** + * {@link ModuleMocker} + */ +function mockerFactory(): ModuleMocker { + return new ModuleMocker(globalThis); +} diff --git a/tests/config/project/test.ts b/tests/config/project/test.ts new file mode 100644 index 0000000000..03d032a381 --- /dev/null +++ b/tests/config/project/test.ts @@ -0,0 +1,11 @@ +/*! + * V4Fire Client Core + * https://github.com/V4Fire/Client + * + * Released under the MIT license + * https://github.com/V4Fire/Client/blob/master/LICENSE + */ + +import base from 'tests/config/super/test'; + +export default base; diff --git a/tests/config/super/test.ts b/tests/config/super/test.ts new file mode 100644 index 0000000000..558a5a1e86 --- /dev/null +++ b/tests/config/super/test.ts @@ -0,0 +1,11 @@ +/*! + * V4Fire Client Core + * https://github.com/V4Fire/Client + * + * Released under the MIT license + * https://github.com/V4Fire/Client/blob/master/LICENSE + */ + +import { test as base } from '@playwright/test'; + +export default base; diff --git a/tests/config/unit/test.ts b/tests/config/unit/test.ts index 64262fc292..1eee3d2482 100644 --- a/tests/config/unit/test.ts +++ b/tests/config/unit/test.ts @@ -6,7 +6,7 @@ * https://github.com/V4Fire/Client/blob/master/LICENSE */ -import { test as base } from '@playwright/test'; +import base from 'tests/config/super/test'; import DemoPage from 'components/pages/p-v4-components-demo/test/api/page'; diff --git a/tests/helpers/component-object/README.md b/tests/helpers/component-object/README.md new file mode 100644 index 0000000000..aabd9a8645 --- /dev/null +++ b/tests/helpers/component-object/README.md @@ -0,0 +1,372 @@ + + +**Table of Contents** + +- [tests/helpers/component-object](#testshelperscomponent-object) + - [Usage](#usage) + - [How to Create a Component and Place It on a Test Page?](#how-to-create-a-component-and-place-it-on-a-test-page) + - [How to Select an Existing Component on the Page?](#how-to-select-an-existing-component-on-the-page) + - [How to Set Props for a Component?](#how-to-set-props-for-a-component) + - [How to Change Props for a Component?](#how-to-change-props-for-a-component) + - [How to Set Child Nodes for a Component?](#how-to-set-child-nodes-for-a-component) + - [How to Track Component Method Calls?](#how-to-track-component-method-calls) + - [Using `spyOn` Method](#using-spyon-method) + - [Tracking Calls on the Prototype](#tracking-calls-on-the-prototype) + - [Setting Up Spies Before Component Initialization](#setting-up-spies-before-component-initialization) + - [How to Set a Mock Function Instead of a Real Method?](#how-to-set-a-mock-function-instead-of-a-real-method) + - [How to Create a `ComponentObject` for My Component?](#how-to-create-a-componentobject-for-my-component) + + + +# tests/helpers/component-object + +The `ComponentObject` is a base class for creating a component object like components for testing. + +The `component object` pattern allows for a more convenient way to interact with components in a testing environment. + +This class can be used as a generic class for any component or can be extended to create a custom `component object` that implements methods for interacting with a specific component. + +## Usage + +### How to Create a Component and Place It on a Test Page? + +1. Initialize the `ComponentObject` by calling the class constructor and providing the page where the component will be located and the component's name: + + ```typescript + import ComponentObject from 'path/to/component-object'; + + // Create an instance of ComponentObject + const myComponent = new ComponentObject(page, 'b-component'); + ``` + +2. Set the props and child nodes that need to be rendered with the component: + + ```typescript + import ComponentObject from 'path/to/component-object'; + + // Create an instance of ComponentObject + const myComponent = new ComponentObject(page, 'b-component'); + + myComponent + .withProps({ + prop1: 'val' + }) + .withChildren({ + renderNext: { + type: 'div', + attrs: { + id: 'renderNext' + } + } + }); + ``` + +3. Call the `build` method, which generates the component's view and renders it on the page: + + ```typescript + import ComponentObject from 'path/to/component-object'; + + // Create an instance of ComponentObject + const myComponent = new ComponentObject(page, 'b-component'); + + myComponent + .withProps({ + prop1: 'val' + }) + .withChildren({ + renderNext: { + type: 'div', + attrs: { + id: 'renderNext' + } + } + }); + + await myComponent.build(); + ``` + +Now that the component is rendered and placed on the page, you can call any methods on it: + +```typescript +// Component handle +const component = myComponent.component; + +// Perform interactions with the component +await component.evaluate((ctx) => { + // Perform actions on the component + ctx.method(); +}); +``` + +### How to Select an Existing Component on the Page? + +Sometimes, you may not want to create a new component but instead select an existing one. To do this, you can use the `pick` method: + +```typescript +import ComponentObject from 'path/to/component-object'; + +// Create an instance of ComponentObject +const myComponent = new ComponentObject(page, 'b-component'); + +await myComponent.pick('#selector'); +``` + +### How to Set Props for a Component? + +You can set props for a component using the `withProps` method, which takes a dictionary of props: + +```typescript +import ComponentObject from 'path/to/component-object'; + +// Create an instance of ComponentObject +const myComponent = new ComponentObject(page, 'b-component'); + +myComponent + .withProps({ + prop1: 'val' + }); +``` + +You can use `withProps` multiple times to set props as many times as needed before the component is created using the `build` method. To overwrite a prop, simply use `withProps` again with the new value: + +```typescript +myComponent + .withProps({ + prop1: 'val' + }); + +myComponent.withProps({ + prop1: 'newVal' +}); + +console.log(myComponent.props) // {prop1: 'newVal'} +``` + +### How to Change Props for a Component? + +Once a component is created (by calling the `build` or `pick` method), you cannot directly change its props because props are `readonly` properties of the component. However, if a prop is linked to a parent component's property, changing the parent's property will also change the prop's value in the component. + +To facilitate this behavior, there is a "sugar" method that encapsulates this logic, using a `b-dummy` component as the parent. To use this sugar method in the `build` method, pass the option `useDummy: true`. This will create a wrapper for the component using `b-dummy`, and you can change props using a special method called `updateProps`: + +```typescript +// Create an instance of ComponentObject +const myComponent = new ComponentObject(page, 'b-component'); + +myComponent + .withProps({ + prop1: 'val' + }); + +await myComponent.build({ useDummy: true }); + +// Change props +await myComponent.updateProps({ prop1: 'newVal' }); +``` + +Please note that there are some nuances to consider when creating a component with a `b-dummy` wrapper, such as you cannot set slots for such a component. + +### How to Set Child Nodes for a Component? + +You can set child nodes (or slots) for a component using the `withChildren` method. It works similarly to `withProps`, but it defines the child elements of the component, not its props. + +Here's an example of setting a child node that should be rendered in the `renderNext` slot: + +```typescript +import ComponentObject from 'path/to/component-object'; + +// Create an instance of ComponentObject +const myComponent = new ComponentObject(page, 'b-component'); + +myComponent + .withChildren({ + renderNext: { + type: 'div', + attrs: { + id: 'renderNext' + } + } + }); + +await myComponent.build(); +``` + +To set the `default` slot, name the child node as `default`: + +```typescript +import ComponentObject from 'path/to/component-object'; + +// Create an instance of ComponentObject +const myComponent = new ComponentObject(page, 'b-component'); + +myComponent + .withChildren({ + default: { + type: 'div', + attrs: { + id: 'renderNext' + } + } + }); + +await myComponent.build(); +``` + +### How to Track Component Method Calls? + +#### Using `spyOn` Method + +To track calls to a component method, you can use the special `Spy` API, which is based on `jest-mock`. + +To create a spy for a method, use the `spyOn` method: + +```typescript +// Create an instance of MyComponentObject +const myComponent = new MyComponentObject(page, 'b-component'); + +await myComponent.build(); + +// Create a spy +const spy = await myComponent.spyOn('someMethod'); + +// Access the component +const component = myComponent.component; + +// Perform interactions with the component +await component.evaluate((ctx) => { + ctx.someMethod(); +}); + +// Access the spy +console.log(await spy.calls); // [[]] +``` + +In this example, we created a spy for the `someMethod` method. After performing the necessary actions with the component, you can access the `spy` object and use its API to find out how many times the method was called and with what arguments. + +#### Tracking Calls on the Prototype + +Sometimes, you may need to track calls to methods on the prototype, as they may be invoked not from the component instance itself but through functions like `call`, etc. For example, the `initLoad` method may be called from the prototype and the `call` function. In such cases, you can set up a spy on the class prototype. + +Let's consider an example of tracking calls to the `initLoad` method of a component. To do this, you can use the `spyOn` method with the additional option `proto`: + +```typescript +// Create an instance of MyComponentObject +const myComponent = new MyComponentObject(page, 'b-component'); + +const initLoadSpy = await myComponent.spyOn('initLoad', { proto: true }); + +await myComponent.build(); +await sleep(200); + +// Access the spy +console.log(await initLoadSpy.calls); // [[]] +``` + +Note that the spy is created before the component is created using the `build` method. This is important because when setting up a spy on the prototype, it needs to be established before the component is created so that it can track the initial call to `initLoad` during component creation. + +#### Setting Up Spies Before Component Initialization + +Sometimes, you may need to set up spies before the component is initialized. To do this, you can use the `beforeDataCreate` hook and define spies within it. + +Here's an example of setting up a spy to track the `emit` method of a component before its creation: + +```typescript +// Create an instance of MyComponentObject +const myComponent = new MyComponentObject(page, 'b-component'); + +await myComponent.withProps({ + '@hook:beforeDataCreate': (ctx) => jestMock.spy(ctx, 'emit'), +}); + +// Extract the spy +const spy = await myComponent.component.getSpy((ctx) => ctx.emit); + +// Access the spy +console.log(await spy.calls); +``` + +Important: The function set on the `@hook:beforeDataCreate` event will be called within the browser context, not in Node.js. To pass this function from Node.js to the browser context, it will be serialized. `jestMock` is a globally available object that redirects its method calls to the `jest-mock` API. + +### How to Set a Mock Function Instead of a Real Method? + +To set up a mock function instead of a real method, you can use the `beforeDataCreate` hook and the `mock` method of the global `jestMock` object. + +```typescript +// Create an instance of ComponentObject +const myComponent = new ComponentObject(page, 'b-component'); + +await myComponent + .withProps({ + prop1: 'val', + '@hook:beforeDataCreate': (ctx) => { + ctx.module.method = jestMock.mock(() => false); + }, + }) + .build(); + +const result = await myComponent.component.evaluate((ctx) => ctx.module.method()); + +console.log(result); // false +``` + +### How to Create a `ComponentObject` for My Component? + +For each component you want to test, you can use a `ComponentObject`. However, the basic API may not provide all the functionality you need since it does not know about your specific component. To make `ComponentObject` provide a more comfortable API for working with your component, you should create your own class that inherits from the basic `ComponentObject`. In your custom class (let's call it `MyComponentObject`), you can implement additional APIs that allow you to write tests more effectively and clearly. + +Here are the steps to create a `MyComponentObject`: + +1. Create a file for your class and inherit it from `ComponentObject`: + + **src/components/base/b-list/test/api/component-object/index.ts** + ```typescript + export class MyComponentObject extends ComponentObject { + + } + ``` + +2. Add the necessary API, such as the container selector, a method to get the number of child nodes in the container, and so on: + + **src/components/base/b-list/test/api/component-object/index.ts** + ```typescript + export class MyComponentObject extends ComponentObject { + + readonly container: Locator; + readonly childList: Locator; + + constructor(page: Page) { + super(page, 'b-list'); + + this.container = this.node.locator(this.elSelector('container')); + this.childList = this.container.locator('> *'); + } + + getChildCount(): Promise { + return this.childList.count(); + } + } + ``` + +3. Use your `MyComponentObject` instead of `ComponentObject`: + + ```typescript + import MyComponentObject from 'path/to/my-component-object'; + + // Create an instance of MyComponentObject + const myComponent = new MyComponentObject(page); + + myComponent + .withProps({ + prop1: 'val' + }) + .withChildren({ + renderNext: { + type: 'div', + attrs: { + id: 'renderNext' + } + } + }); + + await myComponent.build(); + + console.log(await myComponent.getChildCount()); + ``` diff --git a/tests/helpers/component-object/builder.ts b/tests/helpers/component-object/builder.ts new file mode 100644 index 0000000000..8b7ce7efac --- /dev/null +++ b/tests/helpers/component-object/builder.ts @@ -0,0 +1,319 @@ +/*! + * 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 'components/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 { + /** + * 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; + + /** + * 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; + + /** + * Reference to the `b-dummy` wrapper component. + */ + protected dummy?: ComponentInDummy; + + /** + * The component styles that should be inserted into the page + */ + get componentStyles(): CanUndef { + 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(): JSHandle { + if (!this.componentStore) { + throw new ReferenceError('Bad access to the component without "build" or "pick" call'); + } + + return this.componentStore; + } + + /** + * 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 + */ + constructor(page: Page, componentName: 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(`[data-component-object-id="${this.id}"]`); + + 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 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> { + await this.insertComponentStyles(); + + const + name = this.componentName, + fullComponentName = `${name}${options?.functional && !name.endsWith('-functional') ? '-functional' : ''}`; + + if (options?.useDummy) { + const component = await Component.createComponentInDummy(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, + children: this.children + }); + } + + 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; + + /** + * 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; + + /** + * 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): Promise; + + async pick(selectorOrLocator: string | Locator | Promise): Promise { + 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 { + 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 { + 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 { + 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 { + if (!this.dummy) { + throw new ReferenceError('Failed to update children. Missing "b-dummy" component.'); + } + + return this.dummy.update({children}); + } +} diff --git a/tests/helpers/component-object/index.ts b/tests/helpers/component-object/index.ts new file mode 100644 index 0000000000..41cd57705c --- /dev/null +++ b/tests/helpers/component-object/index.ts @@ -0,0 +1,53 @@ +/*! + * V4Fire Client Core + * https://github.com/V4Fire/Client + * + * Released under the MIT license + * https://github.com/V4Fire/Client/blob/master/LICENSE + */ + +import type iBlock from 'components/super/i-block/i-block'; +import ComponentObjectMock from 'tests/helpers/component-object/mock'; + +export default class ComponentObject extends ComponentObjectMock { + /** + * Returns the current value of the component's modifier. To extract the value, + * the .mods property of the component is used. + * + * @param modName - the name of the modifier + * @returns A Promise that resolves to the value of the modifier or undefined + */ + getModVal(modName: string): Promise> { + return this.component.evaluate((ctx, [modName]) => ctx.mods[modName], [modName]); + } + + /** + * Waits for the specified value to be set for the specified modifier + * + * @param modName - the name of the modifier + * @param modVal - the value to wait for + * @returns A Promise that resolves when the specified value is set for the modifier + */ + waitForModVal(modName: string, modVal: string): Promise { + return this.pwPage + .waitForFunction( + ([ctx, modName, modVal]) => ctx.mods[modName] === modVal, + [this.component, modName, modVal] + ) + .then(() => undefined); + } + + /** + * Activates the component (a shorthand for {@link iBlock.activate}) + */ + activate(): Promise { + return this.component.evaluate((ctx) => ctx.activate()); + } + + /** + * Deactivates the component (a shorthand for {@link iBlock.deactivate}) + */ + deactivate(): Promise { + return this.component.evaluate((ctx) => ctx.deactivate()); + } +} diff --git a/tests/helpers/component-object/interface.ts b/tests/helpers/component-object/interface.ts new file mode 100644 index 0000000000..206d4c09eb --- /dev/null +++ b/tests/helpers/component-object/interface.ts @@ -0,0 +1,43 @@ +/*! + * V4Fire Client Core + * https://github.com/V4Fire/Client + * + * Released under the MIT license + * https://github.com/V4Fire/Client/blob/master/LICENSE + */ + +import type iData from 'components/super/i-data/i-data'; + +/** + * Options for configuring a spy. + */ +export interface SpyOptions { + /** + * If set to true, the spy will be installed on the prototype of the component class. + * + * Setting this option is useful for methods such as {@link iData.initLoad} because they + * are called not from an instance of the component, but using the `call` method from the class prototype. + */ + proto?: boolean; +} + +/** + * Options for the `build` method. + */ +export interface BuildOptions { + /** + * If `true`, the component will be created inside a `b-dummy`, and its props will be set + * through the `field` property of `b-dummy`. + * + * Building the component with this option allows updating the component's props using the `updateProps` method. + * + * Using this option does not allow creating child nodes!!! + */ + useDummy?: boolean; + + /** + * If true, a functional version of the component will be created. + * The functional version is achieved by adding a -functional suffix to the component name during its creation. + */ + functional?: boolean; +} diff --git a/tests/helpers/component-object/mock.ts b/tests/helpers/component-object/mock.ts new file mode 100644 index 0000000000..4510a414e0 --- /dev/null +++ b/tests/helpers/component-object/mock.ts @@ -0,0 +1,131 @@ +/*! + * V4Fire Client Core + * https://github.com/V4Fire/Client + * + * Released under the MIT license + * https://github.com/V4Fire/Client/blob/master/LICENSE + */ +import type iBlock from 'components/super/i-block/i-block'; + +import ComponentObjectBuilder from 'tests/helpers/component-object/builder'; +import { createSpy, createMockFn, getSpy } from 'tests/helpers/mock'; + +import type { SpyOptions } from 'tests/helpers/component-object/interface'; +import type { SpyExtractor, SpyObject } from 'tests/helpers/mock/interface'; + +/** + * The {@link ComponentObjectMock} class extends the {@link ComponentObjectBuilder} class + * and provides additional methods for creating spies and mock functions. + * + * It is used for testing components in a mock environment. + */ +export default abstract class ComponentObjectMock extends ComponentObjectBuilder { + /** + * Creates a spy to observe calls to the specified method. + * + * @param path - the path to the method relative to the context (component). + * The {@link Object.get} method is used for searching, so you can use a complex path with separators. + * + * @param spyOptions - options for setting up the spy. + * @param spyOptions.proto - if set to `true`, the spy will be installed on the prototype of the component class. + * In this case, you don't need to add `prototype` to the `path`; it will be added automatically. + * + * @returns A promise that resolves to the spy object. + * + * @example + * ```typescript + * const + * component = new ComponentObject(page, 'b-virtual-scroll'), + * spy = await component.spyOn('initLoad', {proto: true}); // Installs a spy on the prototype of the component class + * + * await component.build(); + * console.log(await spy.calls); + * ``` + * + * @example + * ```typescript + * const component = new ComponentObject(page, 'b-virtual-scroll'); + * const spy = await component.spyOn('someModule.someMethod'); + * + * await component.build(); + * console.log(await spy.calls); + * ``` + */ + async spyOn(path: string, spyOptions?: SpyOptions): Promise { + const evaluateArgs = [path, spyOptions]; + const ctx = spyOptions?.proto ? await this.getComponentClass() : this.component; + + const instance = await createSpy(ctx, (ctx, [path, spyOptions]) => { + if (spyOptions?.proto === true) { + path = `prototype.${path}`; + } + + const + pathArray = path.split('.'), + method = pathArray.pop(), + obj = pathArray.length >= 1 ? Object.get(ctx, pathArray.join('.')) : ctx; + + if (!obj) { + throw new ReferenceError(`Cannot find object by the provided path: ${path}`); + } + + return jestMock.spy(obj, method); + }, evaluateArgs); + + return instance; + } + + /** + * Extracts the spy using the provided function. The provided function should return a reference to the spy. + * + * @param spyExtractor - the function that extracts the spy. + * @returns A promise that resolves to the spy object. + * + * @example + * ```typescript + * await component.withProps({ + * '@hook:beforeDataCreate': (ctx) => jestMock.spy(ctx.localEmitter, 'emit') + * }); + * + * await component.build(); + * + * const + * spy = await component.getSpy((ctx) => ctx.localEmitter.emit); + * + * console.log(await spy.calls); + * ``` + */ + async getSpy(spyExtractor: SpyExtractor): Promise { + return getSpy(this.component, spyExtractor); + } + + /** + * Creates a mock function. + * + * @param fn - the mock function. + * @param args - arguments to pass to the mock function. + * + * @returns A promise that resolves to the mock function object. + * + * @example + * ```typescript + * const + * component = new ComponentObject(page, 'b-virtual-scroll'), + * shouldStopRequestingData = await component.mockFn(() => false); + * + * await component.withProps({ + * shouldStopRequestingData + * }); + * + * await component.build(); + * console.log(await shouldStopRequestingData.calls); + * ``` + */ + async mockFn< + FN extends (...args: any[]) => any = (...args: any[]) => any + >(fn?: FN, ...args: any[]): Promise { + fn ??= Object.cast(() => undefined); + + return createMockFn(this.pwPage, fn!, ...args); + } +} diff --git a/tests/helpers/component/README.md b/tests/helpers/component/README.md index 3c4d256f88..b6d8717fba 100644 --- a/tests/helpers/component/README.md +++ b/tests/helpers/component/README.md @@ -53,15 +53,6 @@ Returns `JSHandles` to the components instances for the specified query selector const componentsHandlers = await Component.getComponents(page, '.b-button'); ``` -### setPropsToComponent - -Sets the specified props for the component which matches the specified query selector. - -```typescript - -await Component.setPropsToComponent(page, '.b-slider', {mode: 'slide'}); -``` - ### waitForRoot Returns `JSHandle` to the root component. diff --git a/tests/helpers/component/index.ts b/tests/helpers/component/index.ts index e2ef3941d6..fd01f32c37 100644 --- a/tests/helpers/component/index.ts +++ b/tests/helpers/component/index.ts @@ -8,11 +8,13 @@ import type { ElementHandle, JSHandle, Page } from 'playwright'; +import type { VNodeDescriptor } from 'components/friends/vdom'; import { expandedStringify } from 'core/prelude/test-env/components/json'; import type iBlock from 'components/super/i-block/i-block'; -import BOM, { WaitForIdleOptions } from 'tests/helpers/bom'; +import type { ComponentInDummy } from 'tests/helpers/component/interface'; +import type bDummy from 'components/dummies/b-dummy/b-dummy'; import { isRenderComponentsVNodeParams } from 'tests/helpers/component/helpers'; /** @@ -110,6 +112,71 @@ export default class Component { return this.waitForComponentByQuery(page, `[data-render-id="${renderId}"]`); } + /** + * Creates a component inside the `b-dummy` component and uses the `field-like` property of `b-dummy` + * to pass props to the inner component. + * + * This function can be useful when you need to test changes to component props. + * Since component props are readonly properties, you cannot change them directly; + * changes are only available through the parent component. This is why the `b-dummy` wrapper is created, + * and the props for the component you want to render are passed as references to the property of `b-dummy`. + * + * The function returns a `handle` to the created component (not to `b-dummy`) + * and adds a method and property for convenience: + * + * - `update` - a method that allows you to modify the component's props. + * + * - `dummy` - the `handle` of the `b-dummy` component. + * + * @param page + * @param componentName + * @param params + */ + static async createComponentInDummy( + page: Page, + componentName: string, + params: RenderComponentsVnodeParams + ): Promise> { + const dummy = await this.createComponent(page, 'b-dummy'); + + const update = async (props, mixInitialProps = false) => { + await dummy.evaluate((ctx, [name, props, mixInitialProps]) => { + const parsed: RenderComponentsVnodeParams = globalThis.expandedParse(props); + + ctx.testComponentAttrs = mixInitialProps ? + Object.assign(ctx.testComponentAttrs, parsed.attrs) : + parsed.attrs ?? {}; + + if (parsed.children) { + ctx.testComponentSlots = compileChild(); + } + + ctx.testComponent = name; + + function compileChild() { + return ctx.vdom.create(Object.entries(parsed.children ?? {}).map(([slotName, child]) => ({ + type: 'template', + attrs: { + slot: slotName + }, + children: ([]).concat((child ?? [])) + }))); + } + + }, [componentName, expandedStringify(props), mixInitialProps]); + }; + + await update(params); + const component = await dummy.evaluateHandle((ctx) => ctx.unsafe.$refs.testComponent); + + Object.assign(component, { + update, + dummy + }); + + return >component; + } + /** * Removes all dynamically created components * @param page @@ -162,39 +229,6 @@ export default class Component { return components; } - /** - * Sets the passed props to a component by the specified selector and waits for `nextTick` after that - * - * @param page - * @param componentSelector - * @param props - * @param [opts] - */ - static async setPropsToComponent( - page: Page, - componentSelector: string, - props: Dictionary, - opts?: WaitForIdleOptions - ): Promise> { - const ctx = await this.waitForComponentByQuery(page, componentSelector); - - await ctx.evaluate(async (ctx, props) => { - for (let keys = Object.keys(props), i = 0; i < keys.length; i++) { - const - prop = keys[i], - val = props[prop]; - - ctx.field.set(prop, val); - } - - await ctx.nextTick(); - }, props); - - await BOM.waitForIdleCallback(page, opts); - - return this.waitForComponentByQuery(page, componentSelector); - } - /** * Returns the root component * diff --git a/tests/helpers/component/interface.ts b/tests/helpers/component/interface.ts new file mode 100644 index 0000000000..37d5fa35dd --- /dev/null +++ b/tests/helpers/component/interface.ts @@ -0,0 +1,25 @@ +/*! + * V4Fire Client Core + * https://github.com/V4Fire/Client + * + * Released under the MIT license + * https://github.com/V4Fire/Client/blob/master/LICENSE + */ + +import type bDummy from 'components/dummies/b-dummy/b-dummy'; +import type { JSHandle } from 'playwright'; + +/** + * Handle component interface that was created with a dummy wrapper. + */ +export interface ComponentInDummy extends JSHandle { + /** + * Updates props and children of a component + * + * @param params + * @param [mixInitialProps] - if true, then the props will not be overwritten, but added to the current ones + */ + update(params: RenderComponentsVnodeParams, mixInitialProps?: boolean): Promise; + + dummy: JSHandle; +} diff --git a/tests/helpers/index.ts b/tests/helpers/index.ts index 817467d3f3..a1cab1de29 100644 --- a/tests/helpers/index.ts +++ b/tests/helpers/index.ts @@ -11,6 +11,7 @@ import BOM from 'tests/helpers/bom'; import Utils from 'tests/helpers/utils'; import Assert from 'tests/helpers/assert'; import Component from 'tests/helpers/component'; +import ComponentObject from 'tests/helpers/component-object'; import Scroll from 'tests/helpers/scroll'; import Router from 'tests/helpers/router'; import Gestures from 'tests/helpers/gestures'; @@ -22,6 +23,7 @@ export { Utils, Assert, Component, + ComponentObject, Scroll, Router, Gestures diff --git a/tests/helpers/mock/README.md b/tests/helpers/mock/README.md new file mode 100644 index 0000000000..46ccecb386 --- /dev/null +++ b/tests/helpers/mock/README.md @@ -0,0 +1,113 @@ + + +***Table of Contents* + +- [tests/helpers/mock](#testshelpersmock) + - [Usage](#usage) + - [How to Create a Spy?](#how-to-create-a-spy) + - [How to Create a Spy and Access It Later?](#how-to-create-a-spy-and-access-it-later) + - [How to Create a Mock Function?](#how-to-create-a-mock-function) + - [How Does This Work?](#how-does-this-work) + - [Mock Functions](#mock-functions) + - [Spy Functions](#spy-functions) + + + +# tests/helpers/mock + +This module provides the ability to create spies and mock functions from a Node.js testing environment and inject them into the page context. + +## Usage + +### How to Create a Spy? + +To create a spy, you need to first identify the object you want to spy on. For example, let's say we have a global object `testObject` that has a method `doSomething`, and we want to track how many times this method is called. + +Let's break down the steps to achieve this: + +1. Get a `handle` for the `testObject`: + + ```typescript + const testObjHandle = await page.evaluateHandle(() => globalThis.testObject); + ``` + +2. Set up a spy on the `doSomething` method: + + ```typescript + const spy = await createSpy(testObjHandle, (ctx) => jestMock.spy(ctx, 'doSomething')); + ``` + + The first argument to the `createSpy` function is the `handle` of the object on which you want to set up the spy. The second argument is the spy constructor function, which takes the object and the method to monitor. `jestMock` is an object available in the global scope that redirects calls to the `jest-mock` library. + + It's important to note that the constructor function is passed from the Node.js context to the browser context, meaning it will be serialized and converted into a string for transmission to the browser. + +3. After setting up the spy, you can access it and, for example, check how many times it has been called: + + ```typescript + const testObjHandle = await page.evaluateHandle(() => globalThis.testObject); + const spy = await createSpy(testObjHandle, (ctx) => jestMock.spy(ctx, 'doSomething')); + + await page.evaluate(() => globalThis.testObject.doSomething()); + + console.log(await spy.calls); // [[]] + ``` + +### How to Create a Spy and Access It Later? + +There are cases where a spy is created asynchronously, for example, in response to an event. In such situations, you can access the spy later using the `getSpy` function. + +Let's consider the scenario below where a spy is created for the `testObject.doSomething` method during a button click: + +```typescript +await page.evaluate(() => { + const button = document.querySelector('button'); + + button.onclick = () => { + jestMock.spy(globalThis.testObject, 'doSomething'); + globalThis.testObject.doSomething(); + }; +}); + +await page.click('button'); + +const testObjHandle = await page.evaluateHandle(() => globalThis.testObject); +const spy = await getSpy(testObjHandle, (ctx) => ctx.doSomething); + +await page.evaluate(() => globalThis.testObject.doSomething()); + +console.log(await spy.calls); // [[], []] +``` + +> While `getSpy` can be replaced with `createSpy`, it is recommended to use `getSpy` for semantic clarity in such cases. + +### How to Create a Mock Function? + +To create a mock function, use the `createMockFn` function. It will create a mock function and automatically inject it into the page. + +```typescript +import { expandedStringify } from 'core/prelude/test-env/components/json'; + +const mockFn = await createMockFn(page, () => 1); + +await page.evaluate(([obj]) => { + const parsed = globalThis.expandedParse(obj); + + parsed.mockFn(); + parsed.mockFn(); + +}, [expandedStringify({ mockFn })]); + +console.log(await mockFn.calls); // [[], []] +``` + +### How Does This Work? + +#### Mock Functions + +A mock function works by converting object representations into strings and then transferring them from Node.js to the browser. For the client, `createMockFn` returns a `SpyObject`, which includes methods for tracking calls, and it also overrides the `toJSON` method. This override is necessary to create a mapping of the mock function's ID to the real function that was previously inserted into the page context. + +#### Spy Functions + +Unlike mock functions, spy functions do not create anything extra. They are simply attached to a function within the context and return a wrapper with methods that, when called, make requests to the spy for various data. + +If you have any further questions or need assistance, please feel free to ask. diff --git a/tests/helpers/mock/index.ts b/tests/helpers/mock/index.ts new file mode 100644 index 0000000000..c0006dbe2e --- /dev/null +++ b/tests/helpers/mock/index.ts @@ -0,0 +1,182 @@ +/*! + * V4Fire Client Core + * https://github.com/V4Fire/Client + * + * Released under the MIT license + * https://github.com/V4Fire/Client/blob/master/LICENSE + */ + +import type { ModuleMocker } from 'jest-mock'; +import type { JSHandle, Page } from 'playwright'; + +import { expandedStringify, setSerializerAsMockFn } from 'core/prelude/test-env/components/json'; +import type { ExtractFromJSHandle, SpyExtractor, SpyObject } from 'tests/helpers/mock/interface'; + +export * from 'tests/helpers/mock/interface'; + +/** + * Wraps an object as a spy object by adding additional properties for accessing spy information. + * + * @param agent - the JSHandle representing the spy or mock function. + * @param obj - the object to wrap as a spy object. + * @returns The wrapped object with spy properties. + */ +export function wrapAsSpy(agent: JSHandle | ReturnType>, obj: T): T & SpyObject { + Object.defineProperties(obj, { + calls: { + get: () => agent.evaluate((ctx) => ctx.mock.calls) + }, + + callsCount: { + get: () => agent.evaluate((ctx) => ctx.mock.calls.length) + }, + + lastCall: { + get: () => agent.evaluate((ctx) => ctx.mock.calls[ctx.mock.calls.length - 1]) + }, + + results: { + get: () => agent.evaluate((ctx) => ctx.mock.results) + } + }); + + return obj; +} + +/** + * Creates a spy object. + * + * @param ctx - the `JSHandle` to spy on. + * @param spyCtor - the function that creates the spy. + * @param argsToCtor - the arguments to pass to the spy constructor function. + * @returns A promise that resolves to the created spy object. + * + * @example + * ```typescript + * const ctx = ...; // JSHandle to spy on + * const spyCtor = (ctx) => jestMock.spy(ctx, 'prop'); // Spy constructor function + * const spy = await createSpy(ctx, spyCtor); + * + * // Access spy properties + * console.log(await spy.calls); + * console.log(await spy.callsCount); + * console.log(await spy.lastCall); + * console.log(await spy.results); + * ``` + */ +export async function createSpy( + ctx: T, + spyCtor: (ctx: ExtractFromJSHandle, ...args: ARGS) => ReturnType, + ...argsToCtor: ARGS +): Promise { + const + agent = await ctx.evaluateHandle>(spyCtor, ...argsToCtor); + + return wrapAsSpy(agent, {}); +} + +/** + * Retrieves an existing {@link SpyObject} from a `JSHandle`. + * + * @param ctx - the `JSHandle` containing the spy object. + * @param spyExtractor - the function to extract the spy object. + * @returns A promise that resolves to the spy object. + * + * @example + * ```typescript + * const component = await Component.createComponent(page, 'b-button', { + * attrs: { + * '@hook:beforeDataCreate': (ctx) => jestMock.spy(ctx.localEmitter, 'emit') + * } + * }); + * + * const spyExtractor = (ctx) => ctx.unsafe.localEmitter.emit; // Spy extractor function + * const spy = await getSpy(ctx, spyExtractor); + * + * // Access spy properties + * console.log(await spy.calls); + * console.log(await spy.callsCount); + * console.log(await spy.lastCall); + * console.log(await spy.results); + * ``` + */ +export async function getSpy( + ctx: T, + spyExtractor: SpyExtractor, []> +): Promise { + return createSpy(ctx, spyExtractor); +} + +/** + * Creates a mock function and injects it into a Page object. + * + * @param page - the Page object to inject the mock function into. + * @param fn - the mock function. + * @param args - the arguments to pass to the function. + * @returns A promise that resolves to the mock function as a {@link SpyObject}. + * + * @example + * ```typescript + * const page = ...; // Page object + * const fn = () => {}; // The mock function + * const mockFn = await createMockFn(page, fn); + * + * // Access spy properties + * console.log(await mockFn.calls); + * console.log(await mockFn.callsCount); + * console.log(await mockFn.lastCall); + * console.log(await mockFn.results); + * ``` + */ +export async function createMockFn( + page: Page, + fn: (...args: any[]) => any, + ...args: any[] +): Promise { + const + {agent, id} = await injectMockIntoPage(page, fn, ...args); + + return setSerializerAsMockFn(agent, id); +} + +/** + * Injects a mock function into a Page object and returns the {@link SpyObject}. + * + * This function also returns the ID of the injected mock function, which is stored in `globalThis`. + * This binding allows the function to be found during object serialization within the page context. + * + * @param page - the Page object to inject the mock function into. + * @param fn - the mock function. + * @param args - the arguments to pass to the function. + * @returns A promise that resolves to an object containing the spy object and the ID of the injected mock function. + * + * @example + * ```typescript + * const page = ...; // Page object + * const fn = () => {}; // The mock function + * const { agent, id } = await injectMockIntoPage(page, fn); + * + * // Access spy properties + * console.log(await agent.calls); + * console.log(await agent.callsCount); + * console.log(await agent.lastCall); + * console.log(await agent.results); + * ``` + */ +async function injectMockIntoPage( + page: Page, + fn: (...args: any[]) => any, + ...args: any[] +): Promise<{agent: SpyObject; id: string}> { + const + tmpFn = `tmp_${Math.random().toString()}`, + argsToProvide = [tmpFn, fn.toString(), expandedStringify(args)]; + + const agent = await page.evaluateHandle(([tmpFn, fnString, args]) => + globalThis[tmpFn] = jestMock.mock((...fnArgs) => + // eslint-disable-next-line no-new-func + new Function(`return ${fnString}`)()(...fnArgs, ...globalThis.expandedParse(args))), + argsToProvide); + + return {agent: wrapAsSpy(agent, {}), id: tmpFn}; +} diff --git a/tests/helpers/mock/interface.ts b/tests/helpers/mock/interface.ts new file mode 100644 index 0000000000..7fb317eb76 --- /dev/null +++ b/tests/helpers/mock/interface.ts @@ -0,0 +1,53 @@ +/*! + * V4Fire Client Core + * https://github.com/V4Fire/Client + * + * Released under the MIT license + * https://github.com/V4Fire/Client/blob/master/LICENSE + */ + +import type { JSHandle } from 'playwright'; +import type { ModuleMocker } from 'jest-mock'; + +/** + * Represents a spy object with properties for accessing spy information. + */ +export interface SpyObject { + /** + * The array of arguments passed to the spy function on each call. + */ + readonly calls: Promise; + + /** + * The number of times the spy function has been called. + */ + readonly callsCount: Promise; + + /** + * The arguments of the most recent call to the spy function. + */ + readonly lastCall: Promise; + + /** + * The results of each call to the spy function. + */ + readonly results: Promise; +} + +/** + * Represents a function that extracts or creates a spy object from a `JSHandle`. + */ +export interface SpyExtractor { + /** + * Extracts or creates a spy object from a `JSHandle`. + * + * @param ctx - the `JSHandle` containing the spy object. + * @param args + */ + (ctx: CTX, ...args: ARGS): ReturnType; +} + +/** + * Extracts the type from a `JSHandle`. + */ +export type ExtractFromJSHandle = T extends JSHandle ? V : never; diff --git a/tests/helpers/network/interceptor/README.md b/tests/helpers/network/interceptor/README.md new file mode 100644 index 0000000000..6c5c477029 --- /dev/null +++ b/tests/helpers/network/interceptor/README.md @@ -0,0 +1,236 @@ + + +**Table of Contents** + +- [tests/helpers/network/interceptor](#testshelpersnetworkinterceptor) + - [Usage](#usage) + - [How to Initialize a Request Interceptor?](#how-to-initialize-a-request-interceptor) + - [How to Respond to a Request Once?](#how-to-respond-to-a-request-once) + - [How to Implement a Delay Before Responding?](#how-to-implement-a-delay-before-responding) + - [How to Set a Custom Request Handler?](#how-to-set-a-custom-request-handler) + - [How to View the Number of Intercepted Requests?](#how-to-view-the-number-of-intercepted-requests) + - [How to View the Parameters of Intercepted Requests?](#how-to-view-the-parameters-of-intercepted-requests) + - [How to Remove Previously Set Request Handlers?](#how-to-remove-previously-set-request-handlers) + - [How to Stop Intercepting Requests?](#how-to-stop-intercepting-requests) + - [How to Respond to Requests Using a Method Instead of Automatically?](#how-to-respond-to-requests-using-a-method-instead-of-automatically) + + + +# tests/helpers/network/interceptor + +This API allows you to intercept any request and respond to it with custom data. + +## Usage + +### How to Initialize a Request Interceptor? + +To initialize a request interceptor, simply create an instance by calling its constructor. Provide the page or context as the first argument and the `url` you want to intercept as the second argument. The `url` can be either a string or a regular expression. + +```typescript +// Create a RequestInterceptor instance +const interceptor = new RequestInterceptor(page, /api/); +``` + +However, after creating an instance of `RequestInterceptor`, request interceptions will not work until you do the following: + +1. Set a response for the request using the `response` method: + + ```typescript + // Create a RequestInterceptor instance + const interceptor = new RequestInterceptor(page, /api/); + + // Set a response for the request using a response status and payload + interceptor.response(200, { message: 'OK' }); + ``` + +2. Start intercepting requests using the `start` method: + + ```typescript + // Create a RequestInterceptor instance + const interceptor = new RequestInterceptor(page, /api/); + + // Set a response for the request using a response status and payload + interceptor.response(200, { message: 'OK' }); + + // Start intercepting requests + await interceptor.start(); + ``` + +After these steps, every request that matches the specified regular expression will be intercepted, and a response with a status code of 200 and a response body containing an object with a `message` field will be sent. + +### How to Respond to a Request Once? + +To respond to a request only once, you can use the `responseOnce` method: + +```typescript +// Create a RequestInterceptor instance +const interceptor = new RequestInterceptor(page, /api/); + +// Set a response for the request using a response status and payload +interceptor.responseOnce(200, { message: 'OK' }); + +// Start intercepting requests +await interceptor.start(); +``` + +This way, you can combine different response scenarios. For example, you can set the first request to respond with a status code of 500, the second with 404, and all others with 200: + +```typescript +// Create a RequestInterceptor instance +const interceptor = new RequestInterceptor(page, /api/); + +interceptor + .responseOnce(500, { message: 'OK' }) + .responseOnce(404, { message: 'OK' }) + .response(200, { message: 'OK' }); + +// Start intercepting requests +await interceptor.start(); +``` + +### How to Implement a Delay Before Responding? + +`RequestInterceptor` provides an option to introduce a delay before responding. You can pass this delay as the third argument in the `response` method: + +```typescript +// Create a RequestInterceptor instance +const interceptor = new RequestInterceptor(page, /api/); + +// Set a response for the request using a response status, payload, and delay +interceptor.response(200, { message: 'OK' }, { delay: 200 }); + +// Start intercepting requests +await interceptor.start(); +``` + +This delay causes a 200ms wait before sending a response to the request. Note that using `delay` in tests is generally not recommended, as it can slow down test execution. However, there are cases where it may be necessary, which is why this feature exists. + +### How to Set a Custom Request Handler? + +To set a custom request handler, pass a function instead of response parameters to the `response` method. This allows you to have full control over request interception. + +```typescript +// Create a RequestInterceptor instance +const interceptor = new RequestInterceptor(page, /api/); + +// Set a custom response handler for the request +interceptor.response((route: Route) => route.fulfill({ status: 200 })); + +// Start intercepting requests +await interceptor.start(); +``` + +### How to View the Number of Intercepted Requests? + +Since `RequestInterceptor` uses the `jest-mock` API, you can access all the functionality provided by this API. To see the number of intercepted requests, you can access the `mock` property of the class and use the `jest-mock` API. + +```typescript +// Create a RequestInterceptor instance +const interceptor = new RequestInterceptor(page, /api/); + +// Set a response for the request using a response status and payload +interceptor.responseOnce(200, { message: 'OK' }); + +// Start intercepting requests +await interceptor.start(); + +// ... + +// Logs the number of times interception occurred +console.log(interceptor.calls.length); +``` + +### How to View the Parameters of Intercepted Requests? + +```typescript +// Create a RequestInterceptor instance +const interceptor = new RequestInterceptor(page, /api/); + +// Set a response for the request using a response status and payload +interceptor.responseOnce(200, { message: 'OK' }); + +// Start intercepting requests +await interceptor.start(); + +// ... + +const calls = provider.calls; +const query = fromQueryString(new URL((providerCalls[0][0]).request().url()).search); + +// Logs the query parameters of the first intercepted request +console.log(query); + +// Or a better way: + +const firstRequest = interceptor.request(0); // Get the first request +// Or +const lastRequest = interceptor.request(-1); // Get the last request + +// Get the query of the first request +const firstRequestQuery = firstRequest?.query(); +``` + +### How to Remove Previously Set Request Handlers? + +To remove handlers set using the `response` and `responseOnce` methods, you can use the `removeHandlers` method: + +```typescript +// Create a RequestInterceptor instance +const interceptor = new RequestInterceptor(page, /api/); + +// Set a response for the request using a response status and payload +interceptor.response(200, { message: 'OK' }); + +// Start intercepting requests +await interceptor.start(); + +// Remove all request handlers +interceptor.removeHandlers(); +``` + +After calling `removeHandlers`, the handler set using the `response` method will no longer trigger. + +### How to Stop Intercepting Requests? + +To stop intercepting requests, use the `stop` method: + +```typescript +// Create a RequestInterceptor instance +const interceptor = new RequestInterceptor(page, /api/); + +// Set a response for the request using a response status and payload +interceptor.response(200, { message: 'OK' }); + +// Start intercepting requests +await interceptor.start(); + +// Stop intercepting requests +await interceptor.stop(); +``` + +### How to Respond to Requests Using a Method Instead of Automatically? + +Sometimes there are situations where you need to delay the moment of responding to a request for some unknown time in advance, and to solve this problem a special "responder" mode is provided. +In this mode, the `RequestInterceptor` still intercepts requests but does not automatically respond to them. +To respond to a request, you need to call a special method. Let's see how it works using an example: + +```typescript +// Create a RequestInterceptor instance +const interceptor = new RequestInterceptor(page, /api/); + +// Set a response for the request using a response status and payload +interceptor.response(200, { message: 'OK' }); + +// Transform the RequestInterceptor to the responder mode +await interceptor.responder(); + +// Start intercepting requests +await interceptor.start(); + +await sleep(2000); +await makeRequest(); +await sleep(2000); + +// Responds to the first request that was made +await interceptor.respond(); +``` diff --git a/tests/helpers/network/interceptor/index.ts b/tests/helpers/network/interceptor/index.ts new file mode 100644 index 0000000000..96186b0bf1 --- /dev/null +++ b/tests/helpers/network/interceptor/index.ts @@ -0,0 +1,346 @@ +/*! + * V4Fire Client Core + * https://github.com/V4Fire/Client + * + * Released under the MIT license + * https://github.com/V4Fire/Client/blob/master/LICENSE + */ + +import Async from '@v4fire/core/core/async'; +import type { BrowserContext, Page, Request, Route } from 'playwright'; +import delay from 'delay'; +import { ModuleMocker } from 'jest-mock'; + +import { fromQueryString } from 'core/url'; + +import type { InterceptedRequest, ResponseHandler, ResponseOptions, ResponsePayload } from 'tests/helpers/network/interceptor/interface'; + +/** + * API that provides a simple way to intercept and respond to any request. + */ +export class RequestInterceptor { + /** + * The route context. + */ + readonly routeCtx: Page | BrowserContext; + + /** + * The route pattern. + */ + readonly routePattern: string | RegExp; + + /** + * The route listener. + */ + readonly routeListener: ResponseHandler; + + /** + * An instance of jest-mock that handles the implementation logic of responses. + */ + readonly mock: ReturnType; + + /** + * {@link Async} + */ + protected readonly async: Async = new Async(); + + /** + * If true, intercepted requests are not automatically responded to, instead use the + * {@link RequestInterceptor.respond} method. + */ + protected isResponder: boolean = false; + + /** + * Queue of requests awaiting response + */ + protected respondQueue: Function[] = []; + + /** + * Number of requests awaiting response + */ + get requestQueueLength(): number { + return this.respondQueue.length; + } + + /** + * Short-hand for {@link RequestInterceptor.prototype.mock.mock.calls} + */ + get calls(): any[] { + return this.mock.mock.calls; + } + + /** + * Creates a new instance of RequestInterceptor. + * + * @param ctx - the page or browser context. + * @param pattern - the route pattern to match against requests. + */ + constructor(ctx: Page | BrowserContext, pattern: string | RegExp) { + this.routeCtx = ctx; + this.routePattern = pattern; + + this.routeListener = async (route: Route, request: Request) => { + await this.mock(route, request); + }; + + const mocker = new ModuleMocker(globalThis); + this.mock = mocker.fn(); + } + + /** + * Disables automatic responses to requests and makes the current instance a "responder". + * The responder allows responding to requests not in automatic mode, but by calling + * the {@link RequestInterceptor.respond} method. That is, when a request is intercepted, + * the response will be sent only after the {@link RequestInterceptor.respond} method is called. + * + * The requests themselves are collected in a queue, and calling the {@link RequestInterceptor.respond} method + * responds to the first request in the queue and removes it from the queue. + */ + responder(): this { + this.isResponder = true; + return this; + } + + /** + * Enables automatic responses to requests and responds to all requests in the queue + */ + async unresponder(): Promise { + if (!this.isResponder) { + throw new Error('Failed to call unresponder on an instance that is not a responder'); + } + + this.isResponder = false; + + for (const response of this.respondQueue) { + await response(); + } + } + + /** + * Responds to the first request in the queue and removes it from the queue. + * If there are no requests in the queue yet, it will wait for the first received one and respond to it. + */ + async respond(): Promise { + if (!this.isResponder) { + throw new Error('Failed to call respond on an instance that is not a responder'); + } + + if (this.requestQueueLength === 0) { + await this.async.wait(() => this.requestQueueLength > 0); + } + + return this.respondQueue.shift()?.(); + } + + /** + * Returns the intercepted request + * @param at - the index of the request (starting from 0) + */ + request(at: number): CanUndef { + // eslint-disable-next-line no-restricted-syntax + const request: CanUndef = this.calls.at(at)?.[0]?.request(); + + if (request == null) { + return; + } + + return Object.assign(request, { + query: () => fromQueryString(request.url()) + }); + } + + /** + * Sets a response for one request. + * + * @example + * ```typescript + * const interceptor = new RequestInterceptor(page, /api/); + * + * interceptor + * .responseOnce((r: Route) => r.fulfill({status: 200})) + * .responseOnce((r: Route) => r.fulfill({status: 500})); + * ``` + * + * @param handler - the response handler function. + * @param opts - the response options. + * @returns The current instance of RequestInterceptor. + */ + responseOnce(handler: ResponseHandler, opts?: ResponseOptions): this; + + /** + * Sets a response for one request. + * + * @example + * ```typescript + * const interceptor = new RequestInterceptor(page, /api/); + * + * interceptor + * .responseOnce(200, {content: 1}) + * .responseOnce(500); + * ``` + * + * Sets the response that will occur with a delay to simulate network latency. + * + * @example + * ```typescript + * const interceptor = new RequestInterceptor(page, /api/); + * + * interceptor + * .responseOnce(200, {content: 1}, {delay: 200}) + * .responseOnce(500, {}, {delay: 300}); + * ``` + * + * @param status - the response status. + * @param payload - the response payload. + * @param opts - the response options. + * @returns The current instance of RequestInterceptor. + */ + responseOnce(status: number, payload: ResponsePayload | ResponseHandler, opts?: ResponseOptions): this; + + responseOnce( + handlerOrStatus: number | ResponseHandler, + payload?: ResponsePayload | ResponseHandler, + opts?: ResponseOptions + ): this { + this.mock.mockImplementationOnce(this.createMockFn(handlerOrStatus, payload, opts)); + return this; + } + + /** + * Sets a response for every request. + * If there are no responses set via {@link RequestInterceptor.responseOnce}, that response will be used. + * + * @example + * ```typescript + * const interceptor = new RequestInterceptor(page, /api/); + * interceptor.response((r: Route) => r.fulfill({status: 200})); + * ``` + * + * @param handler - the response handler function. + * @returns The current instance of RequestInterceptor. + */ + response(handler: ResponseHandler): this; + + /** + * Sets a response for every request. + * If there are no responses set via {@link RequestInterceptor.responseOnce}, that response will be used. + * + * @example + * ```typescript + * const interceptor = new RequestInterceptor(page, /api/); + * interceptor.response(200, {}); + * ``` + * + * @param status - the response status. + * @param payload - the response payload. + * @param opts - the response options. + * @returns The current instance of RequestInterceptor. + */ + response(status: number, payload: ResponsePayload | ResponseHandler, opts?: ResponseOptions): this; + + response( + handlerOrStatus: number | ResponseHandler, + payload?: ResponsePayload | ResponseHandler, + opts?: ResponseOptions + ): this { + this.mock.mockImplementation(this.createMockFn(handlerOrStatus, payload, opts)); + return this; + } + + /** + * Clears the responses that were created via {@link RequestInterceptor.responseOnce} or + * {@link RequestInterceptor.response}. + * + * @returns The current instance of RequestInterceptor. + */ + removeHandlers(): this { + this.mock.mockReset(); + return this; + } + + /** + * Stops the request interception. + * + * @returns A promise that resolves with the current instance of RequestInterceptor. + */ + async stop(): Promise { + await this.routeCtx.unroute(this.routePattern, this.routeListener); + return this; + } + + /** + * Starts the request interception. + * + * @returns A promise that resolves with the current instance of RequestInterceptor. + */ + async start(): Promise { + await this.routeCtx.route(this.routePattern, this.routeListener); + return this; + } + + /** + * Creates a mock response function. + * + * @param handlerOrStatus + * @param payload + * @param opts + */ + protected createMockFn( + handlerOrStatus: number | ResponseHandler, + payload?: ResponsePayload | ResponseHandler, + opts?: ResponseOptions + ): ResponseHandler { + let fn; + + if (Object.isFunction(handlerOrStatus)) { + fn = handlerOrStatus; + + } else { + const status = handlerOrStatus; + fn = this.cookResponseFn(status, payload, opts); + } + + return fn; + } + + /** + * Cooks a response handler. + * + * @param status - the response status. + * @param payload - the response payload. + * @param opts - the response options. + * @returns The response handler function. + */ + protected cookResponseFn( + status: number, + payload?: ResponsePayload | ResponseHandler, + opts?: ResponseOptions + ): ResponseHandler { + return async (route, request) => { + const response = async () => { + if (opts?.delay != null) { + await delay(opts.delay); + } + + const + fulfillOpts = Object.reject(opts, 'delay'), + body = Object.isFunction(payload) ? await payload(route, request) : payload, + contentType = fulfillOpts.contentType ?? 'application/json'; + + return route.fulfill({ + status, + body: contentType === 'application/json' && !Object.isString(body) ? JSON.stringify(body) : body, + contentType, + ...fulfillOpts + }); + }; + + if (this.isResponder) { + this.respondQueue.push(response); + + } else { + return response(); + } + }; + } +} diff --git a/tests/helpers/network/interceptor/interface.ts b/tests/helpers/network/interceptor/interface.ts new file mode 100644 index 0000000000..42fb741500 --- /dev/null +++ b/tests/helpers/network/interceptor/interface.ts @@ -0,0 +1,39 @@ +/*! + * V4Fire Client Core + * https://github.com/V4Fire/Client + * + * Released under the MIT license + * https://github.com/V4Fire/Client/blob/master/LICENSE + */ + +import type { Route, Request } from 'playwright'; + +export type ResponseHandler = (route: Route, request: Request) => CanPromise; + +/** + * {@link Route.fulfill} function options. + * Playwright does not provide an options interface for the fulfill function + */ +export type FulfillOptions = Exclude[0], undefined>; + +/** + * Interface for response options. + */ +export interface ResponseOptions extends Omit { + /** + * The delay before the response to the request is sent. + */ + delay?: number; +} + +/** + * Instance of the intercepted request with additional methods + */ +export interface InterceptedRequest extends Request { + /** + * Returns an object containing the GET parameters from the request + */ + query(): Record; +} + +export type ResponsePayload = object | string | number; diff --git a/tests/helpers/providers/pagination/index.ts b/tests/helpers/providers/pagination/index.ts deleted file mode 100644 index 71582e03e6..0000000000 --- a/tests/helpers/providers/pagination/index.ts +++ /dev/null @@ -1,91 +0,0 @@ -/*! - * V4Fire Client Core - * https://github.com/V4Fire/Client - * - * Released under the MIT license - * https://github.com/V4Fire/Client/blob/master/LICENSE - */ - -import { fromQueryString } from 'core/url'; -import type { BrowserContext, Page } from 'playwright'; - -import type { RequestState, RequestQuery } from 'tests/helpers/providers/pagination/interface'; - -export * from 'tests/helpers/providers/pagination/interface'; - -const requestStates: Dictionary = { - -}; - -/** - * Provides an API to intercepts and mock response to the '/pagination' request. - * For convenient work, the interceptor processes the parameters passed to the request query - - * {@link RequestQuery possible parameters}. - * - * @param pageOrContext - */ -export async function interceptPaginationRequest( - pageOrContext: Page | BrowserContext -): Promise { - return pageOrContext.route(/api/, async (route) => { - const routeQuery = fromQueryString(new URL(route.request().url()).search); - - const query = { - chunkSize: 12, - id: String(Math.random()), - sleep: 100, - ...routeQuery - }; - - const res = { - status: 200 - }; - - await sleep(query.sleep); - - // eslint-disable-next-line no-multi-assign - const state = requestStates[query.id] = requestStates[query.id] ?? { - i: 0, - requestNumber: 0, - totalSent: 0, - failCount: 0, - ...query - }; - - const - isFailCountNotReached = query.failCount != null ? state.failCount <= query.failCount : true; - - if (Object.isNumber(query.failOn) && query.failOn === state.requestNumber && isFailCountNotReached) { - state.failCount++; - res.status = 500; - return undefined; - } - - state.requestNumber++; - - if (state.totalSent === state.total) { - return { - ...query.additionalData, - data: [] - }; - } - - const dataToSend = Array.from(Array(query.chunkSize), () => ({i: state.i++})); - state.totalSent += dataToSend.length; - - return route.fulfill({ - status: res.status, - contentType: 'application/json', - body: JSON.stringify({ - ...query.additionalData, - data: dataToSend - }) - }); - }); -} - -async function sleep(t: number): Promise { - return new Promise((res) => { - setTimeout(res, t); - }); -} diff --git a/tests/helpers/scroll/index.ts b/tests/helpers/scroll/index.ts index 2da225274b..bc3a80a264 100644 --- a/tests/helpers/scroll/index.ts +++ b/tests/helpers/scroll/index.ts @@ -49,7 +49,7 @@ export default class Scroll { * @param [scrollIntoViewOpts] */ static async scrollIntoViewIfNeeded( - ctx: Page | ElementHandle, + ctx: Page, selector: string, scrollIntoViewOpts: Dictionary ): Promise { diff --git a/tests/helpers/utils/index.ts b/tests/helpers/utils/index.ts index 9fe7a331dc..37f35f8d9e 100644 --- a/tests/helpers/utils/index.ts +++ b/tests/helpers/utils/index.ts @@ -6,11 +6,12 @@ * https://github.com/V4Fire/Client/blob/master/LICENSE */ -import type { Page, JSHandle, ElementHandle } from 'playwright'; +import type { Page, JSHandle } from 'playwright'; import { evalFn } from 'core/prelude/test-env/components/json'; import BOM, { WaitForIdleOptions } from 'tests/helpers/bom'; +import type { ExtractFromJSHandle } from 'tests/helpers/mock'; const logsMap = new WeakMap(); @@ -30,10 +31,12 @@ export default class Utils { * // `ctx` refers to `imgNode` * Utils.waitForFunction(imgNode, (ctx, imgUrl) => ctx.src === imgUrl, imgUrl) * ``` + * + * @deprecated https://playwright.dev/docs/api/class-page#page-wait-for-function */ - static waitForFunction( - ctx: ElementHandle, - fn: (this: any, ctx: any, ...args: ARGS) => unknown, + static waitForFunction( + ctx: CTX, + fn: (this: any, ctx: ExtractFromJSHandle, ...args: ARGS) => unknown, ...args: ARGS ): Promise { const diff --git a/tests/helpers/providers/pagination/README.md b/tests/network-interceptors/pagination/README.md similarity index 79% rename from tests/helpers/providers/pagination/README.md rename to tests/network-interceptors/pagination/README.md index f359f724a3..46a3d429d2 100644 --- a/tests/helpers/providers/pagination/README.md +++ b/tests/network-interceptors/pagination/README.md @@ -1,4 +1,4 @@ -# tests/helpers/providers/pagination +# tests/network-interceptors/pagination This module provides API for working with request mocking. diff --git a/tests/network-interceptors/pagination/index.ts b/tests/network-interceptors/pagination/index.ts new file mode 100644 index 0000000000..e9d776c835 --- /dev/null +++ b/tests/network-interceptors/pagination/index.ts @@ -0,0 +1,98 @@ +/*! + * V4Fire Client Core + * https://github.com/V4Fire/Client + * + * Released under the MIT license + * https://github.com/V4Fire/Client/blob/master/LICENSE + */ + +import { fromQueryString } from 'core/url'; +import type { BrowserContext, Page, Route } from 'playwright'; + +import type { RequestState, RequestQuery } from 'tests/network-interceptors/pagination/interface'; + +export * from 'tests/network-interceptors/pagination/interface'; + +const requestStates: Dictionary = { + +}; + +/** + * Provides an API to intercepts and mock response to the '/pagination' request. + * For convenient work, the interceptor processes the parameters passed to the request query - + * {@link RequestQuery possible parameters}. + * + * @param pageOrContext + */ +export async function interceptPaginationRequest( + pageOrContext: Page | BrowserContext +): Promise { + return pageOrContext.route(/api/, paginationHandler); +} + +/** + * Handler for intercepted pagination requests + * @param route + */ +export async function paginationHandler(route: Route): Promise { + const routeQuery = fromQueryString(new URL(route.request().url()).search); + + const query = { + chunkSize: 12, + id: String(Math.random()), + sleep: 100, + ...routeQuery + }; + + const res = { + status: 200 + }; + + await sleep(query.sleep); + + // eslint-disable-next-line no-multi-assign + const state = requestStates[query.id] = requestStates[query.id] ?? { + i: 0, + requestNumber: 0, + totalSent: 0, + failCount: 0, + ...query + }; + + const + isFailCountNotReached = query.failCount != null ? state.failCount <= query.failCount : true; + + if (Object.isNumber(query.failOn) && query.failOn === state.requestNumber && isFailCountNotReached) { + state.failCount++; + res.status = 500; + return undefined; + } + + state.requestNumber++; + + if (state.totalSent === state.total) { + return route.fulfill({ + status: res.status, + contentType: 'application/json', + body: JSON.stringify([]) + }); + } + + const dataToSend = Array.from(Array(query.chunkSize), () => ({i: state.i++})); + state.totalSent += dataToSend.length; + + return route.fulfill({ + status: res.status, + contentType: 'application/json', + body: JSON.stringify({ + ...query.additionalData, + data: dataToSend + }) + }); +} + +async function sleep(t: number): Promise { + return new Promise((res) => { + setTimeout(res, t); + }); +} diff --git a/tests/helpers/providers/pagination/interface.ts b/tests/network-interceptors/pagination/interface.ts similarity index 100% rename from tests/helpers/providers/pagination/interface.ts rename to tests/network-interceptors/pagination/interface.ts diff --git a/yarn.lock b/yarn.lock index 0763a83219..964cf42682 100644 --- a/yarn.lock +++ b/yarn.lock @@ -61,16 +61,6 @@ __metadata: languageName: node linkType: hard -"@babel/code-frame@npm:^7.23.5": - version: 7.23.5 - resolution: "@babel/code-frame@npm:7.23.5" - dependencies: - "@babel/highlight": "npm:^7.23.4" - chalk: "npm:^2.4.2" - checksum: 44e58529c9d93083288dc9e649c553c5ba997475a7b0758cc3ddc4d77b8a7d985dbe78cc39c9bbc61f26d50af6da1ddf0a3427eae8cc222a9370619b671ed8f5 - languageName: node - linkType: hard - "@babel/compat-data@npm:^7.16.8, @babel/compat-data@npm:^7.17.7, @babel/compat-data@npm:^7.20.5, @babel/compat-data@npm:^7.21.5, @babel/compat-data@npm:^7.22.20, @babel/compat-data@npm:^7.22.6, @babel/compat-data@npm:^7.22.9": version: 7.22.20 resolution: "@babel/compat-data@npm:7.22.20" @@ -159,18 +149,6 @@ __metadata: languageName: node linkType: hard -"@babel/generator@npm:^7.23.6": - version: 7.23.6 - resolution: "@babel/generator@npm:7.23.6" - dependencies: - "@babel/types": "npm:^7.23.6" - "@jridgewell/gen-mapping": "npm:^0.3.2" - "@jridgewell/trace-mapping": "npm:^0.3.17" - jsesc: "npm:^2.5.1" - checksum: 864090d5122c0aa3074471fd7b79d8a880c1468480cbd28925020a3dcc7eb6e98bedcdb38983df299c12b44b166e30915b8085a7bc126e68fa7e2aadc7bd1ac5 - languageName: node - linkType: hard - "@babel/generator@npm:~7.21.1": version: 7.21.9 resolution: "@babel/generator@npm:7.21.9" @@ -294,16 +272,6 @@ __metadata: languageName: node linkType: hard -"@babel/helper-function-name@npm:^7.23.0": - version: 7.23.0 - resolution: "@babel/helper-function-name@npm:7.23.0" - dependencies: - "@babel/template": "npm:^7.22.15" - "@babel/types": "npm:^7.23.0" - checksum: 7b2ae024cd7a09f19817daf99e0153b3bf2bc4ab344e197e8d13623d5e36117ed0b110914bc248faa64e8ccd3e97971ec7b41cc6fd6163a2b980220c58dcdf6d - languageName: node - linkType: hard - "@babel/helper-hoist-variables@npm:^7.18.6, @babel/helper-hoist-variables@npm:^7.22.5": version: 7.22.5 resolution: "@babel/helper-hoist-variables@npm:7.22.5" @@ -510,17 +478,6 @@ __metadata: languageName: node linkType: hard -"@babel/highlight@npm:^7.23.4": - version: 7.23.4 - resolution: "@babel/highlight@npm:7.23.4" - dependencies: - "@babel/helper-validator-identifier": "npm:^7.22.20" - chalk: "npm:^2.4.2" - js-tokens: "npm:^4.0.0" - checksum: 62fef9b5bcea7131df4626d009029b1ae85332042f4648a4ce6e740c3fd23112603c740c45575caec62f260c96b11054d3be5987f4981a5479793579c3aac71f - languageName: node - linkType: hard - "@babel/parser@npm:^7.1.0, @babel/parser@npm:^7.13.16, @babel/parser@npm:^7.14.7, @babel/parser@npm:^7.16.4, @babel/parser@npm:^7.17.3, @babel/parser@npm:^7.20.7, @babel/parser@npm:^7.21.5, @babel/parser@npm:^7.21.8, @babel/parser@npm:^7.22.15, @babel/parser@npm:^7.22.16": version: 7.22.16 resolution: "@babel/parser@npm:7.22.16" @@ -530,7 +487,7 @@ __metadata: languageName: node linkType: hard -"@babel/parser@npm:^7.16.7, @babel/parser@npm:^7.23.6": +"@babel/parser@npm:^7.16.7": version: 7.23.6 resolution: "@babel/parser@npm:7.23.6" bin: @@ -917,7 +874,7 @@ __metadata: languageName: node linkType: hard -"@babel/plugin-syntax-jsx@npm:^7.22.5, @babel/plugin-syntax-jsx@npm:^7.7.2": +"@babel/plugin-syntax-jsx@npm:^7.22.5": version: 7.22.5 resolution: "@babel/plugin-syntax-jsx@npm:7.22.5" dependencies: @@ -928,6 +885,17 @@ __metadata: languageName: node linkType: hard +"@babel/plugin-syntax-jsx@npm:^7.7.2": + version: 7.23.3 + resolution: "@babel/plugin-syntax-jsx@npm:7.23.3" + dependencies: + "@babel/helper-plugin-utils": "npm:^7.22.5" + peerDependencies: + "@babel/core": ^7.0.0-0 + checksum: 89037694314a74e7f0e7a9c8d3793af5bf6b23d80950c29b360db1c66859d67f60711ea437e70ad6b5b4b29affe17eababda841b6c01107c2b638e0493bafb4e + languageName: node + linkType: hard + "@babel/plugin-syntax-logical-assignment-operators@npm:^7.10.4, @babel/plugin-syntax-logical-assignment-operators@npm:^7.8.3": version: 7.10.4 resolution: "@babel/plugin-syntax-logical-assignment-operators@npm:7.10.4" @@ -2067,25 +2035,7 @@ __metadata: languageName: node linkType: hard -"@babel/traverse@npm:^7.16.7": - version: 7.23.7 - resolution: "@babel/traverse@npm:7.23.7" - dependencies: - "@babel/code-frame": "npm:^7.23.5" - "@babel/generator": "npm:^7.23.6" - "@babel/helper-environment-visitor": "npm:^7.22.20" - "@babel/helper-function-name": "npm:^7.23.0" - "@babel/helper-hoist-variables": "npm:^7.22.5" - "@babel/helper-split-export-declaration": "npm:^7.22.6" - "@babel/parser": "npm:^7.23.6" - "@babel/types": "npm:^7.23.6" - debug: "npm:^4.3.1" - globals: "npm:^11.1.0" - checksum: 3215e59429963c8dac85c26933372cdd322952aa9930e4bc5ef2d0e4bd7a1510d1ecf8f8fd860ace5d4d9fe496d23805a1ea019a86410aee4111de5f63ee84f9 - languageName: node - linkType: hard - -"@babel/traverse@npm:^7.17.3, @babel/traverse@npm:^7.21.5, @babel/traverse@npm:^7.22.15, @babel/traverse@npm:^7.22.20": +"@babel/traverse@npm:^7.16.7, @babel/traverse@npm:^7.17.3, @babel/traverse@npm:^7.21.5, @babel/traverse@npm:^7.22.15, @babel/traverse@npm:^7.22.20": version: 7.22.20 resolution: "@babel/traverse@npm:7.22.20" dependencies: @@ -2132,7 +2082,7 @@ __metadata: languageName: node linkType: hard -"@babel/types@npm:^7.16.7, @babel/types@npm:^7.23.0, @babel/types@npm:^7.23.6": +"@babel/types@npm:^7.16.7": version: 7.23.6 resolution: "@babel/types@npm:7.23.6" dependencies: @@ -3063,11 +3013,11 @@ __metadata: linkType: hard "@sinonjs/commons@npm:^3.0.0": - version: 3.0.0 - resolution: "@sinonjs/commons@npm:3.0.0" + version: 3.0.1 + resolution: "@sinonjs/commons@npm:3.0.1" dependencies: type-detect: "npm:4.0.8" - checksum: 086720ae0bc370829322df32612205141cdd44e592a8a9ca97197571f8f970352ea39d3bda75b347c43789013ddab36b34b59e40380a49bdae1c2df3aa85fe4f + checksum: a0af217ba7044426c78df52c23cedede6daf377586f3ac58857c565769358ab1f44ebf95ba04bbe38814fba6e316ca6f02870a009328294fc2c555d0f85a7117 languageName: node linkType: hard @@ -4523,6 +4473,30 @@ __metadata: languageName: node linkType: hard +"@textlint/ast-node-types@npm:^12.6.1": + version: 12.6.1 + resolution: "@textlint/ast-node-types@npm:12.6.1" + checksum: a0a5d82fe49838e0be6420641eed9a1ab346ba75cba6c0da59d3998cff26206798ccfb74df500ab7bd96119d2aeada5f47518186dc83904a1226076533642403 + languageName: node + linkType: hard + +"@textlint/markdown-to-ast@npm:^12.1.1": + version: 12.6.1 + resolution: "@textlint/markdown-to-ast@npm:12.6.1" + dependencies: + "@textlint/ast-node-types": "npm:^12.6.1" + debug: "npm:^4.3.4" + mdast-util-gfm-autolink-literal: "npm:^0.1.3" + remark-footnotes: "npm:^3.0.0" + remark-frontmatter: "npm:^3.0.0" + remark-gfm: "npm:^1.0.0" + remark-parse: "npm:^9.0.0" + traverse: "npm:^0.6.7" + unified: "npm:^9.2.2" + checksum: 48d1954f0fb98a2e803d051598552f28c030d086d5dd2da003595bc0a8efd51087a5ccda0f0d991e62c0e55323304c5f5903f8033d2513ad7ce777ee5be0ca00 + languageName: node + linkType: hard + "@tootallnate/once@npm:1": version: 1.1.2 resolution: "@tootallnate/once@npm:1.1.2" @@ -4790,9 +4764,9 @@ __metadata: linkType: hard "@types/http-cache-semantics@npm:*": - version: 4.0.4 - resolution: "@types/http-cache-semantics@npm:4.0.4" - checksum: a59566cff646025a5de396d6b3f44a39ab6a74f2ed8150692e0f31cc52f3661a68b04afe3166ebe0d566bd3259cb18522f46e949576d5204781cd6452b7fe0c5 + version: 4.0.2 + resolution: "@types/http-cache-semantics@npm:4.0.2" + checksum: 6cf83a583a559ecaa95bae6d122d854028c0b0e0e3ad70fb46c0bcb1f447235fcf2e9516993b45bbb41e4dd5b54719cb1614b2e0057278a86b689a75cb732561 languageName: node linkType: hard @@ -4868,6 +4842,15 @@ __metadata: languageName: node linkType: hard +"@types/mdast@npm:^3.0.0": + version: 3.0.12 + resolution: "@types/mdast@npm:3.0.12" + dependencies: + "@types/unist": "npm:^2" + checksum: 7446c87e3c51db5e3daa7490f9d04c183e619a8f6542f5dbaa263599052adc89af17face06609d4f5c5c49aacee2bff04748bba0342cbc4106904f9cf1121a69 + languageName: node + linkType: hard + "@types/mdx@npm:^2.0.0": version: 2.0.7 resolution: "@types/mdx@npm:2.0.7" @@ -4921,9 +4904,9 @@ __metadata: linkType: hard "@types/node@npm:^14.14.41": - version: 14.18.63 - resolution: "@types/node@npm:14.18.63" - checksum: 82a7775898c2ea6db0b610a463512206fb2c7adc1af482c7eb44b99d94375fff51c74f67ae75a63c5532971159f30c866a4d308000624ef02fd9a7175e277019 + version: 14.18.62 + resolution: "@types/node@npm:14.18.62" + checksum: 6842c3b56d3305ad4cfe5475a43c2c8c0303aaaede42c4a16e038e9789c62378bf60043bf5f7b08eeea946b7e232db38b0283142ea855ad2fae0edd17290009e languageName: node linkType: hard @@ -5080,7 +5063,7 @@ __metadata: languageName: node linkType: hard -"@types/unist@npm:^2.0.0": +"@types/unist@npm:^2, @types/unist@npm:^2.0.0, @types/unist@npm:^2.0.2": version: 2.0.8 resolution: "@types/unist@npm:2.0.8" checksum: f4852d10a6752dc70df363917ef74453e5d2fd42824c0f6d09d19d530618e1402193977b1207366af4415aaec81d4e262c64d00345402020c4ca179216e553c7 @@ -5088,12 +5071,12 @@ __metadata: linkType: hard "@types/vinyl@npm:^2.0.4": - version: 2.0.11 - resolution: "@types/vinyl@npm:2.0.11" + version: 2.0.7 + resolution: "@types/vinyl@npm:2.0.7" dependencies: "@types/expect": "npm:^1.20.4" "@types/node": "npm:*" - checksum: 0f69e2d44748d0e55c3fcd4c2b5b59f0dc70b46fd5b9081abb1b81045543fbeb16e33d6ba2a21fb61d2182d91d99ba1c78be65311d1095857834827e2084417c + checksum: c13002667b4b7ecd5e7a78d3dc15fb2f9d183d985ce89c09b0fd9a5aec4177a11062fb7b5a70a7f3b3d0ce8abf2f5a310c4b2be88720ee80912a12941143b747 languageName: node linkType: hard @@ -5403,6 +5386,7 @@ __metadata: cssnano: "npm:5.0.17" del: "npm:6.0.0" delay: "npm:5.0.0" + doctoc: "npm:2.2.1" dpdm: "npm:3.10.0" escaper: "npm:3.0.6" eventemitter2: "npm:6.4.5" @@ -5517,6 +5501,8 @@ __metadata: optional: true delay: optional: true + doctoc: + optional: true extract-loader: optional: true fast-glob: @@ -6282,9 +6268,9 @@ __metadata: linkType: hard "acorn-walk@npm:^8.1.1": - version: 8.3.2 - resolution: "acorn-walk@npm:8.3.2" - checksum: 57dbe2fd8cf744f562431775741c5c087196cd7a65ce4ccb3f3981cdfad25cd24ad2bad404997b88464ac01e789a0a61e5e355b2a84876f13deef39fb39686ca + version: 8.2.0 + resolution: "acorn-walk@npm:8.2.0" + checksum: e69f7234f2adfeb16db3671429a7c80894105bd7534cb2032acf01bb26e6a847952d11a062d071420b43f8d82e33d2e57f26fe87d9cce0853e8143d8910ff1de languageName: node linkType: hard @@ -6316,11 +6302,11 @@ __metadata: linkType: hard "acorn@npm:^8.4.1, acorn@npm:^8.5.0": - version: 8.11.3 - resolution: "acorn@npm:8.11.3" + version: 8.11.2 + resolution: "acorn@npm:8.11.2" bin: acorn: bin/acorn - checksum: b688e7e3c64d9bfb17b596e1b35e4da9d50553713b3b3630cf5690f2b023a84eac90c56851e6912b483fe60e8b4ea28b254c07e92f17ef83d72d78745a8352dd + checksum: ff559b891382ad4cd34cc3c493511d0a7075a51f5f9f02a03440e92be3705679367238338566c5fbd3521ecadd565d29301bc8e16cb48379206bffbff3d72500 languageName: node linkType: hard @@ -6442,6 +6428,15 @@ __metadata: languageName: node linkType: hard +"anchor-markdown-header@npm:^0.6.0": + version: 0.6.0 + resolution: "anchor-markdown-header@npm:0.6.0" + dependencies: + emoji-regex: "npm:~10.1.0" + checksum: 6e5766ae2cb64f07f0aecf58e0c671f07a894033325a1cb3655496042dd1b946a1babe38f421113d31f5df6c3b7604ed677f938a40bb02abad6edf53dc412f8f + languageName: node + linkType: hard + "ansi-colors@npm:^1.0.1": version: 1.1.0 resolution: "ansi-colors@npm:1.1.0" @@ -7938,6 +7933,13 @@ __metadata: languageName: node linkType: hard +"bail@npm:^1.0.0": + version: 1.0.5 + resolution: "bail@npm:1.0.5" + checksum: 6c334940d7eaa4e656a12fb12407b6555649b6deb6df04270fa806e0da82684ebe4a4e47815b271c794b40f8d6fa286e0c248b14ddbabb324a917fab09b7301a + languageName: node + linkType: hard + "balanced-match@npm:^1.0.0": version: 1.0.2 resolution: "balanced-match@npm:1.0.2" @@ -8605,6 +8607,13 @@ __metadata: languageName: node linkType: hard +"ccount@npm:^1.0.0": + version: 1.1.0 + resolution: "ccount@npm:1.1.0" + checksum: b335a79d0aa4308919cf7507babcfa04ac63d389ebed49dbf26990d4607c8a4713cde93cc83e707d84571ddfe1e7615dad248be9bc422ae4c188210f71b08b78 + languageName: node + linkType: hard + "center-align@npm:^0.1.1": version: 0.1.3 resolution: "center-align@npm:0.1.3" @@ -8666,6 +8675,27 @@ __metadata: languageName: node linkType: hard +"character-entities-legacy@npm:^1.0.0": + version: 1.1.4 + resolution: "character-entities-legacy@npm:1.1.4" + checksum: fe03a82c154414da3a0c8ab3188e4237ec68006cbcd681cf23c7cfb9502a0e76cd30ab69a2e50857ca10d984d57de3b307680fff5328ccd427f400e559c3a811 + languageName: node + linkType: hard + +"character-entities@npm:^1.0.0": + version: 1.2.4 + resolution: "character-entities@npm:1.2.4" + checksum: 7c11641c48d1891aaba7bc800d4500804d91a28f46d64e88c001c38e6ab2e7eae28873a77ae16e6c55d24cac35ddfbb15efe56c3012b86684a3c4e95c70216b7 + languageName: node + linkType: hard + +"character-reference-invalid@npm:^1.0.0": + version: 1.1.4 + resolution: "character-reference-invalid@npm:1.1.4" + checksum: 812ebc5e6e8d08fd2fa5245ae78c1e1a4bea4692e93749d256a135c4a442daf931ca18e067cc61ff4a58a419eae52677126a0bc4f05a511290427d60d3057805 + languageName: node + linkType: hard + "charcodes@npm:^0.2.0": version: 0.2.0 resolution: "charcodes@npm:0.2.0" @@ -9428,9 +9458,9 @@ __metadata: linkType: hard "core-js@npm:^3.4": - version: 3.35.0 - resolution: "core-js@npm:3.35.0" - checksum: 0815fce6bcc91d79d4b28885975453b0faa4d17fc2230635102b4f3832cd621035e4032aa3307e1dbe0ee14d5e34bcb64b507fd89bd8f567aedaf29538522e6a + version: 3.32.2 + resolution: "core-js@npm:3.32.2" + checksum: 7df03093f9d9a9157f98c55ce0facb6af60b78ccb5c3b4a30623b00570742688b5cb4d0ff92900cd08c0f853ff1e085831825249369c17966fecac61bd1fd5fc languageName: node linkType: hard @@ -9959,7 +9989,7 @@ __metadata: languageName: node linkType: hard -"debug@npm:*, debug@npm:4, debug@npm:^4.1.0, debug@npm:^4.1.1, debug@npm:^4.3.1, debug@npm:^4.3.2, debug@npm:^4.3.3, debug@npm:^4.3.4": +"debug@npm:*, debug@npm:4, debug@npm:^4.0.0, debug@npm:^4.1.0, debug@npm:^4.1.1, debug@npm:^4.3.2, debug@npm:^4.3.3, debug@npm:^4.3.4": version: 4.3.4 resolution: "debug@npm:4.3.4" dependencies: @@ -10479,6 +10509,22 @@ __metadata: languageName: node linkType: hard +"doctoc@npm:2.2.1": + version: 2.2.1 + resolution: "doctoc@npm:2.2.1" + dependencies: + "@textlint/markdown-to-ast": "npm:^12.1.1" + anchor-markdown-header: "npm:^0.6.0" + htmlparser2: "npm:^7.2.0" + minimist: "npm:^1.2.6" + underscore: "npm:^1.13.2" + update-section: "npm:^0.3.3" + bin: + doctoc: doctoc.js + checksum: c1e4e53351f627a9fdd4f314f6a0fd5d67f918359c5c91d474270a4082d312c5c416dc6151abbd8fbb0dff15f8b159d3b5340f810bff0e0e5e4ce9702272a52b + languageName: node + linkType: hard + "doctrine@npm:^2.1.0": version: 2.1.0 resolution: "doctrine@npm:2.1.0" @@ -10591,7 +10637,7 @@ __metadata: languageName: node linkType: hard -"domhandler@npm:^4.0.0, domhandler@npm:^4.2.0, domhandler@npm:^4.3.1": +"domhandler@npm:^4.0.0, domhandler@npm:^4.2.0, domhandler@npm:^4.2.2, domhandler@npm:^4.3.1": version: 4.3.1 resolution: "domhandler@npm:4.3.1" dependencies: @@ -10858,6 +10904,13 @@ __metadata: languageName: node linkType: hard +"emoji-regex@npm:~10.1.0": + version: 10.1.0 + resolution: "emoji-regex@npm:10.1.0" + checksum: a06227a57164627e45dea28cb15c7cbf1703f0c9f7a0ac844edbcf55e4036a77437becae19d896eca7befd197d5113f7578b32767b358ec69d6e7bb09b87ba55 + languageName: node + linkType: hard + "emojis-list@npm:^3.0.0": version: 3.0.0 resolution: "emojis-list@npm:3.0.0" @@ -10914,6 +10967,13 @@ __metadata: languageName: node linkType: hard +"entities@npm:^3.0.1": + version: 3.0.1 + resolution: "entities@npm:3.0.1" + checksum: 3706e0292ea3f3679720b3d3b1ed6290b164aaeb11116691a922a3acea144503871e0de2170b47671c3b735549b8b7f4741d0d3c2987e8f985ccaa0dd3762eba + languageName: node + linkType: hard + "entities@npm:^4.4.0": version: 4.5.0 resolution: "entities@npm:4.5.0" @@ -12065,6 +12125,15 @@ __metadata: languageName: node linkType: hard +"fault@npm:^1.0.0": + version: 1.0.4 + resolution: "fault@npm:1.0.4" + dependencies: + format: "npm:^0.2.0" + checksum: 5ac610d8b09424e0f2fa8cf913064372f2ee7140a203a79957f73ed557c0e79b1a3d096064d7f40bde8132a69204c1fe25ec23634c05c6da2da2039cff26c4e7 + languageName: node + linkType: hard + "favicons@npm:7.1.0": version: 7.1.0 resolution: "favicons@npm:7.1.0" @@ -12637,6 +12706,13 @@ __metadata: languageName: node linkType: hard +"format@npm:^0.2.0": + version: 0.2.2 + resolution: "format@npm:0.2.2" + checksum: 5f878b8fc1a672c8cbefa4f293bdd977c822862577d70d53456a48b4169ec9b51677c0c995bf62c633b4e5cd673624b7c273f57923b28735a6c0c0a72c382a4a + languageName: node + linkType: hard + "forwarded@npm:0.2.0": version: 0.2.0 resolution: "forwarded@npm:0.2.0" @@ -14234,6 +14310,18 @@ __metadata: languageName: node linkType: hard +"htmlparser2@npm:^7.2.0": + version: 7.2.0 + resolution: "htmlparser2@npm:7.2.0" + dependencies: + domelementtype: "npm:^2.0.1" + domhandler: "npm:^4.2.2" + domutils: "npm:^2.8.0" + entities: "npm:^3.0.1" + checksum: fd097e19c01fb4ac8f44e432ae2908a606a382ccfec90efc91354a5b153540feade679ab8dca5fdebbe4f27c5a700743e2a0794f5a7a1beae9cc59d47e0f24b5 + languageName: node + linkType: hard + "http-cache-semantics@npm:3.8.1": version: 3.8.1 resolution: "http-cache-semantics@npm:3.8.1" @@ -14769,6 +14857,23 @@ __metadata: languageName: node linkType: hard +"is-alphabetical@npm:^1.0.0": + version: 1.0.4 + resolution: "is-alphabetical@npm:1.0.4" + checksum: 6508cce44fd348f06705d377b260974f4ce68c74000e7da4045f0d919e568226dc3ce9685c5a2af272195384df6930f748ce9213fc9f399b5d31b362c66312cb + languageName: node + linkType: hard + +"is-alphanumerical@npm:^1.0.0": + version: 1.0.4 + resolution: "is-alphanumerical@npm:1.0.4" + dependencies: + is-alphabetical: "npm:^1.0.0" + is-decimal: "npm:^1.0.0" + checksum: e2e491acc16fcf5b363f7c726f666a9538dba0a043665740feb45bba1652457a73441e7c5179c6768a638ed396db3437e9905f403644ec7c468fb41f4813d03f + languageName: node + linkType: hard + "is-arguments@npm:^1.0.4, is-arguments@npm:^1.1.1": version: 1.1.1 resolution: "is-arguments@npm:1.1.1" @@ -14848,6 +14953,13 @@ __metadata: languageName: node linkType: hard +"is-buffer@npm:^2.0.0": + version: 2.0.5 + resolution: "is-buffer@npm:2.0.5" + checksum: 3261a8b858edcc6c9566ba1694bf829e126faa88911d1c0a747ea658c5d81b14b6955e3a702d59dabadd58fdd440c01f321aa71d6547105fd21d03f94d0597e7 + languageName: node + linkType: hard + "is-callable@npm:^1.1.3, is-callable@npm:^1.1.4, is-callable@npm:^1.2.7": version: 1.2.7 resolution: "is-callable@npm:1.2.7" @@ -14900,6 +15012,13 @@ __metadata: languageName: node linkType: hard +"is-decimal@npm:^1.0.0": + version: 1.0.4 + resolution: "is-decimal@npm:1.0.4" + checksum: ed483a387517856dc395c68403a10201fddcc1b63dc56513fbe2fe86ab38766120090ecdbfed89223d84ca8b1cd28b0641b93cb6597b6e8f4c097a7c24e3fb96 + languageName: node + linkType: hard + "is-deflate@npm:^1.0.0": version: 1.0.0 resolution: "is-deflate@npm:1.0.0" @@ -15082,6 +15201,13 @@ __metadata: languageName: node linkType: hard +"is-hexadecimal@npm:^1.0.0": + version: 1.0.4 + resolution: "is-hexadecimal@npm:1.0.4" + checksum: a452e047587b6069332d83130f54d30da4faf2f2ebaa2ce6d073c27b5703d030d58ed9e0b729c8e4e5b52c6f1dab26781bb77b7bc6c7805f14f320e328ff8cd5 + languageName: node + linkType: hard + "is-inside-container@npm:^1.0.0": version: 1.0.0 resolution: "is-inside-container@npm:1.0.0" @@ -15228,6 +15354,13 @@ __metadata: languageName: node linkType: hard +"is-plain-obj@npm:^2.0.0": + version: 2.1.0 + resolution: "is-plain-obj@npm:2.1.0" + checksum: cec9100678b0a9fe0248a81743041ed990c2d4c99f893d935545cfbc42876cbe86d207f3b895700c690ad2fa520e568c44afc1605044b535a7820c1d40e38daa + languageName: node + linkType: hard + "is-plain-object@npm:^2.0.1, is-plain-object@npm:^2.0.3, is-plain-object@npm:^2.0.4": version: 2.0.4 resolution: "is-plain-object@npm:2.0.4" @@ -15567,15 +15700,15 @@ __metadata: linkType: hard "istanbul-lib-instrument@npm:^6.0.0": - version: 6.0.0 - resolution: "istanbul-lib-instrument@npm:6.0.0" + version: 6.0.1 + resolution: "istanbul-lib-instrument@npm:6.0.1" dependencies: "@babel/core": "npm:^7.12.3" "@babel/parser": "npm:^7.14.7" "@istanbuljs/schema": "npm:^0.1.2" istanbul-lib-coverage: "npm:^3.2.0" semver: "npm:^7.5.4" - checksum: a52efe2170ac2deeaaacc84d10fe8de41d97264a86e57df77e05c1e72227a333280f640836137b28fda802a2c71b2affb00a703979e6f7a462cc80047a6aff21 + checksum: 95fd8c66e586840989cb3c7819c6da66c4742a6fedbf16b51a5c7f1898941ad07b79ddff020f479d3a1d76743ecdbf255d93c35221875687477d4b118026e7e7 languageName: node linkType: hard @@ -16530,16 +16663,7 @@ __metadata: languageName: node linkType: hard -"keyv@npm:^4.0.0": - version: 4.5.4 - resolution: "keyv@npm:4.5.4" - dependencies: - json-buffer: "npm:3.0.1" - checksum: 167eb6ef64cc84b6fa0780ee50c9de456b422a1e18802209234f7c2cf7eae648c7741f32e50d7e24ccb22b24c13154070b01563d642755b156c357431a191e75 - languageName: node - linkType: hard - -"keyv@npm:^4.5.3": +"keyv@npm:^4.0.0, keyv@npm:^4.5.3": version: 4.5.3 resolution: "keyv@npm:4.5.3" dependencies: @@ -17022,6 +17146,13 @@ __metadata: languageName: node linkType: hard +"longest-streak@npm:^2.0.0": + version: 2.0.4 + resolution: "longest-streak@npm:2.0.4" + checksum: 28b8234a14963002c5c71035dee13a0a11e9e9d18ffa320fdc8796ed7437399204495702ed69cd2a7087b0af041a2a8b562829b7c1e2042e73a3374d1ecf6580 + languageName: node + linkType: hard + "longest@npm:^1.0.0, longest@npm:^1.0.1": version: 1.0.1 resolution: "longest@npm:1.0.1" @@ -17308,6 +17439,15 @@ __metadata: languageName: node linkType: hard +"markdown-table@npm:^2.0.0": + version: 2.0.0 + resolution: "markdown-table@npm:2.0.0" + dependencies: + repeat-string: "npm:^1.0.0" + checksum: 8018cd1a1733ffda916a0548438e50f3d21b6c6b71fb23696b33c0b5922a8cc46035eb4b204a59c6054f063076f934461ae094599656a63f87c1c3a80bd3c229 + languageName: node + linkType: hard + "markdown-to-jsx@npm:^7.1.8": version: 7.3.2 resolution: "markdown-to-jsx@npm:7.3.2" @@ -17365,6 +17505,115 @@ __metadata: languageName: node linkType: hard +"mdast-util-find-and-replace@npm:^1.1.0": + version: 1.1.1 + resolution: "mdast-util-find-and-replace@npm:1.1.1" + dependencies: + escape-string-regexp: "npm:^4.0.0" + unist-util-is: "npm:^4.0.0" + unist-util-visit-parents: "npm:^3.0.0" + checksum: e4c9e50d9bce5ae4c728a925bd60080b94d16aaa312c27e2b70b16ddc29a5d0a0844d6e18efaef08aeb22c68303ec528f20183d1b0420504a0c2c1710cebd76f + languageName: node + linkType: hard + +"mdast-util-footnote@npm:^0.1.0": + version: 0.1.7 + resolution: "mdast-util-footnote@npm:0.1.7" + dependencies: + mdast-util-to-markdown: "npm:^0.6.0" + micromark: "npm:~2.11.0" + checksum: b59d8989d3730ea59786d5e5678d006a552e44080094036d3ed414114ea1e66471746fabdf8578ae46f3c63878c5237dbb7a63eda5b313ef2387db1a00103fd3 + languageName: node + linkType: hard + +"mdast-util-from-markdown@npm:^0.8.0": + version: 0.8.5 + resolution: "mdast-util-from-markdown@npm:0.8.5" + dependencies: + "@types/mdast": "npm:^3.0.0" + mdast-util-to-string: "npm:^2.0.0" + micromark: "npm:~2.11.0" + parse-entities: "npm:^2.0.0" + unist-util-stringify-position: "npm:^2.0.0" + checksum: f42166eb7a3c2a8cf17dffd868a6dfdab6a77d4e4c8f35d7c3d63247a16ddfeae45a59d9f5fa5eacc48d76d82d18cb0157961d03d1732bc616f9ddf3bb450984 + languageName: node + linkType: hard + +"mdast-util-frontmatter@npm:^0.2.0": + version: 0.2.0 + resolution: "mdast-util-frontmatter@npm:0.2.0" + dependencies: + micromark-extension-frontmatter: "npm:^0.2.0" + checksum: bdef2318cf446d90b34863d5fd4019f111816e0211023cf274bf3cc1cf2b35a26fcada9667af6f94b11096bcc556bc0ee23c3f79353d21a250ff192bb67b1bb1 + languageName: node + linkType: hard + +"mdast-util-gfm-autolink-literal@npm:^0.1.0, mdast-util-gfm-autolink-literal@npm:^0.1.3": + version: 0.1.3 + resolution: "mdast-util-gfm-autolink-literal@npm:0.1.3" + dependencies: + ccount: "npm:^1.0.0" + mdast-util-find-and-replace: "npm:^1.1.0" + micromark: "npm:^2.11.3" + checksum: 9f7b888678631fd8c0a522b0689a750aead2b05d57361dbdf02c10381557f1ce874f746226141f3ace1e0e7952495e8d5ce8f9af423a7a66bb300d4635a918eb + languageName: node + linkType: hard + +"mdast-util-gfm-strikethrough@npm:^0.2.0": + version: 0.2.3 + resolution: "mdast-util-gfm-strikethrough@npm:0.2.3" + dependencies: + mdast-util-to-markdown: "npm:^0.6.0" + checksum: 51aa11ca8f1a5745f1eb9ccddb0eca797b3ede6f0c7bf355d594ad57c02c98d95260f00b1c4b07504018e0b22708531eabb76037841f09ce8465444706a06522 + languageName: node + linkType: hard + +"mdast-util-gfm-table@npm:^0.1.0": + version: 0.1.6 + resolution: "mdast-util-gfm-table@npm:0.1.6" + dependencies: + markdown-table: "npm:^2.0.0" + mdast-util-to-markdown: "npm:~0.6.0" + checksum: 06fe08f74fab934845280a289a0439335a1ae3fd0988f2a655afa8189ad109c4debd28b0865e16f9d0fba6fb5fc3769f5f397bade73607537735987411b5da67 + languageName: node + linkType: hard + +"mdast-util-gfm-task-list-item@npm:^0.1.0": + version: 0.1.6 + resolution: "mdast-util-gfm-task-list-item@npm:0.1.6" + dependencies: + mdast-util-to-markdown: "npm:~0.6.0" + checksum: da5ae0d621862502068792947502a6452a10593f5625561b093dd99557280f7ab2dc3280fc124aaf7581311d4a88f1ab0d1307dab3b8bf7c35b47d1d54293c06 + languageName: node + linkType: hard + +"mdast-util-gfm@npm:^0.1.0": + version: 0.1.2 + resolution: "mdast-util-gfm@npm:0.1.2" + dependencies: + mdast-util-gfm-autolink-literal: "npm:^0.1.0" + mdast-util-gfm-strikethrough: "npm:^0.2.0" + mdast-util-gfm-table: "npm:^0.1.0" + mdast-util-gfm-task-list-item: "npm:^0.1.0" + mdast-util-to-markdown: "npm:^0.6.1" + checksum: 64cd342f70d9da4abc11a24ce3e80f09866360081cb7056119726b94c8358b0ca8af60f83399ce39edc76247ce4eb49677d95f5a834d0d9646457a0e5f236410 + languageName: node + linkType: hard + +"mdast-util-to-markdown@npm:^0.6.0, mdast-util-to-markdown@npm:^0.6.1, mdast-util-to-markdown@npm:~0.6.0": + version: 0.6.5 + resolution: "mdast-util-to-markdown@npm:0.6.5" + dependencies: + "@types/unist": "npm:^2.0.0" + longest-streak: "npm:^2.0.0" + mdast-util-to-string: "npm:^2.0.0" + parse-entities: "npm:^2.0.0" + repeat-string: "npm:^1.0.0" + zwitch: "npm:^1.0.0" + checksum: e1fdb7a75f59166abe5d9d26fed5e04cd40bc6ab54cba239350f70c92df093106b9462660a1891210e9d52b2729c14fc107605127e25837b0a4ad74fbdfbd328 + languageName: node + linkType: hard + "mdast-util-to-string@npm:^1.0.0": version: 1.1.0 resolution: "mdast-util-to-string@npm:1.1.0" @@ -17372,6 +17621,13 @@ __metadata: languageName: node linkType: hard +"mdast-util-to-string@npm:^2.0.0": + version: 2.0.0 + resolution: "mdast-util-to-string@npm:2.0.0" + checksum: 0b2113ada10e002fbccb014170506dabe2f2ddacaacbe4bc1045c33f986652c5a162732a2c057c5335cdb58419e2ad23e368e5be226855d4d4e280b81c4e9ec2 + languageName: node + linkType: hard + "mdn-data@npm:2.0.14": version: 2.0.14 resolution: "mdn-data@npm:2.0.14" @@ -17506,6 +17762,91 @@ __metadata: languageName: node linkType: hard +"micromark-extension-footnote@npm:^0.3.0": + version: 0.3.2 + resolution: "micromark-extension-footnote@npm:0.3.2" + dependencies: + micromark: "npm:~2.11.0" + checksum: 73cca7fca9ddc1350db2679f6470b2607e7eb3f6d8994dde7ad52d9840e778c2e21d030c056eb8fb887dc39e581fa3fddf8730461f0885c211236881b747775a + languageName: node + linkType: hard + +"micromark-extension-frontmatter@npm:^0.2.0": + version: 0.2.2 + resolution: "micromark-extension-frontmatter@npm:0.2.2" + dependencies: + fault: "npm:^1.0.0" + checksum: 011a4b1f00288ecf536883901cba62a7a18bf9ac8cc6e99b503552817a0e34954070de3745e9c1134a615f662a013619d873bd60b2d9be0a00975abbe07ac1c9 + languageName: node + linkType: hard + +"micromark-extension-gfm-autolink-literal@npm:~0.5.0": + version: 0.5.7 + resolution: "micromark-extension-gfm-autolink-literal@npm:0.5.7" + dependencies: + micromark: "npm:~2.11.3" + checksum: 107e4aa3926f5e77acbf47b0568985acae173c5190610c7c5356da613d5c957cc4a5a3ed43ee51ae6be146445fbb612861f9d0c7c9b388265fc6abfe6c2df1e2 + languageName: node + linkType: hard + +"micromark-extension-gfm-strikethrough@npm:~0.6.5": + version: 0.6.5 + resolution: "micromark-extension-gfm-strikethrough@npm:0.6.5" + dependencies: + micromark: "npm:~2.11.0" + checksum: 67711633590d3e688759a46aaed9f9d04bcaf29b6615eec17af082eabe1059fbca4beb41ba13db418ae7be3ac90198742fbabe519a70f9b6bb615598c5d6ef1a + languageName: node + linkType: hard + +"micromark-extension-gfm-table@npm:~0.4.0": + version: 0.4.3 + resolution: "micromark-extension-gfm-table@npm:0.4.3" + dependencies: + micromark: "npm:~2.11.0" + checksum: aa1f583966164a57b516cc5690e92a487cbc676936d48f9cecc39fc009c342691588b0793455e166c6c5499804f25306ce8313259b6e36a9d9fd07769b17a5fd + languageName: node + linkType: hard + +"micromark-extension-gfm-tagfilter@npm:~0.3.0": + version: 0.3.0 + resolution: "micromark-extension-gfm-tagfilter@npm:0.3.0" + checksum: 9369736a203836b2933dfdeacab863e7a4976139b9dd46fa5bd6c2feeef50c7dbbcdd641ae95f0481f577d8aa22396bfa7ed9c38515647d4cf3f2c727cc094a3 + languageName: node + linkType: hard + +"micromark-extension-gfm-task-list-item@npm:~0.3.0": + version: 0.3.3 + resolution: "micromark-extension-gfm-task-list-item@npm:0.3.3" + dependencies: + micromark: "npm:~2.11.0" + checksum: e4ccbe6b440234c8ee05d89315e1204c78773724241af31ac328194470a8a61bc6606eab3ce2d9a83da4401b06e07936038654493da715d40522133d1556dda4 + languageName: node + linkType: hard + +"micromark-extension-gfm@npm:^0.3.0": + version: 0.3.3 + resolution: "micromark-extension-gfm@npm:0.3.3" + dependencies: + micromark: "npm:~2.11.0" + micromark-extension-gfm-autolink-literal: "npm:~0.5.0" + micromark-extension-gfm-strikethrough: "npm:~0.6.5" + micromark-extension-gfm-table: "npm:~0.4.0" + micromark-extension-gfm-tagfilter: "npm:~0.3.0" + micromark-extension-gfm-task-list-item: "npm:~0.3.0" + checksum: 653102f7a61de43f9308ae34d70b195710f0bd3dc97a39e392c9ab81ffc975ccccc4cd29dfa0ec5bdad931634f055155314a5e96579ff6f805896fc173c707ac + languageName: node + linkType: hard + +"micromark@npm:^2.11.3, micromark@npm:~2.11.0, micromark@npm:~2.11.3": + version: 2.11.4 + resolution: "micromark@npm:2.11.4" + dependencies: + debug: "npm:^4.0.0" + parse-entities: "npm:^2.0.0" + checksum: cd3bcbc4c113c74d0897e7787103eb9c92c86974b0af1f87d2079b34f1543511a1e72face3f80c1d47c6614c2eaf860d94eee8c06f80dc48bc2441691576364b + languageName: node + linkType: hard + "micromatch@npm:3.1.0": version: 3.1.0 resolution: "micromatch@npm:3.1.0" @@ -19044,6 +19385,20 @@ __metadata: languageName: node linkType: hard +"parse-entities@npm:^2.0.0": + version: 2.0.0 + resolution: "parse-entities@npm:2.0.0" + dependencies: + character-entities: "npm:^1.0.0" + character-entities-legacy: "npm:^1.0.0" + character-reference-invalid: "npm:^1.0.0" + is-alphanumerical: "npm:^1.0.0" + is-decimal: "npm:^1.0.0" + is-hexadecimal: "npm:^1.0.0" + checksum: feb46b516722474797d72331421f3e62856750cfb4f70ba098b36447bf0b169e819cc4fdee53e022874d5f0c81b605d86e1912b9842a70e59a54de2fee81589d + languageName: node + linkType: hard + "parse-filepath@npm:^1.0.1": version: 1.0.2 resolution: "parse-filepath@npm:1.0.2" @@ -20377,9 +20732,9 @@ __metadata: linkType: hard "pure-rand@npm:^6.0.0": - version: 6.0.3 - resolution: "pure-rand@npm:6.0.3" - checksum: 68e6ebbc918d0022870cc436c26fd07b8ae6a71acc9aa83145d6e2ec0022e764926cbffc70c606fd25213c3b7234357d10458939182fb6568c2a364d1098cf34 + version: 6.0.4 + resolution: "pure-rand@npm:6.0.4" + checksum: 34fed0abe99d3db7ddc459c12e1eda6bff05db6a17f2017a1ae12202271ccf276fb223b442653518c719671c1b339bbf97f27ba9276dba0997c89e45c4e6a3bf languageName: node linkType: hard @@ -20989,6 +21344,45 @@ __metadata: languageName: node linkType: hard +"remark-footnotes@npm:^3.0.0": + version: 3.0.0 + resolution: "remark-footnotes@npm:3.0.0" + dependencies: + mdast-util-footnote: "npm:^0.1.0" + micromark-extension-footnote: "npm:^0.3.0" + checksum: d784e52b2703e3981041f4d6e584c8f5ef785fe43ddd94dce9527945af142b26d56adbe913f1575cdc8fd69677a00ef7291e4ade0460f9f9ed45f236e8e2f260 + languageName: node + linkType: hard + +"remark-frontmatter@npm:^3.0.0": + version: 3.0.0 + resolution: "remark-frontmatter@npm:3.0.0" + dependencies: + mdast-util-frontmatter: "npm:^0.2.0" + micromark-extension-frontmatter: "npm:^0.2.0" + checksum: 33bbcf36a51ee4c3813106b933766e0dc4b2b50f8eb4da3a4c577ea0278335589b36b182fb2722c9857d0ea9932ac75368a5dff70c8cf2b74f4747c244068976 + languageName: node + linkType: hard + +"remark-gfm@npm:^1.0.0": + version: 1.0.0 + resolution: "remark-gfm@npm:1.0.0" + dependencies: + mdast-util-gfm: "npm:^0.1.0" + micromark-extension-gfm: "npm:^0.3.0" + checksum: a37823a762c0862dd4c048bc425d584e56fa7862f6c38bf41334ee0b85e11a90bf7a075863fb09aa23993e2d3ec977b4a32994f2b869e26f44625c136b7b2996 + languageName: node + linkType: hard + +"remark-parse@npm:^9.0.0": + version: 9.0.0 + resolution: "remark-parse@npm:9.0.0" + dependencies: + mdast-util-from-markdown: "npm:^0.8.0" + checksum: 67c22c29f61d0af3812d4e076ebcbf9895bfeec3868299b514c25d46cb6d820ac132b71f51adab7ae756c910d6dd95a2040beeda6165b0a85ea153aa77fb3a83 + languageName: node + linkType: hard + "remark-slug@npm:^6.0.0": version: 6.1.0 resolution: "remark-slug@npm:6.1.0" @@ -21048,7 +21442,7 @@ __metadata: languageName: node linkType: hard -"repeat-string@npm:^1.5.2, repeat-string@npm:^1.6.1": +"repeat-string@npm:^1.0.0, repeat-string@npm:^1.5.2, repeat-string@npm:^1.6.1": version: 1.6.1 resolution: "repeat-string@npm:1.6.1" checksum: 1b809fc6db97decdc68f5b12c4d1a671c8e3f65ec4a40c238bc5200e44e85bcc52a54f78268ab9c29fcf5fe4f1343e805420056d1f30fa9a9ee4c2d93e3cc6c0 @@ -23378,7 +23772,7 @@ __metadata: languageName: node linkType: hard -"traverse@npm:^0.6.6": +"traverse@npm:^0.6.6, traverse@npm:^0.6.7": version: 0.6.7 resolution: "traverse@npm:0.6.7" checksum: b06ea2d1db755ae21d2f5bade6e5ddfc6daf4b571fefe0de343c4fbbb022836a1e9c293b334d04b5c73cc689e9dbbdde33bb41a57508a8b82c73683f76de7a01 @@ -23408,6 +23802,13 @@ __metadata: languageName: node linkType: hard +"trough@npm:^1.0.0": + version: 1.0.5 + resolution: "trough@npm:1.0.5" + checksum: 2209753fda70516f990c33f5d573361ccd896f81aaee0378ef6dae5c753b724d75a70b40a741e55edc188db51cfd9cd753ee1a3382687b17f04348860405d6b2 + languageName: node + linkType: hard + "ts-api-utils@npm:^1.0.1": version: 1.0.3 resolution: "ts-api-utils@npm:1.0.3" @@ -23957,7 +24358,7 @@ __metadata: languageName: node linkType: hard -"underscore@npm:1.x.x, underscore@npm:^1.8.3": +"underscore@npm:1.x.x, underscore@npm:^1.13.2, underscore@npm:^1.8.3": version: 1.13.6 resolution: "underscore@npm:1.13.6" checksum: 58cf5dc42cb0ac99c146ae4064792c0a2cc84f3a3c4ad88f5082e79057dfdff3371d896d1ec20379e9ece2450d94fa78f2ef5bfefc199ba320653e32c009bd66 @@ -24065,6 +24466,20 @@ __metadata: languageName: node linkType: hard +"unified@npm:^9.2.2": + version: 9.2.2 + resolution: "unified@npm:9.2.2" + dependencies: + bail: "npm:^1.0.0" + extend: "npm:^3.0.0" + is-buffer: "npm:^2.0.0" + is-plain-obj: "npm:^2.0.0" + trough: "npm:^1.0.0" + vfile: "npm:^4.0.0" + checksum: 871bb5fb0c2de4b16353734563075729f6782dffa58ddc80ff6c84750b8a1cd27d597685bfaf4dafe697b6a6433437e56b46999e7b6c9aa800ce64cb0797eb09 + languageName: node + linkType: hard + "union-value@npm:^1.0.0": version: 1.0.1 resolution: "union-value@npm:1.0.1" @@ -24128,6 +24543,15 @@ __metadata: languageName: node linkType: hard +"unist-util-stringify-position@npm:^2.0.0": + version: 2.0.3 + resolution: "unist-util-stringify-position@npm:2.0.3" + dependencies: + "@types/unist": "npm:^2.0.2" + checksum: affbfd151f0df055ce0dddf443fc41353ab3870cdba6b3805865bd6a41ce22d9d8e65be0ed8839a8731d05b61421d2df9fd8c35b67adf86040bf4b1f8a04a42c + languageName: node + linkType: hard + "unist-util-visit-parents@npm:^3.0.0": version: 3.1.1 resolution: "unist-util-visit-parents@npm:3.1.1" @@ -24234,6 +24658,13 @@ __metadata: languageName: node linkType: hard +"update-section@npm:^0.3.3": + version: 0.3.3 + resolution: "update-section@npm:0.3.3" + checksum: 837b0ea2af95e8616043a0484224687675fafdd476f5def94d032ed8a1674bcc144ef11180b3b8b27cb292018e0c605605db20deb3f13a571ff7cb57090d42da + languageName: node + linkType: hard + "upper-case@npm:^1.1.1": version: 1.1.3 resolution: "upper-case@npm:1.1.3" @@ -24475,6 +24906,28 @@ __metadata: languageName: node linkType: hard +"vfile-message@npm:^2.0.0": + version: 2.0.4 + resolution: "vfile-message@npm:2.0.4" + dependencies: + "@types/unist": "npm:^2.0.0" + unist-util-stringify-position: "npm:^2.0.0" + checksum: fad3d5a3a1b1415f30c6cd433df9971df28032c8cb93f15e7132693ac616e256afe76750d4e4810afece6fff20160f2a7f397c3eac46cf43ade21950a376fe3c + languageName: node + linkType: hard + +"vfile@npm:^4.0.0": + version: 4.2.1 + resolution: "vfile@npm:4.2.1" + dependencies: + "@types/unist": "npm:^2.0.0" + is-buffer: "npm:^2.0.0" + unist-util-stringify-position: "npm:^2.0.0" + vfile-message: "npm:^2.0.0" + checksum: f0de0b50df77344a6d653e0c2967edf310c154f58627a8a423bc7a67f4041c884a6716af1b60013cae180218bac7eed8244bed74d3267c596d0ebd88801663a5 + languageName: node + linkType: hard + "vinyl-fs@npm:3.0.3, vinyl-fs@npm:^3.0.0, vinyl-fs@npm:^3.0.3": version: 3.0.3 resolution: "vinyl-fs@npm:3.0.3" @@ -25428,3 +25881,10 @@ __metadata: checksum: 1f426b90a06a8748bda51528c1f77d733fbaf1d11866924f1e89caceeb2574ced9cd8ce638ae804c0e988ee7cce6d7310bcfba54144fd1940cd4076babc04c58 languageName: node linkType: hard + +"zwitch@npm:^1.0.0": + version: 1.0.5 + resolution: "zwitch@npm:1.0.5" + checksum: 28a1bebacab3bc60150b6b0a2ba1db2ad033f068e81f05e4892ec0ea13ae63f5d140a1d692062ac0657840c8da076f35b94433b5f1c329d7803b247de80f064a + languageName: node + linkType: hard