diff --git a/package-lock.json b/package-lock.json index f3812774..92f35f6e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "0.20.14", "license": "MIT", "dependencies": { - "@playwright/test": "1.55.0", + "@playwright/test": "1.56.0", "create-locator": "0.0.27", "get-modules-graph": "0.0.11", "sort-json-keys": "1.0.3" @@ -20,8 +20,8 @@ "e2ed-install-browsers": "bin/installBrowsers.js" }, "devDependencies": { - "@playwright/browser-chromium": "1.55.0", - "@types/node": "24.3.0", + "@playwright/browser-chromium": "1.56.0", + "@types/node": "24.7.0", "@typescript-eslint/eslint-plugin": "7.18.0", "@typescript-eslint/parser": "7.18.0", "assert-modules-support-case-insensitive-fs": "1.0.1", @@ -34,7 +34,7 @@ "eslint-plugin-typescript-sort-keys": "3.3.0", "husky": "9.1.7", "prettier": "3.6.2", - "typescript": "5.9.2" + "typescript": "5.9.3" }, "engines": { "node": ">=22.14.0" @@ -183,26 +183,26 @@ } }, "node_modules/@playwright/browser-chromium": { - "version": "1.55.0", - "resolved": "https://registry.npmjs.org/@playwright/browser-chromium/-/browser-chromium-1.55.0.tgz", - "integrity": "sha512-HxG0+6v8NGLFLYMxrGb4T4DAmKwwx6C0V3uIn/i91tOVqcNnaBBllhpxLEqXCnxjprL3HDDMXsVPjk1/vsCVAw==", + "version": "1.56.0", + "resolved": "https://registry.npmjs.org/@playwright/browser-chromium/-/browser-chromium-1.56.0.tgz", + "integrity": "sha512-+OABx0PwbzoWXO5qOmonvQlIZq0u89XpDkRYf+ZTOs+wsI3r/NV90rzGr8nsJZTj7o10tdPMmuGmZ3OKP9ag4Q==", "dev": true, "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.55.0" + "playwright-core": "1.56.0" }, "engines": { "node": ">=18" } }, "node_modules/@playwright/test": { - "version": "1.55.0", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.55.0.tgz", - "integrity": "sha512-04IXzPwHrW69XusN/SIdDdKZBzMfOT9UNT/YiJit/xpy2VuAoB8NHc8Aplb96zsWDddLnbkPL3TsmrS04ZU2xQ==", + "version": "1.56.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.56.0.tgz", + "integrity": "sha512-Tzh95Twig7hUwwNe381/K3PggZBZblKUe2wv25oIpzWLr6Z0m4KgV1ZVIjnR6GM9ANEqjZD7XsZEa6JL/7YEgg==", "license": "Apache-2.0", "dependencies": { - "playwright": "1.55.0" + "playwright": "1.56.0" }, "bin": { "playwright": "cli.js" @@ -231,13 +231,13 @@ "dev": true }, "node_modules/@types/node": { - "version": "24.3.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.3.0.tgz", - "integrity": "sha512-aPTXCrfwnDLj4VvXrm+UUCQjNEvJgNA8s5F1cvwQU+3KNltTOkBm1j30uNLyqqPNe7gE3KFzImYoZEfLhp4Yow==", + "version": "24.7.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.7.0.tgz", + "integrity": "sha512-IbKooQVqUBrlzWTi79E8Fw78l8k1RNtlDDNWsFZs7XonuQSJ8oNYfEeclhprUldXISRMLzBpILuKgPlIxm+/Yw==", "dev": true, "license": "MIT", "dependencies": { - "undici-types": "~7.10.0" + "undici-types": "~7.14.0" } }, "node_modules/@types/semver": { @@ -871,9 +871,9 @@ } }, "node_modules/debug": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", - "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "dev": true, "license": "MIT", "dependencies": { @@ -3035,12 +3035,12 @@ } }, "node_modules/playwright": { - "version": "1.55.0", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.55.0.tgz", - "integrity": "sha512-sdCWStblvV1YU909Xqx0DhOjPZE4/5lJsIS84IfN9dAZfcl/CIZ5O8l3o0j7hPMjDvqoTF8ZUcc+i/GL5erstA==", + "version": "1.56.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.56.0.tgz", + "integrity": "sha512-X5Q1b8lOdWIE4KAoHpW3SE8HvUB+ZZsUoN64ZhjnN8dOb1UpujxBtENGiZFE+9F/yhzJwYa+ca3u43FeLbboHA==", "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.55.0" + "playwright-core": "1.56.0" }, "bin": { "playwright": "cli.js" @@ -3053,9 +3053,9 @@ } }, "node_modules/playwright-core": { - "version": "1.55.0", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.55.0.tgz", - "integrity": "sha512-GvZs4vU3U5ro2nZpeiwyb0zuFaqb9sUiAJuyrWpcGouD8y9/HLgGbNRjIph7zU9D3hnPaisMl9zG9CgFi/biIg==", + "version": "1.56.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.56.0.tgz", + "integrity": "sha512-1SXl7pMfemAMSDn5rkPeZljxOCYAmQnYLBTExuh6E8USHXGSX3dx6lYZN/xPpTz1vimXmPA9CDnILvmJaB8aSQ==", "license": "Apache-2.0", "bin": { "playwright-core": "cli.js" @@ -3287,9 +3287,9 @@ } }, "node_modules/semver": { - "version": "7.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", - "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", "dev": true, "license": "ISC", "bin": { @@ -3762,9 +3762,9 @@ } }, "node_modules/typescript": { - "version": "5.9.2", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.2.tgz", - "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", "bin": { @@ -3795,9 +3795,9 @@ } }, "node_modules/undici-types": { - "version": "7.10.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.10.0.tgz", - "integrity": "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag==", + "version": "7.14.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.14.0.tgz", + "integrity": "sha512-QQiYxHuyZ9gQUIrmPo3IA+hUl4KYk8uSA7cHrcKd/l3p1OTpZcM0Tbp9x7FAtXdAYhlasd60ncPpgu6ihG6TOA==", "dev": true, "license": "MIT" }, diff --git a/package.json b/package.json index d82eecea..536eb966 100644 --- a/package.json +++ b/package.json @@ -25,14 +25,14 @@ "url": "git+https://github.com/joomcode/e2ed.git" }, "dependencies": { - "@playwright/test": "1.55.0", + "@playwright/test": "1.56.0", "create-locator": "0.0.27", "get-modules-graph": "0.0.11", "sort-json-keys": "1.0.3" }, "devDependencies": { - "@playwright/browser-chromium": "1.55.0", - "@types/node": "24.3.0", + "@playwright/browser-chromium": "1.56.0", + "@types/node": "24.7.0", "@typescript-eslint/eslint-plugin": "7.18.0", "@typescript-eslint/parser": "7.18.0", "assert-modules-support-case-insensitive-fs": "1.0.1", @@ -45,7 +45,7 @@ "eslint-plugin-typescript-sort-keys": "3.3.0", "husky": "9.1.7", "prettier": "3.6.2", - "typescript": "5.9.2" + "typescript": "5.9.3" }, "peerDependencies": { "@types/node": ">=20", diff --git a/src/actions/asserts/assertUrlMatchRoute.ts b/src/actions/asserts/assertUrlMatchRoute.ts index fb51c630..b7065f18 100644 --- a/src/actions/asserts/assertUrlMatchRoute.ts +++ b/src/actions/asserts/assertUrlMatchRoute.ts @@ -4,7 +4,7 @@ import {assertValueIsDefined, assertValueIsNotNull} from '../../utils/asserts'; import {log} from '../../utils/log'; import type {Route} from '../../Route'; -import type {Url} from '../../types/internal'; +import type {MaybePromise, Url} from '../../types/internal'; type MaybeUrlOrPath = Url | string | null | undefined; @@ -12,7 +12,7 @@ type MaybeUrlOrPath = Url | string | null | undefined; * Asserts that url or url path (which can be wrapped in a promise) match route. */ export const assertUrlMatchRoute = async ( - maybeUrlOrPath: MaybeUrlOrPath | Promise, + maybeUrlOrPath: MaybePromise, route: Route, ): Promise => { const {routeParams} = route; diff --git a/src/types/events.ts b/src/types/events.ts index b4b88150..5601dfaa 100644 --- a/src/types/events.ts +++ b/src/types/events.ts @@ -19,6 +19,11 @@ export type LogEvent = Readonly<{ type: LogEventType; }>; +/** + * Log event with children (for groupping of `TestRun` steps). + */ +export type LogEventWithChildren = LogEvent & Readonly<{children: readonly LogEventWithChildren[]}>; + /** * EndTestRun event (on closing test). * @internal diff --git a/src/types/global.d.ts b/src/types/global.d.ts index a0aec00e..0ce7a781 100644 --- a/src/types/global.d.ts +++ b/src/types/global.d.ts @@ -58,6 +58,10 @@ declare global { class?: string; } >; + } & { + button: {popovertarget?: string}; + input: {popovertarget?: string}; + meta: {charset?: string}; }; /** diff --git a/src/types/index.ts b/src/types/index.ts index d9cdaf3b..0965e5ef 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -16,7 +16,7 @@ export type {ConsoleMessage, ConsoleMessageType} from './console'; export type {UtcTimeInMs} from './date'; export type {DeepMutable, DeepPartial, DeepReadonly, DeepRequired} from './deep'; export type {E2edPrintedFields, JsError} from './errors'; -export type {LogEvent, Onlog, TestRunEvent} from './events'; +export type {LogEvent, LogEventWithChildren, Onlog, TestRunEvent} from './events'; export type {Fn, MergeFunctions} from './fn'; export type { FullMocksConfig, diff --git a/src/types/internal.ts b/src/types/internal.ts index a896da50..6f3eba03 100644 --- a/src/types/internal.ts +++ b/src/types/internal.ts @@ -31,7 +31,7 @@ export type {E2edEnvironment} from './environment'; export type {E2edPrintedFields, JsError} from './errors'; /** @internal */ export type {GlobalErrorType, MaybeWithIsTestRunBroken} from './errors'; -export type {LogEvent, Onlog, TestRunEvent} from './events'; +export type {LogEvent, LogEventWithChildren, Onlog, TestRunEvent} from './events'; /** @internal */ export type {EndTestRunEvent, FullEventsData} from './events'; export type {Fn, MergeFunctions} from './fn'; diff --git a/src/types/report.ts b/src/types/report.ts index 40426edd..a87d4ab4 100644 --- a/src/types/report.ts +++ b/src/types/report.ts @@ -5,7 +5,6 @@ import type {EndE2edReason, ExitCode, TestRunStatus} from '../constants/internal import type {ApiStatistics} from './apiStatistics'; import type {FullPackConfig} from './config'; import type {UtcTimeInMs} from './date'; -import type {SafeHtml} from './html'; import type {TestFilePath} from './paths'; import type {StartInfo} from './startInfo'; import type {FullTestRun, LiteTestRun, RunHash, RunId} from './testRun'; @@ -99,7 +98,7 @@ export type ReportClientState = { readonly fullTestRuns: readonly FullTestRun[]; readonly internalDirectoryName: string; lengthOfReadedJsonReportDataParts: number; - readonly locator: LocatorFunction; + readonly locator: LocatorFunction; readonly pathToScreenshotsDirectoryForReport: string | null; readonly readJsonReportDataObservers: MutationObserver[]; reportClientData?: ReportClientData; diff --git a/src/utils/getDurationWithUnits.ts b/src/utils/getDurationWithUnits.ts index 5ac010fb..4ced581e 100644 --- a/src/utils/getDurationWithUnits.ts +++ b/src/utils/getDurationWithUnits.ts @@ -2,9 +2,9 @@ * Get the duration of time interval in hours, minutes, seconds and milliseconds. * `getDurationWithUnits(1213)` = `'1s 213ms'`. * Should be a pure function without dependencies in the form of a function declaration, - * because it is used in the JS code of HTML report. + * because it is used in the JS client code of HTML report. */ -export function getDurationWithUnits(durationInMs: number): string { +export const getDurationWithUnits = (durationInMs: number): string => { const msInSecond = 1_000; const timeMultiplicator = 60; @@ -37,4 +37,4 @@ export function getDurationWithUnits(durationInMs: number): string { } return parts.slice(0, 2).join(' ') || '0ms'; -} +}; diff --git a/src/utils/parse/parseValueAsJsonIfNeeded.ts b/src/utils/parse/parseValueAsJsonIfNeeded.ts index 2979f0b9..54cb8b75 100644 --- a/src/utils/parse/parseValueAsJsonIfNeeded.ts +++ b/src/utils/parse/parseValueAsJsonIfNeeded.ts @@ -4,24 +4,24 @@ type Return = Readonly<{hasParseError: boolean; value: unknown}>; /** * Parses `unknown` value as JSON, if needed. - * If `isoValueInJsonFormat` is `true`, then parses value as JSON and saves parse error. - * If `isoValueInJsonFormat` is `false`, then returns value as is. - * If `isoValueInJsonFormat` is `undefined`, then safely tries to parse value as JSON. + * If `isValueInJsonFormat` is `true`, then parses value as JSON and saves parse error. + * If `isValueInJsonFormat` is `false`, then returns value as is. + * If `isValueInJsonFormat` is `undefined`, then safely tries to parse value as JSON. */ export const parseValueAsJsonIfNeeded = ( originalValue: unknown, - isoValueInJsonFormat?: boolean, + isValueInJsonFormat?: boolean, ): Return => { let hasParseError = false; let value = originalValue; - if (isoValueInJsonFormat === true) { + if (isValueInJsonFormat === true) { try { value = parseMaybeEmptyValueAsJson(originalValue); } catch { hasParseError = true; } - } else if (isoValueInJsonFormat !== false) { + } else if (isValueInJsonFormat !== false) { try { value = parseMaybeEmptyValueAsJson(originalValue); } catch {} diff --git a/src/utils/report/client/addOnClickOnClass.ts b/src/utils/report/client/addOnClickOnClass.ts index c990ef41..4ebfefaf 100644 --- a/src/utils/report/client/addOnClickOnClass.ts +++ b/src/utils/report/client/addOnClickOnClass.ts @@ -7,7 +7,10 @@ declare const reportClientState: ReportClientState; * This base client function should not use scope variables (except other base functions). * @internal */ -export function addOnClickOnClass(className: string, onclick: (event: HTMLElement) => void): void { +export const addOnClickOnClass = ( + className: string, + onclick: (element: HTMLElement) => void, +): void => { let {clickListeners} = reportClientState; if (!clickListeners) { @@ -34,4 +37,4 @@ export function addOnClickOnClass(className: string, onclick: (event: HTMLElemen } clickListeners[className] = onclick; -} +}; diff --git a/src/utils/report/client/chooseTestRun.ts b/src/utils/report/client/chooseTestRun.tsx similarity index 79% rename from src/utils/report/client/chooseTestRun.ts rename to src/utils/report/client/chooseTestRun.tsx index c1af5cfc..09b20317 100644 --- a/src/utils/report/client/chooseTestRun.ts +++ b/src/utils/report/client/chooseTestRun.tsx @@ -1,13 +1,16 @@ import {assertValueIsDefined as clientAssertValueIsDefined} from './assertValueIsDefined'; -import {maybeRenderApiStatistics as clientMaybeRenderApiStatistics} from './maybeRenderApiStatistics'; -import {renderTestRunDetails as clientRenderTestRunDetails} from './render'; +import { + MaybeApiStatistics as clientMaybeApiStatistics, + TestRunDetails as clientTestRunDetails, +} from './render'; -import type {ReportClientState, RunHash, SafeHtml} from '../../../types/internal'; +import type {ReportClientState, RunHash} from '../../../types/internal'; const assertValueIsDefined: typeof clientAssertValueIsDefined = clientAssertValueIsDefined; -const maybeRenderApiStatistics = clientMaybeRenderApiStatistics; -const renderTestRunDetails = clientRenderTestRunDetails; +const MaybeApiStatistics = clientMaybeApiStatistics; +const TestRunDetails = clientTestRunDetails; +declare const jsx: JSX.Runtime; declare const reportClientState: ReportClientState; /** @@ -47,7 +50,7 @@ export function chooseTestRun(runHash: RunHash): void { if ( !(previousHash in testRunDetailsElementsByHash) && - !previousTestRunDetailsElement.classList.contains('test-details-empty') + !previousTestRunDetailsElement.classList.contains('empty-state') ) { testRunDetailsElementsByHash[previousHash] = previousTestRunDetailsElement; } @@ -62,9 +65,9 @@ export function chooseTestRun(runHash: RunHash): void { return; } - let rightColumnHtml: SafeHtml | undefined = maybeRenderApiStatistics(runHash); + let rightColumnHtml = ; - if (rightColumnHtml === undefined) { + if (rightColumnHtml.length === 0) { const {fullTestRuns} = reportClientState; const fullTestRun = fullTestRuns.find((testRun) => testRun.runHash === runHash); @@ -77,7 +80,7 @@ export function chooseTestRun(runHash: RunHash): void { return; } - rightColumnHtml = renderTestRunDetails(fullTestRun); + rightColumnHtml = ; } e2edRightColumnContainer.innerHTML = String(rightColumnHtml); diff --git a/src/utils/report/client/clickOnRetry.ts b/src/utils/report/client/clickOnRetry.ts index db67b0e1..2cb02e8d 100644 --- a/src/utils/report/client/clickOnRetry.ts +++ b/src/utils/report/client/clickOnRetry.ts @@ -3,8 +3,8 @@ * This base client function should not use scope variables (except other base functions). * @internal */ -export function clickOnRetry(element: HTMLElement): void { - const chosenRetryId = element.getAttribute('aria-controls'); +export const clickOnRetry = (element: HTMLElement): void => { + const chosenRetryId = element.dataset['retry']; const retry = Number(chosenRetryId?.match(/\d+/)?.[0]); const allRetryElements: NodeListOf = document.querySelectorAll('.retry'); @@ -12,20 +12,18 @@ export function clickOnRetry(element: HTMLElement): void { retryElement.hidden = retryElement.id !== chosenRetryId; } - const previousChosenRetryButton = document.querySelector( - '.nav-tabs__button[aria-selected="true"]', - ); + const previousChosenRetryButton = document.querySelector('.retry-link[aria-current="true"]'); if (previousChosenRetryButton) { - previousChosenRetryButton.ariaSelected = 'false'; + previousChosenRetryButton.ariaCurrent = null; } // eslint-disable-next-line no-param-reassign - element.ariaSelected = 'true'; + element.ariaCurrent = 'true'; - const leftSection = document.querySelector('.main__section._position_left'); + const leftSection = document.querySelector('.column-2'); if (leftSection) { leftSection.ariaLabel = `Retry ${retry}`; } -} +}; diff --git a/src/utils/report/client/clickOnScreenshot.ts b/src/utils/report/client/clickOnScreenshot.ts new file mode 100644 index 00000000..d61c5c3a --- /dev/null +++ b/src/utils/report/client/clickOnScreenshot.ts @@ -0,0 +1,32 @@ +/** + * Handler for click on screenshot in step details. + * This base client function should not use scope variables (except other base functions). + * @internal + */ +export const clickOnScreenshot = (element: HTMLElement): void => { + const image = element.firstElementChild as HTMLImageElement | null; + + if (image === null || image.tagName !== 'IMG') { + // eslint-disable-next-line no-console + console.error('Cannot find element in clicked button', element); + + return; + } + + const screenshotDialogImage = document.getElementById( + 'screenshotDialogImage', + ) as HTMLImageElement | null; + const screenshotDialogTitle = document.getElementById('screenshotDialogTitle'); + + if (screenshotDialogImage) { + screenshotDialogImage.alt = image.title; + screenshotDialogImage.src = image.src; + screenshotDialogImage.title = image.title; + } + + if (screenshotDialogTitle) { + screenshotDialogTitle.textContent = image.title; + } + + (document.getElementById('screenshotDialog') as HTMLDialogElement | null)?.showModal(); +}; diff --git a/src/utils/report/client/clickOnStep.ts b/src/utils/report/client/clickOnStep.ts index 34b0f25b..abacc763 100644 --- a/src/utils/report/client/clickOnStep.ts +++ b/src/utils/report/client/clickOnStep.ts @@ -3,9 +3,9 @@ * This base client function should not use scope variables (except other base functions). * @internal */ -export function clickOnStep(element: HTMLElement): void { +export const clickOnStep = (element: HTMLElement): void => { const expanded = element.ariaExpanded === 'true'; // eslint-disable-next-line no-param-reassign element.ariaExpanded = String(!expanded); -} +}; diff --git a/src/utils/report/client/clickOnTestRun.ts b/src/utils/report/client/clickOnTestRun.ts index 2c1aaa30..0a4eef4a 100644 --- a/src/utils/report/client/clickOnTestRun.ts +++ b/src/utils/report/client/clickOnTestRun.ts @@ -9,17 +9,17 @@ const chooseTestRun = clientChooseTestRun; * This base client function should not use scope variables (except other base functions). * @internal */ -export function clickOnTestRun(element: HTMLElement): void { +export const clickOnTestRun = (element: HTMLElement): void => { const runHash = (element.dataset as {runhash: RunHash}).runhash; - const previousChosenTestRunButton = document.querySelector('.test-button[aria-selected="true"]'); + const previousChosenTestRunButton = document.querySelector('.test-link[aria-current="true"]'); if (previousChosenTestRunButton) { - previousChosenTestRunButton.ariaSelected = 'false'; + previousChosenTestRunButton.ariaCurrent = null; } // eslint-disable-next-line no-param-reassign - element.ariaSelected = 'true'; + element.ariaCurrent = 'true'; chooseTestRun(runHash); -} +}; diff --git a/src/utils/report/client/createJsxRuntime.ts b/src/utils/report/client/createJsxRuntime.ts index a6fc045b..1fa0e241 100644 --- a/src/utils/report/client/createJsxRuntime.ts +++ b/src/utils/report/client/createJsxRuntime.ts @@ -33,16 +33,57 @@ export function createJsxRuntime(): JSX.Runtime { ); const childrenHtml = createSafeHtmlWithoutSanitize`${childrenParts.join('')}`; + const isVoidElement = [ + 'area', + 'base', + 'br', + 'col', + 'embed', + 'hr', + 'img', + 'input', + 'link', + 'meta', + 'source', + 'track', + 'wbr', + ].includes(type); + + let closePart = createSafeHtmlWithoutSanitize``; + + if (isVoidElement) { + if (childrenHtml.length > 0) { + // eslint-disable-next-line no-console + console.error(`Element <${type}> is void element, but has children`, childrenHtml); + + closePart = childrenHtml; + } + } else { + closePart = sanitizeHtml`${childrenHtml}`; + } + if (properties == null) { - return sanitizeHtml`<${type}>${childrenHtml}`; + return sanitizeHtml`<${type}>${closePart}`; } - const attributesParts: readonly SafeHtml[] = Object.entries(properties).map( - ([key, value]) => sanitizeHtml`${key}="${value}"`, - ); - const attributesHtml = createSafeHtmlWithoutSanitize`${attributesParts.join('')}`; + const attributesParts: readonly SafeHtml[] = Object.entries(properties) + .filter(([key, value]) => { + if (value == null) { + return false; + } + + if (value !== false) { + return true; + } + + const lowerCaseKey = key.toLocaleLowerCase(); + + return lowerCaseKey.startsWith('aria-') || lowerCaseKey.startsWith('data-'); + }) + .map(([key, value]) => sanitizeHtml`${key.toLowerCase()}="${value}"`); + const attributesHtml = createSafeHtmlWithoutSanitize`${attributesParts.join(' ')}`; - return sanitizeHtml`<${type} ${attributesHtml}>${childrenHtml}`; + return sanitizeHtml`<${type} ${attributesHtml}>${closePart}`; }; const Fragment: JSX.Fragment = (properties) => { diff --git a/src/utils/report/client/groupLogEvents.ts b/src/utils/report/client/groupLogEvents.ts new file mode 100644 index 00000000..c4e3b760 --- /dev/null +++ b/src/utils/report/client/groupLogEvents.ts @@ -0,0 +1,49 @@ +import {LogEventType} from '../../../constants/internal'; + +import type {LogEvent, LogEventWithChildren} from '../../../types/internal'; + +/** + * Group log events to log events with children (for groupping of `TestRun` steps). + * This base client function should not use scope variables (except other base functions). + * @internal + */ +export const groupLogEvents = (logEvents: readonly LogEvent[]): readonly LogEventWithChildren[] => { + const topLevelTypes: readonly LogEventType[] = [ + LogEventType.Action, + LogEventType.Assert, + LogEventType.Entity, + LogEventType.InternalAction, + LogEventType.InternalAssert, + ]; + + const result: LogEventWithChildren[] = []; + + for (const logEvent of logEvents) { + const last = result.at(-1); + const newEvent: LogEventWithChildren = {children: [], ...logEvent}; + + if (topLevelTypes.includes(logEvent.type)) { + if (last && !topLevelTypes.includes(last.type)) { + const firstTopLevel: LogEventWithChildren = { + children: [...result], + message: 'Initialization', + payload: undefined, + time: last.time, + type: LogEventType.InternalCore, + }; + + result.length = 0; + + result.push(firstTopLevel); + } + + result.push(newEvent); + } else if (last && topLevelTypes.includes(last.type)) { + (last.children as LogEventWithChildren[]).push(newEvent); + } else { + result.push(newEvent); + } + } + + return result; +}; diff --git a/src/utils/report/client/index.ts b/src/utils/report/client/index.ts index 1d5fdaf0..7a66f02e 100644 --- a/src/utils/report/client/index.ts +++ b/src/utils/report/client/index.ts @@ -9,15 +9,17 @@ export {chooseTestRun} from './chooseTestRun'; /** @internal */ export {clickOnRetry} from './clickOnRetry'; /** @internal */ +export {clickOnScreenshot} from './clickOnScreenshot'; +/** @internal */ export {clickOnStep} from './clickOnStep'; /** @internal */ export {clickOnTestRun} from './clickOnTestRun'; /** @internal */ export {createJsxRuntime} from './createJsxRuntime'; /** @internal */ -export {initialScript} from './initialScript'; +export {groupLogEvents} from './groupLogEvents'; /** @internal */ -export {maybeRenderApiStatistics} from './maybeRenderApiStatistics'; +export {initialScript} from './initialScript'; /** @internal */ export {onDomContentLoad} from './onDomContentLoad'; /** @internal */ @@ -30,17 +32,20 @@ export {readJsonReportData} from './readJsonReportData'; export {readPartOfJsonReportData} from './readPartOfJsonReportData'; /** @internal */ export { + ApiStatistics, ApiStatisticsItem, - renderApiStatistics, - renderAttributes, - renderDatesInterval, - renderDuration, - renderStep, - renderStepContent, - renderSteps, - renderTestRunDescription, - renderTestRunDetails, - renderTestRunError, + DatesInterval, + Duration, + List, + MaybeApiStatistics, + SafeHtml, + Screenshot, + Step, + StepContent, + Steps, + TestRunDescription, + TestRunDetails, + TestRunError, } from './render'; /** @internal */ export { diff --git a/src/utils/report/client/initialScript.ts b/src/utils/report/client/initialScript.ts index 63a47cab..9a31e3bb 100644 --- a/src/utils/report/client/initialScript.ts +++ b/src/utils/report/client/initialScript.ts @@ -1,19 +1,16 @@ -import { - createSimpleLocator as clientCreateSimpleLocator, - type LocatorFunction, -} from 'create-locator'; +import {createSimpleLocator as clientCreateSimpleLocator} from 'create-locator'; import {addDomContentLoadedHandler as clientAddDomContentLoadedHandler} from './addDomContentLoadedHandler'; import {addOnClickOnClass as clientAddOnClickOnClass} from './addOnClickOnClass'; import {clickOnRetry as clientClickOnRetry} from './clickOnRetry'; +import {clickOnScreenshot as clientClickOnScreenshot} from './clickOnScreenshot'; import {clickOnStep as clientClickOnStep} from './clickOnStep'; import {clickOnTestRun as clientClickOnTestRun} from './clickOnTestRun'; import {createJsxRuntime as clientCreateJsxRuntime} from './createJsxRuntime'; import {onDomContentLoad as clientOnDomContentLoad} from './onDomContentLoad'; -import {renderAttributes as clientRenderAttributes} from './render'; import {setReadJsonReportDataObservers as clientSetReadJsonReportDataObservers} from './setReadJsonReportDataObservers'; -import type {ReportClientState, SafeHtml} from '../../../types/internal'; +import type {ReportClientState} from '../../../types/internal'; // eslint-disable-next-line @typescript-eslint/no-unused-vars declare let jsx: JSX.Runtime; @@ -22,12 +19,12 @@ declare const reportClientState: ReportClientState; const addDomContentLoadedHandler = clientAddDomContentLoadedHandler; const addOnClickOnClass = clientAddOnClickOnClass; const clickOnRetry = clientClickOnRetry; +const clickOnScreenshot = clientClickOnScreenshot; const clickOnStep = clientClickOnStep; const clickOnTestRun = clientClickOnTestRun; const createJsxRuntime = clientCreateJsxRuntime; const createSimpleLocator = clientCreateSimpleLocator; const onDomContentLoad = clientOnDomContentLoad; -const renderAttributes = clientRenderAttributes; const setReadJsonReportDataObservers = clientSetReadJsonReportDataObservers; /** @@ -35,20 +32,19 @@ const setReadJsonReportDataObservers = clientSetReadJsonReportDataObservers; * This client function should not use scope variables (except global functions). * @internal */ -export function initialScript(): void { +export const initialScript = (): void => { jsx = createJsxRuntime(); - const {locator: locatorAttributes} = createSimpleLocator(reportClientState.createLocatorOptions); - const locator: LocatorFunction = (...args) => - renderAttributes(locatorAttributes(...(args as [string]))); + const {locator} = createSimpleLocator(reportClientState.createLocatorOptions); Object.assign>(reportClientState, {locator}); - addOnClickOnClass('nav-tabs__button', clickOnRetry); + addOnClickOnClass('retry-link', clickOnRetry); addOnClickOnClass('step-expanded', clickOnStep); - addOnClickOnClass('test-button', clickOnTestRun); + addOnClickOnClass('step__screenshot-button', clickOnScreenshot); + addOnClickOnClass('test-link', clickOnTestRun); setReadJsonReportDataObservers(); addDomContentLoadedHandler(onDomContentLoad); -} +}; diff --git a/src/utils/report/client/onFirstJsonReportDataLoad.ts b/src/utils/report/client/onFirstJsonReportDataLoad.ts index 9a11790b..74bdcee4 100644 --- a/src/utils/report/client/onFirstJsonReportDataLoad.ts +++ b/src/utils/report/client/onFirstJsonReportDataLoad.ts @@ -11,13 +11,13 @@ declare const reportClientState: ReportClientState; * This client function should not use scope variables (except global functions). * @internal */ -export function onFirstJsonReportDataLoad(): void { +export const onFirstJsonReportDataLoad = (): void => { if (window.location.hash !== '') { return; } const buttonForFailedTestRun = document.querySelector( - '.retry:not([hidden]) .test-button_status_failed', + '.retry:not([hidden]) .test-link[data-status="failed"]', ); if (!buttonForFailedTestRun) { @@ -38,4 +38,4 @@ export function onFirstJsonReportDataLoad(): void { } }, scrollDelayInMs); } -} +}; diff --git a/src/utils/report/client/render/renderApiStatistics.ts b/src/utils/report/client/render/ApiStatistics.tsx similarity index 60% rename from src/utils/report/client/render/renderApiStatistics.ts rename to src/utils/report/client/render/ApiStatistics.tsx index d354f81f..af51cede 100644 --- a/src/utils/report/client/render/renderApiStatistics.ts +++ b/src/utils/report/client/render/ApiStatistics.tsx @@ -1,19 +1,20 @@ -import {createSafeHtmlWithoutSanitize as clientCreateSafeHtmlWithoutSanitize} from '../sanitizeHtml'; - import {ApiStatisticsItem as clientApiStatisticsItem} from './ApiStatisticsItem'; +import {List as clientList} from './List'; import type { - ApiStatistics, + ApiStatistics as ApiStatisticsType, ApiStatisticsReportHash, ObjectEntries, SafeHtml, } from '../../../../types/internal'; -const createSafeHtmlWithoutSanitize = clientCreateSafeHtmlWithoutSanitize; const ApiStatisticsItem = clientApiStatisticsItem; +const List = clientList; + +declare const jsx: JSX.Runtime; -type Options = Readonly<{ - apiStatistics: ApiStatistics; +type Props = Readonly<{ + apiStatistics: ApiStatisticsType; hash: ApiStatisticsReportHash; }>; @@ -22,7 +23,7 @@ type Options = Readonly<{ * This base client function should not use scope variables (except other base functions). * @internal */ -export function renderApiStatistics({apiStatistics, hash}: Options): SafeHtml { +export const ApiStatistics: JSX.Component = ({apiStatistics, hash}) => { let header: string | undefined; const items: SafeHtml[] = []; @@ -38,11 +39,13 @@ export function renderApiStatistics({apiStatistics, hash}: Options): SafeHtml { pageCount += count; pageDuration += duration; - pageItems.push(ApiStatisticsItem({count, duration, name: url, url})); + pageItems.push( + , + ); } items.push( - ApiStatisticsItem({count: pageCount, duration: pageDuration, isHeader: true, name}), + , ); items.push(...pageItems); } @@ -54,12 +57,12 @@ export function renderApiStatistics({apiStatistics, hash}: Options): SafeHtml { // eslint-disable-next-line max-depth for (const [statusCode, {count, duration, size}] of Object.entries(byStatusCode)) { items.push( - ApiStatisticsItem({ - count, - duration, - name: `${method} ${url} ${statusCode}`, - size, - }), + , ); } } @@ -72,23 +75,25 @@ export function renderApiStatistics({apiStatistics, hash}: Options): SafeHtml { >) { for (const [statusCode, {count, duration, size}] of Object.entries(byStatusCode)) { items.push( - ApiStatisticsItem({ - count, - duration, - name: `${url} ${statusCode}`, - size, - url, - }), + , ); } } } - return createSafeHtmlWithoutSanitize`
-

-

${header}

-
-
${items.join('')}
-
-
`; -} + return ( +
+

+

{header}

+
    + +
+
+ ); +}; diff --git a/src/utils/report/client/render/ApiStatisticsItem.tsx b/src/utils/report/client/render/ApiStatisticsItem.tsx index f451a93b..2d139a83 100644 --- a/src/utils/report/client/render/ApiStatisticsItem.tsx +++ b/src/utils/report/client/render/ApiStatisticsItem.tsx @@ -1,8 +1,8 @@ -import {renderDuration as clientRenderDuration} from './renderDuration'; +import {Duration as clientDuration} from './Duration'; import type {SafeHtml, Url} from '../../../../types/internal'; -const renderDuration = clientRenderDuration; +const Duration = clientDuration; declare const jsx: JSX.Runtime; @@ -29,7 +29,6 @@ export const ApiStatisticsItem: JSX.Component = ({ url, }) => { const bytesInKiB = 1_024; - const durationHtml = renderDuration(duration / count); const countHtml = `${count}x`; const sizeHtml = size === undefined ? '' : `${(size / count / bytesInKiB).toFixed(2)} KiB / `; @@ -48,12 +47,15 @@ export const ApiStatisticsItem: JSX.Component = ({ } return ( - - {nameHtml} - - {countHtml} / {sizeHtml} - {durationHtml} - - +
  • + +
    + {nameHtml} + + {countHtml} / {sizeHtml} + + +
    +
  • ); }; diff --git a/src/utils/report/client/render/renderDatesInterval.ts b/src/utils/report/client/render/DatesInterval.tsx similarity index 57% rename from src/utils/report/client/render/renderDatesInterval.ts rename to src/utils/report/client/render/DatesInterval.tsx index 24cf22c3..b4a47950 100644 --- a/src/utils/report/client/render/renderDatesInterval.ts +++ b/src/utils/report/client/render/DatesInterval.tsx @@ -1,22 +1,20 @@ /* eslint-disable @typescript-eslint/no-magic-numbers */ -import {sanitizeHtml as clientSanitizeHtml} from '../sanitizeHtml'; +import type {UtcTimeInMs} from '../../../../types/internal'; -import type {SafeHtml, UtcTimeInMs} from '../../../../types/internal'; - -type Options = Readonly<{ +type Props = Readonly<{ endTimeInMs: UtcTimeInMs; startTimeInMs: UtcTimeInMs; }>; -const sanitizeHtml = clientSanitizeHtml; +declare const jsx: JSX.Runtime; /** * Renders the interval between two dates. * This base client function should not use scope variables (except other base functions). * @internal */ -export function renderDatesInterval({endTimeInMs, startTimeInMs}: Options): SafeHtml { +export const DatesInterval: JSX.Component = ({endTimeInMs, startTimeInMs}) => { const startDate = new Date(startTimeInMs); const endDate = new Date(endTimeInMs); @@ -28,6 +26,12 @@ export function renderDatesInterval({endTimeInMs, startTimeInMs}: Options): Safe const startTime = startDateTime.slice(11, 19); const endTime = endDateTime.slice(11, 19); - return sanitizeHtml` – - UTC`; -} + return ( + <> + {' '} + – UTC + + ); +}; diff --git a/src/utils/report/client/render/renderDuration.ts b/src/utils/report/client/render/Duration.tsx similarity index 53% rename from src/utils/report/client/render/renderDuration.ts rename to src/utils/report/client/render/Duration.tsx index e04b5964..07fba604 100644 --- a/src/utils/report/client/render/renderDuration.ts +++ b/src/utils/report/client/render/Duration.tsx @@ -1,19 +1,21 @@ import {getDurationWithUnits as clientGetDurationWithUnits} from '../../../getDurationWithUnits'; -import {createSafeHtmlWithoutSanitize as clientCreateSafeHtmlWithoutSanitize} from '../sanitizeHtml'; - -import type {SafeHtml} from '../../../../types/internal'; +import {SafeHtml as clientSafeHtml} from './SafeHtml'; const getDurationWithUnits = clientGetDurationWithUnits; -const createSafeHtmlWithoutSanitize = clientCreateSafeHtmlWithoutSanitize; +const SafeHtml = clientSafeHtml; + +declare const jsx: JSX.Runtime; + +type Props = Readonly<{durationInMs: number}>; /** * Renders the duration of time interval in hours, minutes, seconds and milliseconds. * This base client function should not use scope variables (except other base functions). * @internal */ -export function renderDuration(durationInMs: number): SafeHtml { +export const Duration: JSX.Component = ({durationInMs}) => { const durationWithUnits = getDurationWithUnits(durationInMs); - return createSafeHtmlWithoutSanitize`${durationWithUnits}`; -} + return ; +}; diff --git a/src/utils/report/client/render/List.tsx b/src/utils/report/client/render/List.tsx new file mode 100644 index 00000000..3415a846 --- /dev/null +++ b/src/utils/report/client/render/List.tsx @@ -0,0 +1,28 @@ +import {assertValueIsDefined as clientAssertValueIsDefined} from '../assertValueIsDefined'; +import {createSafeHtmlWithoutSanitize as clientCreateSafeHtmlWithoutSanitize} from '../sanitizeHtml'; + +import type {SafeHtml} from '../../../../types/internal'; + +const assertValueIsDefined: typeof clientAssertValueIsDefined = clientAssertValueIsDefined; +const createSafeHtmlWithoutSanitize = clientCreateSafeHtmlWithoutSanitize; + +declare const jsx: JSX.Runtime; + +type Props = Readonly<{ + elements?: readonly SafeHtml[]; + separator?: string; + withoutSanitize?: readonly string[]; +}>; + +/** + * Renders (join) list of any rendered elements. + * This base client function should not use scope variables (except other base functions). + * @internal + */ +export const List: JSX.Component = ({elements, separator = '', withoutSanitize}) => { + const strings = elements || withoutSanitize; + + assertValueIsDefined(strings); + + return createSafeHtmlWithoutSanitize`${strings.join(separator)}`; +}; diff --git a/src/utils/report/client/maybeRenderApiStatistics.ts b/src/utils/report/client/render/MaybeApiStatistics.tsx similarity index 68% rename from src/utils/report/client/maybeRenderApiStatistics.ts rename to src/utils/report/client/render/MaybeApiStatistics.tsx index bad9d9f1..e297b706 100644 --- a/src/utils/report/client/maybeRenderApiStatistics.ts +++ b/src/utils/report/client/render/MaybeApiStatistics.tsx @@ -1,23 +1,21 @@ -import {renderApiStatistics as clientRenderApiStatistics} from './render'; +import {ApiStatistics as clientApiStatistics} from './ApiStatistics'; -import type { - ApiStatisticsReportHash, - ReportClientState, - RunHash, - SafeHtml, -} from '../../../types/internal'; +import type {ApiStatisticsReportHash, ReportClientState, RunHash} from '../../../../types/internal'; -const renderApiStatistics = clientRenderApiStatistics; +const ApiStatistics = clientApiStatistics; +declare const jsx: JSX.Runtime; declare const reportClientState: ReportClientState; +type Props = Readonly<{runHash: RunHash}>; + /** * Renders `ApiStatistics` by `runHash`, if this is a one of kind of `ApiStatistics` hash * (pages, requests or resources). * This base client function should not use scope variables (except other base functions). * @internal */ -export function maybeRenderApiStatistics(runHash: RunHash): SafeHtml | undefined { +export const MaybeApiStatistics: JSX.Component = ({runHash}) => { const hash = String(runHash); const pagesHash: ApiStatisticsReportHash = 'api-statistics-pages'; @@ -25,7 +23,7 @@ export function maybeRenderApiStatistics(runHash: RunHash): SafeHtml | undefined const resourcesHash: ApiStatisticsReportHash = 'api-statistics-resources'; if (hash !== pagesHash && hash !== requestsHash && hash !== resourcesHash) { - return; + return <>; } const {reportClientData} = reportClientState; @@ -36,10 +34,10 @@ export function maybeRenderApiStatistics(runHash: RunHash): SafeHtml | undefined `Cannot find report client data in JSON report data (tried to click "${hash}"). Probably JSON report data not yet completely loaded. Please try click again later`, ); - return; + return <>; } const {apiStatistics} = reportClientData; - return renderApiStatistics({apiStatistics, hash}); -} + return ; +}; diff --git a/src/utils/report/client/render/SafeHtml.tsx b/src/utils/report/client/render/SafeHtml.tsx new file mode 100644 index 00000000..e6957209 --- /dev/null +++ b/src/utils/report/client/render/SafeHtml.tsx @@ -0,0 +1,15 @@ +import {createSafeHtmlWithoutSanitize as clientCreateSafeHtmlWithoutSanitize} from '../sanitizeHtml'; + +const createSafeHtmlWithoutSanitize = clientCreateSafeHtmlWithoutSanitize; + +declare const jsx: JSX.Runtime; + +type Props = Readonly<{withoutSanitize: string}>; + +/** + * Renders any `SafeHtml` string. + * This base client function should not use scope variables (except other base functions). + * @internal + */ +export const SafeHtml: JSX.Component = ({withoutSanitize}) => + createSafeHtmlWithoutSanitize`${withoutSanitize}`; diff --git a/src/utils/report/client/render/Screenshot.tsx b/src/utils/report/client/render/Screenshot.tsx new file mode 100644 index 00000000..bd8eb286 --- /dev/null +++ b/src/utils/report/client/render/Screenshot.tsx @@ -0,0 +1,26 @@ +declare const jsx: JSX.Runtime; + +type Props = Readonly<{ + name: string; + open?: boolean; + url: string; +}>; + +/** + * Renders screenshot of test in HTML report. + * This base client function should not use scope variables (except other base functions). + * @internal + */ +export const Screenshot: JSX.Component = ({name, open = false, url}) => ( +
    + {name} + +
    +); diff --git a/src/utils/report/client/render/Step.tsx b/src/utils/report/client/render/Step.tsx new file mode 100644 index 00000000..2e5ee7f6 --- /dev/null +++ b/src/utils/report/client/render/Step.tsx @@ -0,0 +1,89 @@ +import {LogEventStatus, LogEventType} from '../../../../constants/internal'; + +import {Duration as clientDuration} from './Duration'; +import {StepContent as clientStepContent} from './StepContent'; +import {Steps as clientSteps} from './Steps'; + +import type { + LogEventWithChildren, + ReportClientState, + UtcTimeInMs, +} from '../../../../types/internal'; + +const Duration = clientDuration; +const StepContent = clientStepContent; +const Steps = clientSteps; + +declare const jsx: JSX.Runtime; +declare const reportClientState: ReportClientState; + +type Props = Readonly<{ + isEnd?: boolean; + logEvent: LogEventWithChildren; + nextLogEventTime: UtcTimeInMs; +}>; + +/** + * Renders single step of test run. + * This base client function should not use scope variables (except other base functions). + * @internal + */ +export const Step: JSX.Component = ({isEnd = false, logEvent, nextLogEventTime}) => { + const {children, message, payload, time, type} = logEvent; + const date = new Date(time).toISOString(); + const isPayloadEmpty = !payload || Object.keys(payload).length === 0; + const popoverId = Math.random().toString(16).slice(2); + const status = payload?.logEventStatus ?? LogEventStatus.Passed; + + let pathToScreenshotOfPage: string | undefined; + + if (type === LogEventType.InternalAction && typeof payload?.['pathToScreenshot'] === 'string') { + const {pathToScreenshot} = payload; + const {pathToScreenshotsDirectoryForReport} = reportClientState; + + if (pathToScreenshotsDirectoryForReport !== null) { + const pathToDirectoryWithoutSlashes = pathToScreenshotsDirectoryForReport.replace(/\/+$/, ''); + + pathToScreenshotOfPage = `${pathToDirectoryWithoutSlashes}/${pathToScreenshot}`; + } + } + + let content = <>; + + if (!isEnd) { + content = + isPayloadEmpty && children.length === 0 ? ( +
    + {message} + + + +
    + ) : ( +
    + + {message} + + + + + + +
    + ); + } + + return ( +
  • + +
    + {date} +
    + {content} +
  • + ); +}; diff --git a/src/utils/report/client/render/StepContent.tsx b/src/utils/report/client/render/StepContent.tsx new file mode 100644 index 00000000..74e0d6da --- /dev/null +++ b/src/utils/report/client/render/StepContent.tsx @@ -0,0 +1,63 @@ +import {LogEventType} from '../../../../constants/internal'; + +import {List as clientList} from './List'; +import {Screenshot as clientScreenshot} from './Screenshot'; + +import type {LogPayload, SafeHtml} from '../../../../types/internal'; + +const List = clientList; +const Screenshot = clientScreenshot; + +declare const jsx: JSX.Runtime; + +type Props = Readonly<{ + pathToScreenshotOfPage: string | undefined; + payload: LogPayload | undefined; + type: LogEventType; +}>; + +/** + * Renders content of single step of test run. + * This base client function should not use scope variables (except other base functions). + * @internal + */ +export const StepContent: JSX.Component = ({pathToScreenshotOfPage, payload, type}) => { + if (payload === undefined) { + return <>; + } + + const payloadString = JSON.stringify(payload, null, 2); + const screenshots: SafeHtml[] = []; + + if (pathToScreenshotOfPage !== undefined) { + screenshots.push(); + } + + if (type === LogEventType.InternalAssert) { + const {actualScreenshotUrl, diffScreenshotUrl, expectedScreenshotUrl} = payload; + + if (typeof actualScreenshotUrl === 'string') { + screenshots.push(); + } + + if (typeof diffScreenshotUrl === 'string') { + screenshots.push(); + } + + if (typeof expectedScreenshotUrl === 'string') { + screenshots.push(); + } + } + + return ( + <> +
    + Details +
    +          {payloadString}
    +        
    +
    + + + ); +}; diff --git a/src/utils/report/client/render/Steps.tsx b/src/utils/report/client/render/Steps.tsx new file mode 100644 index 00000000..cfe30b27 --- /dev/null +++ b/src/utils/report/client/render/Steps.tsx @@ -0,0 +1,63 @@ +import {LogEventType} from '../../../../constants/internal'; + +import {assertValueIsDefined as clientAssertValueIsDefined} from '../assertValueIsDefined'; + +import {List as clientList} from './List'; +import {Step as clientStep} from './Step'; + +import type {LogEventWithChildren, SafeHtml, UtcTimeInMs} from '../../../../types/internal'; + +const assertValueIsDefined: typeof clientAssertValueIsDefined = clientAssertValueIsDefined; +const List = clientList; +const Step = clientStep; + +declare const jsx: JSX.Runtime; + +type Props = Readonly<{ + endTimeInMs: UtcTimeInMs; + isRoot?: boolean; + logEvents: readonly LogEventWithChildren[]; +}>; + +/** + * Renders list of step of test run. + * This base client function should not use scope variables (except other base functions). + * @internal + */ +export const Steps: JSX.Component = ({endTimeInMs, isRoot = false, logEvents}) => { + if (logEvents.length === 0) { + return <>; + } + + const stepHtmls: SafeHtml[] = []; + + for (let index = 0; index < logEvents.length; index += 1) { + const logEvent = logEvents[index]; + + assertValueIsDefined(logEvent); + + const nextLogEvent = logEvents[index + 1]; + const nextLogEventTime = nextLogEvent?.time ?? endTimeInMs; + const stepHtml = ; + + stepHtmls.push(stepHtml); + } + + if (isRoot) { + const endLogEvent: LogEventWithChildren = { + children: [], + message: '', + payload: undefined, + time: endTimeInMs, + type: LogEventType.InternalUtil, + }; + + stepHtmls.push(); + } + + return ( +
      + +
    + ); +}; diff --git a/src/utils/report/client/render/TestRunDescription.tsx b/src/utils/report/client/render/TestRunDescription.tsx new file mode 100644 index 00000000..3fc09f3c --- /dev/null +++ b/src/utils/report/client/render/TestRunDescription.tsx @@ -0,0 +1,76 @@ +import {parseMarkdownLinks as clientParseMarkdownLinks} from '../parseMarkdownLinks'; + +import {DatesInterval as clientDatesInterval} from './DatesInterval'; +import {Duration as clientDuration} from './Duration'; +import {List as clientList} from './List'; + +import type {FullTestRun, ReportClientState, SafeHtml} from '../../../../types/internal'; + +const parseMarkdownLinks = clientParseMarkdownLinks; +const DatesInterval = clientDatesInterval; +const Duration = clientDuration; +const List = clientList; + +declare const jsx: JSX.Runtime; +declare const reportClientState: ReportClientState; + +type Props = Readonly<{fullTestRun: FullTestRun}>; + +/** + * Renders tag `
    ` with test run description. + * The value strings of meta can contain links in markdown format. + * This base client function should not use scope variables (except other base functions). + * @internal + */ +export const TestRunDescription: JSX.Component = ({fullTestRun}) => { + const {endTimeInMs, outputDirectoryName, runError, startTimeInMs} = fullTestRun; + const {meta} = fullTestRun.options; + const metaHtmls: SafeHtml[] = []; + + for (const [key, value] of Object.entries(meta)) { + const valueWithLinks = parseMarkdownLinks`${value}`; + const metaHtml = ( + <> +
    {key}
    +
    {valueWithLinks}
    + + ); + + metaHtmls.push(metaHtml); + } + + let traceHtml = <>; + + if (runError !== undefined) { + const {internalDirectoryName} = reportClientState; + const traceLabel = 'Download trace'; + const traceName = 'trace.zip'; + const traceUrl = `./${internalDirectoryName}/${outputDirectoryName}/${traceName}`; + + traceHtml = ( + <> +
    {traceLabel}
    +
    + + {traceName} + +
    + + ); + } + + return ( +
    + + {traceHtml} +
    Date
    +
    + +
    +
    Duration
    +
    + +
    +
    + ); +}; diff --git a/src/utils/report/client/render/TestRunDetails.tsx b/src/utils/report/client/render/TestRunDetails.tsx new file mode 100644 index 00000000..d6422220 --- /dev/null +++ b/src/utils/report/client/render/TestRunDetails.tsx @@ -0,0 +1,52 @@ +import {assertValueIsDefined as clientAssertValueIsDefined} from '../assertValueIsDefined'; +import {groupLogEvents as clientGroupLogEvents} from '../groupLogEvents'; + +import {Steps as clientSteps} from './Steps'; +import {TestRunDescription as clientTestRunDescription} from './TestRunDescription'; +import {TestRunError as clientTestRunError} from './TestRunError'; + +import type {FullTestRun, ReportClientState} from '../../../../types/internal'; + +declare const jsx: JSX.Runtime; +declare const reportClientState: ReportClientState; + +const assertValueIsDefined: typeof clientAssertValueIsDefined = clientAssertValueIsDefined; +const groupLogEvents = clientGroupLogEvents; +const Steps = clientSteps; +const TestRunDescription = clientTestRunDescription; +const TestRunError = clientTestRunError; + +type Props = Readonly<{fullTestRun: FullTestRun}>; + +/** + * Renders test run details for report. + * This base client function should not use scope variables (except other base functions). + * @internal + */ +export const TestRunDetails: JSX.Component = ({fullTestRun}) => { + const {endTimeInMs, filePath, logEvents, name, runError, status} = fullTestRun; + const {locator} = reportClientState; + + const firstStatusString = status[0]; + + assertValueIsDefined(firstStatusString); + + const capitalizedStatus = `${firstStatusString.toUpperCase()}${status.slice(1)}`; + const logEventsWithChildren = groupLogEvents(logEvents); + + return ( +
    +

    {filePath}

    +

    + + {capitalizedStatus} + + {name} +

    + + +

    Execution

    + +
    + ); +}; diff --git a/src/utils/report/client/render/renderTestRunError.ts b/src/utils/report/client/render/TestRunError.tsx similarity index 50% rename from src/utils/report/client/render/renderTestRunError.ts rename to src/utils/report/client/render/TestRunError.tsx index 41364f8d..4c633f48 100644 --- a/src/utils/report/client/render/renderTestRunError.ts +++ b/src/utils/report/client/render/TestRunError.tsx @@ -1,15 +1,19 @@ import {sanitizeHtml as clientSanitizeHtml} from '../sanitizeHtml'; -import type {RunError, SafeHtml} from '../../../../types/internal'; +import type {RunError} from '../../../../types/internal'; const sanitizeHtml = clientSanitizeHtml; +declare const jsx: JSX.Runtime; + +type Props = Readonly<{runError: RunError}>; + /** - * Renders `TestRun` error as simple message. + * Renders `TestRun` error as a simple message. * This base client function should not use scope variables (except other base functions). * @internal */ -export function renderTestRunError(runError: RunError): SafeHtml { +export const TestRunError: JSX.Component = ({runError}) => { if (runError === undefined) { return sanitizeHtml``; } @@ -19,11 +23,13 @@ export function renderTestRunError(runError: RunError): SafeHtml { const runErrorWithoutStyle = String(runError).replace(stylesRegexp, ''); - return sanitizeHtml` -
    -
    - ${runErrorWithoutStyle} -
    -
    -`; -} + return ( +
    +
    +
    +          {runErrorWithoutStyle}
    +        
    +
    +
    + ); +}; diff --git a/src/utils/report/client/render/index.ts b/src/utils/report/client/render/index.ts index 5f7e1c3c..b038eff5 100644 --- a/src/utils/report/client/render/index.ts +++ b/src/utils/report/client/render/index.ts @@ -1,22 +1,28 @@ /** @internal */ +export {ApiStatistics} from './ApiStatistics'; +/** @internal */ export {ApiStatisticsItem} from './ApiStatisticsItem'; /** @internal */ -export {renderApiStatistics} from './renderApiStatistics'; +export {DatesInterval} from './DatesInterval'; +/** @internal */ +export {Duration} from './Duration'; +/** @internal */ +export {List} from './List'; /** @internal */ -export {renderAttributes} from './renderAttributes'; +export {MaybeApiStatistics} from './MaybeApiStatistics'; /** @internal */ -export {renderDatesInterval} from './renderDatesInterval'; +export {SafeHtml} from './SafeHtml'; /** @internal */ -export {renderDuration} from './renderDuration'; +export {Screenshot} from './Screenshot'; /** @internal */ -export {renderStep} from './renderStep'; +export {Step} from './Step'; /** @internal */ -export {renderStepContent} from './renderStepContent'; +export {StepContent} from './StepContent'; /** @internal */ -export {renderSteps} from './renderSteps'; +export {Steps} from './Steps'; /** @internal */ -export {renderTestRunDescription} from './renderTestRunDescription'; +export {TestRunDescription} from './TestRunDescription'; /** @internal */ -export {renderTestRunDetails} from './renderTestRunDetails'; +export {TestRunDetails} from './TestRunDetails'; /** @internal */ -export {renderTestRunError} from './renderTestRunError'; +export {TestRunError} from './TestRunError'; diff --git a/src/utils/report/client/render/renderAttributes.ts b/src/utils/report/client/render/renderAttributes.ts deleted file mode 100644 index f847f793..00000000 --- a/src/utils/report/client/render/renderAttributes.ts +++ /dev/null @@ -1,22 +0,0 @@ -import {createSafeHtmlWithoutSanitize as clientCreateSafeHtmlWithoutSanitize} from '../sanitizeHtml'; - -import type {Attributes} from 'create-locator'; - -import type {SafeHtml} from '../../../../types/internal'; - -const createSafeHtmlWithoutSanitize = clientCreateSafeHtmlWithoutSanitize; - -/** - * Renders attributes object to safe HTML string. - * This base client function should not use scope variables (except other base functions). - * @internal - */ -export function renderAttributes(attributes: Attributes): SafeHtml { - const parts: string[] = []; - - for (const key of Object.keys(attributes)) { - parts.push(`${key}="${attributes[key]}"`); - } - - return createSafeHtmlWithoutSanitize`${parts.join(' ')}`; -} diff --git a/src/utils/report/client/render/renderStep.ts b/src/utils/report/client/render/renderStep.ts deleted file mode 100644 index 869b9975..00000000 --- a/src/utils/report/client/render/renderStep.ts +++ /dev/null @@ -1,60 +0,0 @@ -import {LogEventStatus, LogEventType} from '../../../../constants/internal'; - -import {sanitizeHtml as clientSanitizeHtml} from '../sanitizeHtml'; - -import {renderDuration as clientRenderDuration} from './renderDuration'; -import {renderStepContent as clientRenderStepContent} from './renderStepContent'; - -import type {LogEvent, ReportClientState, SafeHtml, UtcTimeInMs} from '../../../../types/internal'; - -const renderDuration = clientRenderDuration; -const renderStepContent = clientRenderStepContent; -const sanitizeHtml = clientSanitizeHtml; - -declare const reportClientState: ReportClientState; - -type Options = Readonly<{ - logEvent: LogEvent; - nextLogEventTime: UtcTimeInMs; -}>; - -/** - * Renders single step of test run. - * This base client function should not use scope variables (except other base functions). - * @internal - */ -export function renderStep({logEvent, nextLogEventTime}: Options): SafeHtml { - const {message, payload, time, type} = logEvent; - const durationInMs = nextLogEventTime - time; - const isPayloadEmpty = !payload || Object.keys(payload).length === 0; - const status = payload?.logEventStatus ?? LogEventStatus.Passed; - - let pathToScreenshotOfPage: string | undefined; - - if (type === LogEventType.InternalAction && typeof payload?.['pathToScreenshot'] === 'string') { - const {pathToScreenshot} = payload; - const {pathToScreenshotsDirectoryForReport} = reportClientState; - - if (pathToScreenshotsDirectoryForReport !== null) { - const pathToDirectoryWithoutSlashes = pathToScreenshotsDirectoryForReport.replace(/\/+$/, ''); - - pathToScreenshotOfPage = `${pathToDirectoryWithoutSlashes}/${pathToScreenshot}`; - } - } - - const content = renderStepContent({ - pathToScreenshotOfPage, - payload: isPayloadEmpty ? undefined : payload, - type, - }); - const maybeEmptyClass = isPayloadEmpty ? 'step-expanded_is-empty' : ''; - const isErrorScreenshot = pathToScreenshotOfPage !== undefined; - - return sanitizeHtml` - -${content} -`; -} diff --git a/src/utils/report/client/render/renderStepContent.ts b/src/utils/report/client/render/renderStepContent.ts deleted file mode 100644 index 83e2e797..00000000 --- a/src/utils/report/client/render/renderStepContent.ts +++ /dev/null @@ -1,62 +0,0 @@ -import {LogEventType} from '../../../../constants/internal'; - -import { - createSafeHtmlWithoutSanitize as clientCreateSafeHtmlWithoutSanitize, - sanitizeHtml as clientSanitizeHtml, -} from '../sanitizeHtml'; - -import type {LogPayload, SafeHtml} from '../../../../types/internal'; - -const createSafeHtmlWithoutSanitize = clientCreateSafeHtmlWithoutSanitize; -const sanitizeHtml = clientSanitizeHtml; - -type Options = Readonly<{ - pathToScreenshotOfPage: string | undefined; - payload: LogPayload | undefined; - type: LogEventType; -}>; - -/** - * Renders content of single step of test run. - * This base client function should not use scope variables (except other base functions). - * @internal - */ -export function renderStepContent({pathToScreenshotOfPage, payload, type}: Options): SafeHtml { - if (payload === undefined) { - return sanitizeHtml``; - } - - const payloadString = JSON.stringify(payload, null, 2); - const code = sanitizeHtml`${payloadString}`; - const images: SafeHtml[] = []; - - if (pathToScreenshotOfPage !== undefined) { - images.push( - sanitizeHtml`Screenshot of page`, - ); - } - - if (type === LogEventType.InternalAssert) { - const {actualScreenshotUrl, diffScreenshotUrl, expectedScreenshotUrl} = payload; - - if (typeof actualScreenshotUrl === 'string') { - images.push(sanitizeHtml`Actual`); - } - - if (typeof diffScreenshotUrl === 'string') { - images.push(sanitizeHtml`Diff`); - } - - if (typeof expectedScreenshotUrl === 'string') { - images.push( - sanitizeHtml`Expected`, - ); - } - } - - const imagesHtml = createSafeHtmlWithoutSanitize`${images.join('')}`; - const content = images.length > 0 ? sanitizeHtml`
    ${code}
    ${imagesHtml}` : code; - const contentTag = images.length > 0 ? 'div' : 'pre'; - - return sanitizeHtml`<${contentTag} class="step-expanded-panel step__panel">${content}`; -} diff --git a/src/utils/report/client/render/renderSteps.ts b/src/utils/report/client/render/renderSteps.ts deleted file mode 100644 index 2efe2909..00000000 --- a/src/utils/report/client/render/renderSteps.ts +++ /dev/null @@ -1,38 +0,0 @@ -import {assertValueIsDefined as clientAssertValueIsDefined} from '../assertValueIsDefined'; -import {createSafeHtmlWithoutSanitize as clientCreateSafeHtmlWithoutSanitize} from '../sanitizeHtml'; - -import {renderStep as clientRenderStep} from './renderStep'; - -import type {LogEvent, SafeHtml, UtcTimeInMs} from '../../../../types/internal'; - -const assertValueIsDefined: typeof clientAssertValueIsDefined = clientAssertValueIsDefined; -const createSafeHtmlWithoutSanitize = clientCreateSafeHtmlWithoutSanitize; -const renderStep = clientRenderStep; - -type Options = Readonly<{ - endTimeInMs: UtcTimeInMs; - logEvents: readonly LogEvent[]; -}>; - -/** - * Renders list of step of test run. - * This base client function should not use scope variables (except other base functions). - * @internal - */ -export function renderSteps({endTimeInMs, logEvents}: Options): SafeHtml { - const stepHtmls: SafeHtml[] = []; - - for (let index = 0; index < logEvents.length; index += 1) { - const logEvent = logEvents[index]; - - assertValueIsDefined(logEvent); - - const nextLogEvent = logEvents[index + 1]; - const nextLogEventTime = nextLogEvent?.time ?? endTimeInMs; - const stepHtml = renderStep({logEvent, nextLogEventTime}); - - stepHtmls.push(stepHtml); - } - - return createSafeHtmlWithoutSanitize`${stepHtmls.join('')}`; -} diff --git a/src/utils/report/client/render/renderTestRunDescription.ts b/src/utils/report/client/render/renderTestRunDescription.ts deleted file mode 100644 index 30574c73..00000000 --- a/src/utils/report/client/render/renderTestRunDescription.ts +++ /dev/null @@ -1,71 +0,0 @@ -import {parseMarkdownLinks as clientParseMarkdownLinks} from '../parseMarkdownLinks'; -import { - createSafeHtmlWithoutSanitize as clientCreateSafeHtmlWithoutSanitize, - sanitizeHtml as clientSanitizeHtml, -} from '../sanitizeHtml'; - -import {renderDatesInterval as clientRenderDatesInterval} from './renderDatesInterval'; -import {renderDuration as clientRenderDuration} from './renderDuration'; - -import type {FullTestRun, ReportClientState, SafeHtml} from '../../../../types/internal'; - -const createSafeHtmlWithoutSanitize = clientCreateSafeHtmlWithoutSanitize; -const parseMarkdownLinks = clientParseMarkdownLinks; -const renderDatesInterval = clientRenderDatesInterval; -const renderDuration = clientRenderDuration; -const sanitizeHtml = clientSanitizeHtml; - -declare const reportClientState: ReportClientState; - -/** - * Renders tag `
    ` with test run description. - * The value strings of meta can contain links in markdown format. - * This base client function should not use scope variables (except other base functions). - * @internal - */ -export function renderTestRunDescription(fullTestRun: FullTestRun): SafeHtml { - const {endTimeInMs, outputDirectoryName, runError, startTimeInMs} = fullTestRun; - const durationInMs = endTimeInMs - startTimeInMs; - const {meta} = fullTestRun.options; - const metaHtmls: SafeHtml[] = []; - - for (const [key, value] of Object.entries(meta)) { - const valueWithLinks = parseMarkdownLinks`${value}`; - const metaHtml = sanitizeHtml` -
    ${key}
    -
    ${valueWithLinks}
    `; - - metaHtmls.push(metaHtml); - } - - let traceHtml: SafeHtml = createSafeHtmlWithoutSanitize``; - - if (runError !== undefined) { - const {internalDirectoryName} = reportClientState; - const traceLabel = 'Download trace'; - const traceName = 'trace.zip'; - const traceUrl = `./${internalDirectoryName}/${outputDirectoryName}/${traceName}`; - - traceHtml = sanitizeHtml` -
    ${traceLabel}
    -
    - ${traceName} -
    `; - } - - const metaProperties = createSafeHtmlWithoutSanitize`${metaHtmls.join('')}`; - - return sanitizeHtml` -
    - ${metaProperties} - ${traceHtml} -
    Date
    -
    - ${renderDatesInterval({endTimeInMs, startTimeInMs})} -
    -
    Duration
    -
    - ${renderDuration(durationInMs)} -
    -
    `; -} diff --git a/src/utils/report/client/render/renderTestRunDetails.ts b/src/utils/report/client/render/renderTestRunDetails.ts deleted file mode 100644 index 37c15f0b..00000000 --- a/src/utils/report/client/render/renderTestRunDetails.ts +++ /dev/null @@ -1,47 +0,0 @@ -import {assertValueIsDefined as clientAssertValueIsDefined} from '../assertValueIsDefined'; -import {sanitizeHtml as clientSanitizeHtml} from '../sanitizeHtml'; - -import {renderSteps as clientRenderSteps} from './renderSteps'; -import {renderTestRunDescription as clientRenderTestRunDescription} from './renderTestRunDescription'; -import {renderTestRunError as clientRenderTestRunError} from './renderTestRunError'; - -import type {FullTestRun, ReportClientState, SafeHtml} from '../../../../types/internal'; - -declare const reportClientState: ReportClientState; - -const assertValueIsDefined: typeof clientAssertValueIsDefined = clientAssertValueIsDefined; -const renderSteps = clientRenderSteps; -const renderTestRunDescription = clientRenderTestRunDescription; -const renderTestRunError = clientRenderTestRunError; -const sanitizeHtml = clientSanitizeHtml; - -/** - * Renders tag `
    ` with test run details. - * This base client function should not use scope variables (except other base functions). - * @internal - */ -export function renderTestRunDetails(fullTestRun: FullTestRun): SafeHtml { - const {endTimeInMs, filePath, logEvents, name, runError, status} = fullTestRun; - const {locator} = reportClientState; - - const firstStatusString = status[0]; - - assertValueIsDefined(firstStatusString); - - const capitalizedStatus = `${firstStatusString.toUpperCase()}${status.slice(1)}`; - - return sanitizeHtml`
    -

    ${filePath}

    -

    - ${capitalizedStatus}${name} -

    -
    - ${renderTestRunDescription(fullTestRun)} -
    -

    Execution

    - ${renderSteps({endTimeInMs, logEvents})} - ${renderTestRunError(runError)} -
    -
    -
    `; -} diff --git a/src/utils/report/client/sanitizeHtml.ts b/src/utils/report/client/sanitizeHtml.ts index 257c0326..349aefa0 100644 --- a/src/utils/report/client/sanitizeHtml.ts +++ b/src/utils/report/client/sanitizeHtml.ts @@ -104,5 +104,5 @@ export function sanitizeHtml( * @internal */ export function sanitizeJson(json: string): string { - return json.replace(/ ( + +); diff --git a/src/utils/report/render/Errors.tsx b/src/utils/report/render/Errors.tsx new file mode 100644 index 00000000..96ee9585 --- /dev/null +++ b/src/utils/report/render/Errors.tsx @@ -0,0 +1,23 @@ +import {List} from '../client'; + +declare const jsx: JSX.Runtime; + +type Props = Readonly<{errors: readonly string[]}>; + +/** + * Renders report errors. + * @internal + */ +export const Errors: JSX.Component = ({errors}) => { + if (errors.length === 0) { + return <>; + } + + const renderedErrors = errors.map((error) =>
    {error}
    ); + + return ( +
    + +
    + ); +}; diff --git a/src/utils/report/render/Favicon.tsx b/src/utils/report/render/Favicon.tsx new file mode 100644 index 00000000..74b83a60 --- /dev/null +++ b/src/utils/report/render/Favicon.tsx @@ -0,0 +1,12 @@ +declare const jsx: JSX.Runtime; + +/** + * Renders tag `` with favicon in data-uri format. + * @internal + */ +export const Favicon: JSX.Component = () => { + const faviconInBase64 = + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAACXBIWXMAAAsTAAALEwEAmpwYAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAOoSURBVHgBtVfNTxNBFH9v2tIFI9bEBPEjKV70YGI96MGL9WaMIpw8Cge94kHPtPFqAvwFQOLdLRL1YEL9C6gHD5zYqAnChYJatrY7zzezLLTL7naB8ksm3c7X+5zfm0GIiUx+NWOfNkZAiLtIlJOAWQTKqDECrAogixArIOUX47dtVstD1Tj7YqcJxv21LKZwQiKOeQJjAXEO6rJofxq0IqeFDSiLd/pPTSLIF3AsYMFeGCiGjgZ1KquhB5f4MwvdgQX/6F6QN4S/I/VgI9dl4aD34j1TD37m/ANtHjgBy/044Il2D0QJJyhL6YyCoCFD7JwFSfe4cx4OB22gyi+vI+l9GMPrk7xhNngdFe33gwXvn+3+lFVLjWxMC0lLcU8ISlmClpk6BLuuXw1egXN2aWA8bEOXH/qmAGkMomGx18btxcFya6cbgpSYDFvlYGMmbMx4uJa3+3uXOwlnq2c4bDf9wvUYjKxmDNm7GbRQMVx9YeBs0Fh6eGMqBkcEWt2KpOEwvYbQEcf1AJ1qZuzBdwQyBxFQVqeT9UJ1IZqSOQnFXWVrENgDbYnFVk8QUAGiE05bvbN4obwDnZEkpFwYH6vMVnGGJm+awlkAmY/aLIbV+d1mcZvTa9LD65tRR4iVq7RWviCoXEnI5nht8aLpH9MnTCmPkG+uvAHVdlHhdjPZ6fxycNhDFD6OaPZibTzUaiaexvJEFs9ch+TVl+CsfQTa/qZGVA7lBRwR+g7AzFgvDYxWzWDhOnzMftTYBqr9APWbuv4aMNnvTckKtREcEtpqURsKcnkb0KX1ntuzkLzyDKCxBeLcHUhce+XNKPMpIAtcd3QWrJQlKjI3TNdjzE84TqVJNYDad1AhqH++BYkrz7Uy9Gdl1LHeWoLj+xViQuULL3hqPFob08nVAX8/XKpQY6uohGuFLj8B7LusTwsL195DtRnz/SwcBaQKElfEBpSjrl5KBgnxWAvkYsSFbQ72jGIqTsu+1UPd946hjB+ag9LDv6b5YwK6BCIw2aBSnNuxW45HOJ4ypBwfF1zOhdMshZ2YPRbuthf8UCcIEUy/MvtlwC3Ly3By90EPlr1wfsj7s8+Eis2EuufpQnFiwndlwEEFGLY5aDlCjMLJKKH3VjJaO4MfJm5Sdvdhwpb7hSsEFiM10Y0ThT6p4mLvPmgGc0Pnx6n2BhR46lOICfe17MyTwOkwwbEV8JDhU2K798c8C7jBS9ue56qoofc8TzABmfGe5/8BRCSvtlyDCPcAAAAASUVORK5CYII='; + + return ; +}; diff --git a/src/utils/report/render/Head.tsx b/src/utils/report/render/Head.tsx new file mode 100644 index 00000000..d5485c14 --- /dev/null +++ b/src/utils/report/render/Head.tsx @@ -0,0 +1,48 @@ +import {List} from '../client'; +import {getContentFromRenderedElement} from '../getContentFromRenderedElement'; +import {getCspHash} from '../getCspHash'; + +import {Favicon} from './Favicon'; +import {Script} from './Script'; +import {Style} from './Style'; + +declare const jsx: JSX.Runtime; + +type Props = Readonly<{imgCspHosts: string; reportFileName: string}>; + +/** + * Renders tag ``. + * @internal + */ +export const Head: JSX.Component = ({imgCspHosts, reportFileName}) => { + const renderedScript = `; + return ( + + ); }); - return createSafeHtmlWithoutSanitize`${scripts.join('')}`; + return ; }; diff --git a/src/utils/report/render/Logo.tsx b/src/utils/report/render/Logo.tsx new file mode 100644 index 00000000..ab1ba3b8 --- /dev/null +++ b/src/utils/report/render/Logo.tsx @@ -0,0 +1,31 @@ +import {readFileSync} from 'node:fs'; +import {join} from 'node:path'; + +import {INSTALLED_E2ED_DIRECTORY_PATH, READ_FILE_OPTIONS} from '../../../constants/internal'; + +import {SafeHtml} from '../client'; + +declare const jsx: JSX.Runtime; + +/** + * Renders SVG logo for report page. + * @internal + */ +export const Logo: JSX.Component = () => { + const pathToLogo = join(INSTALLED_E2ED_DIRECTORY_PATH, 'logo.svg'); + + const logoString = readFileSync(pathToLogo, READ_FILE_OPTIONS); + + return ( + + ); +}; diff --git a/src/utils/report/render/Metadata.tsx b/src/utils/report/render/Metadata.tsx new file mode 100644 index 00000000..82716bd3 --- /dev/null +++ b/src/utils/report/render/Metadata.tsx @@ -0,0 +1,32 @@ +import type {ApiStatisticsReportHash} from '../../../types/internal'; + +declare const jsx: JSX.Runtime; + +type Props = Readonly<{menuIndex: number}>; + +/** + * Renders metadata of whole `e2ed` run. + * @internal + */ +export const Metadata: JSX.Component = ({menuIndex}) => { + const pagesHash: ApiStatisticsReportHash = 'api-statistics-pages'; + const requestsHash: ApiStatisticsReportHash = 'api-statistics-requests'; + const resourcesHash: ApiStatisticsReportHash = 'api-statistics-resources'; + + return ( + + ); +}; diff --git a/src/utils/report/render/Navigation.tsx b/src/utils/report/render/Navigation.tsx new file mode 100644 index 00000000..515ee3c1 --- /dev/null +++ b/src/utils/report/render/Navigation.tsx @@ -0,0 +1,24 @@ +import {locator} from './locator'; +import {Logo} from './Logo'; +import {RetriesButtons} from './RetriesButtons'; + +import type {RetryProps} from '../../../types/internal'; + +declare const jsx: JSX.Runtime; + +type Props = Readonly<{retries: readonly RetryProps[]}>; + +/** + * Renders tag `