diff --git a/.eslintrc.yaml b/.eslintrc.yaml index d070629d..f845469d 100644 --- a/.eslintrc.yaml +++ b/.eslintrc.yaml @@ -18,7 +18,7 @@ root: true ignorePatterns: [build, local] rules: class-methods-use-this: off - complexity: [error, {max: 10}] + complexity: [error, {max: 12}] consistent-return: off curly: [error, all] default-param-last: off diff --git a/autotests/pageObjects/pages/Main.ts b/autotests/pageObjects/pages/Main.ts index 5e345d0a..4edb69b2 100644 --- a/autotests/pageObjects/pages/Main.ts +++ b/autotests/pageObjects/pages/Main.ts @@ -70,7 +70,6 @@ export class Main extends Page { await this.waitForDomContentLoaded(); await waitForAllRequestsComplete( - // eslint-disable-next-line complexity ({url}) => { if ( url.startsWith('https://assets.msn.com/') || diff --git a/autotests/tests/readJsonDataFromHtmlReport.ts b/autotests/tests/readJsonDataFromHtmlReport.ts new file mode 100644 index 00000000..0f8ac830 --- /dev/null +++ b/autotests/tests/readJsonDataFromHtmlReport.ts @@ -0,0 +1,816 @@ +/* eslint-disable @typescript-eslint/no-magic-numbers, max-lines */ + +import {test} from 'autotests'; +import {expect} from 'e2ed'; +import {assertValueIsDefined} from 'e2ed/utils'; +import {readJsonDataFromHtmlReport} from 'e2ed/utils/parse'; + +type Block = Readonly> | readonly Readonly>[]; + +type ChunkSize = number | ((index: number) => number); + +type CorruptionCase = Readonly<{ + blockIndex: number; + isTruncated: boolean; + relativePosition: number; +}>; + +type CorruptionExpectation = Readonly<{ + errorPrefix: string; + intactNames: readonly string[]; + isTruncated: boolean; + situation: string; + sourceHtml: string; + stringError: string; +}>; + +type Item = Readonly<{end: number; json: string; name: string}>; + +type NamedSource = Readonly<{name: string; source: () => Source}>; + +type ReadResult = Readonly<{ + error: string | undefined; + jsons: readonly string[]; + names: readonly string[]; + yields: number; +}>; + +type Report = Readonly<{ + blocks: readonly ReportBlock[]; + html: string; + items: readonly Item[]; +}>; + +type ReportBlock = Readonly<{contentEnd: number; contentStart: number; isArray: boolean}>; + +type Source = Parameters[0]; + +const scriptTag = '', + '

Отчёт

', +].join(''); + +const parseError = 'Cannot parse JSON data in "},{"mainParams":" 😀`, + payload: { + list: withFakeSeparator ? [{a: 'b'}, {mainParams: 'fake'}] : [{a: 'b'}], + nested: {value: 'ends with quote'}, + }, + time: logIndex, + type: 0, + })), + name: `Test ${index} ✅`, + options: {meta: {testId: String(index)}}, + outputDirectoryName: String(index), + retryIndex: 1, + runId: `run-id-${index}`, + runLabel: 'r:1,c:1', + startTimeInMs: 1_790_000_000_000 + index, + status: index % 3 === 0 ? 'failed' : 'passed', +}); + +/* eslint-enable sort-keys */ + +const createClientData = (): Readonly> => ({ + apiStatistics: { + pages: {'Main «страница»': {'/': {count: 2, duration: 30}}}, + requests: {'https://example.com/': {'200': {count: 3, duration: 40, size: 50}}}, + resources: {'https://example.com/app.js': {'200': {count: 1, duration: 5, size: 60}}}, + }, +}); + +/** + * Creates HTML report in the same format as `JsonData` component renders it. + */ +const createReport = (blocks: readonly Block[]): Report => { + const items: Item[] = []; + const reportBlocks: ReportBlock[] = []; + let html = htmlPrefix; + + for (const block of blocks) { + html += scriptTag; + + const contentStart = html.length; + const jsonItems = Array.isArray(block) ? block : [block]; + + if (Array.isArray(block)) { + html += '['; + } + + for (const [index, jsonItem] of jsonItems.entries()) { + const json = JSON.stringify(jsonItem); + + html += `${index === 0 ? '' : ','}${sanitizeJson(json)}`; + + items.push({end: html.length, json, name: getItemName(jsonItem)}); + } + + if (Array.isArray(block)) { + html += ']'; + } + + reportBlocks.push({contentEnd: html.length, contentStart, isArray: Array.isArray(block)}); + + html += ''; + } + + html += ''; + + return {blocks: reportBlocks, html, items}; +}; + +const createRandom = (seed: number): (() => number) => { + let state = seed; + + return () => { + state = (state * 1_103_515_245 + 12_345) % 2_147_483_648; + + return state / 2_147_483_648; + }; +}; + +function* toChunks( + source: Chunk, + chunkSize: ChunkSize, +): Generator { + let index = 0; + + for (let start = 0; start < source.length; index += 1) { + const size = typeof chunkSize === 'number' ? chunkSize : chunkSize(index); + + yield source.slice(start, start + size) as Chunk; + + start += size; + } +} + +async function* toAsyncChunks( + source: Chunk, + chunkSize: ChunkSize, +): AsyncGenerator { + for (const chunk of toChunks(source, chunkSize)) { + yield await Promise.resolve(chunk); + } +} + +async function* toAsyncParts(parts: readonly string[]): AsyncGenerator { + for (const part of parts) { + yield await Promise.resolve(part); + } +} + +const read = async (source: Source): Promise => { + const jsons: string[] = []; + const names: string[] = []; + let error: string | undefined; + let yields = 0; + + try { + for await (const jsonData of readJsonDataFromHtmlReport(source)) { + yields += 1; + + for (const item of Array.isArray(jsonData) ? jsonData : [jsonData]) { + jsons.push(JSON.stringify(item)); + names.push(getItemName(item)); + } + } + } catch (readError) { + error = readError instanceof Error ? readError.message : String(readError); + } + + return {error, jsons, names, yields}; +}; + +const measure = async (action: () => Promise): Promise => { + const startTime = Date.now(); + + await action(); + + return Date.now() - startTime; +}; + +const getFirstContentStart = (report: Report): number => report.blocks[0]?.contentStart ?? 0; + +const isReadCompletely = (result: ReadResult, report: Report): boolean => + result.error === undefined && + result.names.join('\n') === report.items.map(({name}) => name).join('\n'); + +const getSourcesFailures = async ( + report: Report, + sources: readonly NamedSource[], +): Promise => { + const failures: string[] = []; + + for (const {name, source} of sources) { + const result = await read(source()); + + if (!isReadCompletely(result, report)) { + failures.push(`${name}: ${result.error ?? `${result.names.length} items`}`); + } + } + + return failures; +}; + +const getTagSplitFailures = async (report: Report): Promise => { + const {html} = report; + const failures: string[] = []; + const tagStart = getFirstContentStart(report) - scriptTag.length; + + for (let delta = 1; delta < scriptTag.length; delta += 1) { + const twoParts = [html.slice(0, tagStart + delta), html.slice(tagStart + delta)]; + const threeParts = [ + html.slice(0, tagStart - 3), + html.slice(tagStart - 3, tagStart + delta), + html.slice(tagStart + delta), + ]; + + for (const parts of [twoParts, threeParts]) { + if (!isReadCompletely(await read(toAsyncParts(parts)), report)) { + failures.push(`split at ${delta} of tag into ${parts.length} parts`); + } + } + } + + return failures; +}; + +const getSeparatorSplitFailures = async (report: Report): Promise => { + const {html} = report; + const failures: string[] = []; + const separatorIndex = html.indexOf(separator, getFirstContentStart(report)); + + for (let delta = 1; delta < separator.length; delta += 1) { + const index = separatorIndex + delta; + const result = await read(toAsyncParts([html.slice(0, index), html.slice(index)])); + + if (!isReadCompletely(result, report)) { + failures.push(`split at ${delta} of separator`); + } + } + + return failures; +}; + +const getExpectedTruncationError = (report: Report, cut: number): string => { + for (const {contentEnd, contentStart, isArray} of report.blocks) { + if (cut === contentStart) { + return truncatedInside; + } + + if (cut > contentStart && cut < contentEnd) { + return isArray ? truncatedInsideRuns : truncatedInside; + } + } + + return truncatedAfter; +}; + +const getTruncationPoints = (report: Report): readonly number[] => { + const {blocks, html} = report; + const firstContentStart = getFirstContentStart(report); + const points = new Set(); + const random = createRandom(7); + + for (const {contentEnd, contentStart} of blocks) { + for (let delta = -3; delta <= 12; delta += 1) { + points.add(contentStart + delta); + points.add(contentEnd + delta); + } + } + + for ( + let index = html.indexOf(separator); + index !== -1; + index = html.indexOf(separator, index + 1) + ) { + for (let delta = 0; delta <= separator.length + 2; delta += 1) { + points.add(index + delta); + } + } + + for (let index = 0; index < 100; index += 1) { + points.add(firstContentStart + Math.floor(random() * (html.length - firstContentStart))); + } + + return [...points] + .filter((point) => point >= firstContentStart && point < html.length) + .sort((a, b) => a - b); +}; + +const getTruncationFailures = async ( + report: Report, + points: readonly number[], +): Promise => { + const failures: string[] = []; + + for (const cut of points) { + const truncatedHtml = report.html.slice(0, cut); + const idealNames = report.items.filter(({end}) => end <= cut).map(({name}) => name); + const expectedError = getExpectedTruncationError(report, cut); + const sources: readonly NamedSource[] = [ + {name: 'string', source: () => truncatedHtml}, + {name: 'chunks of 4096', source: () => toAsyncChunks(truncatedHtml, 4096)}, + {name: 'chunks of 7', source: () => toAsyncChunks(truncatedHtml, 7)}, + ]; + + for (const {name, source} of sources) { + const result = await read(source()); + + if (result.names.join('\n') !== idealNames.join('\n')) { + failures.push(`cut ${cut} (${name}): ${result.names.length} items of ${idealNames.length}`); + } + + if (result.error !== expectedError) { + failures.push( + `cut ${cut} (${name}): error "${result.error}" instead of "${expectedError}"`, + ); + } + } + } + + return failures; +}; + +const corruptBlock = (report: Report, blockIndex: number, relativePosition: number): string => { + const block = report.blocks[blockIndex]; + + assertValueIsDefined(block, 'block is defined', {blockIndex}); + + const {contentEnd, contentStart} = block; + const separatorIndex = report.html.indexOf( + separator, + contentStart + Math.floor((contentEnd - contentStart) * relativePosition), + ); + const position = + separatorIndex === -1 || separatorIndex > contentEnd + ? contentStart + 1 + : separatorIndex + '"},{'.length; + + return `${report.html.slice(0, position)}@${report.html.slice(position)}`; +}; + +const getCorruptionExpectation = ( + report: Report, + {blockIndex, isTruncated, relativePosition}: CorruptionCase, +): CorruptionExpectation => { + const block = report.blocks[blockIndex]; + const lastBlock = report.blocks.at(-1); + + assertValueIsDefined(block, 'block is defined', {blockIndex}); + assertValueIsDefined(lastBlock, 'lastBlock is defined'); + + const corruptedHtml = corruptBlock(report, blockIndex, relativePosition); + const brokenContent = corruptedHtml.slice(block.contentStart, block.contentEnd + 1); + const errorPrefix = block === lastBlock && !isTruncated ? parseLastError : parseError; + const stringErrors = [`${errorPrefix}${brokenContent.slice(0, 200)}`]; + + if (isTruncated) { + stringErrors.push(truncatedAfter); + } + + return { + errorPrefix, + intactNames: report.items + .filter(({end}) => end <= block.contentStart || end > block.contentEnd) + .map(({name}) => name), + isTruncated, + situation: `block #${blockIndex} at ${relativePosition}${isTruncated ? ' (truncated)' : ''}`, + sourceHtml: isTruncated ? corruptedHtml.slice(0, lastBlock.contentEnd + 6) : corruptedHtml, + stringError: stringErrors.join('.\n'), + }; +}; + +const getStringCorruptionFailures = async ({ + intactNames, + situation, + sourceHtml, + stringError, +}: CorruptionExpectation): Promise => { + const failures: string[] = []; + const result = await read(sourceHtml); + + if (result.names.join('\n') !== intactNames.join('\n')) { + failures.push(`${situation}, string: items ${result.names.join(', ')}`); + } + + if (result.error !== stringError) { + failures.push(`${situation}, string: error "${result.error}"`); + } + + return failures; +}; + +const getChunksCorruptionFailures = async ( + report: Report, + {errorPrefix, intactNames, isTruncated, situation, sourceHtml}: CorruptionExpectation, +): Promise => { + const failures: string[] = []; + const allNames = report.items.map(({name}) => name); + const result = await read(toAsyncChunks(sourceHtml, 4096)); + const missingNames = intactNames.filter((name) => !result.names.includes(name)); + const unknownNames = result.names.filter((name) => !allNames.includes(name)); + const errors = result.error?.split('.\n') ?? []; + + if (missingNames.length > 0 || unknownNames.length > 0) { + failures.push( + `${situation}, chunks: missing ${String(missingNames)}, unknown ${String(unknownNames)}`, + ); + } + + const hasParseError = errors.some((error) => error.startsWith(errorPrefix)); + const hasTooLongError = errors.some((error) => error.length > errorPrefix.length + 200); + + if (!hasParseError || hasTooLongError || (isTruncated && !errors.includes(truncatedAfter))) { + failures.push(`${situation}, chunks: error "${result.error}"`); + } + + return failures; +}; + +const getCorruptionFailures = async (report: Report): Promise => { + const lastBlockIndex = report.blocks.length - 1; + const cases: readonly CorruptionCase[] = report.blocks + .flatMap((_, blockIndex) => + [0, 0.5].flatMap((relativePosition) => + [false, true].map((isTruncated) => ({blockIndex, isTruncated, relativePosition})), + ), + ) + .filter(({blockIndex, isTruncated}) => !isTruncated || blockIndex !== lastBlockIndex); + const failures: string[] = []; + + for (const corruptionCase of cases) { + const expectation = getCorruptionExpectation(report, corruptionCase); + + failures.push(...(await getStringCorruptionFailures(expectation))); + failures.push(...(await getChunksCorruptionFailures(report, expectation))); + } + + return failures; +}; + +const getReadChunksCountOnFirstYield = async ( + chunks: readonly string[], +): Promise => { + let readChunksCount = 0; + let readChunksCountOnFirstYield: number | undefined; + + async function* source(): AsyncGenerator { + for (const chunk of chunks) { + readChunksCount += 1; + + yield await Promise.resolve(chunk); + } + } + + try { + for await (const jsonData of readJsonDataFromHtmlReport(source())) { + if (Array.isArray(jsonData) && jsonData.length > 0) { + readChunksCountOnFirstYield ??= readChunksCount; + } + } + } catch {} + + return readChunksCountOnFirstYield; +}; + +test( + 'readJsonDataFromHtmlReport(...) function works correctly', + {meta: {testId: '37'}, testTimeout: 60_000}, + // eslint-disable-next-line max-lines-per-function, max-statements + async () => { + const encoder = new TextEncoder(); + const testRuns = Array.from({length: 40}, (_, index) => + createTestRun(index, index === 5 ? 600 : 4 + (index % 8), index === 7), + ); + const report = createReport([ + testRuns.slice(0, 3), + testRuns.slice(3, 25), + testRuns.slice(25, 26), + createClientData(), + testRuns.slice(26), + ]); + const {html} = report; + const bytes = encoder.encode(html); + const expectedNames = report.items.map(({name}) => name); + const expectedJsons = report.items.map(({json}) => json); + + await expect(html.length, 'Test report is big enough for many chunks').gt(150_000); + + await expect(bytes.length, 'Test report contains multibyte characters').gt(html.length); + + const fromString = await read(html); + + await expect(fromString.jsons, 'All JSON data from complete report is read from string').eql( + expectedJsons, + ); + + await expect(fromString.error, 'Complete report is read without error').eql(undefined); + + await expect(fromString.yields, 'Every ${SCRIPT_TAG}`; + +/** + * Reads JSON data from complete JSON source. + * @internal + */ +export const readJsonData = (jsonSource: string, doNotParseLast = false): Return => { + const parts = jsonSource.split(separator); + + const lastPart = parts.pop(); + + assertValueIsDefined(lastPart, 'Cannot parse JSON data from HTML report'); + + const errors: string[] = []; + const jsonData: HtmlReportJsonData[] = []; + + for (const part of parts) { + const dataOrError = parseJsonData(part); + + if (typeof dataOrError === 'string') { + errors.push(dataOrError); + } else { + jsonData.push(dataOrError); + } + } + + const error = mergeErrors(errors); + + try { + if (doNotParseLast) { + return {error, jsonData, tail: lastPart}; + } + + jsonData.push(JSON.parse(lastPart) as HtmlReportJsonData); + } catch { + return {error, jsonData, tail: lastPart}; + } + + return {error, jsonData, tail: ''}; +}; diff --git a/src/utils/parse/readJsonDataFromHtmlReport/readJsonDataFromEnd.ts b/src/utils/parse/readJsonDataFromHtmlReport/readJsonDataFromEnd.ts new file mode 100644 index 00000000..f0120164 --- /dev/null +++ b/src/utils/parse/readJsonDataFromHtmlReport/readJsonDataFromEnd.ts @@ -0,0 +1,59 @@ +import {INVALID_JSON_MESSAGE_LENGTH} from './constants'; +import {mergeErrors} from './mergeErrors'; +import {parseJsonData} from './parseJsonData'; +import {readJsonData} from './readJsonData'; +import {readJsonDataFromTruncatedJson} from './readJsonDataFromTruncatedJson'; + +import type {HtmlReportJsonDataWithError} from '../../../types/internal'; + +const endOfReport = ''; +const truncatedAfterError = 'HTML report is truncated after `, so any other end means truncation + if (source.endsWith(endOfReport)) { + const {error, jsonData, tail} = readJsonData(source.slice(0, -endOfReport.length)); + + if (tail !== '') { + const cannotParseError = [ + 'Cannot parse JSON data in the last ')) { + partialJsonSource.push(lastPart); + + return {isTailValid, jsonData: [], tail: partialJsonSource}; + } + + const index = lastPart.lastIndexOf(SEPARATOR); + + if (index === -1) { + partialJsonSource.push(lastPart); + + return {isTailValid: true, jsonData: [], tail: partialJsonSource}; + } + + const {error, jsonData, tail} = readJsonData( + [...partialJsonSource, lastPart.slice(0, index), `${END_OF_RUN}]`].join(''), + ); + + if (error !== undefined) { + errors.push(error); + } + + return { + isTailValid: tail === '' || lastPart.includes('', index), + jsonData, + tail: + tail === '' + ? ['[', lastPart.slice(index + PRE_SEPARATOR.length)] + : [tail.slice(0, -1), lastPart.slice(index + END_OF_RUN.length)], + }; +}; diff --git a/src/utils/parse/readJsonDataFromHtmlReport/throwIfAny.ts b/src/utils/parse/readJsonDataFromHtmlReport/throwIfAny.ts new file mode 100644 index 00000000..c51cccd2 --- /dev/null +++ b/src/utils/parse/readJsonDataFromHtmlReport/throwIfAny.ts @@ -0,0 +1,13 @@ +import {mergeErrors} from './mergeErrors'; + +/** + * Throws error, if any. + * @internal + */ +export const throwIfAny = (errors: readonly (string | undefined)[]): void => { + const error = mergeErrors(errors); + + if (error !== undefined) { + throw new Error(error); + } +}; diff --git a/src/utils/report/client/readJsonReportData.ts b/src/utils/report/client/readJsonReportData.ts index e22dbb1c..3742cb99 100644 --- a/src/utils/report/client/readJsonReportData.ts +++ b/src/utils/report/client/readJsonReportData.ts @@ -16,8 +16,8 @@ declare const reportClientState: ReportClientState; * @internal */ export const readJsonReportData = (areAllScriptsLoaded = false): void => { - const {lengthOfReadedJsonReportDataParts} = reportClientState; - const scripts = document.querySelectorAll('body > script.e2edJsonReportData'); + const {jsonReportDataClass, lengthOfReadedJsonReportDataParts} = reportClientState; + const scripts = document.querySelectorAll(`body > script.${jsonReportDataClass}`); const {length} = scripts; if (length <= lengthOfReadedJsonReportDataParts) { diff --git a/src/utils/report/client/readPartOfJsonReportData.ts b/src/utils/report/client/readPartOfJsonReportData.ts index 6d7b9383..f244933a 100644 --- a/src/utils/report/client/readPartOfJsonReportData.ts +++ b/src/utils/report/client/readPartOfJsonReportData.ts @@ -1,4 +1,4 @@ -import type {FullTestRun, ReportClientState, ScriptJsonData} from '../../../types/internal'; +import type {FullTestRun, HtmlReportJsonData, ReportClientState} from '../../../types/internal'; declare const reportClientState: ReportClientState; @@ -15,7 +15,7 @@ type Options = Readonly<{ */ export const readPartOfJsonReportData = ({scriptToRead, shouldLogError}: Options): boolean => { try { - const data = JSON.parse(scriptToRead?.textContent ?? '') as ScriptJsonData; + const data = JSON.parse(scriptToRead?.textContent ?? '') as HtmlReportJsonData; if ('apiStatistics' in data) { reportClientState.reportClientData = data; diff --git a/src/utils/report/render/JsonData.tsx b/src/utils/report/render/JsonData.tsx index 368c9f3e..cf97765d 100644 --- a/src/utils/report/render/JsonData.tsx +++ b/src/utils/report/render/JsonData.tsx @@ -1,12 +1,12 @@ -import {FAILED_TEST_RUN_STATUSES} from '../../../constants/internal'; +import {FAILED_TEST_RUN_STATUSES, JSON_REPORT_DATA_ATTRIBUTES} from '../../../constants/internal'; import {List, SafeHtml, sanitizeJson} from '../client'; import type { FullTestRun, + HtmlReportJsonData, ReportClientData, ReportData, - ScriptJsonData, } from '../../../types/internal'; declare const jsx: JSX.Runtime; @@ -50,7 +50,7 @@ export const JsonData: JSX.Component = ({reportData}) => { const [lastRetryErrors, lastRetryRest] = filterErrors(lastRetry?.fullTestRuns ?? []); const [restErrors, rest] = filterErrors(fullTestRunsNotFromLastRetry); - const parts: readonly ScriptJsonData[] = [ + const parts: readonly HtmlReportJsonData[] = [ lastRetryErrors, lastRetryRest, restErrors, @@ -58,12 +58,12 @@ export const JsonData: JSX.Component = ({reportData}) => { rest, ].filter((part) => !('length' in part) || part.length > 0); - const scripts = parts.map((fullTestRuns) => { - const json = JSON.stringify(fullTestRuns); + const scripts = parts.map((htmlReportJsonData) => { + const json = JSON.stringify(htmlReportJsonData); const sanitizedJson = sanitizeJson(json); return ( - ); diff --git a/src/utils/report/render/ScriptGlobals.tsx b/src/utils/report/render/ScriptGlobals.tsx index 214e6607..7fce4231 100644 --- a/src/utils/report/render/ScriptGlobals.tsx +++ b/src/utils/report/render/ScriptGlobals.tsx @@ -1,4 +1,4 @@ -import {INTERNAL_DIRECTORY_NAME} from '../../../constants/internal'; +import {INTERNAL_DIRECTORY_NAME, JSON_REPORT_DATA_CLASS} from '../../../constants/internal'; import {getFullPackConfig} from '../../config'; @@ -23,6 +23,7 @@ export const ScriptGlobals: JSX.Component = () => { e2edRightColumnContainer: undefined, fullTestRuns: [], internalDirectoryName: INTERNAL_DIRECTORY_NAME, + jsonReportDataClass: JSON_REPORT_DATA_CLASS, lengthOfReadedJsonReportDataParts: 0, locator, pathToScreenshotsDirectoryForReport, diff --git a/src/utils/step/processStepError.ts b/src/utils/step/processStepError.ts index af7fa2ef..a06eec30 100644 --- a/src/utils/step/processStepError.ts +++ b/src/utils/step/processStepError.ts @@ -15,7 +15,6 @@ type Options = Readonly<{ * Processes `step` error. * @internal */ -// eslint-disable-next-line complexity export const processStepError = ({error, errorProperties, logEvent}: Options): unknown => { const message = `Caught an error in step "${errorProperties.stepName}"`; let stepError: unknown = error;