From b8659086d1d4c4a223877ab3cacc27dcd546cff7 Mon Sep 17 00:00:00 2001 From: volkanceylan Date: Fri, 18 Oct 2024 13:43:09 +0300 Subject: [PATCH] Moved test-utils from common-features repository --- packages/.gitignore | 1 + packages/test-utils/.gitignore | 1 + packages/test-utils/editorutils.ts | 15 ++ packages/test-utils/entitydialogutils.ts | 51 +++++ packages/test-utils/index.ts | 4 + packages/test-utils/jest-css-workaround.cjs | 7 + packages/test-utils/jest-defaults.d.ts | 1 + packages/test-utils/jest-defaults.js | 50 +++++ packages/test-utils/jest-setup-afterenv.js | 17 ++ packages/test-utils/jest-setup.js | 23 ++ packages/test-utils/jsdom-global.js | 105 +++++++++ packages/test-utils/mocks.ts | 230 ++++++++++++++++++++ packages/test-utils/package.json | 9 + packages/test-utils/test-utils.esproj | 11 + packages/test-utils/tsconfig.json | 16 ++ packages/test-utils/waitutils.ts | 22 ++ pnpm-lock.yaml | 9 + 17 files changed, 572 insertions(+) create mode 100644 packages/test-utils/.gitignore create mode 100644 packages/test-utils/editorutils.ts create mode 100644 packages/test-utils/entitydialogutils.ts create mode 100644 packages/test-utils/index.ts create mode 100644 packages/test-utils/jest-css-workaround.cjs create mode 100644 packages/test-utils/jest-defaults.d.ts create mode 100644 packages/test-utils/jest-defaults.js create mode 100644 packages/test-utils/jest-setup-afterenv.js create mode 100644 packages/test-utils/jest-setup.js create mode 100644 packages/test-utils/jsdom-global.js create mode 100644 packages/test-utils/mocks.ts create mode 100644 packages/test-utils/package.json create mode 100644 packages/test-utils/test-utils.esproj create mode 100644 packages/test-utils/tsconfig.json create mode 100644 packages/test-utils/waitutils.ts diff --git a/packages/.gitignore b/packages/.gitignore index eb9dccfa20..46ea11f19a 100644 --- a/packages/.gitignore +++ b/packages/.gitignore @@ -1,4 +1,5 @@ artifacts/ +dynamic-data/ coverage/ out/ /*/dist/**/*.js diff --git a/packages/test-utils/.gitignore b/packages/test-utils/.gitignore new file mode 100644 index 0000000000..a86b16c151 --- /dev/null +++ b/packages/test-utils/.gitignore @@ -0,0 +1 @@ +dynamic-data/ diff --git a/packages/test-utils/editorutils.ts b/packages/test-utils/editorutils.ts new file mode 100644 index 0000000000..1add67376f --- /dev/null +++ b/packages/test-utils/editorutils.ts @@ -0,0 +1,15 @@ +import { Fluent } from "@serenity-is/corelib"; + +export function typeText(editor: { value: string, element: Fluent }, value: string) { + editor.element.trigger("focus"); + editor.value = value; + editor.element.trigger("blur"); + editor.element.trigger("change"); +} + +export function typeNumber(editor: { value: number, element: Fluent }, value: number) { + editor.element.trigger("focus"); + editor.value = value; + editor.element.trigger("blur"); + editor.element.trigger("change"); +} \ No newline at end of file diff --git a/packages/test-utils/entitydialogutils.ts b/packages/test-utils/entitydialogutils.ts new file mode 100644 index 0000000000..8d0322da97 --- /dev/null +++ b/packages/test-utils/entitydialogutils.ts @@ -0,0 +1,51 @@ +import { EntityDialog } from "@serenity-is/corelib"; +import { waitForAjaxRequests } from "./waitutils"; + +export class EntityDialogWrapper> { + constructor(public readonly actual: TDialog) { + } + + clickDeleteButton(): Promise { + var button = this.actual.element.findFirst(".delete-button"); + if (!button.length) + throw "Delete button not found in the dialog!"; + if (button.hasClass("disabled")) + throw "Delete button is disabled!"; + const spy = jest.spyOn(window, "confirm").mockReturnValue(true); + button.click(); + spy.mockRestore(); + return waitForAjaxRequests(); + } + + clickSaveButton(): Promise { + var button = this.actual.element.findFirst(".save-and-close-button"); + if (!button.length) + throw "Save button not found in the dialog!"; + if (button.hasClass("disabled")) + throw "Save button is disabled!"; + button.click(); + return waitForAjaxRequests(); + } + + getTextInput(name: string) { + var input = this.actual["byId"](name); + if (!input.length) + throw `getTextInput: Input with name ${name} is not found in the dialog!`; + return input.val(); + } + + setTextInput(name: string, value: any) { + var input = this.actual["byId"](name); + if (!input.length) + throw `setTextInput: Input with name ${name} is not found in the dialog!`; + input.val(value).trigger("change"); + } + + waitForAjaxRequests(timeout: number = 10000): Promise { + return waitForAjaxRequests(timeout); + } + + getForm(type: { new(prefix: string): TForm }): TForm { + return new type(this.actual.idPrefix); + } +} \ No newline at end of file diff --git a/packages/test-utils/index.ts b/packages/test-utils/index.ts new file mode 100644 index 0000000000..13d7cfd78b --- /dev/null +++ b/packages/test-utils/index.ts @@ -0,0 +1,4 @@ +export * from "./entitydialogutils"; +export * from "./editorutils"; +export * from "./mocks"; +export * from "./waitutils"; \ No newline at end of file diff --git a/packages/test-utils/jest-css-workaround.cjs b/packages/test-utils/jest-css-workaround.cjs new file mode 100644 index 0000000000..b727b9254e --- /dev/null +++ b/packages/test-utils/jest-css-workaround.cjs @@ -0,0 +1,7 @@ +module.exports = { + process() { + return { + code: `module.exports = "";`, + }; + } +}; \ No newline at end of file diff --git a/packages/test-utils/jest-defaults.d.ts b/packages/test-utils/jest-defaults.d.ts new file mode 100644 index 0000000000..08e0a196e0 --- /dev/null +++ b/packages/test-utils/jest-defaults.d.ts @@ -0,0 +1 @@ +export default function jestDefaults(): any; \ No newline at end of file diff --git a/packages/test-utils/jest-defaults.js b/packages/test-utils/jest-defaults.js new file mode 100644 index 0000000000..0eb697ea2e --- /dev/null +++ b/packages/test-utils/jest-defaults.js @@ -0,0 +1,50 @@ +import { join, resolve } from "path"; +import { fileURLToPath } from 'url'; + +const testUtils = resolve(join(fileURLToPath(new URL('.', import.meta.url)), './')); +const serenityRoot = resolve(join(testUtils, "../../")); + +export default () => ({ + coveragePathIgnorePatterns: [ + "/node_modules/", + "/src/Serenity.Assets/" + ], + extensionsToTreatAsEsm: ['.ts', '.tsx'], + moduleNameMapper: { + "^@serenity-is/(.*)$": ["/node_modules/@serenity-is/$1", "/../node_modules/@serenity-is/$1", "/../../node_modules/@serenity-is/$1"] + }, + setupFiles: [ + `${testUtils}/jest-setup.js`, + ], + setupFilesAfterEnv: [ + `${testUtils}/jest-setup-afterenv.js` + ], + testEnvironment: `${testUtils}/jsdom-global.js`, + testMatch: [ + "/test/**/*.spec.ts*", + "/src/**/*.spec.ts*" + ], + transform: { + '\\.css$': `${testUtils}/jest-css-workaround.cjs`, + "^.+\.(t|j)sx?$": [`${serenityRoot}/node_modules/@swc/jest`, { + jsc: { + parser: { + syntax: "typescript", + decorators: true, + tsx: true + }, + keepClassNames: true, + transform: { + react: { + runtime: 'automatic', + importSource: 'jsx-dom' + } + } + }, + module: { + type: "commonjs" + } + }] + }, + transformIgnorePatterns: [] +}); \ No newline at end of file diff --git a/packages/test-utils/jest-setup-afterenv.js b/packages/test-utils/jest-setup-afterenv.js new file mode 100644 index 0000000000..cbc301ca1b --- /dev/null +++ b/packages/test-utils/jest-setup-afterenv.js @@ -0,0 +1,17 @@ +if (Object["__definePropertyMocked__"] !== true) { + Object["__definePropertyMocked__"] = true; + const originalDefineProperty = Object.defineProperty; + const mutableDefineProperty = (obj, prop, attributes) => { + // this is to prevent the error `Cannot redefine property: prototype`; prototype can not be configurable... + if (prop === "prototype") return originalDefineProperty(obj, prop, attributes); + return originalDefineProperty( + obj, + prop, + { + configurable: true, + ...attributes + } + ); + } + Object.defineProperty = mutableDefineProperty; +} diff --git a/packages/test-utils/jest-setup.js b/packages/test-utils/jest-setup.js new file mode 100644 index 0000000000..96d347fb50 --- /dev/null +++ b/packages/test-utils/jest-setup.js @@ -0,0 +1,23 @@ +if (!process.env.LISTENING_TO_UNHANDLED_REJECTION) { + process.on('unhandledRejection', err => { + try { + if (!err || !err.reason) + return; + + const reason = err.reason; + if (reason.origin == "serviceCall" || + reason.origin == "test") { + err.preventDefault(); + + if (!reason.silent && + (reason.kind ?? "exception") === "exception") { + console.error(err); + } + } + } + catch { + } + }) + // Avoid memory leak by adding too many listeners + process.env.LISTENING_TO_UNHANDLED_REJECTION = true +} \ No newline at end of file diff --git a/packages/test-utils/jsdom-global.js b/packages/test-utils/jsdom-global.js new file mode 100644 index 0000000000..09dab16f78 --- /dev/null +++ b/packages/test-utils/jsdom-global.js @@ -0,0 +1,105 @@ +import pkg from "../../../Serenity/node_modules/jest-environment-jsdom/build/index.js"; + +const JSDOMEnvironment = pkg.default || pkg.JSDOMEnvironment || pkg; + +export default class JSDOMEnvironmentGlobal extends JSDOMEnvironment { + async setup() { + await super.setup(); + addCSSEscape(this.global); + this.global.jsdom = this.dom; + } + + async teardown() { + this.global.jsdom = undefined; + await super.teardown(); + } +} + + + +function addCSSEscape(window) { + if (typeof window !== "undefined" && (!window.CSS || !window.CSS.escape)) { + // https://drafts.csswg.org/cssom/#serialize-an-identifier + var cssEscape = function (value) { + if (arguments.length == 0) { + throw new TypeError('`CSS.escape` requires an argument.'); + } + var string = String(value); + var length = string.length; + var index = -1; + var codeUnit; + var result = ''; + var firstCodeUnit = string.charCodeAt(0); + + if ( + // If the character is the first character and is a `-` (U+002D), and + // there is no second character, […] + length == 1 && + firstCodeUnit == 0x002D + ) { + return '\\' + string; + } + + while (++index < length) { + codeUnit = string.charCodeAt(index); + // Note: there’s no need to special-case astral symbols, surrogate + // pairs, or lone surrogates. + + // If the character is NULL (U+0000), then the REPLACEMENT CHARACTER + // (U+FFFD). + if (codeUnit == 0x0000) { + result += '\uFFFD'; + continue; + } + + if ( + // If the character is in the range [\1-\1F] (U+0001 to U+001F) or is + // U+007F, […] + (codeUnit >= 0x0001 && codeUnit <= 0x001F) || codeUnit == 0x007F || + // If the character is the first character and is in the range [0-9] + // (U+0030 to U+0039), […] + (index == 0 && codeUnit >= 0x0030 && codeUnit <= 0x0039) || + // If the character is the second character and is in the range [0-9] + // (U+0030 to U+0039) and the first character is a `-` (U+002D), […] + ( + index == 1 && + codeUnit >= 0x0030 && codeUnit <= 0x0039 && + firstCodeUnit == 0x002D + ) + ) { + // https://drafts.csswg.org/cssom/#escape-a-character-as-code-point + result += '\\' + codeUnit.toString(16) + ' '; + continue; + } + + // If the character is not handled by one of the above rules and is + // greater than or equal to U+0080, is `-` (U+002D) or `_` (U+005F), or + // is in one of the ranges [0-9] (U+0030 to U+0039), [A-Z] (U+0041 to + // U+005A), or [a-z] (U+0061 to U+007A), […] + if ( + codeUnit >= 0x0080 || + codeUnit == 0x002D || + codeUnit == 0x005F || + codeUnit >= 0x0030 && codeUnit <= 0x0039 || + codeUnit >= 0x0041 && codeUnit <= 0x005A || + codeUnit >= 0x0061 && codeUnit <= 0x007A + ) { + // the character itself + result += string.charAt(index); + continue; + } + + // Otherwise, the escaped character. + // https://drafts.csswg.org/cssom/#escape-a-character + result += '\\' + string.charAt(index); + } + return result; + }; + + if (!window.CSS) { + window.CSS = {}; + } + + window.CSS.escape = cssEscape; + } +} diff --git a/packages/test-utils/mocks.ts b/packages/test-utils/mocks.ts new file mode 100644 index 0000000000..e55cb286d3 --- /dev/null +++ b/packages/test-utils/mocks.ts @@ -0,0 +1,230 @@ +import { ScriptData, scriptDataHooks } from "@serenity-is/corelib"; + +let orgFetchScriptData: any; + +export function mockDynamicData() { + + if (orgFetchScriptData != void 0) + return; + + orgFetchScriptData = scriptDataHooks.fetchScriptData ?? null; + scriptDataHooks.fetchScriptData = (name: string) => { + try { + return jest.requireActual("./dynamic-data/" + name + ".json"); + } + catch (e) { + console.warn("Failed to load mock dynamic data for: " + name); + } + } +} + +export function unmockDynamicData() { + if (!orgFetchScriptData) + return; + + scriptDataHooks.fetchScriptData = orgFetchScriptData == null ? void 0 : orgFetchScriptData; +} + +import { resolveServiceUrl } from "@serenity-is/corelib"; + +export type MockFetchInfo = { + url: string; + init: RequestInit | BodyInit; + data: any; + status?: number; + statusText?: string; + aborted?: boolean; + responseHeaders: Record; +} + +var orgFetch: any; +var fetchSpy: jest.Mock, [url: string, init: RequestInit], any> & { requests: MockFetchInfo[] } +var fetchMap: Record any> = {}; + +export function mockFetch(map?: { [urlOrService: string]: ((info: MockFetchInfo) => any) }) { + if (!fetchSpy) { + orgFetch = (window as any).fetch; + fetchSpy = (window as any).fetch = jest.fn(async (url: string, init: RequestInit) => { + var callback = fetchMap[url] ?? fetchMap["*"]; + if (!callback) { + console.error(`Mock fetch is not configured for URL: (${url})!`); + throw `Mock fetch is not configured for URL: (${url})!`; + } + + var requestData = typeof init.body == "string" ? JSON.parse(init.body) : null; + + var info: MockFetchInfo = { + url: url, + init: init, + data: requestData, + status: 200, + aborted: false, + responseHeaders: { + "content-type": "application/json" + } + } + + if (init && init.signal) { + init.signal.addEventListener("abort", () => { info.aborted = true }); + } + + fetchSpy.requests.push(info); + + var responseData = callback(info); + return new JsonResponse(responseData, info); + }) as any; + fetchSpy.requests = []; + } + + if (map) { + for (var key of Object.keys(map)) { + var url = key == "*" ? "*" : resolveServiceUrl(key); + fetchMap[url] = map[key]; + } + } + + mockXHR(); + + return fetchSpy; +} + +export function unmockFetch() { + if (fetchSpy !== null && + (window as any).fetch === fetchSpy) { + fetchSpy = null; + (window as any).fetch = orgFetch; + } + unmockXHR(); +} + +class JsonResponse { + constructor(private response: any, private info: MockFetchInfo) { + } + + get ok() { return this.info.status >= 200 && this.info.status < 300 } + + get headers() { + return ({ + get: (x) => { + return this.info.responseHeaders[x?.toLowerCase()]; + } + }) + } + + get status() { + return this.info.status; + } + + get statusText() { + return this.info.statusText ?? ""; + } + + text() { + return typeof this.response === "string" ? this.response : JSON.stringify(this.response); + } + + json() { + return this.response; + } +} + +var xhrOriginal: any; + +class MockXHR { + declare public _info: MockFetchInfo; + declare public _responseData: any; + + get status() { return this._info.status ?? 200; } + get statusText() { return this._info.statusText; } + get responseText() { return JSON.stringify(this._responseData) } + + getResponseHeader(name: string): string { + return this._info?.responseHeaders[name]; + } + + open(_method: string, url: string, _async?: boolean): void { + this._info ??= {} as any; + this._info.url = url; + } + + abort() { + this._info ??= {} as any; + this._info.aborted = true; + } + + send(body?: Document | XMLHttpRequestBodyInit): void { + var url = this._info?.url; + var callback = fetchMap[url] ?? fetchMap["*"]; + if (!callback) { + console.error(`URL is not configured on the mock XHR implementation: (${url})!`); + throw `URL is not configured on the mock XHR implementation: (${url})!`; + } + + var requestData = typeof body == "string" ? JSON.parse(body) : null; + + this._info = { + url: url, + init: body instanceof Document ? null : body, + data: requestData, + status: 200, + aborted: false, + responseHeaders: { + "content-type": "application/json" + } + } + + fetchSpy.requests.push(this._info); + + this._responseData = callback(this._info); + } + + setRequestHeader(name: string, value: string): void { + } +} + +function mockXHR() { + xhrOriginal ??= window.XMLHttpRequest; + window.XMLHttpRequest = MockXHR as any; +} + +function unmockXHR() { + if (xhrOriginal) { + window["XMLHttpRequest"] = (xhrOriginal ?? window["XMLHttpRequest"]) as any; + xhrOriginal = null; + } +} + +export function mockAdmin() { + ScriptData.set("RemoteData.UserData", { Username: "admin", IsAdmin: true }); +} + +export function mockGridSize() { + if (document.getElementById('mockGridSize')) + return; + var style = document.createElement('style') + style.id = "mockGridSize"; + style.innerHTML = ` + .grid-container { min-height: 10000px !important; height: 10000px !important; min-width: 10000px !important; width: 10000px !important; } + .slick-header.ui-state-default, .slick-headerrow.ui-state-default, .slick-footerrow.ui-state-default, .slick-group-header.ui-state-default { width: 100%; } + .slick-header-columns, .slick-headerrow-columns, .slick-footerrow-columns, .slick-group-header-columns { position: relative; } + .slick-header-column.ui-state-default, .slick-group-header-column.ui-state-default { position: relative;display: inline-block; height: 16px; line-height: 16px; float: left; } + .slick-footerrow-column.ui-state-default { border-right: 1px solid silver; float: left; line-height: 20px; } + .slick-resizable-handle { position: absolute; font-size: 0.1px; display: block; width: 4px; right: 0px; top: 0; height: 100%; } + .grid-canvas { position: relative; } + .slick-row.ui-widget-content, .slick-row.ui-state-active { position: absolute; width: 100%; } + .slick-cell, .slick-headerrow-column, .slick-footerrow-column { position: absolute; } + .slick-group-toggle { display: inline-block; } + .slick-selection { z-index: 10; position: absolute; } + .slick-pane { position: absolute; outline: 0; width: 100%; } + .slick-pane-header { display: block; } + .slick-header { position: relative; } + .slick-headerrow { position: relative; } + .slick-top-panel-scroller { position: relative; } + .slick-top-panel { width: 10000px } + .slick-viewport { position: relative; outline: 0; width: 10000px !important; height: 10000px !important; } + .slick-row { height: 20px !important; width: 10000px !important; } + .slick-cell { width: 30px !important; height: 20px !important; } + `; + + document.head.appendChild(style); +} diff --git a/packages/test-utils/package.json b/packages/test-utils/package.json new file mode 100644 index 0000000000..19423b66f5 --- /dev/null +++ b/packages/test-utils/package.json @@ -0,0 +1,9 @@ +{ + "name": "test-utils", + "private": true, + "devDependencies": { + "@serenity-is/corelib": "workspace:*", + "@types/jest": "29.5.13" + }, + "type": "module" +} \ No newline at end of file diff --git a/packages/test-utils/test-utils.esproj b/packages/test-utils/test-utils.esproj new file mode 100644 index 0000000000..a52145484c --- /dev/null +++ b/packages/test-utils/test-utils.esproj @@ -0,0 +1,11 @@ + + + + src\ + Jest + + + $(MSBuildProjectDirectory)\dist + $(DefaultItemExcludes);out\** + + \ No newline at end of file diff --git a/packages/test-utils/tsconfig.json b/packages/test-utils/tsconfig.json new file mode 100644 index 0000000000..c710c43af7 --- /dev/null +++ b/packages/test-utils/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "noEmit": true, + "lib": [ + "es2015", + "dom" + ], + "outDir": "./out", + "types": [ + "jest" + ] + }, + "include": [ + "." + ] +} diff --git a/packages/test-utils/waitutils.ts b/packages/test-utils/waitutils.ts new file mode 100644 index 0000000000..0d20f50e89 --- /dev/null +++ b/packages/test-utils/waitutils.ts @@ -0,0 +1,22 @@ +import { getActiveRequests } from "@serenity-is/corelib"; + +export function waitForAjaxRequests(timeout: number = 10000): Promise { + return waitUntil(() => typeof globalThis.jQuery !== 'undefined' ? globalThis.jQuery.active == 0 : (getActiveRequests() <= 0), timeout); +} + +export function waitUntil(predicate: () => boolean, timeout: number = 10000, checkInterval: number = 10): Promise { + var start = Date.now(); + return new Promise((resolve, reject) => { + let interval = setInterval(() => { + if (!predicate()) { + if (Date.now() - start > timeout) { + clearInterval(interval); + reject("Timed out while waiting for condition to be true!"); + } + return; + } + clearInterval(interval); + resolve(void 0); + }, checkInterval) + }) +} \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c28ad46933..fb9881bd5f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -55,6 +55,15 @@ importers: packages/sleekgrid: {} + packages/test-utils: + devDependencies: + '@serenity-is/corelib': + specifier: workspace:* + version: link:../corelib + '@types/jest': + specifier: 29.5.13 + version: 29.5.13 + packages/tsbuild: dependencies: esbuild: