From 7438d8729c1dcf8bc2596665a761e4dece81eadd Mon Sep 17 00:00:00 2001 From: Maksim Stepanov <17935127+delatrie@users.noreply.github.com> Date: Sat, 1 Feb 2025 02:34:03 +0700 Subject: [PATCH 01/17] feat: xcresult reader (WIP) --- packages/reader/src/parsing.ts | 246 +++++++++++ packages/reader/src/toolRunner.ts | 190 +++++++++ packages/reader/src/xcresult/index.ts | 540 ++++++++++++++++++++++++ packages/reader/src/xcresult/model.ts | 158 +++++++ packages/reader/src/xcresult/xcUtils.ts | 37 ++ packages/reader/test/toolRunner.test.ts | 117 +++++ 6 files changed, 1288 insertions(+) create mode 100644 packages/reader/src/parsing.ts create mode 100644 packages/reader/src/toolRunner.ts create mode 100644 packages/reader/src/xcresult/index.ts create mode 100644 packages/reader/src/xcresult/model.ts create mode 100644 packages/reader/src/xcresult/xcUtils.ts create mode 100644 packages/reader/test/toolRunner.test.ts diff --git a/packages/reader/src/parsing.ts b/packages/reader/src/parsing.ts new file mode 100644 index 00000000..3dbe55bd --- /dev/null +++ b/packages/reader/src/parsing.ts @@ -0,0 +1,246 @@ +/** + * A symbol to make a unique type. + */ +const unvalidated = Symbol("unvalidated"); + +/** + * This type serves a purpose similar to `unknown` but it keeps the underlying type available for future inferences. + * + * The type is contravariant on T: if `T extends U` then `Unvalidated extends Unvalidated`. That allows + * passing supertypes to an ensureX function (e.g., passing `string | number` to `ensureString`). + */ +export type Unvalidated = { [unvalidated]: (_: T) => never } | undefined; + +/** + * Represents a (partially) validated type. + * If `T` is a primitive type, the resulting type is just `T` (i.e., `ShallowValid` is `string`). + * If `T` is an aggregate type, the resulting type is an aggregate of the same shape as `T` but consisting of + * unvalidated elements (i.e., `ShallowValid` is `Unvalidated[]`). + */ +export type ShallowValid = T extends object + ? T extends (...v: any[]) => any + ? T + : { + [k in keyof T]: T[k] extends Unvalidated ? Unvalidated : Unvalidated; + } + : T; + +export type ParsingTypeGuard = (value: Unvalidated | ShallowValid) => value is ShallowValid; + +export type NestedTypeGuards = T extends object + ? T extends (...v: any[]) => any + ? never + : { + [k in keyof T]: T[k] extends Unvalidated ? NestedTypeGuards : NestedTypeGuards; + } + : (value: Unvalidated | T) => value is T; + +/** + * Applies a type guard to an unvalidated value. If the type guard returns `true`, reveals the value. Otherwise, + * returns `undefined`. + * @example + * ```ts + * const unvalidated: Unvalidated = JSON.parse('"foo"'); + * console.log(check(unvalidated, isString)?.toUpperCase()); // prints FOO + * ``` + */ +export const check = (value: Unvalidated, guard: ParsingTypeGuard): ShallowValid | undefined => + guard(value) ? value : undefined; + +/** + * A type guard to check boolean values. + * @example + * ```ts + * const unvalidated: Unvalidated = JSON.parse("true"); + * if (isBoolean(unvalidated)) { + * const value: boolean = unvalidated; + * } + * ``` + */ +export const isBoolean: ParsingTypeGuard = (value) => typeof value === "boolean"; + +/** + * A type guard to check string values. + * @example + * ```ts + * const unvalidated: Unvalidated = JSON.parse('"foo"'); + * if (isString(unvalidated)) { + * const value: string = unvalidated; + * } + * ``` + */ +export const isString: ParsingTypeGuard = (value) => typeof value === "string"; + +/** + * A type guard to check numeric values. + * @example + * ```ts + * const unvalidated: Unvalidated = JSON.parse("10"); + * if (isNumber(unvalidated)) { + * const value: number = unvalidated; + * } + * ``` + */ +export const isNumber: ParsingTypeGuard = (value) => typeof value === "number"; + +/** + * A type guard to check literal values. + * @example + * ```ts + * const unvalidated: Unvalidated<"foo" | "bar"> = JSON.parse('"foo"'); + * if (isLiteral(unvalidated, ["foo", "bar"])) { + * const value: "foo" | "bar" = unvalidated; + * } + * ``` + */ +export const isLiteral = ( + value: Unvalidated, + literals: L, +): value is ShallowValid => literals.includes(value); + +/** + * A type guard to check arrays and tuples. + * @example + * ```ts + * const unvalidated: Unvalidated = JSON.parse('["foo", "bar"]'); + * if (isArray(unvalidated)) { + * const value: ShallowValid = unvalidated; // `value` is an array of unvalidated strings. + * } + * ``` + */ +export const isArray = (value: Unvalidated | ShallowValid): value is ShallowValid => + Array.isArray(value); + +/** + * A type guard to check objects (except arrays/tuples). + * @see isArray for arrays and tuples. + * @example + * ```ts + * type TObj = { foo: string }; + * const unvalidated: Unvalidated = JSON.parse('{ "foo": "bar" }'); + * if (isObject(unvalidated)) { + * const value: ShallowValid = unvalidated; // the type of `value` is `{ foo: Unvalidated }`. + * } + * ``` + */ +export const isObject = ( + value: T extends any[] ? never : Unvalidated | ShallowValid, +): value is T extends any[] ? never : ShallowValid => + typeof value === "object" && value !== null && !Array.isArray(value); + +/** + * Checks a value to be `true` or `false`. If that's the case, returns the value as is. Otherwise, returns `undefined`. + * @example + * ```ts + * const unvalidated: Unvalidated = JSON.parse("true"); + * const value: boolean = ensureBoolean(unvalidated) ?? false; + * ``` + */ +export const ensureBoolean = (value: Unvalidated) => check(value, isBoolean); + +/** + * Checks if a value is a number. If that's the case, returns the value as is. Otherwise, returns `undefined`. + * @example + * ```ts + * const unvalidated: Unvalidated = JSON.parse("1"); + * const value: number = ensureNumber(unvalidated) ?? 0; + * ``` + */ +export const ensureNumber = (value: Unvalidated) => check(value, isNumber); + +/** + * Checks if a value is a string. If that's the case, returns the value as is. Otherwise, returns `undefined`. + * @example + * ```ts + * const unvalidated: Unvalidated = JSON.parse('"foo"'); + * const value: string = ensureString(unvalidated) ?? ""; + * ``` + */ +export const ensureString = (value: Unvalidated): string | undefined => check(value, isString); + +/** + * Checks if a value is one of the provided literals. If that's the case, returns the value as is. Otherwise, returns + * `undefined`. + * @example + * ```ts + * const unvalidated: Unvalidated<"foo" | "bar" | number> = JSON.parse('"foo"'); + * const value: "foo" | "bar" | undefined = ensureLiteral(unvalidated, ["foo", "bar"]); + * ``` + */ +export const ensureLiteral = ( + value: Unvalidated, + literals: L, +): L[number] | undefined => { + if (isLiteral(value, literals)) { + return value; + } +}; + +/** + * Checks if a value is an array or a tuple. If that's the case, returns the value but marks the elements as unvalidated. + * Otherwise, returns `undefined`. + * @example + * ```ts + * type TArr = [string, number]; + * const unvalidated: Unvalidated = JSON.parse('["foo", 1]'); + * const value: ShallowValid | undefined = ensureArray(unvalidated); // the type of `value` is `[Unvalidated, Unvalidated] | undefined`. + * ``` + */ +export const ensureArray = (value: Unvalidated) => check(value, isArray); + +/** + * If the value is an array, returns an array of shallowly validated items. Otherwise, returns an empty array. + * @param elementGuard a type guard to filter out invalid array items. + * @example + * ```ts + * const unvalidated: Unvalidated = JSON.parse("[1, 2, 3]"); + * for (const n of ensureArrayWithItems(unvalidated, isNumber)) { + * // n is number here + * } + * ``` + */ +export const ensureArrayWithItems = (value: Unvalidated, elementGuard: ParsingTypeGuard) => + (ensureArray(value) ?? []).filter(elementGuard) as ShallowValid[]; + +/** + * Checks if a value is a non-null object that is also neither an array nor a tuple. If that's the case, returns the + * value and marks all the property values as unvalidated. Otherwise, returns `undefined`. + * @example + * ```ts + * type TObj = { + * foo: string; + * bar: number; + * }; + * const unvalidated: Unvalidated = JSON.parse('{ "foo": "foo", "bar": 1 }'); + * const value: ShallowValid | undefined = ensureObject(unvalidated); // the type of `value` is `{ foo: Unvalidated; bar: Unvalidated }`. + * ``` + */ +export const ensureObject = ( + value: T extends any[] ? never : Unvalidated, +): ShallowValid | undefined => { + if (isObject(value)) { + return value; + } +}; + +/** + * If a value is a number, returns its integer part. Otherwise, if the value is a string representing an integer number, + * returns the result of `parseInt(x, 10)`. Otherwise, returns `undefined`. + * @example + * ```ts + * const unvalidated: Unvalidated = JSON.parse('"1"'); + * const value: number = ensureInt(unvalidated) ?? 0; + * ``` + */ +export const ensureInt = (value: Unvalidated | Unvalidated | number | string) => { + if (typeof value === "number") { + return Math.floor(value); + } + + if (typeof value === "string") { + const parsed = parseInt(value, 10); + if (!isNaN(parsed)) { + return parsed; + } + } +}; diff --git a/packages/reader/src/toolRunner.ts b/packages/reader/src/toolRunner.ts new file mode 100644 index 00000000..b3529a07 --- /dev/null +++ b/packages/reader/src/toolRunner.ts @@ -0,0 +1,190 @@ +import { spawn } from "node:child_process"; +import type { Unvalidated } from "./parsing.js"; + +const LINE_SPLIT_PATTERN = /\r\n|\r|\n/; + +export type ProcessRunOptions = { + exitCode?: number | ((code: number) => boolean); + encoding?: BufferEncoding; + timeout?: number; + timeoutSignal?: NodeJS.Signals; + ignoreStderr?: boolean; +}; + +export const invokeCliTool = async ( + executable: string, + args: readonly string[], + { timeout, timeoutSignal, ignoreStderr, encoding, exitCode: expectedExitCode = 0 }: ProcessRunOptions = {}, +) => { + const toolProcess = spawn(executable, args, { + stdio: ["ignore", "ignore", ignoreStderr ? "ignore" : "pipe"], + shell: false, + timeout: timeout, + killSignal: timeoutSignal, + }); + + const stderr: string[] = []; + + if (!ignoreStderr) { + toolProcess.stderr?.setEncoding(encoding ?? "utf-8").on("data", (chunk) => stderr.push(String(chunk))); + } + + let onSuccess: () => void; + let onError: (e: Error) => void; + + const resultPromise = new Promise((resolve, reject) => { + onSuccess = resolve; + onError = reject; + }); + + toolProcess.on("exit", (code, signal) => { + if (signal) { + onError( + new Error( + timeout && toolProcess.killed + ? `${executable} was terminated by timeout (${timeout} ms)` + : `${executable} was terminated with ${signal}`, + ), + ); + return; + } + + if (typeof expectedExitCode === "number" ? code === expectedExitCode : expectedExitCode(code!)) { + onError(new Error(`${executable} finished with an unexpected exit code ${code}`)); + return; + } + + onSuccess(); + }); + + return await resultPromise; +}; + +export const invokeStdoutCliTool = async function* ( + executable: string, + args: readonly string[], + { timeout, timeoutSignal, encoding, exitCode: expectedExitCode = 0, ignoreStderr }: ProcessRunOptions = {}, +) { + const emitChunk = (chunk: string) => { + const lines = (unfinishedLineBuffer + chunk).split(LINE_SPLIT_PATTERN); + if (lines.length) { + unfinishedLineBuffer = lines.at(-1)!; + bufferedLines.push(...lines.slice(0, -1)); + maybeContinueConsumption(); + } + }; + + const emitFinalChunk = () => { + if (unfinishedLineBuffer) { + bufferedLines.push(unfinishedLineBuffer); + unfinishedLineBuffer = ""; + maybeContinueConsumption(); + } + }; + + const emitError = (message: string) => { + if (stderr.length) { + message = `${message}\n\nStandard error:\n\n${stderr.join("\n")}`; + } + bufferedError = new Error(message); + maybeContinueConsumption(); + }; + + const checkExitCode = (code: number) => { + if (typeof expectedExitCode === "number") { + return code === expectedExitCode; + } + + return expectedExitCode(code); + }; + + const maybeContinueConsumption = () => { + if (continueConsumption) { + const continueConsumptionLocal = continueConsumption; + continueConsumption = undefined; + continueConsumptionLocal(); + } + }; + + const stdIoEncoding = encoding ?? "utf-8"; + const bufferedLines: string[] = []; + let unfinishedLineBuffer = ""; + let done = false; + let bufferedError: Error | undefined; + + const stderr: string[] = []; + + let continueConsumption: (() => void) | undefined; + + const toolProcess = spawn(executable, args, { + stdio: ["ignore", "pipe", ignoreStderr ? "ignore" : "pipe"], + shell: false, + timeout, + killSignal: timeoutSignal, + }); + + toolProcess.stdout?.setEncoding(stdIoEncoding).on("data", (chunk) => { + emitChunk(String(chunk)); + }); + + toolProcess.stderr?.setEncoding(stdIoEncoding).on("data", (chunk) => { + stderr.push(String(chunk)); + }); + + toolProcess.on("exit", (code, signal) => { + emitFinalChunk(); + + done = true; + + if (bufferedError) { + return; + } + + if (signal) { + emitError( + timeout && toolProcess.killed + ? `${executable} was terminated by timeout (${timeout} ms)` + : `${executable} was terminated with ${signal}`, + ); + return; + } + + if (!checkExitCode(code!)) { + emitError(`${executable} finished with an unexpected exit code ${code}`); + return; + } + + continueConsumption?.(); + }); + + while (true) { + if (bufferedLines.length) { + yield* bufferedLines; + bufferedLines.splice(0); + } + + if (bufferedError) { + throw bufferedError; + } + + if (done) { + return; + } + + await new Promise((resolve) => { + continueConsumption = resolve; + }); + } +}; + +export const invokeJsonCliTool = async ( + tool: string, + args: readonly string[], + options: ProcessRunOptions = {}, +): Promise> => { + const lines: string[] = []; + for await (const line of invokeStdoutCliTool(tool, args, options)) { + lines.push(line); + } + return JSON.parse(lines.join("")); +}; diff --git a/packages/reader/src/xcresult/index.ts b/packages/reader/src/xcresult/index.ts new file mode 100644 index 00000000..933321b6 --- /dev/null +++ b/packages/reader/src/xcresult/index.ts @@ -0,0 +1,540 @@ +import type { ResultFile } from "@allurereport/plugin-api"; +import type { + RawStep, + RawTestAttachment, + RawTestLabel, + RawTestParameter, + RawTestResult, + RawTestStatus, + RawTestStepResult, + ResultsReader, +} from "@allurereport/reader-api"; +import { PathResultFile } from "@allurereport/reader-api"; +import * as console from "node:console"; +import { randomUUID } from "node:crypto"; +import { mkdtemp, rm } from "node:fs/promises"; +import path from "node:path"; +import { + ensureArray, + ensureArrayWithItems, + ensureInt, + ensureLiteral, + ensureObject, + ensureString, + isArray, + isLiteral, + isNumber, + isObject, + isString, +} from "../parsing.js"; +import type { ShallowValid, Unvalidated } from "../parsing.js"; +import { XcTestNodeTypeValues, XcTestResultValues } from "./model.js"; +import type { + TestDetailsRunData, + TestRunCoordinates, + XcParsingContext, + XcTestActivityAttachment, + XcTestActivityNode, + XcTestResult, + XcTestResultNode, + XcTestRunArgument, + XcTestRunDevice, +} from "./model.js"; +import { exportAttachments, getTestActivities, getTestDetails, getTests } from "./xcUtils.js"; + +const DEFAULT_BUNDLE_NAME = "The test bundle name is not defined"; +const DEFAULT_SUITE_NAME = "The test suite name is not defined"; +const DEFAULT_TEST_NAME = "The test name is not defined"; + +const SURROGATE_DEVICE_ID = randomUUID(); +const SURROGATE_TEST_PLAN_ID = randomUUID(); +const SURROGATE_ARGS_ID = randomUUID(); + +const MS_IN_S = 1_000; +const DURATION_PATTERN = /\d+\.\d+/; +const ATTACHMENT_NAME_INFIX_PATTERN = /_\d+_[\dA-F]{8}-[\dA-F]{4}-[\dA-F]{4}-[\dA-F]{4}-[\dA-F]{12}/g; + +const readerId = "xcresult"; + +export const xcresult: ResultsReader = { + read: async (visitor, data) => { + const originalFileName = data.getOriginalFileName(); + if (originalFileName.endsWith(".xfresult")) { + let attachmentsDir: string | undefined; + try { + attachmentsDir = await mkdtemp("allure-"); + await exportAttachments(originalFileName, attachmentsDir); + const tests = await getTests(originalFileName); + if (isObject(tests)) { + const { testNodes } = tests; + if (isArray(testNodes)) { + const ctx = { filename: originalFileName, suites: [], attachmentsDir }; + for await (const testResultOrAttachment of processXcNodes(ctx, testNodes)) { + if ("readContent" in testResultOrAttachment) { + await visitor.visitAttachmentFile(testResultOrAttachment, { readerId }); + } else { + await visitor.visitTestResult(testResultOrAttachment, { + readerId, + metadata: { originalFileName }, + }); + } + } + } + } + return true; + } catch (e) { + console.error("error parsing", originalFileName, e); + return false; + } finally { + if (attachmentsDir) { + try { + await rm(attachmentsDir, { recursive: true, force: true }); + } catch (e) { + console.error("when parsing", originalFileName, "- can't remove the tmp dir", attachmentsDir, e); + } + } + } + } + return false; + }, + + readerId: () => readerId, +}; + +const processXcResultNode = async function* ( + ctx: XcParsingContext, + node: ShallowValid, +): AsyncGenerator { + const { nodeType } = node; + + switch (ensureLiteral(nodeType, XcTestNodeTypeValues)) { + case "Unit test bundle": + case "UI test bundle": + yield* processXcBundleNode(ctx, node); + case "Test Suite": + yield* processXcTestSuiteNode(ctx, node); + case "Test Case": + yield* processXcTestCaseNode(ctx, node); + } +}; + +const processXcBundleNode = async function* (ctx: XcParsingContext, node: ShallowValid) { + const { children, name } = node; + + yield* processXcNodes({ ...ctx, bundle: ensureString(name) ?? DEFAULT_BUNDLE_NAME }, ensureArray(children) ?? []); +}; + +const processXcTestSuiteNode = async function* (ctx: XcParsingContext, node: ShallowValid) { + const { children, name } = node; + + yield* processXcNodes( + { ...ctx, suites: [...ctx.suites, ensureString(name) ?? DEFAULT_SUITE_NAME] }, + ensureArray(children) ?? [], + ); +}; + +const processXcTestCaseNode = async function* ( + { filename, bundle, suites, attachmentsDir }: XcParsingContext, + node: ShallowValid, +) { + const { nodeIdentifier, name: displayName } = node; + if (isString(nodeIdentifier)) { + const testDetails = await getTestDetails(filename, nodeIdentifier); + const testActivities = await getTestActivities(filename, nodeIdentifier); + + if (isObject(testDetails) && isObject(testActivities)) { + const { testName, tags, testRuns: detailsTestRuns, devices: testDetailsDevices } = testDetails; + + const crossDeviceTesting = isArray(testDetailsDevices) && testDetailsDevices.length > 1; + const detailsRunLookup = createTestDetailsRunLookup(detailsTestRuns); + + const name = ensureString(displayName) ?? ensureString(testName) ?? DEFAULT_TEST_NAME; + const fullName = convertFullName(nodeIdentifier, bundle); + const testCaseLabels = convertTestCaseLabels(bundle, suites, nodeIdentifier, tags); + + const { testRuns: activityTestRuns } = testActivities; + for (const activityTestRun of ensureArrayWithItems(activityTestRuns, isObject)) { + const { + device: activityTestRunDevice, + arguments: activityTestRunArguments, + testPlanConfiguration: activityTestRunTestPlan, + activities, + } = activityTestRun; + const { + labels: deviceLabels, + parameters: deviceParameters, + deviceId, + } = processActivityTestRunDevice(activityTestRunDevice, crossDeviceTesting); + const { configurationId } = ensureObject(activityTestRunTestPlan) ?? {}; + const args = convertActivitiesTestRunArgs(activityTestRunArguments); + + const { + duration, + parameters = [], + result = "unknown", + } = findNextAttemptDataFromTestDetails(detailsRunLookup, deviceId, ensureString(configurationId), args) ?? {}; + + const { steps, attachmentFiles } = convertXcActivitiesToAllureSteps(attachmentsDir, activities); + + yield* attachmentFiles; + + yield { + uuid: randomUUID(), + fullName, + name, + start: 0, + duration: duration, + status: convertXcResultToAllureStatus(result), + message: "", + trace: "", + steps, + labels: [...testCaseLabels, ...deviceLabels], + links: [], + parameters: [...deviceParameters, ...pairParameterNamesWithValues(parameters, args)], + } as RawTestResult; + } + } + } +}; + +const convertXcActivitiesToAllureSteps = ( + attachmentsDir: string, + activities: Unvalidated, + parentActivityAttachments: Iterator<{ potentialNames: Set; uuid: string }> = [].values(), +): { steps: RawStep[]; attachmentFiles: ResultFile[] } => { + const attachmentFiles: ResultFile[] = []; + let nextAttachmentOfParentActivity = parentActivityAttachments.next(); + return { + steps: ensureArrayWithItems(activities, isObject).map( + ({ title: unvalidatedTitle, attachments, childActivities, startTime }) => { + const title = ensureString(unvalidatedTitle); + const start = isNumber(startTime) ? secondsToMilliseconds(startTime) : undefined; + + const { potentialNames: potentialAttachmentNames, uuid: attachmentFileName } = + nextAttachmentOfParentActivity.done ? {} : nextAttachmentOfParentActivity.value; + + const isAttachment = + isString(title) && isAttachmentActivity(potentialAttachmentNames, title, childActivities, attachments); + + if (isAttachment && attachmentFileName) { + const attachmentUuid = randomUUID(); + const attachmentPath = path.join(attachmentsDir, attachmentFileName); + attachmentFiles.push(new PathResultFile(attachmentPath, attachmentUuid)); + + nextAttachmentOfParentActivity = parentActivityAttachments.next(); + + return { + type: "attachment", + start, + name: title, + originalFileName: attachmentUuid, + } as RawTestAttachment; + } + + const stepAttachments = ensureArrayWithItems(attachments, isObject) + .map<{ potentialNames: Set; uuid: string } | undefined>(({ name, uuid }) => + isString(name) && isString(uuid) + ? { potentialNames: getPotentialFileNamesFromXcSuggestedName(name), uuid } + : undefined, + ) + .filter((entry) => typeof entry !== "undefined"); + + const { steps: substeps, attachmentFiles: substepAttachmentFiles } = convertXcActivitiesToAllureSteps( + attachmentsDir, + childActivities, + stepAttachments.values(), + ); + + attachmentFiles.push(...substepAttachmentFiles); + + return { + type: "step", + duration: 0, + message: "", + name: title, + parameters: [], + start, + status: "passed", + steps: substeps, + stop: 0, + trace: "", + } as RawTestStepResult; + }, + ), + attachmentFiles, + }; +}; + +const isAttachmentActivity = ( + potentialAttachmentNames: Set | undefined, + title: string, + childActivities: Unvalidated, + attachments: Unvalidated, +) => + typeof childActivities === "undefined" && + typeof attachments === "undefined" && + (potentialAttachmentNames?.has(title) ?? false); + +const getPotentialFileNamesFromXcSuggestedName = (xcSuggestedAttachmentName: string) => + new Set( + [...xcSuggestedAttachmentName.matchAll(ATTACHMENT_NAME_INFIX_PATTERN)].map( + ({ 0: { length }, index }) => + xcSuggestedAttachmentName.slice(0, index) + xcSuggestedAttachmentName.slice(index + length), + ), + ); + +const convertXcResultToAllureStatus = (xcResult: XcTestResult): RawTestStatus => { + switch (xcResult) { + case "Expected Failure": + return "passed"; + case "Failed": + return "failed"; + case "Passed": + return "passed"; + case "Skipped": + return "skipped"; + default: + return "unknown"; + } +}; + +const pairParameterNamesWithValues = ( + names: readonly (string | undefined)[], + values: readonly (string | undefined)[], +): RawTestParameter[] => + names + .slice(0, values.length) + .map((p, i) => { + const value = values[i]; + return typeof p !== "undefined" && typeof value !== "undefined" ? { name: p, value } : undefined; + }) + .filter((p) => typeof p !== "undefined"); + +const convertActivitiesTestRunArgs = (args: Unvalidated): (string | undefined)[] => + isArray(args) ? args.map((a) => (isObject(a) && isString(a.value) ? a.value : undefined)) : []; + +const createTestDetailsRunLookup = (nodes: Unvalidated) => + groupByMap( + collectRunsFromTestDetails(nodes), + ([{ device }]) => device ?? SURROGATE_DEVICE_ID, + (deviceRuns) => + groupByMap( + deviceRuns, + ([{ testPlan: configuration }]) => configuration ?? SURROGATE_TEST_PLAN_ID, + (configRuns) => + groupByMap( + configRuns, + ([{ args }]) => (args && args.length ? getArgKey(args.map((arg) => arg?.value)) : SURROGATE_ARGS_ID), + (argRuns) => { + // Make sure retries are ordered by the repetition index + argRuns.sort(([{ attempt: attemptA }], [{ attempt: attemptB }]) => (attemptA ?? 0) - (attemptB ?? 0)); + return argRuns.map(([, data]) => data); + }, + ), + ), + ); + +const groupBy = (values: T[], keyFn: (v: T) => K): Map => + values.reduce((m, v) => { + const key = keyFn(v); + if (!m.get(key)?.push(v)) { + m.set(key, [v]); + } + return m; + }, new Map()); + +const groupByMap = (values: T[], keyFn: (v: T) => K, groupMapFn: (group: T[]) => G): Map => + new Map( + groupBy(values, keyFn) + .entries() + .map(([k, g]) => [k, groupMapFn(g)]), + ); + +const findNextAttemptDataFromTestDetails = ( + lookup: Map>>, + device: string | undefined, + testPlan: string | undefined, + args: readonly (string | undefined)[] | undefined, +) => { + const attempt = lookup + .get(device ?? SURROGATE_DEVICE_ID) + ?.get(testPlan ?? SURROGATE_TEST_PLAN_ID) + ?.get(args && args.length ? getArgKey(args) : SURROGATE_ARGS_ID) + ?.find(({ emitted }) => !emitted); + if (attempt) { + attempt.emitted = true; + } + return attempt; +}; + +const getArgKey = (args: readonly (string | undefined)[]) => args.filter((v) => typeof v !== "undefined").join(", "); + +const collectRunsFromTestDetails = ( + nodes: Unvalidated, + coordinates: TestRunCoordinates = {}, +): [TestRunCoordinates, TestDetailsRunData][] => { + return ensureArrayWithItems(nodes, isObject).flatMap((node) => { + const { children, duration, nodeIdentifier, name: nodeName, result } = node; + let coordinateCreated = true; + let repetition: number | undefined; + switch (ensureLiteral(node.nodeType, XcTestNodeTypeValues)) { + case "Device": + if (isString(nodeIdentifier)) { + coordinates = { ...coordinates, device: nodeIdentifier }; + } + case "Repetition": + repetition = ensureInt(nodeIdentifier); + if (repetition) { + coordinates = { ...coordinates, attempt: repetition }; + } + case "Arguments": + // If the test case is parametrized, the test-details/testRuns tree contains nested 'Arguments' nodes. + // We're only interested in the outmost ones; nested nodes can be safely ignored. + if ("args" in coordinates) { + return []; + } + + if (isString(nodeName)) { + coordinates = { ...coordinates, args: extractArguments(children) }; + } + case "Test Plan Configuration": + if (isString(nodeIdentifier)) { + coordinates = { ...coordinates, testPlan: nodeIdentifier }; + } + default: + coordinateCreated = false; + } + + const runs = collectRunsFromTestDetails(children, coordinates); + return runs.length + ? runs + : coordinateCreated + ? [ + coordinates, + { + duration: parseDuration(duration), + parameters: coordinates.args?.map((arg) => arg?.name) ?? [], + result: ensureLiteral(result, XcTestResultValues) ?? "unknown", + }, + ] + : []; + }); +}; + +const extractArguments = (nodes: Unvalidated) => { + if (isArray(nodes)) { + const argumentsNodeIndex = nodes.findIndex((node) => isObject(node) && isLiteral(node.nodeType, ["Arguments"])); + const { children } = nodes.splice(argumentsNodeIndex, 1)[0] as any as ShallowValid; + return ensureArrayWithItems(children, isObject) + .filter(({ nodeType }) => isLiteral(nodeType, ["Test Value"])) + .map(({ name }) => { + if (isString(name) && name) { + const colonIndex = name.indexOf(":"); + if (colonIndex !== -1) { + return { + name: name.slice(0, colonIndex).trim(), + value: name.slice(colonIndex + 1).trim(), + }; + } + } + }); + } + return []; +}; + +const convertFullName = (testId: string, testBundle: string | undefined) => + testBundle ? `${testBundle}/${testId}` : testId; + +const convertTestCaseLabels = ( + bundle: string | undefined, + suites: readonly string[], + testId: string, + tags: Unvalidated, +) => { + const labels: RawTestLabel[] = []; + + if (bundle) { + labels.push({ name: "package", value: bundle }); + } + + const [testClass, testMethod] = convertTestClassAndMethod(testId); + + if (testClass) { + labels.push({ name: "testClass", value: testClass }); + } + + if (testMethod) { + labels.push({ name: "testMethod", value: testMethod }); + } + + if (suites.length) { + if (suites.length === 1) { + labels.push({ name: "suite", value: suites[0] }); + } + if (suites.length === 2) { + labels.push({ name: "suite", value: suites[0] }, { name: "subSuite", value: suites[1] }); + } else { + const [parentSuite, suite, ...subSuites] = suites; + labels.push( + { name: "parentSuite", value: parentSuite }, + { name: "suite", value: suite }, + { name: "subSuite", value: subSuites.join(" > ") }, + ); + } + } + + labels.push(...ensureArrayWithItems(tags, isString).map((t) => ({ name: "tag", value: t }))); + + return labels; +}; + +const processActivityTestRunDevice = (device: Unvalidated, showDevice: boolean) => { + const labels: RawTestLabel[] = []; + const parameters: RawTestParameter[] = []; + + const { architecture, deviceId, deviceName, modelName, osVersion, platform } = ensureObject(device) ?? {}; + + const host = convertHost(device); + if (isString(deviceName) && deviceName) { + labels.push({ name: "host", value: host }); + parameters.push({ name: "Device name", value: deviceName, hidden: !showDevice }); + if (showDevice) { + const osPart = isString(platform) ? (isString(osVersion) ? `${platform} ${osVersion}` : platform) : undefined; + const deviceDetails = [modelName, architecture, osPart].filter(isString).join(", "); + parameters.push({ name: "Device details", value: deviceDetails, excluded: true }); + } + } + + return { labels, parameters, deviceId: ensureString(deviceId) }; +}; + +const convertHost = (device: Unvalidated) => { + if (isObject(device)) { + const { deviceName, deviceId } = device; + return ensureString(deviceName) ?? ensureString(deviceId); + } +}; + +const convertTestClassAndMethod = (testId: string) => { + const parts = testId.split("/"); + return [parts.slice(0, -1).join("."), parts.at(-1)]; +}; + +const processXcNodes = async function* (ctx: XcParsingContext, children: readonly Unvalidated[]) { + for (const child of children) { + if (isObject(child)) { + yield* processXcResultNode(ctx, child); + } + } +}; + +const parseDuration = (duration: Unvalidated) => { + if (isString(duration)) { + const match = DURATION_PATTERN.exec(duration); + if (match) { + return secondsToMilliseconds(parseFloat(match[0])); + } + } +}; + +const secondsToMilliseconds = (seconds: number) => Math.round(seconds * MS_IN_S); diff --git a/packages/reader/src/xcresult/model.ts b/packages/reader/src/xcresult/model.ts new file mode 100644 index 00000000..94aa1609 --- /dev/null +++ b/packages/reader/src/xcresult/model.ts @@ -0,0 +1,158 @@ +export type XcTestResultCollection = { + testPlanConfigurations: XcTestPlanConfiguration[]; + devices: XcTestRunDevice[]; + testNodes: XcTestResultNode[]; +}; + +export type XcTestDetails = { + testIdentifier: string; + testName: string; + testDescription: string; + duration: string; + startTime?: number; + testPlanConfiguration: XcTestPlanConfiguration[]; + devices: XcTestRunDevice[]; + arguments?: XcTestResultArgument[]; + testRuns: XcTestResultNode[]; + testResult: XcTestResult; + hasPerformanceMetrics: boolean; + hasMediaAttachments: boolean; + tags?: string[]; + bugs?: XcBug[]; + functionName?: string; +}; + +export type XcTestActivityCollection = { + testIdentifier: string; + testName: string; + testRuns: XcTestRunActivity[]; +}; + +export type XcTestRunActivity = { + device: XcTestRunDevice; + testPlanConfiguration: XcTestPlanConfiguration; + arguments?: XcTestRunArgument[]; + activities: XcTestActivityNode[]; +}; + +export type XcTestPlanConfiguration = { + configurationId: string; + configurationName: string; +}; + +export type XcTestRunDevice = { + deviceId?: string; + deviceName: string; + architecture: string; + modelName: string; + platform?: string; + osVersion: string; +}; + +export type XcTestRunArgument = { + value: string; +}; + +export type XcTestActivityNode = { + title: string; + startTime?: number; + attachments?: XcTestActivityAttachment[]; + childActivities?: XcTestActivityNode[]; +}; + +export type XcTestActivityAttachment = { + name: string; + payloadId?: string; + uuid: string; + timestamp: number; + lifetime?: string; +}; + +export type XcTestResultNode = { + nodeIdentifier?: string; + nodeType: XcTestNodeType; + name: string; + details?: string; + duration?: string; + result?: XcTestResult; + tags?: string[]; + children?: XcTestResultNode[]; +}; + +export type XcBug = { + url?: string; + identifier?: string; + title?: string; +}; + +export const XcTestNodeTypeValues = [ + "Test Plan", + "Unit test bundle", + "UI test bundle", + "Test Suite", + "Test Case", + "Device", + "Test Plan Configuration", + "Arguments", + "Repetition", + "Test Case Run", + "Failure Message", + "Source Code Reference", + "Attachment", + "Expression", + "Test Value", +] as const; + +export type XcTestNodeType = (typeof XcTestNodeTypeValues)[number]; + +export const XcTestResultValues = ["Passed", "Failed", "Skipped", "Expected Failure", "unknown"] as const; + +export type XcTestResult = (typeof XcTestResultValues)[number]; + +export type XcTestResultArgument = { + value: string; +}; + +export type XcTestAttachmentsManifestEntry = { + attachments: XcTestAttachment[]; + testIdentifier: string; +}; + +export type XcTestAttachment = { + configurationName: string; + deviceId: string; + deviceName: string; + exportedFileName: string; + isAssociatedWithFailure: boolean; + suggestedHumanReadableName: string; + timestamp: number; +}; + +export type XcParsingContext = { + filename: string; + suites: readonly string[]; + bundle?: string; + attachmentsDir: string; +}; + +export type XcAttachments = Map; + +export type XcAttachmentMetadata = { + path: string; + name?: string; + timestamp?: number; +}; + +export type TestDetailsRunData = { + duration?: number; + result?: XcTestResult; + parameters?: (string | undefined)[]; + emitted?: boolean; +}; + +export type TestRunCoordinates = { + device?: string; + testPlan?: string; + attempt?: number; + args?: ({ name: string; value: string } | undefined)[]; +}; diff --git a/packages/reader/src/xcresult/xcUtils.ts b/packages/reader/src/xcresult/xcUtils.ts new file mode 100644 index 00000000..ca7b03ce --- /dev/null +++ b/packages/reader/src/xcresult/xcUtils.ts @@ -0,0 +1,37 @@ +import { invokeCliTool, invokeJsonCliTool } from "../toolRunner.js"; +import type { XcTestActivityCollection, XcTestDetails, XcTestResultCollection } from "./model.js"; + +export const xcrun = async (utilityName: string, ...args: readonly string[]) => { + return await invokeJsonCliTool("xcrun", [utilityName, ...args], { timeout: 1000 }); +}; + +export const xcresulttool = async (...args: readonly string[]) => await xcrun("xcresulttool", ...args); + +export const getTests = async (xcResultPath: string) => + await xcresulttool("get", "test-results", "tests", "--path", xcResultPath); + +export const getTestDetails = async (xcResultPath: string, testId: string) => + await xcresulttool("get", "test-results", "test-details", "--test-id", testId, "--path", xcResultPath); + +export const getTestActivities = async (xcResultPath: string, testId: string) => + await xcresulttool( + "get", + "test-results", + "activities", + "--test-id", + testId, + "--path", + xcResultPath, + ); + +export const exportAttachments = async (xcResultPath: string, outputPath: string) => { + await invokeCliTool("xcrun", [ + "xcresulttool", + "export", + "attachments", + "--path", + xcResultPath, + "--output-path", + outputPath, + ]); +}; diff --git a/packages/reader/test/toolRunner.test.ts b/packages/reader/test/toolRunner.test.ts new file mode 100644 index 00000000..ca7f707f --- /dev/null +++ b/packages/reader/test/toolRunner.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, it } from "vitest"; +import { type ProcessRunOptions, invokeJsonCliTool, invokeStdoutCliTool } from "../src/toolRunner.js"; + +describe("invokeStdoutCliTool", () => { + const collectAsync = async (code: string, options: ProcessRunOptions = {}) => { + const lines: string[] = []; + for await (const line of invokeStdoutCliTool("node", ["-e", code], options)) { + lines.push(line); + } + return lines; + }; + + it("should iterate empty output", async () => { + expect(await collectAsync("")).toEqual([]); + }); + + it("should iterate over a single line", async () => { + expect(await collectAsync("console.log('Hello, world')")).toEqual(["Hello, world"]); + }); + + it("should iterate over a multiple lines", async () => { + expect( + await collectAsync(` + console.log("Lorem Ipsum"); + console.log("Dolor Sit Amet,"); + console.log("Consectetur Adipiscing Elit"); + `), + ).toEqual(["Lorem Ipsum", "Dolor Sit Amet,", "Consectetur Adipiscing Elit"]); + }); + + it("should emit unterminated line", async () => { + expect(await collectAsync("process.stdout.write('Lorem Ipsum');")).toEqual(["Lorem Ipsum"]); + }); + + it("should stop with a timeout", async () => { + await expect( + invokeStdoutCliTool("node", ["-e", "setTimeout(() => {}, 1000);"], { timeout: 10 }).next(), + ).rejects.toThrow("node was terminated by timeout (10 ms)"); + }); + + it("should stop with a specific timeout signal", async () => { + await expect( + invokeStdoutCliTool( + "node", + ["-e", "process.on('SIGTERM', () => { return false; }); setTimeout(() => {}, 1000);"], + { + timeout: 50, + timeoutSignal: "SIGINT", + }, + ).next(), + ).rejects.toThrow("node was terminated by timeout (50 ms)"); + }); + + it("should throw on non-zero exit code", async () => { + await expect(invokeStdoutCliTool("node", ["-e", "process.exit(1)"]).next()).rejects.toThrow( + "node finished with an unexpected exit code 1", + ); + }); + + it("should accept a user-defined exit code", async () => { + await expect(invokeStdoutCliTool("node", ["-e", "process.exit(1)"], { exitCode: 1 }).next()).resolves.toMatchObject( + { done: true }, + ); + }); + + it("should accept if a user-defined exit code predicate returns true", async () => { + await expect( + invokeStdoutCliTool("node", ["-e", "process.exit(1)"], { exitCode: (e) => e > 0 }).next(), + ).resolves.toMatchObject({ done: true }); + }); + + it("should throw if a user-defined exit code predicate returns false", async () => { + await expect(invokeStdoutCliTool("node", ["-e", ""], { exitCode: (e) => e > 0 }).next()).rejects.toThrow( + "node finished with an unexpected exit code 0", + ); + }); + + it("shows stderr if failed", async () => { + await expect(invokeStdoutCliTool("node", ["-e", "console.error('foo'); process.exit(1);"]).next()).rejects.toThrow( + "node finished with an unexpected exit code 1\n\nStandard error:\n\nfoo\n", + ); + }); + + it("ignores stderr if ignoreStderr is set", async () => { + await expect( + invokeStdoutCliTool("node", ["-e", "console.error('foo'); process.exit(1);"], { ignoreStderr: true }).next(), + ).rejects.toThrow(/^node finished with an unexpected exit code 1$/); + }); + + it("should use the specified encoding to decode stdout", async () => { + expect(await collectAsync("process.stdout.write(Buffer.from([0xAC, 0x20]));", { encoding: "utf-16le" })).toEqual([ + "€", + ]); + }); +}); + +describe("invokeJsonCliTool", () => { + it("should return a JSON entity", async () => { + expect(await invokeJsonCliTool("node", ["-e", "console.log('[1, 2, 3]')"])).toEqual([1, 2, 3]); + }); + + it("should collect all output", async () => { + expect( + await invokeJsonCliTool("node", [ + "-e", + ` + process.stdout.write("{"); + process.stdout.write('"foo":'); + process.stdout.write(' "bar",'); + console.log(' "baz": "qux"'); + process.stdout.write('}'); + `, + ]), + ).toEqual({ foo: "bar", baz: "qux" }); + process.stdout.write(Buffer.from([0xac, 0x20])); + }); +}); From 5894341266b033097610944c978e61044e71a943 Mon Sep 17 00:00:00 2001 From: Maksim Stepanov <17935127+delatrie@users.noreply.github.com> Date: Thu, 6 Feb 2025 03:10:41 +0700 Subject: [PATCH 02/17] feat: better validation helpers --- packages/reader/src/parsing.ts | 246 ------------------- packages/reader/src/toolRunner.ts | 4 +- packages/reader/src/validation.ts | 330 ++++++++++++++++++++++++++ packages/reader/src/xcresult/index.ts | 63 +++-- 4 files changed, 363 insertions(+), 280 deletions(-) delete mode 100644 packages/reader/src/parsing.ts create mode 100644 packages/reader/src/validation.ts diff --git a/packages/reader/src/parsing.ts b/packages/reader/src/parsing.ts deleted file mode 100644 index 3dbe55bd..00000000 --- a/packages/reader/src/parsing.ts +++ /dev/null @@ -1,246 +0,0 @@ -/** - * A symbol to make a unique type. - */ -const unvalidated = Symbol("unvalidated"); - -/** - * This type serves a purpose similar to `unknown` but it keeps the underlying type available for future inferences. - * - * The type is contravariant on T: if `T extends U` then `Unvalidated extends Unvalidated`. That allows - * passing supertypes to an ensureX function (e.g., passing `string | number` to `ensureString`). - */ -export type Unvalidated = { [unvalidated]: (_: T) => never } | undefined; - -/** - * Represents a (partially) validated type. - * If `T` is a primitive type, the resulting type is just `T` (i.e., `ShallowValid` is `string`). - * If `T` is an aggregate type, the resulting type is an aggregate of the same shape as `T` but consisting of - * unvalidated elements (i.e., `ShallowValid` is `Unvalidated[]`). - */ -export type ShallowValid = T extends object - ? T extends (...v: any[]) => any - ? T - : { - [k in keyof T]: T[k] extends Unvalidated ? Unvalidated : Unvalidated; - } - : T; - -export type ParsingTypeGuard = (value: Unvalidated | ShallowValid) => value is ShallowValid; - -export type NestedTypeGuards = T extends object - ? T extends (...v: any[]) => any - ? never - : { - [k in keyof T]: T[k] extends Unvalidated ? NestedTypeGuards : NestedTypeGuards; - } - : (value: Unvalidated | T) => value is T; - -/** - * Applies a type guard to an unvalidated value. If the type guard returns `true`, reveals the value. Otherwise, - * returns `undefined`. - * @example - * ```ts - * const unvalidated: Unvalidated = JSON.parse('"foo"'); - * console.log(check(unvalidated, isString)?.toUpperCase()); // prints FOO - * ``` - */ -export const check = (value: Unvalidated, guard: ParsingTypeGuard): ShallowValid | undefined => - guard(value) ? value : undefined; - -/** - * A type guard to check boolean values. - * @example - * ```ts - * const unvalidated: Unvalidated = JSON.parse("true"); - * if (isBoolean(unvalidated)) { - * const value: boolean = unvalidated; - * } - * ``` - */ -export const isBoolean: ParsingTypeGuard = (value) => typeof value === "boolean"; - -/** - * A type guard to check string values. - * @example - * ```ts - * const unvalidated: Unvalidated = JSON.parse('"foo"'); - * if (isString(unvalidated)) { - * const value: string = unvalidated; - * } - * ``` - */ -export const isString: ParsingTypeGuard = (value) => typeof value === "string"; - -/** - * A type guard to check numeric values. - * @example - * ```ts - * const unvalidated: Unvalidated = JSON.parse("10"); - * if (isNumber(unvalidated)) { - * const value: number = unvalidated; - * } - * ``` - */ -export const isNumber: ParsingTypeGuard = (value) => typeof value === "number"; - -/** - * A type guard to check literal values. - * @example - * ```ts - * const unvalidated: Unvalidated<"foo" | "bar"> = JSON.parse('"foo"'); - * if (isLiteral(unvalidated, ["foo", "bar"])) { - * const value: "foo" | "bar" = unvalidated; - * } - * ``` - */ -export const isLiteral = ( - value: Unvalidated, - literals: L, -): value is ShallowValid => literals.includes(value); - -/** - * A type guard to check arrays and tuples. - * @example - * ```ts - * const unvalidated: Unvalidated = JSON.parse('["foo", "bar"]'); - * if (isArray(unvalidated)) { - * const value: ShallowValid = unvalidated; // `value` is an array of unvalidated strings. - * } - * ``` - */ -export const isArray = (value: Unvalidated | ShallowValid): value is ShallowValid => - Array.isArray(value); - -/** - * A type guard to check objects (except arrays/tuples). - * @see isArray for arrays and tuples. - * @example - * ```ts - * type TObj = { foo: string }; - * const unvalidated: Unvalidated = JSON.parse('{ "foo": "bar" }'); - * if (isObject(unvalidated)) { - * const value: ShallowValid = unvalidated; // the type of `value` is `{ foo: Unvalidated }`. - * } - * ``` - */ -export const isObject = ( - value: T extends any[] ? never : Unvalidated | ShallowValid, -): value is T extends any[] ? never : ShallowValid => - typeof value === "object" && value !== null && !Array.isArray(value); - -/** - * Checks a value to be `true` or `false`. If that's the case, returns the value as is. Otherwise, returns `undefined`. - * @example - * ```ts - * const unvalidated: Unvalidated = JSON.parse("true"); - * const value: boolean = ensureBoolean(unvalidated) ?? false; - * ``` - */ -export const ensureBoolean = (value: Unvalidated) => check(value, isBoolean); - -/** - * Checks if a value is a number. If that's the case, returns the value as is. Otherwise, returns `undefined`. - * @example - * ```ts - * const unvalidated: Unvalidated = JSON.parse("1"); - * const value: number = ensureNumber(unvalidated) ?? 0; - * ``` - */ -export const ensureNumber = (value: Unvalidated) => check(value, isNumber); - -/** - * Checks if a value is a string. If that's the case, returns the value as is. Otherwise, returns `undefined`. - * @example - * ```ts - * const unvalidated: Unvalidated = JSON.parse('"foo"'); - * const value: string = ensureString(unvalidated) ?? ""; - * ``` - */ -export const ensureString = (value: Unvalidated): string | undefined => check(value, isString); - -/** - * Checks if a value is one of the provided literals. If that's the case, returns the value as is. Otherwise, returns - * `undefined`. - * @example - * ```ts - * const unvalidated: Unvalidated<"foo" | "bar" | number> = JSON.parse('"foo"'); - * const value: "foo" | "bar" | undefined = ensureLiteral(unvalidated, ["foo", "bar"]); - * ``` - */ -export const ensureLiteral = ( - value: Unvalidated, - literals: L, -): L[number] | undefined => { - if (isLiteral(value, literals)) { - return value; - } -}; - -/** - * Checks if a value is an array or a tuple. If that's the case, returns the value but marks the elements as unvalidated. - * Otherwise, returns `undefined`. - * @example - * ```ts - * type TArr = [string, number]; - * const unvalidated: Unvalidated = JSON.parse('["foo", 1]'); - * const value: ShallowValid | undefined = ensureArray(unvalidated); // the type of `value` is `[Unvalidated, Unvalidated] | undefined`. - * ``` - */ -export const ensureArray = (value: Unvalidated) => check(value, isArray); - -/** - * If the value is an array, returns an array of shallowly validated items. Otherwise, returns an empty array. - * @param elementGuard a type guard to filter out invalid array items. - * @example - * ```ts - * const unvalidated: Unvalidated = JSON.parse("[1, 2, 3]"); - * for (const n of ensureArrayWithItems(unvalidated, isNumber)) { - * // n is number here - * } - * ``` - */ -export const ensureArrayWithItems = (value: Unvalidated, elementGuard: ParsingTypeGuard) => - (ensureArray(value) ?? []).filter(elementGuard) as ShallowValid[]; - -/** - * Checks if a value is a non-null object that is also neither an array nor a tuple. If that's the case, returns the - * value and marks all the property values as unvalidated. Otherwise, returns `undefined`. - * @example - * ```ts - * type TObj = { - * foo: string; - * bar: number; - * }; - * const unvalidated: Unvalidated = JSON.parse('{ "foo": "foo", "bar": 1 }'); - * const value: ShallowValid | undefined = ensureObject(unvalidated); // the type of `value` is `{ foo: Unvalidated; bar: Unvalidated }`. - * ``` - */ -export const ensureObject = ( - value: T extends any[] ? never : Unvalidated, -): ShallowValid | undefined => { - if (isObject(value)) { - return value; - } -}; - -/** - * If a value is a number, returns its integer part. Otherwise, if the value is a string representing an integer number, - * returns the result of `parseInt(x, 10)`. Otherwise, returns `undefined`. - * @example - * ```ts - * const unvalidated: Unvalidated = JSON.parse('"1"'); - * const value: number = ensureInt(unvalidated) ?? 0; - * ``` - */ -export const ensureInt = (value: Unvalidated | Unvalidated | number | string) => { - if (typeof value === "number") { - return Math.floor(value); - } - - if (typeof value === "string") { - const parsed = parseInt(value, 10); - if (!isNaN(parsed)) { - return parsed; - } - } -}; diff --git a/packages/reader/src/toolRunner.ts b/packages/reader/src/toolRunner.ts index b3529a07..71feaa9c 100644 --- a/packages/reader/src/toolRunner.ts +++ b/packages/reader/src/toolRunner.ts @@ -1,5 +1,5 @@ import { spawn } from "node:child_process"; -import type { Unvalidated } from "./parsing.js"; +import type { Unknown } from "./validation.js"; const LINE_SPLIT_PATTERN = /\r\n|\r|\n/; @@ -181,7 +181,7 @@ export const invokeJsonCliTool = async ( tool: string, args: readonly string[], options: ProcessRunOptions = {}, -): Promise> => { +): Promise> => { const lines: string[] = []; for await (const line of invokeStdoutCliTool(tool, args, options)) { lines.push(line); diff --git a/packages/reader/src/validation.ts b/packages/reader/src/validation.ts new file mode 100644 index 00000000..7248b4b1 --- /dev/null +++ b/packages/reader/src/validation.ts @@ -0,0 +1,330 @@ +// eslint-disable-next-line @typescript-eslint/no-unused-vars +const unknownKey = Symbol("This must be an Unknown"); + +/** + * Indicates that the value came from an unreliable source and can't be used without checking its validity. + * Behaves similar to the built-in `unknown` type but keeps the underlying type to be inferred automatically. + * Use guard functions (e.g., `isString`), or ensure functions (e.g., `ensureString`) to reveal the value. + */ +export type Unknown = typeof unknownKey | ShallowKnown | undefined | null; + +/** + * Returns primitive types and functions as is. + * For object types, returns a new object type with its property types marked as `Unknown`. + * This type is distributive. + * @example + * ```ts + * type ArrayOfUnknownStrings = ShallowKnown; // Unknown[] + * type TupleOfUnknownStringAndNumber = ShallowKnown<[string, number]>; // [Unknown, Unknown] + * type ObjectWithUnknownName = ShallowKnown<{ name: string }>; // { name: Unknown } + * ``` + */ +export type ShallowKnown = T extends object + ? T extends (...v: any[]) => any + ? T + : { [key in keyof T]: Unknown } + : T; + +/** + * Returns the first type argument if it's a super-type of the second one. Othwewise, returns `never`. + * This type is not distributive. + */ +export type IsSuper = [SubType] extends [never] + ? never + : [SubType] extends [SuperType] + ? SuperType + : never; + +/** + * Returns the second type argument if it's a sub-type of the first one. Otherwise, returns `never`. + * This type is not distributive. + */ +export type Narrow = [SubType] extends [never] + ? never + : [SubType] extends [SuperType] + ? SubType + : never; + +/** + * Infers the element type of an array. This type is distributive. + */ +export type ArrayElement = T extends readonly (infer E)[] ? (E[] extends T ? E : never) : never; + +/** + * Returns the argument as is if it's an object (but not array), all properties of which are of the same type. + */ +export type IsHomogeneousObject = T extends object + ? T extends readonly any[] + ? never + : T extends { [key in keyof T]: infer U } + ? { [key in keyof T]: U } extends T + ? T + : never + : never + : never; + +/** + * If the argument is an object (but not array), all properties of which are of the same type, returns the object + * type with the same set of proeprties but marks them as optional and change the value type to the second argument. + */ +export type NarrowHomogeneousObject = T extends object + ? T extends readonly any[] + ? never + : T extends { [key in keyof T]: infer U } + ? { [key in keyof T]: U } extends T + ? { [key in keyof T]?: R } + : never + : never + : never; + +/** + * If the argument is an object (but not array), all properties of which are of the same type, returns the property + * type. + */ +export type HomogeneousObjectItem = T extends object + ? T extends readonly any[] + ? never + : T extends { [key in keyof T]: infer U } + ? { [key in keyof T]: U } extends T + ? U + : never + : never + : never; + +/** + * Unites the second and the forth arguments depending on their condition types, which are the first and the second + * arguments respectively. + */ +export type ConditionalUnion = [CA] extends [never] ? B : [CB] extends [never] ? A : A | B; + +/** + * A type guard to check string values. + * @example + * ```ts + * const raw: Unknown = JSON.parse('"foo"'); + * if (isString(raw)) { + * const value: string = raw; + * } + * ``` + */ +export const isString = (value: Unknown>): value is ShallowKnown> => + typeof value === "string"; + +/** + * A type guard to check numeric values. + * @example + * ```ts + * const raw: Unknown = JSON.parse("101"); + * if (isNumber(raw)) { + * const value: number = raw; + * } + * ``` + */ +export const isNumber = (value: Unknown>): value is ShallowKnown> => + typeof value === "number"; + +/** + * A type guard to check boolean values. + * @example + * ```ts + * const raw: Unknown = JSON.parse("true"); + * if (isBoolean(raw)) { + * const value: boolean = raw; + * } + * ``` + */ +export const isBoolean = (value: Unknown>): value is ShallowKnown> => + typeof value === "boolean"; + +/** + * A type guard to check array values. + * @example + * ```ts + * const raw: Unknown = JSON.parse("[1, 2, 3]"); + * if (isArray(raw)) { + * const value: ShallowKnown = raw; // raw is Unknown[] here + * } + * ``` + */ +export const isArray = ( + value: Unknown>>, +): value is ShallowKnown>, readonly any[]>> => Array.isArray(value); + +/** + * A type guard to check object values (except arrays and tuples; for those, use `isArray`. + * @example + * ```ts + * const raw: Unknown<{ name: string }> = JSON.parse('{ "name": "foo" }'); + * if (isArray(raw)) { + * const value: ShallowKnown<{ name: string }> = raw; // raw is { name: Unknown } here + * } + * ``` + */ +export const isObject = ( + value: Unknown, object>>>, +): value is ShallowKnown, object>>> => + typeof value === "object" && value !== null && !Array.isArray(value); + +/** + * A type guard to check literal types. + * @example + * ```ts + * const raw: Unknown<"foo" | "bar"> = JSON.parse('"foo"'); + * if (isLiteral(raw, ["foo", "bar"])) { + * const value: "foo" | "bar" = raw; + * } + * ``` + */ +export const isLiteral = ( + value: Unknown>, + literals: L, +): value is ShallowKnown => literals.includes(value); + +/** + * If the value is a string, returns it as is. Otherwise, returns `undefined`. + * @example + * ```ts + * const raw: Unknown = JSON.parse('"foo"'); + * const value: string = ensureString(raw) ?? "default"; + * ``` + */ +export const ensureString = (value: Unknown): string | undefined => + typeof value === "string" ? value : undefined; + +/** + * If the value is a number, returns it as is. Otherwise, returns `undefined`. + * @example + * ```ts + * const raw: Unknown = JSON.parse("1"); + * const value: number = ensureNumber(raw) ?? -1; + * ``` + */ +export const ensureNumber = (value: Unknown): number | undefined => + typeof value === "number" ? value : undefined; + +/** + * If the value is a boolean, returns it as is. Otherwise, returns `undefined`. + * @example + * ```ts + * const raw: Unknown = JSON.parse("true"); + * const value: boolean = ensureBoolean(raw) ?? false; + * ``` + */ +export const ensureBoolean = (value: Unknown): boolean | undefined => + typeof value === "boolean" ? value : undefined; + +/** + * If the value is a number, returns its integer part. Otherwise, if the value is a string, parses it as a base 10 + * integer. If the parsing succeeds, returns the parsed integer. Otherwise, returns `undefined`. + * @example + * ```ts + * const raw: Unknown = JSON.parse('"123"'); + * const value: number = ensureInt(raw) ?? -1; + * ``` + */ +export const ensureInt = (value: Unknown): number | undefined => { + if (typeof value === "number") { + return Math.floor(value); + } + + if (typeof value === "string") { + const parsed = parseInt(value, 10); + if (!isNaN(parsed)) { + return parsed; + } + } +}; + +/** + * If the value is an array or tuple, marks it as `ShallowKnown` and returns as is. Otherwise, returns `undefined`. + * @example + * ```ts + * const raw: Unknown = JSON.parse("[1, 2, 3]"); + * const value: ShallowKnown = ensureArray(raw) ?? []; // value is Unknown[] + * ``` + */ +export const ensureArray = (value: Unknown>>) => + isArray(value) ? value : undefined; + +/** + * If the value is an object (but not an array or a tuple; for those use `ensureArray`), marks it as `ShallowKnown` and + * returns it as is. Otherwise, returns `undefined`. + * @example + * ```ts + * const raw: Unknown<{ name: string }> = JSON.parse('{ "name": "foo" }'); + * const value: ShallowKnown<{ name: string }> | undefined = ensureObject(raw) ?? []; // value is { name: Unknown } | undefined + * ``` + */ +export const ensureObject = (value: Unknown, object>>>) => + isObject(value) ? value : undefined; + +/** + * If the value is one of the provided literals, returns it as it. Otherwise, returns `undefined`. + * @example + * ```ts + * const raw: Unknown<"foo" | "bar"> = JSON.parse('"foo"'); + * const value: "foo" | "bar" = ensureLiteral(raw, ["foo", "bar"]) ?? "foo"; + * ``` + */ +export const ensureLiteral = (value: Unknown, literals: L) => + literals.includes(value) ? (value as ShallowKnown) : undefined; + +/** + * If the value is an array, returns a new array with elements of the original array conforming to the provided type + * guard. Otherwise, returns `undefined`. + * @example + * ```ts + * const raw: Unknown = JSON.parse("[1, 2, 3]"); + * const value: number[] = ensureArrayWithItems(raw, isNumber) ?? []; + * ``` + */ +export const ensureArrayWithItems = >>( + value: Unknown[]>>>, + guard: (v: Unknown>) => v is R, +): R[] | undefined => ensureArray(value as Unknown>>)?.filter(guard); + +/** + * If the value is an object (but not an array; for arrays, see `ensureArrayWithItems`), returns a new object of + * the same shape as the original one but with only proeprties that confirms to the provided type guard. + * @example + * ```ts + * const raw: Unknown<{ name: string }> = JSON.parse('{ "name": "foo" }'); + * const value: { name?: string } | undefined = ensureObjectWithProps(raw, isString); + * ``` + */ +export const ensureObjectWithProps = >>( + value: Unknown>>>, + guard: (v: Unknown>) => v is R, +): NarrowHomogeneousObject | undefined => { + const obj = ensureObject(value as Unknown, object>>>); + if (obj) { + return Object.entries(obj).reduce( + (a, [k, v]: [string, Unknown>]) => { + if (guard(v)) { + (a as any)[k] = v; + } + return a; + }, + {} as NarrowHomogeneousObject, + ); + } +}; + +/** + * If the value is an array, returns a new array with elements of the original array conforming to the provided type + * guard. + * Otherwise, if the value is an object, returns a new object of the same shape as the original one but with only + * proeprties that confirms to the provided type guard. + * @example + * ```ts + * const raw: Unknown<{ name: string } | string[]> = JSON.parse('["foo", "bar"]'); + * const value: string[] | { name?: string } | undefined = ensureItems(raw, isString); + * ``` + */ +export const ensureItems = | ArrayElement>>( + value: Unknown | ArrayElement[]>>>, + guard: (v: Unknown | ArrayElement>) => v is R, +): ConditionalUnion, R[], IsHomogeneousObject, NarrowHomogeneousObject> | undefined => { + // @ts-ignore + return ensureArrayWithItems(value, guard) ?? ensureObjectWithProps(value, guard); +}; diff --git a/packages/reader/src/xcresult/index.ts b/packages/reader/src/xcresult/index.ts index 933321b6..f18c754b 100644 --- a/packages/reader/src/xcresult/index.ts +++ b/packages/reader/src/xcresult/index.ts @@ -26,8 +26,8 @@ import { isNumber, isObject, isString, -} from "../parsing.js"; -import type { ShallowValid, Unvalidated } from "../parsing.js"; +} from "../validation.js"; +import type { ShallowKnown, Unknown } from "../validation.js"; import { XcTestNodeTypeValues, XcTestResultValues } from "./model.js"; import type { TestDetailsRunData, @@ -103,7 +103,7 @@ export const xcresult: ResultsReader = { const processXcResultNode = async function* ( ctx: XcParsingContext, - node: ShallowValid, + node: ShallowKnown, ): AsyncGenerator { const { nodeType } = node; @@ -118,13 +118,13 @@ const processXcResultNode = async function* ( } }; -const processXcBundleNode = async function* (ctx: XcParsingContext, node: ShallowValid) { +const processXcBundleNode = async function* (ctx: XcParsingContext, node: ShallowKnown) { const { children, name } = node; yield* processXcNodes({ ...ctx, bundle: ensureString(name) ?? DEFAULT_BUNDLE_NAME }, ensureArray(children) ?? []); }; -const processXcTestSuiteNode = async function* (ctx: XcParsingContext, node: ShallowValid) { +const processXcTestSuiteNode = async function* (ctx: XcParsingContext, node: ShallowKnown) { const { children, name } = node; yield* processXcNodes( @@ -135,7 +135,7 @@ const processXcTestSuiteNode = async function* (ctx: XcParsingContext, node: Sha const processXcTestCaseNode = async function* ( { filename, bundle, suites, attachmentsDir }: XcParsingContext, - node: ShallowValid, + node: ShallowKnown, ) { const { nodeIdentifier, name: displayName } = node; if (isString(nodeIdentifier)) { @@ -153,7 +153,7 @@ const processXcTestCaseNode = async function* ( const testCaseLabels = convertTestCaseLabels(bundle, suites, nodeIdentifier, tags); const { testRuns: activityTestRuns } = testActivities; - for (const activityTestRun of ensureArrayWithItems(activityTestRuns, isObject)) { + for (const activityTestRun of ensureArrayWithItems(activityTestRuns, isObject) ?? []) { const { device: activityTestRunDevice, arguments: activityTestRunArguments, @@ -199,13 +199,13 @@ const processXcTestCaseNode = async function* ( const convertXcActivitiesToAllureSteps = ( attachmentsDir: string, - activities: Unvalidated, + activities: Unknown, parentActivityAttachments: Iterator<{ potentialNames: Set; uuid: string }> = [].values(), -): { steps: RawStep[]; attachmentFiles: ResultFile[] } => { +): { steps: RawStep[] | undefined; attachmentFiles: ResultFile[] } => { const attachmentFiles: ResultFile[] = []; let nextAttachmentOfParentActivity = parentActivityAttachments.next(); return { - steps: ensureArrayWithItems(activities, isObject).map( + steps: ensureArrayWithItems(activities, isObject)?.map( ({ title: unvalidatedTitle, attachments, childActivities, startTime }) => { const title = ensureString(unvalidatedTitle); const start = isNumber(startTime) ? secondsToMilliseconds(startTime) : undefined; @@ -231,12 +231,10 @@ const convertXcActivitiesToAllureSteps = ( } as RawTestAttachment; } - const stepAttachments = ensureArrayWithItems(attachments, isObject) - .map<{ potentialNames: Set; uuid: string } | undefined>(({ name, uuid }) => - isString(name) && isString(uuid) - ? { potentialNames: getPotentialFileNamesFromXcSuggestedName(name), uuid } - : undefined, - ) + const stepAttachments = (ensureArrayWithItems(attachments, isObject) ?? []) + .map< + { potentialNames: Set; uuid: string } | undefined + >(({ name, uuid }) => (isString(name) && isString(uuid) ? { potentialNames: getPotentialFileNamesFromXcSuggestedName(name), uuid } : undefined)) .filter((entry) => typeof entry !== "undefined"); const { steps: substeps, attachmentFiles: substepAttachmentFiles } = convertXcActivitiesToAllureSteps( @@ -268,8 +266,8 @@ const convertXcActivitiesToAllureSteps = ( const isAttachmentActivity = ( potentialAttachmentNames: Set | undefined, title: string, - childActivities: Unvalidated, - attachments: Unvalidated, + childActivities: Unknown, + attachments: Unknown, ) => typeof childActivities === "undefined" && typeof attachments === "undefined" && @@ -310,10 +308,10 @@ const pairParameterNamesWithValues = ( }) .filter((p) => typeof p !== "undefined"); -const convertActivitiesTestRunArgs = (args: Unvalidated): (string | undefined)[] => +const convertActivitiesTestRunArgs = (args: Unknown): (string | undefined)[] => isArray(args) ? args.map((a) => (isObject(a) && isString(a.value) ? a.value : undefined)) : []; -const createTestDetailsRunLookup = (nodes: Unvalidated) => +const createTestDetailsRunLookup = (nodes: Unknown) => groupByMap( collectRunsFromTestDetails(nodes), ([{ device }]) => device ?? SURROGATE_DEVICE_ID, @@ -370,10 +368,10 @@ const findNextAttemptDataFromTestDetails = ( const getArgKey = (args: readonly (string | undefined)[]) => args.filter((v) => typeof v !== "undefined").join(", "); const collectRunsFromTestDetails = ( - nodes: Unvalidated, + nodes: Unknown, coordinates: TestRunCoordinates = {}, ): [TestRunCoordinates, TestDetailsRunData][] => { - return ensureArrayWithItems(nodes, isObject).flatMap((node) => { + return (ensureArrayWithItems(nodes, isObject) ?? []).flatMap((node) => { const { children, duration, nodeIdentifier, name: nodeName, result } = node; let coordinateCreated = true; let repetition: number | undefined; @@ -421,11 +419,11 @@ const collectRunsFromTestDetails = ( }); }; -const extractArguments = (nodes: Unvalidated) => { +const extractArguments = (nodes: Unknown) => { if (isArray(nodes)) { const argumentsNodeIndex = nodes.findIndex((node) => isObject(node) && isLiteral(node.nodeType, ["Arguments"])); - const { children } = nodes.splice(argumentsNodeIndex, 1)[0] as any as ShallowValid; - return ensureArrayWithItems(children, isObject) + const { children } = nodes.splice(argumentsNodeIndex, 1)[0] as any as ShallowKnown; + return (ensureArrayWithItems(children, isObject) ?? []) .filter(({ nodeType }) => isLiteral(nodeType, ["Test Value"])) .map(({ name }) => { if (isString(name) && name) { @@ -449,7 +447,7 @@ const convertTestCaseLabels = ( bundle: string | undefined, suites: readonly string[], testId: string, - tags: Unvalidated, + tags: Unknown, ) => { const labels: RawTestLabel[] = []; @@ -483,12 +481,12 @@ const convertTestCaseLabels = ( } } - labels.push(...ensureArrayWithItems(tags, isString).map((t) => ({ name: "tag", value: t }))); + labels.push(...(ensureArrayWithItems(tags, isString)?.map((t) => ({ name: "tag", value: t })) ?? [])); return labels; }; -const processActivityTestRunDevice = (device: Unvalidated, showDevice: boolean) => { +const processActivityTestRunDevice = (device: Unknown, showDevice: boolean) => { const labels: RawTestLabel[] = []; const parameters: RawTestParameter[] = []; @@ -500,7 +498,8 @@ const processActivityTestRunDevice = (device: Unvalidated, show parameters.push({ name: "Device name", value: deviceName, hidden: !showDevice }); if (showDevice) { const osPart = isString(platform) ? (isString(osVersion) ? `${platform} ${osVersion}` : platform) : undefined; - const deviceDetails = [modelName, architecture, osPart].filter(isString).join(", "); + const deviceDetailParts = [modelName, architecture, osPart]; + const deviceDetails = ensureArrayWithItems(deviceDetailParts, isString)?.join(", ") ?? ""; parameters.push({ name: "Device details", value: deviceDetails, excluded: true }); } } @@ -508,7 +507,7 @@ const processActivityTestRunDevice = (device: Unvalidated, show return { labels, parameters, deviceId: ensureString(deviceId) }; }; -const convertHost = (device: Unvalidated) => { +const convertHost = (device: Unknown) => { if (isObject(device)) { const { deviceName, deviceId } = device; return ensureString(deviceName) ?? ensureString(deviceId); @@ -520,7 +519,7 @@ const convertTestClassAndMethod = (testId: string) => { return [parts.slice(0, -1).join("."), parts.at(-1)]; }; -const processXcNodes = async function* (ctx: XcParsingContext, children: readonly Unvalidated[]) { +const processXcNodes = async function* (ctx: XcParsingContext, children: readonly Unknown[]) { for (const child of children) { if (isObject(child)) { yield* processXcResultNode(ctx, child); @@ -528,7 +527,7 @@ const processXcNodes = async function* (ctx: XcParsingContext, children: readonl } }; -const parseDuration = (duration: Unvalidated) => { +const parseDuration = (duration: Unknown) => { if (isString(duration)) { const match = DURATION_PATTERN.exec(duration); if (match) { From 669d679b0b246c2e80bf178597722c6c0af3819f Mon Sep 17 00:00:00 2001 From: Maksim Stepanov <17935127+delatrie@users.noreply.github.com> Date: Thu, 6 Feb 2025 22:54:56 +0700 Subject: [PATCH 03/17] xcresult: model for legacy API data --- .../src/xcresult/xcresulttool/legacy/model.ts | 426 ++++++++++++++++++ 1 file changed, 426 insertions(+) create mode 100644 packages/reader/src/xcresult/xcresulttool/legacy/model.ts diff --git a/packages/reader/src/xcresult/xcresulttool/legacy/model.ts b/packages/reader/src/xcresult/xcresulttool/legacy/model.ts new file mode 100644 index 00000000..d33adf64 --- /dev/null +++ b/packages/reader/src/xcresult/xcresulttool/legacy/model.ts @@ -0,0 +1,426 @@ +export type XcObject = { + _type: { + _name: T; + }; +}; + +export type XcValue = XcObject & { + _value: Value; +}; + +export type XcArray = XcObject<"Array"> & { + _values: Element[]; +}; + +export type XcSortedKeyValueArrayPair = XcObject<"SortedKeyValueArrayPair"> & { + key: XcString; + value: XcObject; +}; + +export type XcSortedKeyValueArray = XcObject<"SortedKeyValueArray"> & { + storage: XcArray; +}; + +export type XcBool = XcValue<"Bool", boolean>; + +export type XcData = XcValue<"Data", string>; + +export type XcDate = XcValue<"Date", string>; + +export type XcDouble = XcValue<"Double", number>; + +export type XcInt = XcValue<"Int", number>; + +export type XcInt16 = XcValue<"Int16", number>; + +export type XcInt32 = XcValue<"Int32", number>; + +export type XcInt64 = XcValue<"Int64", number>; + +export type XcInt8 = XcValue<"Int8", number>; + +export type XcString = XcValue<"String", string>; + +export type XcUInt16 = XcValue<"UInt16", number>; + +export type XcUInt32 = XcValue<"UInt32", number>; + +export type XcUInt64 = XcValue<"UInt64", number>; + +export type XcUInt8 = XcValue<"UInt8", number>; + +export type XcURL = XcValue<"URL", string>; + +export type XcReference = XcObject<"Reference"> & { + id: XcString; + targetType?: XcTypeDefinition; +}; + +/** + * `get object` without --id + */ +export type XcActionsInvocationRecord = XcObject<"ActionsInvocationRecord"> & { + metadataRef?: XcReference; + metrics: XcResultMetrics; + issues: XcResultIssueSummaries; + actions: XcArray; + archive?: XcArchiveInfo; +}; + +export type XcResultMetrics = XcObject<"ResultMetrics"> & { + analyzerWarningCount: XcInt; + errorCount: XcInt; + testsCount: XcInt; + testsFailedCount: XcInt; + testsSkippedCount: XcInt; + warningCount: XcInt; + totalCoveragePercentage?: XcDouble; +}; + +export type XcResultIssueSummaries = XcObject<"ResultIssueSummaries"> & { + analyzerWarningSummaries: XcArray; + errorSummaries: XcArray; + testFailureSummaries: XcArray; + warningSummaries: XcArray; + testWarningSummaries: XcArray; +}; + +export type XcActionRecord = XcObject<"ActionRecord"> & { + schemeCommandName: XcString; + schemeTaskName: XcString; + title?: XcString; + startedTime: XcDate; + endedTime: XcDate; + runDestination: XcActionRunDestinationRecord; + buildResult: XcActionResult; + actionResult: XcActionResult; + testPlanName?: XcString; +}; + +export type XcArchiveInfo = XcObject<"ArchiveInfo"> & { + path?: XcString; +}; + +export type XcTypeDefinition = XcObject<"TypeDefinition"> & { + name: XcString; + supertype?: XcTypeDefinition; +}; + +export type XcIssueSummary = XcObject & { + issueType: XcString; + message: XcString; + producingTarget?: XcString; + documentLocationInCreatingWorkspace?: XcDocumentLocation; +}; + +export type XcTestFailureIssueSummary = XcIssueSummary<"TestFailureIssueSummary"> & { + testCaseName: XcString; +}; + +export type XcTestIssueSummary = XcIssueSummary<"TestIssueSummary"> & { + testCaseName: XcString; +}; + +export type XcActionRunDestinationRecord = XcObject<"ActionRunDestinationRecord"> & { + displayName: XcString; + targetArchitecture: XcString; + targetDeviceRecord: XcActionDeviceRecord; + localComputerRecord: XcActionDeviceRecord; + targetSDKRecord: XcActionSDKRecord; +}; + +export type XcActionResult = XcObject<"ActionResult"> & { + resultName: XcString; + status: XcString; + metrics: XcResultMetrics; + issues: XcResultIssueSummaries; + coverage: XcCodeCoverageInfo; + timelineRef?: XcReference; + logRef?: XcReference; + testsRef?: XcReference; + diagnosticsRef?: XcReference; + consoleLogRef?: XcReference; +}; + +export type XcDocumentLocation = XcObject<"DocumentLocation"> & { + url: XcString; + concreteTypeName: XcString; +}; + +export type XcActionDeviceRecord = XcObject<"ActionDeviceRecord"> & { + name: XcString; + isConcreteDevice: XcBool; + operatingSystemVersion: XcString; + operatingSystemVersionWithBuildNumber: XcString; + nativeArchitecture: XcString; + modelName: XcString; + modelCode: XcString; + modelUTI: XcString; + identifier: XcString; + isWireless: XcBool; + cpuKind: XcString; + cpuCount?: XcInt; + cpuSpeedInMhz?: XcInt; + busSpeedInMhz?: XcInt; + ramSizeInMegabytes?: XcInt; + physicalCPUCoresPerPackage?: XcInt; + logicalCPUCoresPerPackage?: XcInt; + platformRecord: XcActionPlatformRecord; +}; + +export type XcActionSDKRecord = XcObject<"ActionSDKRecord"> & { + name: XcString; + identifier: XcString; + operatingSystemVersion: XcString; + isInternal: XcBool; +}; + +export type XcCodeCoverageInfo = XcObject<"CodeCoverageInfo"> & { + hasCoverageData: XcBool; + reportRef?: XcReference; + archiveRef?: XcReference; +}; + +export type XcActionPlatformRecord = XcObject<"ActionPlatformRecord"> & { + identifier: XcString; + userDescription: XcString; +}; + +/** + * `get object` with --id of XcActionsInvocationRecord.actions[number].actionResult.testsRef + */ +export type XcActionTestPlanRunSummaries = XcObject<"ActionTestPlanRunSummaries"> & { + summaries: XcArray; +}; + +export type XcActionAbstractTestSummary = XcObject & { + name?: XcString; +}; + +// done +export type XcActionTestPlanRunSummary = XcActionAbstractTestSummary<"ActionTestPlanRunSummary"> & { + testableSummaries: XcArray; +}; + +// done +export type XcActionTestableSummary = XcActionAbstractTestSummary<"ActionTestableSummary"> & { + identifierURL?: XcString; + projectRelativePath?: XcString; + targetName?: XcString; + testKind?: XcString; + tests: XcArray; + diagnosticsDirectoryName?: XcString; + failureSummaries: XcArray; + testLanguage?: XcString; + testRegion?: XcString; +}; + +// cur +export type XcActionTestSummaryIdentifiableObjectBase = XcActionAbstractTestSummary & { + identifier?: XcString; + identifierURL?: XcString; +}; + +export type XcActionTestMetadata = XcActionTestSummaryIdentifiableObjectBase<"ActionTestMetadata"> & { + testStatus: XcString; + duration?: XcDouble; + summaryRef?: XcReference; + performanceMetricsCount?: XcInt; + failureSummariesCount?: XcInt; + activitySummariesCount?: XcInt; +}; + +export type XcActionTestSummary = XcActionTestSummaryIdentifiableObjectBase<"ActionTestSummary"> & { + testStatus: XcString; + duration: XcDouble; + performanceMetrics: XcArray; + failureSummaries: XcArray; + expectedFailures: XcArray; + skipNoticeSummary?: XcActionTestNoticeSummary; + activitySummaries: XcArray; + repetitionPolicySummary?: XcActionTestRepetitionPolicySummary; + arguments: XcArray; + configuration?: XcActionTestConfiguration; + warningSummaries: XcArray; + summary?: XcString; + documentation: XcArray; + trackedIssues: XcArray; + tags: XcArray; +}; + +export type XcActionTestSummaryGroup = XcActionTestSummaryIdentifiableObjectBase<"ActionTestSummaryGroup"> & { + duration: XcDouble; + subtests: XcArray; + skipNoticeSummary?: XcActionTestNoticeSummary; + summary?: XcString; + documentation: XcArray; + trackedIssues: XcArray; + tags: XcArray; +}; + +export type XcActionTestSummaryIdentifiableObject = + | XcActionTestMetadata + | XcActionTestSummary + | XcActionTestSummaryGroup; + +export type XcActionTestFailureSummary = XcObject<"ActionTestFailureSummary"> & { + message?: XcString; + fileName: XcString; + lineNumber: XcInt; + isPerformanceFailure: XcBool; + uuid: XcString; + issueType?: XcString; + detailedDescription?: XcString; + attachments: XcArray; + associatedError?: XcTestAssociatedError; + sourceCodeContext?: XcSourceCodeContext; + timestamp?: XcDate; +}; + +export type XcActionTestAttachment = XcObject<"ActionTestAttachment"> & { + uniformTypeIdentifier: XcString; + name?: XcString; + uuid?: XcString; + timestamp?: XcDate; + userInfo?: XcSortedKeyValueArray; + lifetime: XcString; + inActivityIdentifier: XcInt; + filename?: XcString; + payloadRef?: XcReference; + payloadSize: XcInt; +}; + +export type XcTestAssociatedError = XcObject<"TestAssociatedError"> & { + domain?: XcString; + code?: XcInt; + userInfo?: XcSortedKeyValueArray; +}; + +export type XcSourceCodeContext = XcObject<"SourceCodeContext"> & { + location?: XcSourceCodeLocation; + callStack: XcArray; +}; + +export type XcSourceCodeLocation = XcObject<"SourceCodeLocation"> & { + filePath?: XcString; + lineNumber?: XcInt; +}; + +export type XcSourceCodeFrame = XcObject<"SourceCodeFrame"> & { + addressString?: XcString; + sumbolInfo?: XcSourceCodeSymbolInfo; +}; + +export type XcSourceCodeSymbolInfo = XcObject<"SourceCodeSymbolInfo"> & { + imageName?: XcString; + symbolName?: XcString; + location?: XcSourceCodeLocation; +}; + +export type XcActionTestPerformanceMetricSummary = XcObject<"ActionTestPerformanceMetricSummary"> & { + displayName: XcString; + unitOfMeasurement: XcString; + measurements: XcArray; + identifier?: XcString; + baselineName?: XcString; + baselineAverage?: XcDouble; + maxPercentRegression?: XcDouble; + maxPercentRelativeStandardDeviation?: XcDouble; + maxRegression?: XcDouble; + maxStandardDeviation?: XcDouble; + polarity?: XcString; +}; + +export type XcActionTestExpectedFailure = XcObject<"ActionTestExpectedFailure"> & { + uuid: XcString; + failureReason?: XcString; + failureSummary?: XcActionTestFailureSummary; + isTopLevelFailure: XcBool; +}; + +export type XcActionTestNoticeSummary = XcObject<"ActionTestNoticeSummary"> & { + message?: XcString; + fileName: XcString; + lineNumber: XcInt; + timestamp?: XcDate; +}; + +export type XcActionTestActivitySummary = XcObject<"ActionTestActivitySummary"> & { + title: XcString; + activityType: XcString; + uuid: XcString; + start?: XcDate; + finish?: XcDate; + attachments: XcArray; + subactivities: XcArray; + failureSummaryIDs: XcArray; + expectedFailureIDs: XcArray; + warningSummaryIDs: XcArray; +}; + +export type XcActionTestRepetitionPolicySummary = XcObject<"ActionTestRepetitionPolicySummary"> & { + iteration?: XcInt; + totalIterations?: XcInt; + repetitionMode?: XcString; +}; + +export type XcTestArgument = XcObject<"TestArgument"> & { + parameter?: XcTestParameter; + identifier?: XcString; + description: XcString; + debugDescription?: XcString; + typeName?: XcString; + value: XcTestValue; +}; + +export type XcActionTestConfiguration = XcObject<"ActionTestConfiguration"> & { + values: XcSortedKeyValueArray; +}; + +export type XcActionTestIssueSummary = XcObject<"ActionTestIssueSummary"> & { + message?: XcString; + fileName: XcString; + lineNumber: XcInt; + uuid: XcString; + issueType?: XcString; + detailedDescription?: XcString; + attachments: XcArray; + associatedError?: XcTestAssociatedError; + sourceCodeContext?: XcSourceCodeContext; + timestamp?: XcDate; +}; + +export type XcTestDocumentation = XcObject<"TestDocumentation"> & { + content: XcString; + format: XcString; +}; + +export type XcIssueTrackingMetadata = XcObject<"IssueTrackingMetadata"> & { + identifier: XcString; + url?: XcURL; + comment?: XcString; + summary: XcString; +}; + +export type XcTestTag = XcObject<"TestTag"> & { + identifier: XcString; + name: XcString; + anchors: XcArray; +}; + +export type XcTestParameter = XcObject<"TestParameter"> & { + label: XcString; + name?: XcString; + typeName?: XcString; + fullyQualifiedTypeName?: XcString; +}; + +export type XcTestValue = XcObject<"TestValue"> & { + description: XcString; + debugDescription?: XcString; + typeName?: XcString; + fullyQualifiedTypeName?: XcString; + label?: XcString; + isCollection: XcBool; + children: XcArray; +}; From a1496f2c2084769af01df663cefcf6b2a936597e Mon Sep 17 00:00:00 2001 From: Maksim Stepanov <17935127+delatrie@users.noreply.github.com> Date: Fri, 7 Feb 2025 00:02:43 +0700 Subject: [PATCH 04/17] xcresult: split models/cli between reader, new API, legacy API --- packages/reader/src/xcresult/index.ts | 45 +++--- packages/reader/src/xcresult/model.ts | 137 +--------------- .../{xcUtils.ts => xcresulttool/cli.ts} | 23 ++- .../reader/src/xcresult/xcresulttool/index.ts | 0 .../src/xcresult/xcresulttool/legacy/cli.ts | 37 +++++ .../src/xcresult/xcresulttool/legacy/index.ts | 0 .../src/xcresult/xcresulttool/legacy/model.ts | 31 ++-- .../xcresult/xcresulttool/legacy/parsing.ts | 14 ++ .../reader/src/xcresult/xcresulttool/model.ts | 150 ++++++++++++++++++ 9 files changed, 250 insertions(+), 187 deletions(-) rename packages/reader/src/xcresult/{xcUtils.ts => xcresulttool/cli.ts} (60%) create mode 100644 packages/reader/src/xcresult/xcresulttool/index.ts create mode 100644 packages/reader/src/xcresult/xcresulttool/legacy/cli.ts create mode 100644 packages/reader/src/xcresult/xcresulttool/legacy/index.ts create mode 100644 packages/reader/src/xcresult/xcresulttool/legacy/parsing.ts create mode 100644 packages/reader/src/xcresult/xcresulttool/model.ts diff --git a/packages/reader/src/xcresult/index.ts b/packages/reader/src/xcresult/index.ts index f18c754b..1d690eb4 100644 --- a/packages/reader/src/xcresult/index.ts +++ b/packages/reader/src/xcresult/index.ts @@ -28,19 +28,18 @@ import { isString, } from "../validation.js"; import type { ShallowKnown, Unknown } from "../validation.js"; -import { XcTestNodeTypeValues, XcTestResultValues } from "./model.js"; +import type { TestDetailsRunData, TestRunCoordinates } from "./model.js"; +import { exportAttachments, getTestActivities, getTestDetails, getTests } from "./xcresulttool/cli.js"; +import { XcTestNodeTypeValues, XcTestResultValues } from "./xcresulttool/model.js"; import type { - TestDetailsRunData, - TestRunCoordinates, + XcActivityNode, + XcAttachment, + XcDevice, XcParsingContext, - XcTestActivityAttachment, - XcTestActivityNode, + XcTestNode, XcTestResult, - XcTestResultNode, XcTestRunArgument, - XcTestRunDevice, -} from "./model.js"; -import { exportAttachments, getTestActivities, getTestDetails, getTests } from "./xcUtils.js"; +} from "./xcresulttool/model.js"; const DEFAULT_BUNDLE_NAME = "The test bundle name is not defined"; const DEFAULT_SUITE_NAME = "The test suite name is not defined"; @@ -103,7 +102,7 @@ export const xcresult: ResultsReader = { const processXcResultNode = async function* ( ctx: XcParsingContext, - node: ShallowKnown, + node: ShallowKnown, ): AsyncGenerator { const { nodeType } = node; @@ -118,13 +117,13 @@ const processXcResultNode = async function* ( } }; -const processXcBundleNode = async function* (ctx: XcParsingContext, node: ShallowKnown) { +const processXcBundleNode = async function* (ctx: XcParsingContext, node: ShallowKnown) { const { children, name } = node; yield* processXcNodes({ ...ctx, bundle: ensureString(name) ?? DEFAULT_BUNDLE_NAME }, ensureArray(children) ?? []); }; -const processXcTestSuiteNode = async function* (ctx: XcParsingContext, node: ShallowKnown) { +const processXcTestSuiteNode = async function* (ctx: XcParsingContext, node: ShallowKnown) { const { children, name } = node; yield* processXcNodes( @@ -135,7 +134,7 @@ const processXcTestSuiteNode = async function* (ctx: XcParsingContext, node: Sha const processXcTestCaseNode = async function* ( { filename, bundle, suites, attachmentsDir }: XcParsingContext, - node: ShallowKnown, + node: ShallowKnown, ) { const { nodeIdentifier, name: displayName } = node; if (isString(nodeIdentifier)) { @@ -199,7 +198,7 @@ const processXcTestCaseNode = async function* ( const convertXcActivitiesToAllureSteps = ( attachmentsDir: string, - activities: Unknown, + activities: Unknown, parentActivityAttachments: Iterator<{ potentialNames: Set; uuid: string }> = [].values(), ): { steps: RawStep[] | undefined; attachmentFiles: ResultFile[] } => { const attachmentFiles: ResultFile[] = []; @@ -266,8 +265,8 @@ const convertXcActivitiesToAllureSteps = ( const isAttachmentActivity = ( potentialAttachmentNames: Set | undefined, title: string, - childActivities: Unknown, - attachments: Unknown, + childActivities: Unknown, + attachments: Unknown, ) => typeof childActivities === "undefined" && typeof attachments === "undefined" && @@ -311,7 +310,7 @@ const pairParameterNamesWithValues = ( const convertActivitiesTestRunArgs = (args: Unknown): (string | undefined)[] => isArray(args) ? args.map((a) => (isObject(a) && isString(a.value) ? a.value : undefined)) : []; -const createTestDetailsRunLookup = (nodes: Unknown) => +const createTestDetailsRunLookup = (nodes: Unknown) => groupByMap( collectRunsFromTestDetails(nodes), ([{ device }]) => device ?? SURROGATE_DEVICE_ID, @@ -368,7 +367,7 @@ const findNextAttemptDataFromTestDetails = ( const getArgKey = (args: readonly (string | undefined)[]) => args.filter((v) => typeof v !== "undefined").join(", "); const collectRunsFromTestDetails = ( - nodes: Unknown, + nodes: Unknown, coordinates: TestRunCoordinates = {}, ): [TestRunCoordinates, TestDetailsRunData][] => { return (ensureArrayWithItems(nodes, isObject) ?? []).flatMap((node) => { @@ -419,10 +418,10 @@ const collectRunsFromTestDetails = ( }); }; -const extractArguments = (nodes: Unknown) => { +const extractArguments = (nodes: Unknown) => { if (isArray(nodes)) { const argumentsNodeIndex = nodes.findIndex((node) => isObject(node) && isLiteral(node.nodeType, ["Arguments"])); - const { children } = nodes.splice(argumentsNodeIndex, 1)[0] as any as ShallowKnown; + const { children } = nodes.splice(argumentsNodeIndex, 1)[0] as any as ShallowKnown; return (ensureArrayWithItems(children, isObject) ?? []) .filter(({ nodeType }) => isLiteral(nodeType, ["Test Value"])) .map(({ name }) => { @@ -486,7 +485,7 @@ const convertTestCaseLabels = ( return labels; }; -const processActivityTestRunDevice = (device: Unknown, showDevice: boolean) => { +const processActivityTestRunDevice = (device: Unknown, showDevice: boolean) => { const labels: RawTestLabel[] = []; const parameters: RawTestParameter[] = []; @@ -507,7 +506,7 @@ const processActivityTestRunDevice = (device: Unknown, showDevi return { labels, parameters, deviceId: ensureString(deviceId) }; }; -const convertHost = (device: Unknown) => { +const convertHost = (device: Unknown) => { if (isObject(device)) { const { deviceName, deviceId } = device; return ensureString(deviceName) ?? ensureString(deviceId); @@ -519,7 +518,7 @@ const convertTestClassAndMethod = (testId: string) => { return [parts.slice(0, -1).join("."), parts.at(-1)]; }; -const processXcNodes = async function* (ctx: XcParsingContext, children: readonly Unknown[]) { +const processXcNodes = async function* (ctx: XcParsingContext, children: readonly Unknown[]) { for (const child of children) { if (isObject(child)) { yield* processXcResultNode(ctx, child); diff --git a/packages/reader/src/xcresult/model.ts b/packages/reader/src/xcresult/model.ts index 94aa1609..24f10add 100644 --- a/packages/reader/src/xcresult/model.ts +++ b/packages/reader/src/xcresult/model.ts @@ -1,139 +1,4 @@ -export type XcTestResultCollection = { - testPlanConfigurations: XcTestPlanConfiguration[]; - devices: XcTestRunDevice[]; - testNodes: XcTestResultNode[]; -}; - -export type XcTestDetails = { - testIdentifier: string; - testName: string; - testDescription: string; - duration: string; - startTime?: number; - testPlanConfiguration: XcTestPlanConfiguration[]; - devices: XcTestRunDevice[]; - arguments?: XcTestResultArgument[]; - testRuns: XcTestResultNode[]; - testResult: XcTestResult; - hasPerformanceMetrics: boolean; - hasMediaAttachments: boolean; - tags?: string[]; - bugs?: XcBug[]; - functionName?: string; -}; - -export type XcTestActivityCollection = { - testIdentifier: string; - testName: string; - testRuns: XcTestRunActivity[]; -}; - -export type XcTestRunActivity = { - device: XcTestRunDevice; - testPlanConfiguration: XcTestPlanConfiguration; - arguments?: XcTestRunArgument[]; - activities: XcTestActivityNode[]; -}; - -export type XcTestPlanConfiguration = { - configurationId: string; - configurationName: string; -}; - -export type XcTestRunDevice = { - deviceId?: string; - deviceName: string; - architecture: string; - modelName: string; - platform?: string; - osVersion: string; -}; - -export type XcTestRunArgument = { - value: string; -}; - -export type XcTestActivityNode = { - title: string; - startTime?: number; - attachments?: XcTestActivityAttachment[]; - childActivities?: XcTestActivityNode[]; -}; - -export type XcTestActivityAttachment = { - name: string; - payloadId?: string; - uuid: string; - timestamp: number; - lifetime?: string; -}; - -export type XcTestResultNode = { - nodeIdentifier?: string; - nodeType: XcTestNodeType; - name: string; - details?: string; - duration?: string; - result?: XcTestResult; - tags?: string[]; - children?: XcTestResultNode[]; -}; - -export type XcBug = { - url?: string; - identifier?: string; - title?: string; -}; - -export const XcTestNodeTypeValues = [ - "Test Plan", - "Unit test bundle", - "UI test bundle", - "Test Suite", - "Test Case", - "Device", - "Test Plan Configuration", - "Arguments", - "Repetition", - "Test Case Run", - "Failure Message", - "Source Code Reference", - "Attachment", - "Expression", - "Test Value", -] as const; - -export type XcTestNodeType = (typeof XcTestNodeTypeValues)[number]; - -export const XcTestResultValues = ["Passed", "Failed", "Skipped", "Expected Failure", "unknown"] as const; - -export type XcTestResult = (typeof XcTestResultValues)[number]; - -export type XcTestResultArgument = { - value: string; -}; - -export type XcTestAttachmentsManifestEntry = { - attachments: XcTestAttachment[]; - testIdentifier: string; -}; - -export type XcTestAttachment = { - configurationName: string; - deviceId: string; - deviceName: string; - exportedFileName: string; - isAssociatedWithFailure: boolean; - suggestedHumanReadableName: string; - timestamp: number; -}; - -export type XcParsingContext = { - filename: string; - suites: readonly string[]; - bundle?: string; - attachmentsDir: string; -}; +import type { XcTestResult } from "./xcresulttool/model.js"; export type XcAttachments = Map; diff --git a/packages/reader/src/xcresult/xcUtils.ts b/packages/reader/src/xcresult/xcresulttool/cli.ts similarity index 60% rename from packages/reader/src/xcresult/xcUtils.ts rename to packages/reader/src/xcresult/xcresulttool/cli.ts index ca7b03ce..8235c01e 100644 --- a/packages/reader/src/xcresult/xcUtils.ts +++ b/packages/reader/src/xcresult/xcresulttool/cli.ts @@ -1,28 +1,25 @@ -import { invokeCliTool, invokeJsonCliTool } from "../toolRunner.js"; -import type { XcTestActivityCollection, XcTestDetails, XcTestResultCollection } from "./model.js"; +import console from "node:console"; +import { invokeCliTool, invokeJsonCliTool } from "../../toolRunner.js"; +import type { XcActivities, XcTestDetails, XcTests } from "./model.js"; export const xcrun = async (utilityName: string, ...args: readonly string[]) => { - return await invokeJsonCliTool("xcrun", [utilityName, ...args], { timeout: 1000 }); + try { + return await invokeJsonCliTool("xcrun", [utilityName, ...args], { timeout: 1000 }); + } catch (e) { + console.error(e); + } }; export const xcresulttool = async (...args: readonly string[]) => await xcrun("xcresulttool", ...args); export const getTests = async (xcResultPath: string) => - await xcresulttool("get", "test-results", "tests", "--path", xcResultPath); + await xcresulttool("get", "test-results", "tests", "--path", xcResultPath); export const getTestDetails = async (xcResultPath: string, testId: string) => await xcresulttool("get", "test-results", "test-details", "--test-id", testId, "--path", xcResultPath); export const getTestActivities = async (xcResultPath: string, testId: string) => - await xcresulttool( - "get", - "test-results", - "activities", - "--test-id", - testId, - "--path", - xcResultPath, - ); + await xcresulttool("get", "test-results", "activities", "--test-id", testId, "--path", xcResultPath); export const exportAttachments = async (xcResultPath: string, outputPath: string) => { await invokeCliTool("xcrun", [ diff --git a/packages/reader/src/xcresult/xcresulttool/index.ts b/packages/reader/src/xcresult/xcresulttool/index.ts new file mode 100644 index 00000000..e69de29b diff --git a/packages/reader/src/xcresult/xcresulttool/legacy/cli.ts b/packages/reader/src/xcresult/xcresulttool/legacy/cli.ts new file mode 100644 index 00000000..f093ae6b --- /dev/null +++ b/packages/reader/src/xcresult/xcresulttool/legacy/cli.ts @@ -0,0 +1,37 @@ +import console from "node:console"; +import type { Unknown } from "../../../validation.js"; +import { xcresulttool } from "../cli.js"; +import type { XcActionsInvocationRecord, XcReference } from "./model.js"; +import { getRef } from "./parsing.js"; + +let legacyRunSucceeded = false; +let noLegacyApi = false; + +export const xcresulttoolGetLegacy = async ( + xcResultPath: string, + ...args: readonly string[] +): Promise> => { + if (noLegacyApi) { + return undefined; + } + + const result = await xcresulttool("get", "--legacy", "--format", "json", "--path", xcResultPath, ...args); + if (typeof result === "undefined") { + if (!legacyRunSucceeded) { + noLegacyApi = true; + console.warn("The legacy API of xcresulttool is unavailable"); + } + return undefined; + } + + legacyRunSucceeded = true; + return result; +}; + +export const getRoot = async (xcResultPath: string) => + await xcresulttoolGetLegacy(xcResultPath); + +export const getById = async (xcResultPath: string, ref: Unknown) => { + const id = getRef(ref); + return id ? await xcresulttoolGetLegacy("--id", id) : undefined; +}; diff --git a/packages/reader/src/xcresult/xcresulttool/legacy/index.ts b/packages/reader/src/xcresult/xcresulttool/legacy/index.ts new file mode 100644 index 00000000..e69de29b diff --git a/packages/reader/src/xcresult/xcresulttool/legacy/model.ts b/packages/reader/src/xcresult/xcresulttool/legacy/model.ts index d33adf64..14abbcce 100644 --- a/packages/reader/src/xcresult/xcresulttool/legacy/model.ts +++ b/packages/reader/src/xcresult/xcresulttool/legacy/model.ts @@ -57,7 +57,7 @@ export type XcReference = XcObject<"Reference"> & { }; /** - * `get object` without --id + * `xcrun xcresulttool get object` (without --id) */ export type XcActionsInvocationRecord = XcObject<"ActionsInvocationRecord"> & { metadataRef?: XcReference; @@ -187,7 +187,7 @@ export type XcActionPlatformRecord = XcObject<"ActionPlatformRecord"> & { }; /** - * `get object` with --id of XcActionsInvocationRecord.actions[number].actionResult.testsRef + * `xcrun xcresulttool get object --id '...'` with --id of XcActionsInvocationRecord.actions[number].actionResult.testsRef */ export type XcActionTestPlanRunSummaries = XcObject<"ActionTestPlanRunSummaries"> & { summaries: XcArray; @@ -197,12 +197,10 @@ export type XcActionAbstractTestSummary = XcObject & name?: XcString; }; -// done export type XcActionTestPlanRunSummary = XcActionAbstractTestSummary<"ActionTestPlanRunSummary"> & { testableSummaries: XcArray; }; -// done export type XcActionTestableSummary = XcActionAbstractTestSummary<"ActionTestableSummary"> & { identifierURL?: XcString; projectRelativePath?: XcString; @@ -215,7 +213,6 @@ export type XcActionTestableSummary = XcActionAbstractTestSummary<"ActionTestabl testRegion?: XcString; }; -// cur export type XcActionTestSummaryIdentifiableObjectBase = XcActionAbstractTestSummary & { identifier?: XcString; identifierURL?: XcString; @@ -230,6 +227,20 @@ export type XcActionTestMetadata = XcActionTestSummaryIdentifiableObjectBase<"Ac activitySummariesCount?: XcInt; }; +export type XcActionTestSummaryGroup = XcActionTestSummaryIdentifiableObjectBase<"ActionTestSummaryGroup"> & { + duration: XcDouble; + subtests: XcArray; + skipNoticeSummary?: XcActionTestNoticeSummary; + summary?: XcString; + documentation: XcArray; + trackedIssues: XcArray; + tags: XcArray; +}; + +/** + * `xcrun xcresulttool get object --id '...'` with --id of + * XcActionTestPlanRunSummaries.summaries[number].testableSummaries[number].tests[number](.subtests[number])*.summaryRef + */ export type XcActionTestSummary = XcActionTestSummaryIdentifiableObjectBase<"ActionTestSummary"> & { testStatus: XcString; duration: XcDouble; @@ -248,16 +259,6 @@ export type XcActionTestSummary = XcActionTestSummaryIdentifiableObjectBase<"Act tags: XcArray; }; -export type XcActionTestSummaryGroup = XcActionTestSummaryIdentifiableObjectBase<"ActionTestSummaryGroup"> & { - duration: XcDouble; - subtests: XcArray; - skipNoticeSummary?: XcActionTestNoticeSummary; - summary?: XcString; - documentation: XcArray; - trackedIssues: XcArray; - tags: XcArray; -}; - export type XcActionTestSummaryIdentifiableObject = | XcActionTestMetadata | XcActionTestSummary diff --git a/packages/reader/src/xcresult/xcresulttool/legacy/parsing.ts b/packages/reader/src/xcresult/xcresulttool/legacy/parsing.ts new file mode 100644 index 00000000..c5bd7b9f --- /dev/null +++ b/packages/reader/src/xcresult/xcresulttool/legacy/parsing.ts @@ -0,0 +1,14 @@ +/* eslint no-underscore-dangle: ["error", { "allow": ["_value"] }] */ +import type { Unknown } from "../../../validation.js"; +import { ensureObject, ensureString } from "../../../validation.js"; +import type { XcReference, XcString } from "./model.js"; + +export const getString = (value: Unknown) => { + const obj = ensureObject(value); + return obj ? ensureString(obj._value) : undefined; +}; + +export const getRef = (ref: Unknown) => { + const obj = ensureObject(ref); + return obj ? getString(obj.id) : undefined; +}; diff --git a/packages/reader/src/xcresult/xcresulttool/model.ts b/packages/reader/src/xcresult/xcresulttool/model.ts new file mode 100644 index 00000000..a73119bf --- /dev/null +++ b/packages/reader/src/xcresult/xcresulttool/model.ts @@ -0,0 +1,150 @@ +/** + * `xcrun xcresulttool get test-results tests` + */ +export type XcTests = { + testPlanConfigurations: XcConfiguration[]; + devices: XcDevice[]; + testNodes: XcTestNode[]; +}; + +export type XcConfiguration = { + configurationId: string; + configurationName: string; +}; + +export type XcDevice = { + deviceId?: string; + deviceName: string; + architecture: string; + modelName: string; + platform?: string; + osVersion: string; +}; + +export type XcTestNode = { + nodeIdentifier?: string; + nodeType: XcTestNodeType; + name: string; + details?: string; + duration?: string; + result?: XcTestResult; + tags?: string[]; + children?: XcTestNode[]; +}; + +export const XcTestNodeTypeValues = [ + "Test Plan", + "Unit test bundle", + "UI test bundle", + "Test Suite", + "Test Case", + "Device", + "Test Plan Configuration", + "Arguments", + "Repetition", + "Test Case Run", + "Failure Message", + "Source Code Reference", + "Attachment", + "Expression", + "Test Value", +] as const; + +export type XcTestNodeType = (typeof XcTestNodeTypeValues)[number]; + +export const XcTestResultValues = ["Passed", "Failed", "Skipped", "Expected Failure", "unknown"] as const; + +export type XcTestResult = (typeof XcTestResultValues)[number]; + +/** + * `xcrun xcresulttool get test-results test-details --test-id '...'`, where --test-id is the value of + * XcTests.testNodes[number](.children[number])*.nodeIdentifier + */ +export type XcTestDetails = { + testIdentifier: string; + testName: string; + testDescription: string; + duration: string; + startTime?: number; + testPlanConfiguration: XcConfiguration[]; + devices: XcDevice[]; + arguments?: XcTestResultArgument[]; + testRuns: XcTestNode[]; + testResult: XcTestResult; + hasPerformanceMetrics: boolean; + hasMediaAttachments: boolean; + tags?: string[]; + bugs?: XcBug[]; + functionName?: string; +}; + +export type XcTestResultArgument = { + value: string; +}; + +export type XcBug = { + url?: string; + identifier?: string; + title?: string; +}; + +/** + * `xcrun xcresulttool get test-results activities --test-id '...'`, where --test-id is the value of + * XcTests.testNodes[number](.children[number])*.nodeIdentifier + */ +export type XcActivities = { + testIdentifier: string; + testName: string; + testRuns: XcTestRunActivity[]; +}; + +export type XcTestRunActivity = { + device: XcDevice; + testPlanConfiguration: XcConfiguration; + arguments?: XcTestRunArgument[]; + activities: XcActivityNode[]; +}; + +export type XcTestRunArgument = { + value: string; +}; + +export type XcActivityNode = { + title: string; + startTime?: number; + attachments?: XcAttachment[]; + childActivities?: XcActivityNode[]; +}; + +export type XcAttachment = { + name: string; + payloadId?: string; + uuid: string; + timestamp: number; + lifetime?: string; +}; + +/** + * The type of the manifest entry created by `xcrun resulttool export attachments` + */ +export type XcTestAttachmentDetails = { + attachments: XcTestAttachment[]; + testIdentifier: string; +}; + +export type XcTestAttachment = { + configurationName: string; + deviceId: string; + deviceName: string; + exportedFileName: string; + isAssociatedWithFailure: boolean; + suggestedHumanReadableName: string; + timestamp: number; +}; + +export type XcParsingContext = { + filename: string; + suites: readonly string[]; + bundle?: string; + attachmentsDir: string; +}; From c71f61ee0777308de8a682dad763efae116bf74a Mon Sep 17 00:00:00 2001 From: Maksim Stepanov <17935127+delatrie@users.noreply.github.com> Date: Wed, 12 Feb 2025 08:19:12 +0700 Subject: [PATCH 05/17] feat: support legacy xcresulttool API --- packages/reader/src/validation.ts | 14 +- packages/reader/src/xcresult/index.ts | 95 +-- packages/reader/src/xcresult/model.ts | 59 +- packages/reader/src/xcresult/utils.ts | 343 +++++++++ .../src/xcresult/xcresulttool/legacy/cli.ts | 4 +- .../src/xcresult/xcresulttool/legacy/index.ts | 653 ++++++++++++++++++ .../src/xcresult/xcresulttool/legacy/model.ts | 450 ++---------- .../xcresult/xcresulttool/legacy/parsing.ts | 77 ++- .../src/xcresult/xcresulttool/legacy/utils.ts | 41 ++ .../xcresult/xcresulttool/legacy/xcModel.ts | 441 ++++++++++++ 10 files changed, 1693 insertions(+), 484 deletions(-) create mode 100644 packages/reader/src/xcresult/utils.ts create mode 100644 packages/reader/src/xcresult/xcresulttool/legacy/utils.ts create mode 100644 packages/reader/src/xcresult/xcresulttool/legacy/xcModel.ts diff --git a/packages/reader/src/validation.ts b/packages/reader/src/validation.ts index 7248b4b1..8bc3e6a6 100644 --- a/packages/reader/src/validation.ts +++ b/packages/reader/src/validation.ts @@ -48,7 +48,7 @@ export type Narrow = [SubType] extends [never] /** * Infers the element type of an array. This type is distributive. */ -export type ArrayElement = T extends readonly (infer E)[] ? (E[] extends T ? E : never) : never; +export type ArrayElement = T extends readonly (infer E)[] ? E : never; /** * Returns the argument as is if it's an object (but not array), all properties of which are of the same type. @@ -97,6 +97,16 @@ export type HomogeneousObjectItem = T extends object */ export type ConditionalUnion = [CA] extends [never] ? B : [CB] extends [never] ? A : A | B; +/** + * A type guard to check possibly undefined values. + * @example + * ```ts + * const withUndefined: (string | undefined)[] = ["foo", undefined, "bar"]; + * const withoudUndefined = withUndefined.filter(isDefined); + * ``` + */ +export const isDefined = (value: T | undefined): value is T => typeof value !== "undefined"; + /** * A type guard to check string values. * @example @@ -236,7 +246,7 @@ export const ensureInt = (value: Unknown): number | undefined => { }; /** - * If the value is an array or tuple, marks it as `ShallowKnown` and returns as is. Otherwise, returns `undefined`. + * If the value is an array or a tuple, marks it as `ShallowKnown` and returns as is. Otherwise, returns `undefined`. * @example * ```ts * const raw: Unknown = JSON.parse("[1, 2, 3]"); diff --git a/packages/reader/src/xcresult/index.ts b/packages/reader/src/xcresult/index.ts index 1d690eb4..ec8f1b1a 100644 --- a/packages/reader/src/xcresult/index.ts +++ b/packages/reader/src/xcresult/index.ts @@ -28,7 +28,16 @@ import { isString, } from "../validation.js"; import type { ShallowKnown, Unknown } from "../validation.js"; -import type { TestDetailsRunData, TestRunCoordinates } from "./model.js"; +import type { TestDetailsRunData, TestRunCoordinates, TestRunSelector } from "./model.js"; +import { + DEFAULT_BUNDLE_NAME, + DEFAULT_SUITE_NAME, + DEFAULT_TEST_NAME, + createTestRunLookup, + getTargetDetails, + lookupNextTestAttempt, + secondsToMilliseconds, +} from "./utils.js"; import { exportAttachments, getTestActivities, getTestDetails, getTests } from "./xcresulttool/cli.js"; import { XcTestNodeTypeValues, XcTestResultValues } from "./xcresulttool/model.js"; import type { @@ -41,15 +50,6 @@ import type { XcTestRunArgument, } from "./xcresulttool/model.js"; -const DEFAULT_BUNDLE_NAME = "The test bundle name is not defined"; -const DEFAULT_SUITE_NAME = "The test suite name is not defined"; -const DEFAULT_TEST_NAME = "The test name is not defined"; - -const SURROGATE_DEVICE_ID = randomUUID(); -const SURROGATE_TEST_PLAN_ID = randomUUID(); -const SURROGATE_ARGS_ID = randomUUID(); - -const MS_IN_S = 1_000; const DURATION_PATTERN = /\d+\.\d+/; const ATTACHMENT_NAME_INFIX_PATTERN = /_\d+_[\dA-F]{8}-[\dA-F]{4}-[\dA-F]{4}-[\dA-F]{4}-[\dA-F]{12}/g; @@ -171,7 +171,11 @@ const processXcTestCaseNode = async function* ( duration, parameters = [], result = "unknown", - } = findNextAttemptDataFromTestDetails(detailsRunLookup, deviceId, ensureString(configurationId), args) ?? {}; + } = findNextAttemptDataFromTestDetails(detailsRunLookup, { + device: deviceId, + testPlan: ensureString(configurationId), + args, + }) ?? {}; const { steps, attachmentFiles } = convertXcActivitiesToAllureSteps(attachmentsDir, activities); @@ -311,61 +315,19 @@ const convertActivitiesTestRunArgs = (args: Unknown): (stri isArray(args) ? args.map((a) => (isObject(a) && isString(a.value) ? a.value : undefined)) : []; const createTestDetailsRunLookup = (nodes: Unknown) => - groupByMap( - collectRunsFromTestDetails(nodes), - ([{ device }]) => device ?? SURROGATE_DEVICE_ID, - (deviceRuns) => - groupByMap( - deviceRuns, - ([{ testPlan: configuration }]) => configuration ?? SURROGATE_TEST_PLAN_ID, - (configRuns) => - groupByMap( - configRuns, - ([{ args }]) => (args && args.length ? getArgKey(args.map((arg) => arg?.value)) : SURROGATE_ARGS_ID), - (argRuns) => { - // Make sure retries are ordered by the repetition index - argRuns.sort(([{ attempt: attemptA }], [{ attempt: attemptB }]) => (attemptA ?? 0) - (attemptB ?? 0)); - return argRuns.map(([, data]) => data); - }, - ), - ), - ); - -const groupBy = (values: T[], keyFn: (v: T) => K): Map => - values.reduce((m, v) => { - const key = keyFn(v); - if (!m.get(key)?.push(v)) { - m.set(key, [v]); - } - return m; - }, new Map()); - -const groupByMap = (values: T[], keyFn: (v: T) => K, groupMapFn: (group: T[]) => G): Map => - new Map( - groupBy(values, keyFn) - .entries() - .map(([k, g]) => [k, groupMapFn(g)]), - ); + createTestRunLookup(collectRunsFromTestDetails(nodes)); const findNextAttemptDataFromTestDetails = ( lookup: Map>>, - device: string | undefined, - testPlan: string | undefined, - args: readonly (string | undefined)[] | undefined, + selector: TestRunSelector, ) => { - const attempt = lookup - .get(device ?? SURROGATE_DEVICE_ID) - ?.get(testPlan ?? SURROGATE_TEST_PLAN_ID) - ?.get(args && args.length ? getArgKey(args) : SURROGATE_ARGS_ID) - ?.find(({ emitted }) => !emitted); + const attempt = lookupNextTestAttempt(lookup, selector, ({ emitted }) => !emitted); if (attempt) { attempt.emitted = true; } return attempt; }; -const getArgKey = (args: readonly (string | undefined)[]) => args.filter((v) => typeof v !== "undefined").join(", "); - const collectRunsFromTestDetails = ( nodes: Unknown, coordinates: TestRunCoordinates = {}, @@ -410,7 +372,7 @@ const collectRunsFromTestDetails = ( coordinates, { duration: parseDuration(duration), - parameters: coordinates.args?.map((arg) => arg?.name) ?? [], + parameters: coordinates.args?.map((arg) => arg?.parameter) ?? [], result: ensureLiteral(result, XcTestResultValues) ?? "unknown", }, ] @@ -429,7 +391,7 @@ const extractArguments = (nodes: Unknown) => { const colonIndex = name.indexOf(":"); if (colonIndex !== -1) { return { - name: name.slice(0, colonIndex).trim(), + parameter: name.slice(0, colonIndex).trim(), value: name.slice(colonIndex + 1).trim(), }; } @@ -494,12 +456,17 @@ const processActivityTestRunDevice = (device: Unknown, showDevice: boo const host = convertHost(device); if (isString(deviceName) && deviceName) { labels.push({ name: "host", value: host }); - parameters.push({ name: "Device name", value: deviceName, hidden: !showDevice }); + parameters.push({ name: "Target", value: deviceName, hidden: !showDevice }); if (showDevice) { - const osPart = isString(platform) ? (isString(osVersion) ? `${platform} ${osVersion}` : platform) : undefined; - const deviceDetailParts = [modelName, architecture, osPart]; - const deviceDetails = ensureArrayWithItems(deviceDetailParts, isString)?.join(", ") ?? ""; - parameters.push({ name: "Device details", value: deviceDetails, excluded: true }); + const targetDetails = getTargetDetails({ + architecture: ensureString(architecture), + model: ensureString(modelName), + platform: ensureString(platform), + osVersion: ensureString(osVersion), + }); + if (targetDetails) { + parameters.push({ name: "Target details", value: targetDetails, excluded: true }); + } } } @@ -534,5 +501,3 @@ const parseDuration = (duration: Unknown) => { } } }; - -const secondsToMilliseconds = (seconds: number) => Math.round(seconds * MS_IN_S); diff --git a/packages/reader/src/xcresult/model.ts b/packages/reader/src/xcresult/model.ts index 24f10add..09d7cd7b 100644 --- a/packages/reader/src/xcresult/model.ts +++ b/packages/reader/src/xcresult/model.ts @@ -1,3 +1,4 @@ +import type { RawTestLabel, RawTestLink, RawTestParameter } from "@allurereport/reader-api"; import type { XcTestResult } from "./xcresulttool/model.js"; export type XcAttachments = Map; @@ -19,5 +20,61 @@ export type TestRunCoordinates = { device?: string; testPlan?: string; attempt?: number; - args?: ({ name: string; value: string } | undefined)[]; + args?: TestRunArgs; +}; + +export type TestRunArgs = ({ parameter: string; value: string } | undefined)[]; + +export type TestRunSelector = { + device?: string; + testPlan?: string; + attempt?: number; + args?: (string | undefined)[]; +}; + +export type TestRunLookup = Map>>; + +export type TargetDescriptor = { + model?: string; + architecture?: string; + platform?: string; + osVersion?: string; +}; + +export type AllureApiCallBase = { + type: Type; + value: Value; +}; + +export type AllureNameApiCall = AllureApiCallBase<"name", string>; +export type AllureDescriptionApiCall = AllureApiCallBase<"description", string>; +export type AllurePreconditionApiCall = AllureApiCallBase<"precondition", string>; +export type AllureExpectedResultApiCall = AllureApiCallBase<"expectedResult", string>; +export type AllureLabelApiCall = AllureApiCallBase<"label", RawTestLabel>; +export type AllureLinkApiCall = AllureApiCallBase<"link", RawTestLink>; +export type AllureParameterApiCall = AllureApiCallBase<"parameter", RawTestParameter>; +export type AllureFlakyApiCall = AllureApiCallBase<"flaky", boolean>; +export type AllureMutedApiCall = AllureApiCallBase<"muted", boolean>; +export type AllureKnownApiCall = AllureApiCallBase<"known", boolean>; + +export type AllureApiCall = + | AllureNameApiCall + | AllureDescriptionApiCall + | AllurePreconditionApiCall + | AllureExpectedResultApiCall + | AllureLabelApiCall + | AllureLinkApiCall + | AllureParameterApiCall + | AllureFlakyApiCall + | AllureMutedApiCall + | AllureKnownApiCall; + +export type LabelsInputData = { + hostName: string | undefined; + projectName: string | undefined; + bundle: string | undefined; + suites: readonly string[]; + className: string | undefined; + functionName: string | undefined; + tags: readonly string[]; }; diff --git a/packages/reader/src/xcresult/utils.ts b/packages/reader/src/xcresult/utils.ts new file mode 100644 index 00000000..86da32ad --- /dev/null +++ b/packages/reader/src/xcresult/utils.ts @@ -0,0 +1,343 @@ +import type { + RawStep, + RawTestLabel, + RawTestLink, + RawTestParameter, + RawTestResult, + RawTestStatus, + RawTestStepResult, +} from "@allurereport/reader-api"; +import { randomUUID } from "node:crypto"; +import { isDefined, isNumber } from "../validation.js"; +import type { Unknown } from "../validation.js"; +import type { + AllureApiCall, + LabelsInputData, + TargetDescriptor, + TestRunArgs, + TestRunCoordinates, + TestRunLookup, + TestRunSelector, +} from "./model.js"; + +export const MS_IN_S = 1_000; +export const ALLURE_API_ACTIVITY_PREFIX = "allure."; + +export const statusPriorities = new Map([ + ["failed", 0], + ["broken", 1], + ["unknown", 2], + ["skipped", 3], + ["passed", 4], +]); + +export const getWorstStatus = (steps: readonly RawStep[]): RawTestStatus | undefined => { + const statuses = steps.filter((s): s is RawTestStepResult => "status" in s).map(({ status }) => status ?? "unknown"); + return statuses.sort((a, b) => statusPriorities.get(a)! - statusPriorities.get(b)!)[0]; +}; + +export const DEFAULT_BUNDLE_NAME = "The test bundle name is not defined"; +export const DEFAULT_SUITE_NAME = "The test suite name is not defined"; +export const DEFAULT_TEST_NAME = "The test name is not defined"; +export const DEFAULT_STEP_NAME = "The test name is not defined"; +export const DEFAULT_ATTACHMENT_NAME = "Attachment"; +export const DEFAULT_EXPECTED_FAILURE_REASON = "Expected failure"; + +export const SURROGATE_DEVICE_ID = randomUUID(); +export const SURROGATE_TEST_PLAN_ID = randomUUID(); +export const SURROGATE_ARGS_ID = randomUUID(); + +export const getArgsKeyByValues = (values: readonly (string | undefined)[]) => values.map(String).join(","); + +export const getArgsKey = (args: TestRunArgs) => getArgsKeyByValues(args.map((arg) => arg?.value)); + +export const createTestRunLookup = (entries: readonly (readonly [TestRunCoordinates, T])[]): TestRunLookup => + groupByMap( + entries, + ([{ device }]) => device ?? SURROGATE_DEVICE_ID, + (deviceRuns) => + groupByMap( + deviceRuns, + ([{ testPlan }]) => testPlan ?? SURROGATE_TEST_PLAN_ID, + (configRuns) => + groupByMap( + configRuns, + ([{ args }]) => (args && args.length ? getArgsKey(args) : SURROGATE_ARGS_ID), + (argRuns) => { + // Make sure retries are ordered by the repetition index + argRuns.sort(([{ attempt: attemptA }], [{ attempt: attemptB }]) => (attemptA ?? 0) - (attemptB ?? 0)); + return argRuns.map(([, data]) => data); + }, + ), + ), + ); + +export const lookupTestAttempts = (lookup: TestRunLookup, { args, device, testPlan }: TestRunSelector) => + lookup + .get(device ?? SURROGATE_DEVICE_ID) + ?.get(testPlan ?? SURROGATE_TEST_PLAN_ID) + ?.get(args ? getArgsKeyByValues(args) : SURROGATE_ARGS_ID); + +export const lookupTestAttempt = (lookup: TestRunLookup, selector: TestRunSelector) => { + const attempts = lookupTestAttempts(lookup, selector); + const { attempt = 0 } = selector; + return attempts?.[attempt]; +}; + +export const lookupNextTestAttempt = ( + lookup: TestRunLookup, + selector: TestRunSelector, + pred: (data: Data) => boolean, +) => { + const attempts = lookupTestAttempts(lookup, selector); + return attempts?.find(pred); +}; + +export const groupBy = (values: readonly T[], keyFn: (v: T) => K): Map => + values.reduce((m, v) => { + const key = keyFn(v); + if (!m.get(key)?.push(v)) { + m.set(key, [v]); + } + return m; + }, new Map()); + +export const groupByMap = ( + values: readonly T[], + keyFn: (v: T) => K, + groupMapFn: (group: T[]) => G, +): Map => + new Map( + groupBy(values, keyFn) + .entries() + .map(([k, g]) => [k, groupMapFn(g)]), + ); + +export const getTargetDetails = ({ architecture, model, platform, osVersion }: TargetDescriptor = {}) => { + const osPart = platform ? (osVersion ? `${platform} ${osVersion}` : platform) : undefined; + + return [model, architecture, osPart].filter(isDefined).join(", ") || undefined; // coerce empty string to undefined +}; + +export const compareByStart = ({ start: startA }: RawStep, { start: startB }: RawStep) => (startA ?? 0) - (startB ?? 0); + +export const toSortedSteps = (...stepArrays: readonly (readonly RawStep[])[]) => { + const allSteps = stepArrays.reduce((result, steps) => { + result.push(...steps); + return result; + }, []); + allSteps.sort(compareByStart); + return allSteps; +}; + +export const secondsToMilliseconds = (seconds: Unknown) => + isNumber(seconds) ? Math.round(MS_IN_S * seconds) : undefined; + +export const parseAsAllureApiActivity = (title: string | undefined): AllureApiCall | undefined => { + if (isPotentialAllureApiActivity(title)) { + const maybeApiCall = title.slice(ALLURE_API_ACTIVITY_PREFIX.length); + const apiValueSeparatorIndex = indexOfAny(maybeApiCall, ":", "="); + if (apiValueSeparatorIndex !== -1) { + const apiCall = maybeApiCall.slice(0, apiValueSeparatorIndex).trim(); + const value = maybeApiCall.slice(apiValueSeparatorIndex + 1); + switch (apiCall) { + case "id": + return { type: "label", value: { name: "ALLURE_ID", value } }; + case "name": + return { type: "name", value }; + case "description": + return { type: "description", value }; + case "precondition": + return { type: "precondition", value }; + case "expectedResult": + return { type: "expectedResult", value }; + case "flaky": + return { type: "flaky", value: parseBooleanApiArg(value) }; + case "muted": + return { type: "muted", value: parseBooleanApiArg(value) }; + case "known": + return { type: "known", value: parseBooleanApiArg(value) }; + default: + return parseComplexAllureApiCall(apiCall, value); + } + } + } +}; + +export const applyApiCalls = (testResult: RawTestResult, apiCalls: readonly AllureApiCall[]) => + groupByMap( + apiCalls, + (v) => v.type, + (g) => g.map(({ value }) => value), + ) + .entries() + .forEach(([type, values]) => applyApiCallGroup(testResult, type, values)); + +const applyApiCallGroup = ( + testResult: RawTestResult, + type: AllureApiCall["type"], + values: readonly AllureApiCall["value"][], +) => { + switch (type) { + case "name": + testResult.name = values.at(-1) as string; + break; + case "flaky": + testResult.flaky = values.at(-1) as boolean; + break; + case "muted": + testResult.muted = values.at(-1) as boolean; + break; + case "known": + testResult.known = values.at(-1) as boolean; + break; + case "description": + testResult.description = mergeMarkdownBlocks(testResult.description, ...(values as string[])); + break; + case "precondition": + testResult.precondition = mergeMarkdownBlocks(testResult.precondition, ...(values as string[])); + break; + case "expectedResult": + testResult.expectedResult = mergeMarkdownBlocks(testResult.expectedResult, ...(values as string[])); + break; + case "label": + testResult.labels = [...(testResult.labels ?? []), ...(values as RawTestLabel[])]; + break; + case "link": + testResult.links = [...(testResult.links ?? []), ...(values as RawTestLink[])]; + break; + case "parameter": + testResult.parameters = [...(testResult.parameters ?? []), ...(values as RawTestParameter[])]; + break; + } +}; + +export const createTestLabels = ({ + hostName, + projectName, + bundle, + suites, + className, + functionName, + tags, +}: LabelsInputData) => { + const labels: RawTestLabel[] = []; + + if (hostName) { + labels.push({ name: "host", value: hostName }); + } + + const packageName = [projectName, bundle].filter(isDefined).join("."); + if (packageName) { + labels.push({ name: "package", value: packageName }); + } + if (className) { + labels.push({ name: "testClass", value: className }); + } + if (functionName) { + labels.push({ name: "testMethod", value: functionName }); + } + + if (bundle) { + labels.push({ name: "parentSuite", value: bundle }); + } + + const [suite, ...subSuites] = suites; + + if (suite) { + labels.push({ name: "suite", value: suite }); + } + + const subSuite = subSuites.join(" > "); + if (subSuite) { + labels.push({ name: "subSuite", value: subSuite }); + } + + labels.push(...tags.map((value) => ({ name: "tag", value }))); + + return labels; +}; + +const mergeMarkdownBlocks = (...blocks: readonly (string | undefined)[]) => + blocks.filter(isDefined).reduce((a, b) => `${a}\n\n${b}`); + +const isPotentialAllureApiActivity = (title: string | undefined): title is `allure.${string}` => + isDefined(title) && title.startsWith(ALLURE_API_ACTIVITY_PREFIX); + +const parseComplexAllureApiCall = (apiCall: string, value: string): AllureApiCall | undefined => { + const apiFnEnd = apiCall.indexOf("."); + if (apiFnEnd !== -1) { + const apiFn = apiCall.slice(0, apiFnEnd); + const { primary: primaryOption, secondary: secondaryOptions } = parseAllureApiCallOptions( + apiCall.slice(apiFnEnd + 1), + ); + switch (apiFn) { + case "label": + return parseAllureLabelApiCall(primaryOption, value); + case "link": + return parseAllureLinkApiCall(primaryOption, secondaryOptions, value); + case "parameter": + return parseAllureParameterApiCall(primaryOption, secondaryOptions, value); + } + } +}; + +const parseAllureLabelApiCall = (name: string, value: string): AllureApiCall | undefined => + name ? { type: "label", value: { name, value } } : undefined; + +const parseAllureLinkApiCall = (name: string, [type]: string[], url: string): AllureApiCall | undefined => { + return { type: "link", value: { name, type, url } }; +}; + +const parseAllureParameterApiCall = (name: string, options: string[], value: string): AllureApiCall | undefined => { + const parameter: RawTestParameter = { name, value }; + options.forEach((option) => { + switch (option.toLowerCase()) { + case "hidden": + parameter.hidden = true; + break; + case "excluded": + parameter.excluded = true; + break; + case "masked": + parameter.masked = true; + break; + } + }); + return { type: "parameter", value: parameter }; +}; + +const parseAllureApiCallOptions = (options: string): { primary: string; secondary: string[] } => { + const primaryEnd = options.indexOf("["); + if (primaryEnd !== -1) { + const primary = decodeURIComponentSafe(options.slice(0, primaryEnd)); + const secondaryEnd = options.indexOf("]"); + if (secondaryEnd === options.length) { + return { + primary, + secondary: options + .slice(primaryEnd + 1, -1) + .split(",") + .map((v) => decodeURIComponentSafe(v.trim())), + }; + } + return { primary, secondary: [] }; + } + return { primary: decodeURIComponentSafe(options), secondary: [] }; +}; + +const indexOfAny = (input: string, ...searchStrings: readonly string[]) => + searchStrings.reduce((a, e) => { + const indexOfE = input.indexOf(e); + return a === -1 ? indexOfE : Math.min(a, indexOfE); + }, 0); + +const parseBooleanApiArg = (value: string) => !value || value.toLowerCase() === "true"; + +const decodeURIComponentSafe = (value: string) => { + try { + return decodeURIComponent(value); + } catch { + return value; + } +}; diff --git a/packages/reader/src/xcresult/xcresulttool/legacy/cli.ts b/packages/reader/src/xcresult/xcresulttool/legacy/cli.ts index f093ae6b..59452490 100644 --- a/packages/reader/src/xcresult/xcresulttool/legacy/cli.ts +++ b/packages/reader/src/xcresult/xcresulttool/legacy/cli.ts @@ -1,8 +1,8 @@ import console from "node:console"; import type { Unknown } from "../../../validation.js"; import { xcresulttool } from "../cli.js"; -import type { XcActionsInvocationRecord, XcReference } from "./model.js"; import { getRef } from "./parsing.js"; +import type { XcActionsInvocationRecord, XcReference } from "./xcModel.js"; let legacyRunSucceeded = false; let noLegacyApi = false; @@ -33,5 +33,5 @@ export const getRoot = async (xcResultPath: string) => export const getById = async (xcResultPath: string, ref: Unknown) => { const id = getRef(ref); - return id ? await xcresulttoolGetLegacy("--id", id) : undefined; + return id ? await xcresulttoolGetLegacy(xcResultPath, "--id", id) : undefined; }; diff --git a/packages/reader/src/xcresult/xcresulttool/legacy/index.ts b/packages/reader/src/xcresult/xcresulttool/legacy/index.ts index e69de29b..563d4728 100644 --- a/packages/reader/src/xcresult/xcresulttool/legacy/index.ts +++ b/packages/reader/src/xcresult/xcresulttool/legacy/index.ts @@ -0,0 +1,653 @@ +import type { ResultFile } from "@allurereport/plugin-api"; +import type { + RawTestAttachment, + RawTestLink, + RawTestParameter, + RawTestResult, + RawTestStatus, + RawTestStepResult, +} from "@allurereport/reader-api"; +import { PathResultFile } from "@allurereport/reader-api"; +import { randomUUID } from "node:crypto"; +import path from "node:path"; +import type { ShallowKnown, Unknown } from "../../../validation.js"; +import { ensureObject, ensureString, isDefined, isObject } from "../../../validation.js"; +import { + DEFAULT_ATTACHMENT_NAME, + DEFAULT_BUNDLE_NAME, + DEFAULT_EXPECTED_FAILURE_REASON, + DEFAULT_STEP_NAME, + DEFAULT_SUITE_NAME, + DEFAULT_TEST_NAME, + applyApiCalls, + createTestLabels, + getTargetDetails, + getWorstStatus, + parseAsAllureApiActivity, + secondsToMilliseconds, + toSortedSteps, +} from "../../utils.js"; +import { getById, getRoot } from "./cli.js"; +import type { + ActionParametersInputData, + ActivityProcessingResult, + LegacyActionDiscriminator, + LegacyDestinationData, + LegacyParsingContext, + LegacyParsingState, +} from "./model.js"; +import { + getBool, + getDate, + getDouble, + getInt, + getObjectArray, + getString, + getStringArray, + getURL, + getUnionType, +} from "./parsing.js"; +import { + convertTraceLine, + withNewSuite as ensureSuiteNesting, + resolveFailureStepStatus, + resolveTestStatus, +} from "./utils.js"; +import type { + XcActionDeviceRecord, + XcActionPlatformRecord, + XcActionRecord, + XcActionRunDestinationRecord, + XcActionTestActivitySummary, + XcActionTestAttachment, + XcActionTestExpectedFailure, + XcActionTestFailureSummary, + XcActionTestMetadata, + XcActionTestPlanRunSummaries, + XcActionTestRepetitionPolicySummary, + XcActionTestSummary, + XcActionTestSummaryGroup, + XcActionTestSummaryIdentifiableObject, + XcArray, + XcIssueTrackingMetadata, + XcSourceCodeContext, + XcString, + XcTestArgument, + XcTestParameter, + XcTestTag, + XcTestValue, +} from "./xcModel.js"; +import { XcActionTestSummaryIdentifiableObjectTypes } from "./xcModel.js"; + +const IDENTIFIER_URL_PREFIX = "test://com.apple.xcode/"; +const ACTIVITY_TYPE_ATTACHMENT = "com.apple.dt.xctest.activity-type.attachmentContainer"; + +export default async function* ( + context: LegacyParsingContext, +): AsyncGenerator { + const { xcResultPath } = context; + const root = await getRoot(xcResultPath); + if (isObject(root)) { + const actions = getObjectArray(root.actions); + const actionDescriminators = parseActionDiscriminators(actions); + const multiTarget = isMultiTarget(actionDescriminators); + const multiTestPlan = isMultiTestPlan(actionDescriminators); + for (const { actionResult } of actions) { + const { destination, testPlan } = actionDescriminators.shift()!; + if (isObject(actionResult)) { + const { testsRef } = actionResult; + const summaries = await getById(xcResultPath, testsRef); + if (isObject(summaries)) { + for (const { testableSummaries } of getObjectArray(summaries.summaries)) { + for (const { name, tests } of getObjectArray(testableSummaries)) { + const bundle = getString(name) ?? DEFAULT_BUNDLE_NAME; + yield* traverseActionTestSummaries(context, tests, { + bundle, + suites: [], + destination, + testPlan, + multiTarget, + multiTestPlan, + }); + } + } + } + } + } + } +} + +const parseActionDiscriminators = (actions: ShallowKnown[]): LegacyActionDiscriminator[] => { + return actions.map(({ runDestination, testPlanName }) => ({ + destination: parseDestination(runDestination), + testPlan: getString(testPlanName), + })); +}; + +const isMultiTarget = (discriminators: LegacyActionDiscriminator[]) => + new Set( + discriminators + .map(({ destination }) => destination) + .filter(isDefined) + .map(({ name }) => name) + .filter(isDefined), + ).size > 1; + +const isMultiTestPlan = (discriminators: LegacyActionDiscriminator[]) => + new Set( + discriminators + .map(({ testPlan }) => testPlan) + .filter(isDefined) + .filter(isDefined), + ).size > 1; + +const parseDestination = (element: Unknown): LegacyDestinationData | undefined => { + if (isObject(element)) { + const { displayName, targetArchitecture, targetDeviceRecord, localComputerRecord } = element; + const targetName = getString(displayName); + const hostName = parseHostName(localComputerRecord); + const architecture = getString(targetArchitecture); + const { model, platform, osVersion } = parseTargetDevice(targetDeviceRecord) ?? {}; + + return { + name: targetName, + hostName, + targetDetails: getTargetDetails({ architecture, model, platform, osVersion }), + }; + } +}; + +const parseHostName = (element: Unknown) => { + if (isObject(element)) { + return getString(element.name); + } +}; + +const parseTargetDevice = (element: Unknown) => { + if (isObject(element)) { + const { modelName, operatingSystemVersion, platformRecord } = element; + return { + model: getString(modelName), + platform: parsePlatform(platformRecord), + osVersion: getString(operatingSystemVersion), + }; + } +}; + +const parsePlatform = (element: Unknown) => { + if (isObject(element)) { + return getString(element.userDescription); + } +}; + +const traverseActionTestSummaries = async function* ( + context: LegacyParsingContext, + array: Unknown>, + state: LegacyParsingState, +): AsyncGenerator { + for (const obj of getObjectArray(array)) { + switch (getUnionType(obj, XcActionTestSummaryIdentifiableObjectTypes)) { + case "ActionTestMetadata": + yield* visitActionTestMetadata(context, obj as ShallowKnown, state); + break; + case "ActionTestSummary": + yield* visitActionTestSummary(context, obj as ShallowKnown, state); + break; + case "ActionTestSummaryGroup": + yield* visitActionTestSummaryGroup(context, obj as ShallowKnown, state); + break; + } + } +}; + +const visitActionTestMetadata = async function* ( + context: LegacyParsingContext, + { summaryRef }: ShallowKnown, + state: LegacyParsingState, +): AsyncGenerator { + const { xcResultPath } = context; + const summary = await getById(xcResultPath, summaryRef); + if (isObject(summary)) { + yield* visitActionTestSummary(context, summary, state); + } +}; + +const visitActionTestSummary = async function* ( + { attachmentsDir }: LegacyParsingContext, + { + arguments: args, + duration, + identifierURL, + name: rawName, + summary, + activitySummaries, + tags, + trackedIssues, + failureSummaries, + expectedFailures, + testStatus, + repetitionPolicySummary, + }: ShallowKnown, + state: LegacyParsingState, +): AsyncGenerator { + const { bundle, className, suites, destination: { hostName } = {} } = state; + const fullName = getString(identifierURL) ?? randomUUID(); + const projectName = parseProjectName(fullName); + const functionName = getString(rawName); + const name = getString(summary) ?? functionName ?? DEFAULT_TEST_NAME; + const status = getString(testStatus); + const labels = createTestLabels({ + hostName, + projectName, + bundle, + className, + functionName, + suites: suites.map(({ name: suite }) => suite), + tags: parseTestTags(tags), + }); + const parameters = getAllTestResultParameters(state, args, repetitionPolicySummary); + const failures = processFailures(attachmentsDir, failureSummaries, expectedFailures); + const { + steps: activitySteps, + files, + apiCalls, + } = processActivities(attachmentsDir, failures, getObjectArray(activitySummaries)); + const { message, trace, steps: failureSteps } = resolveTestFailures(failures); + const steps = toSortedSteps(activitySteps, failureSteps); + const testResult: RawTestResult = { + uuid: randomUUID(), + fullName, + name, + duration: secondsToMilliseconds(getDouble(duration)), + labels, + parameters, + steps, + links: parseTrackedIssues(trackedIssues), + message, + status: resolveTestStatus(status, steps), + trace, + }; + applyApiCalls(testResult, apiCalls); + + yield* files; + yield* iterateFailureFiles(failures); + yield testResult; +}; + +const iterateFailureFiles = function* (failures: FailureMap) { + for (const { files } of failures.values()) { + yield* files; + } +}; + +const parseTrackedIssues = (issues: Unknown>): RawTestLink[] => + getObjectArray(issues) + .map(({ comment, identifier, url: rawUrl }) => { + const name = getString(comment); + const url = getURL(rawUrl) ?? getString(identifier); + return url ? { type: "issue", name, url } : undefined; + }) + .filter(isDefined); + +type FailureMapValue = { + step: RawTestStepResult; + files: ResultFile[]; + isTopLevel?: boolean; +}; + +type FailureMap = Map; + +const processFailures = ( + attachmentsDir: string, + failures: Unknown>, + expectedFailures: Unknown>, +): FailureMap => { + const failureEntries = getObjectArray(failures).map((summary) => toFailureMapEntry(attachmentsDir, summary)); + const expectedFailureEntries = getObjectArray(expectedFailures).map(({ uuid, failureReason, failureSummary }) => + isObject(failureSummary) + ? toFailureMapEntry(attachmentsDir, failureSummary, { + uuid, + status: "passed", + mapMessage: (message) => { + const prefix = getString(failureReason) ?? DEFAULT_EXPECTED_FAILURE_REASON; + return message ? `${prefix}:\n ${message}` : prefix; + }, + }) + : undefined, + ); + return new Map([...failureEntries, ...expectedFailureEntries].filter(isDefined)); +}; + +const toFailureMapEntry = ( + attachmentsDir: string, + { + attachments, + message: rawMessage, + sourceCodeContext, + timestamp, + uuid: rawUuid, + isTopLevelFailure, + issueType, + }: ShallowKnown, + { + uuid: explicitUuid, + mapMessage, + status: explicitStatus, + }: { uuid?: Unknown; mapMessage?: (message: string | undefined) => string; status?: RawTestStatus } = {}, +) => { + const { steps, files } = parseAttachments(attachmentsDir, getObjectArray(attachments)); + const message = getString(rawMessage); + const status = explicitStatus ?? resolveFailureStepStatus(getString(issueType)); + const trace = convertStackTrace(sourceCodeContext); + const start = getDate(timestamp); + const uuid = getString(explicitUuid) ?? getString(rawUuid); + return uuid + ? ([ + uuid, + { + step: { + type: "step", + start, + stop: start, + duration: 0, + message: mapMessage?.(message) ?? message, + name: message, + status, + steps, + trace, + }, + files, + isTopLevel: getBool(isTopLevelFailure), + }, + ] as [string, FailureMapValue]) + : undefined; +}; + +const convertStackTrace = (sourceCodeContext: Unknown) => { + if (isObject(sourceCodeContext)) { + const { callStack } = sourceCodeContext; + return getObjectArray(callStack) + .map(({ symbolInfo }) => symbolInfo) + .filter(isObject) + .map(({ location, symbolName }) => { + const { filePath, lineNumber } = ensureObject(location) ?? {}; + return convertTraceLine(getString(symbolName), getString(filePath), getInt(lineNumber)); + }) + .filter(isDefined) + .join("\n"); + } +}; + +const processActivities = ( + attachmentsDir: string, + failures: FailureMap, + activities: readonly ShallowKnown[], +): ActivityProcessingResult => + mergeActivityProcessingResults( + ...activities.map( + ({ + activityType, + title, + start, + finish, + attachments: rawAttachments, + subactivities: rawSubactivities, + failureSummaryIDs, + }) => { + const attachments = getObjectArray(rawAttachments); + const subactivities = getObjectArray(rawSubactivities); + const failureIds = getStringArray(failureSummaryIDs); + + const parsedAttachments = parseAttachments(attachmentsDir, attachments); + if (getString(activityType) === ACTIVITY_TYPE_ATTACHMENT) { + return parsedAttachments; + } + + const name = getString(title); + + if (attachments.length === 0 && subactivities.length === 0 && failureIds.length === 0) { + const parsedAllureApiCall = parseAsAllureApiActivity(name); + if (isDefined(parsedAllureApiCall)) { + return { + steps: [], + files: [], + apiCalls: [parsedAllureApiCall], + }; + } + } + + const { steps: thisStepAttachmentSteps, files: thisStepFiles } = parsedAttachments; + const { + steps: substeps, + files: substepFiles, + apiCalls, + } = processActivities(attachmentsDir, failures, subactivities); + + const failureSteps = failureIds.map((uuid) => failures.get(uuid)).filter(isDefined); + const { steps: nestedFailureSteps, message, trace } = resolveFailuresOfStep(failureIds, failureSteps); + + const steps = toSortedSteps(thisStepAttachmentSteps, substeps, nestedFailureSteps); + + return { + steps: [ + { + type: "step", + name: name ?? DEFAULT_STEP_NAME, + start: getDate(start), + stop: getDate(finish), + status: getWorstStatus(steps) ?? "passed", + message, + trace, + steps, + } as RawTestStepResult, + ], + files: [...thisStepFiles, ...substepFiles], + apiCalls, + }; + }, + ), + ); + +type StepFailure = { + message?: string; + trace?: string; + steps: RawTestStepResult[]; +}; + +const resolveFailuresOfStep = (failureUids: string[], failures: readonly FailureMapValue[]): StepFailure => + resolveFailures( + failureUids.length > failures.length + ? [ + ...failures, + ...new Array(failureUids.length - failures.length).fill({ + files: [], + step: { + type: "step", + duration: 0, + message: "Un unknown failure has occured", + status: "broken", + }, + }), + ] + : failures, + ); + +const resolveTestFailures = (failures: FailureMap): StepFailure => + resolveFailures(Array.from(failures.values()).filter(({ isTopLevel }) => isTopLevel)); + +const resolveFailures = (failures: readonly FailureMapValue[]): StepFailure => { + switch (failures.length) { + case 0: + return { steps: [] }; + case 1: + return prepareOneFailure(failures as [FailureMapValue]); + default: + return prepareMultipleFailures(failures as [FailureMapValue, ...FailureMapValue[]]); + } +}; + +const prepareOneFailure = ([ + { + step: { message, trace }, + }, +]: readonly [FailureMapValue]): StepFailure => ({ + message, + trace, + steps: [], +}); + +const prepareMultipleFailures = (failures: readonly [FailureMapValue, ...FailureMapValue[]]): StepFailure => { + const [ + { + step: { message, trace }, + }, + ] = failures; + const steps = failures.map(({ step }) => step); + return { + message: `${failures.length} failures has occured. The first one is:\n ${message}`, + trace, + steps, + }; +}; + +const mergeActivityProcessingResults = (...results: readonly ActivityProcessingResult[]) => { + return results.reduce( + (target, { steps, files, apiCalls }) => { + const { steps: targetSteps, files: targetFiles, apiCalls: targetApiCalls } = target; + targetSteps.push(...steps); + targetFiles.push(...files); + targetApiCalls.push(...apiCalls); + return target; + }, + { steps: [], files: [], apiCalls: [] }, + ); +}; + +const parseAttachments = (attachmentsDir: string, attachments: readonly ShallowKnown[]) => + attachments + .map(({ name: rawName, timestamp, uuid: rawUuid }) => { + const uuid = getString(rawUuid); + if (uuid) { + const start = getDate(timestamp); + const fileName = randomUUID(); + return { + step: { + type: "attachment", + originalFileName: fileName, + name: getString(rawName) ?? DEFAULT_ATTACHMENT_NAME, + start, + stop: start, + } as RawTestAttachment, + file: new PathResultFile(path.join(attachmentsDir, uuid), fileName), + }; + } + }) + .filter(isDefined) + .reduce( + (parsedAttachments, { step, file }) => { + const { steps, files } = parsedAttachments; + steps.push(step); + files.push(file); + return parsedAttachments; + }, + { steps: [], files: [], apiCalls: [] }, + ); + +const getAllTestResultParameters = ( + context: LegacyParsingState, + args: Unknown>, + repetition: Unknown, +) => + [...convertActionParameters(context), convertRepetitionParameter(repetition), ...convertTestParameters(args)].filter( + isDefined, + ); + +const convertActionParameters = ({ destination, testPlan, multiTarget, multiTestPlan }: ActionParametersInputData) => { + const parameters: RawTestParameter[] = []; + if (multiTestPlan && testPlan) { + // Doesn't affect the history. Only illustrates what test plan caused the test to be run + parameters.push({ name: "Test plan", value: testPlan, excluded: true }); + } + if (destination) { + const { name, targetDetails } = destination; + if (isDefined(name)) { + parameters.push({ name: "Target", value: name, excluded: !multiTarget }); + if (multiTarget && targetDetails) { + parameters.push({ name: "Target details", value: targetDetails }); + } + } + } + return parameters; +}; + +const convertTestParameters = (args: Unknown>): (RawTestParameter | undefined)[] => + getObjectArray(args).map(({ parameter, value }) => { + const parameterName = getParameterName(parameter); + const argumentValue = getArgumentValue(value); + return isDefined(parameterName) && isDefined(argumentValue) + ? { + name: parameterName, + value: argumentValue, + } + : undefined; + }); + +const convertRepetitionParameter = ( + repetition: Unknown, +): RawTestParameter | undefined => { + if (isObject(repetition)) { + const { iteration, totalIterations } = repetition; + const current = getInt(iteration); + const total = getInt(totalIterations); + if (current) { + return { + name: "Repetition", + value: total ? `Repetition ${current} of ${total}` : `Repetition ${current}`, + excluded: true, + }; + } + } +}; + +const parseProjectName = (url: string | undefined) => { + if (url && url.startsWith(IDENTIFIER_URL_PREFIX)) { + const urlPath = url.slice(IDENTIFIER_URL_PREFIX.length); + const projectNameEnd = urlPath.indexOf("/"); + if (projectNameEnd !== -1) { + const projectName = urlPath.slice(0, projectNameEnd); + try { + return decodeURIComponent(projectName); + } catch { + return projectName; + } + } + } +}; + +const parseTestTags = (tags: Unknown>): string[] => + getObjectArray(tags) + .map(({ name }) => getString(name)) + .filter(isDefined); + +const visitActionTestSummaryGroup = async function* ( + context: LegacyParsingContext, + { name, identifierURL, summary, subtests }: ShallowKnown, + state: LegacyParsingState, +): AsyncGenerator { + const suiteId = getString(name); + const suiteName = getString(summary) ?? suiteId ?? DEFAULT_SUITE_NAME; + const suiteUri = getString(identifierURL); + const { suites, className } = state; + state = { + ...state, + suites: ensureSuiteNesting(suites, suiteUri, suiteName), + className: className ?? suiteId, + }; + yield* traverseActionTestSummaries(context, subtests, state); +}; + +const getParameterName = (parameter: Unknown) => + isObject(parameter) ? ensureString(parameter.name) : undefined; + +const getArgumentValue = (parameter: Unknown) => + isObject(parameter) ? ensureString(parameter.description) : undefined; diff --git a/packages/reader/src/xcresult/xcresulttool/legacy/model.ts b/packages/reader/src/xcresult/xcresulttool/legacy/model.ts index 14abbcce..edd3fea8 100644 --- a/packages/reader/src/xcresult/xcresulttool/legacy/model.ts +++ b/packages/reader/src/xcresult/xcresulttool/legacy/model.ts @@ -1,427 +1,61 @@ -export type XcObject = { - _type: { - _name: T; - }; -}; - -export type XcValue = XcObject & { - _value: Value; -}; - -export type XcArray = XcObject<"Array"> & { - _values: Element[]; -}; - -export type XcSortedKeyValueArrayPair = XcObject<"SortedKeyValueArrayPair"> & { - key: XcString; - value: XcObject; -}; - -export type XcSortedKeyValueArray = XcObject<"SortedKeyValueArray"> & { - storage: XcArray; -}; - -export type XcBool = XcValue<"Bool", boolean>; - -export type XcData = XcValue<"Data", string>; - -export type XcDate = XcValue<"Date", string>; - -export type XcDouble = XcValue<"Double", number>; - -export type XcInt = XcValue<"Int", number>; - -export type XcInt16 = XcValue<"Int16", number>; - -export type XcInt32 = XcValue<"Int32", number>; - -export type XcInt64 = XcValue<"Int64", number>; - -export type XcInt8 = XcValue<"Int8", number>; - -export type XcString = XcValue<"String", string>; - -export type XcUInt16 = XcValue<"UInt16", number>; - -export type XcUInt32 = XcValue<"UInt32", number>; - -export type XcUInt64 = XcValue<"UInt64", number>; - -export type XcUInt8 = XcValue<"UInt8", number>; - -export type XcURL = XcValue<"URL", string>; - -export type XcReference = XcObject<"Reference"> & { - id: XcString; - targetType?: XcTypeDefinition; -}; - -/** - * `xcrun xcresulttool get object` (without --id) - */ -export type XcActionsInvocationRecord = XcObject<"ActionsInvocationRecord"> & { - metadataRef?: XcReference; - metrics: XcResultMetrics; - issues: XcResultIssueSummaries; - actions: XcArray; - archive?: XcArchiveInfo; -}; - -export type XcResultMetrics = XcObject<"ResultMetrics"> & { - analyzerWarningCount: XcInt; - errorCount: XcInt; - testsCount: XcInt; - testsFailedCount: XcInt; - testsSkippedCount: XcInt; - warningCount: XcInt; - totalCoveragePercentage?: XcDouble; -}; - -export type XcResultIssueSummaries = XcObject<"ResultIssueSummaries"> & { - analyzerWarningSummaries: XcArray; - errorSummaries: XcArray; - testFailureSummaries: XcArray; - warningSummaries: XcArray; - testWarningSummaries: XcArray; -}; - -export type XcActionRecord = XcObject<"ActionRecord"> & { - schemeCommandName: XcString; - schemeTaskName: XcString; - title?: XcString; - startedTime: XcDate; - endedTime: XcDate; - runDestination: XcActionRunDestinationRecord; - buildResult: XcActionResult; - actionResult: XcActionResult; - testPlanName?: XcString; -}; - -export type XcArchiveInfo = XcObject<"ArchiveInfo"> & { - path?: XcString; -}; - -export type XcTypeDefinition = XcObject<"TypeDefinition"> & { - name: XcString; - supertype?: XcTypeDefinition; -}; - -export type XcIssueSummary = XcObject & { - issueType: XcString; - message: XcString; - producingTarget?: XcString; - documentLocationInCreatingWorkspace?: XcDocumentLocation; -}; - -export type XcTestFailureIssueSummary = XcIssueSummary<"TestFailureIssueSummary"> & { - testCaseName: XcString; -}; - -export type XcTestIssueSummary = XcIssueSummary<"TestIssueSummary"> & { - testCaseName: XcString; -}; - -export type XcActionRunDestinationRecord = XcObject<"ActionRunDestinationRecord"> & { - displayName: XcString; - targetArchitecture: XcString; - targetDeviceRecord: XcActionDeviceRecord; - localComputerRecord: XcActionDeviceRecord; - targetSDKRecord: XcActionSDKRecord; -}; - -export type XcActionResult = XcObject<"ActionResult"> & { - resultName: XcString; - status: XcString; - metrics: XcResultMetrics; - issues: XcResultIssueSummaries; - coverage: XcCodeCoverageInfo; - timelineRef?: XcReference; - logRef?: XcReference; - testsRef?: XcReference; - diagnosticsRef?: XcReference; - consoleLogRef?: XcReference; -}; +import type { ResultFile } from "@allurereport/plugin-api"; +import type { RawStep } from "@allurereport/reader-api"; +import type { AllureApiCall } from "../../model.js"; -export type XcDocumentLocation = XcObject<"DocumentLocation"> & { - url: XcString; - concreteTypeName: XcString; +export type LegacyTestResultData = { + issues: LegacyIssueTrackingMetadata[]; + trace?: string; + steps: []; }; -export type XcActionDeviceRecord = XcObject<"ActionDeviceRecord"> & { - name: XcString; - isConcreteDevice: XcBool; - operatingSystemVersion: XcString; - operatingSystemVersionWithBuildNumber: XcString; - nativeArchitecture: XcString; - modelName: XcString; - modelCode: XcString; - modelUTI: XcString; - identifier: XcString; - isWireless: XcBool; - cpuKind: XcString; - cpuCount?: XcInt; - cpuSpeedInMhz?: XcInt; - busSpeedInMhz?: XcInt; - ramSizeInMegabytes?: XcInt; - physicalCPUCoresPerPackage?: XcInt; - logicalCPUCoresPerPackage?: XcInt; - platformRecord: XcActionPlatformRecord; +export type LegacyIssueTrackingMetadata = { + url: string; + title?: string; }; -export type XcActionSDKRecord = XcObject<"ActionSDKRecord"> & { - name: XcString; - identifier: XcString; - operatingSystemVersion: XcString; - isInternal: XcBool; +export type LegacyStepResultData = { + trace?: string; + steps: []; }; -export type XcCodeCoverageInfo = XcObject<"CodeCoverageInfo"> & { - hasCoverageData: XcBool; - reportRef?: XcReference; - archiveRef?: XcReference; +export type LegacyActionDiscriminator = { + destination: LegacyDestinationData | undefined; + testPlan: string | undefined; }; -export type XcActionPlatformRecord = XcObject<"ActionPlatformRecord"> & { - identifier: XcString; - userDescription: XcString; +export type LegacyDestinationData = { + name?: string; + targetDetails?: string; + hostName?: string; }; -/** - * `xcrun xcresulttool get object --id '...'` with --id of XcActionsInvocationRecord.actions[number].actionResult.testsRef - */ -export type XcActionTestPlanRunSummaries = XcObject<"ActionTestPlanRunSummaries"> & { - summaries: XcArray; +export type ActivityProcessingResult = { + steps: RawStep[]; + files: ResultFile[]; + apiCalls: AllureApiCall[]; }; -export type XcActionAbstractTestSummary = XcObject & { - name?: XcString; +export type Suite = { + name: string; + uri: string | undefined; }; -export type XcActionTestPlanRunSummary = XcActionAbstractTestSummary<"ActionTestPlanRunSummary"> & { - testableSummaries: XcArray; +export type LegacyParsingContext = { + xcResultPath: string; + attachmentsDir: string; }; -export type XcActionTestableSummary = XcActionAbstractTestSummary<"ActionTestableSummary"> & { - identifierURL?: XcString; - projectRelativePath?: XcString; - targetName?: XcString; - testKind?: XcString; - tests: XcArray; - diagnosticsDirectoryName?: XcString; - failureSummaries: XcArray; - testLanguage?: XcString; - testRegion?: XcString; +export type LegacyParsingState = { + bundle?: string; + suites: Suite[]; + className?: string; + destination?: LegacyDestinationData; + testPlan?: string; + multiTarget: boolean; + multiTestPlan: boolean; }; -export type XcActionTestSummaryIdentifiableObjectBase = XcActionAbstractTestSummary & { - identifier?: XcString; - identifierURL?: XcString; -}; - -export type XcActionTestMetadata = XcActionTestSummaryIdentifiableObjectBase<"ActionTestMetadata"> & { - testStatus: XcString; - duration?: XcDouble; - summaryRef?: XcReference; - performanceMetricsCount?: XcInt; - failureSummariesCount?: XcInt; - activitySummariesCount?: XcInt; -}; - -export type XcActionTestSummaryGroup = XcActionTestSummaryIdentifiableObjectBase<"ActionTestSummaryGroup"> & { - duration: XcDouble; - subtests: XcArray; - skipNoticeSummary?: XcActionTestNoticeSummary; - summary?: XcString; - documentation: XcArray; - trackedIssues: XcArray; - tags: XcArray; -}; - -/** - * `xcrun xcresulttool get object --id '...'` with --id of - * XcActionTestPlanRunSummaries.summaries[number].testableSummaries[number].tests[number](.subtests[number])*.summaryRef - */ -export type XcActionTestSummary = XcActionTestSummaryIdentifiableObjectBase<"ActionTestSummary"> & { - testStatus: XcString; - duration: XcDouble; - performanceMetrics: XcArray; - failureSummaries: XcArray; - expectedFailures: XcArray; - skipNoticeSummary?: XcActionTestNoticeSummary; - activitySummaries: XcArray; - repetitionPolicySummary?: XcActionTestRepetitionPolicySummary; - arguments: XcArray; - configuration?: XcActionTestConfiguration; - warningSummaries: XcArray; - summary?: XcString; - documentation: XcArray; - trackedIssues: XcArray; - tags: XcArray; -}; - -export type XcActionTestSummaryIdentifiableObject = - | XcActionTestMetadata - | XcActionTestSummary - | XcActionTestSummaryGroup; - -export type XcActionTestFailureSummary = XcObject<"ActionTestFailureSummary"> & { - message?: XcString; - fileName: XcString; - lineNumber: XcInt; - isPerformanceFailure: XcBool; - uuid: XcString; - issueType?: XcString; - detailedDescription?: XcString; - attachments: XcArray; - associatedError?: XcTestAssociatedError; - sourceCodeContext?: XcSourceCodeContext; - timestamp?: XcDate; -}; - -export type XcActionTestAttachment = XcObject<"ActionTestAttachment"> & { - uniformTypeIdentifier: XcString; - name?: XcString; - uuid?: XcString; - timestamp?: XcDate; - userInfo?: XcSortedKeyValueArray; - lifetime: XcString; - inActivityIdentifier: XcInt; - filename?: XcString; - payloadRef?: XcReference; - payloadSize: XcInt; -}; - -export type XcTestAssociatedError = XcObject<"TestAssociatedError"> & { - domain?: XcString; - code?: XcInt; - userInfo?: XcSortedKeyValueArray; -}; - -export type XcSourceCodeContext = XcObject<"SourceCodeContext"> & { - location?: XcSourceCodeLocation; - callStack: XcArray; -}; - -export type XcSourceCodeLocation = XcObject<"SourceCodeLocation"> & { - filePath?: XcString; - lineNumber?: XcInt; -}; - -export type XcSourceCodeFrame = XcObject<"SourceCodeFrame"> & { - addressString?: XcString; - sumbolInfo?: XcSourceCodeSymbolInfo; -}; - -export type XcSourceCodeSymbolInfo = XcObject<"SourceCodeSymbolInfo"> & { - imageName?: XcString; - symbolName?: XcString; - location?: XcSourceCodeLocation; -}; - -export type XcActionTestPerformanceMetricSummary = XcObject<"ActionTestPerformanceMetricSummary"> & { - displayName: XcString; - unitOfMeasurement: XcString; - measurements: XcArray; - identifier?: XcString; - baselineName?: XcString; - baselineAverage?: XcDouble; - maxPercentRegression?: XcDouble; - maxPercentRelativeStandardDeviation?: XcDouble; - maxRegression?: XcDouble; - maxStandardDeviation?: XcDouble; - polarity?: XcString; -}; - -export type XcActionTestExpectedFailure = XcObject<"ActionTestExpectedFailure"> & { - uuid: XcString; - failureReason?: XcString; - failureSummary?: XcActionTestFailureSummary; - isTopLevelFailure: XcBool; -}; - -export type XcActionTestNoticeSummary = XcObject<"ActionTestNoticeSummary"> & { - message?: XcString; - fileName: XcString; - lineNumber: XcInt; - timestamp?: XcDate; -}; - -export type XcActionTestActivitySummary = XcObject<"ActionTestActivitySummary"> & { - title: XcString; - activityType: XcString; - uuid: XcString; - start?: XcDate; - finish?: XcDate; - attachments: XcArray; - subactivities: XcArray; - failureSummaryIDs: XcArray; - expectedFailureIDs: XcArray; - warningSummaryIDs: XcArray; -}; - -export type XcActionTestRepetitionPolicySummary = XcObject<"ActionTestRepetitionPolicySummary"> & { - iteration?: XcInt; - totalIterations?: XcInt; - repetitionMode?: XcString; -}; - -export type XcTestArgument = XcObject<"TestArgument"> & { - parameter?: XcTestParameter; - identifier?: XcString; - description: XcString; - debugDescription?: XcString; - typeName?: XcString; - value: XcTestValue; -}; - -export type XcActionTestConfiguration = XcObject<"ActionTestConfiguration"> & { - values: XcSortedKeyValueArray; -}; - -export type XcActionTestIssueSummary = XcObject<"ActionTestIssueSummary"> & { - message?: XcString; - fileName: XcString; - lineNumber: XcInt; - uuid: XcString; - issueType?: XcString; - detailedDescription?: XcString; - attachments: XcArray; - associatedError?: XcTestAssociatedError; - sourceCodeContext?: XcSourceCodeContext; - timestamp?: XcDate; -}; - -export type XcTestDocumentation = XcObject<"TestDocumentation"> & { - content: XcString; - format: XcString; -}; - -export type XcIssueTrackingMetadata = XcObject<"IssueTrackingMetadata"> & { - identifier: XcString; - url?: XcURL; - comment?: XcString; - summary: XcString; -}; - -export type XcTestTag = XcObject<"TestTag"> & { - identifier: XcString; - name: XcString; - anchors: XcArray; -}; - -export type XcTestParameter = XcObject<"TestParameter"> & { - label: XcString; - name?: XcString; - typeName?: XcString; - fullyQualifiedTypeName?: XcString; -}; - -export type XcTestValue = XcObject<"TestValue"> & { - description: XcString; - debugDescription?: XcString; - typeName?: XcString; - fullyQualifiedTypeName?: XcString; - label?: XcString; - isCollection: XcBool; - children: XcArray; -}; +export type ActionParametersInputData = Pick< + LegacyParsingState, + "destination" | "testPlan" | "multiTarget" | "multiTestPlan" +>; diff --git a/packages/reader/src/xcresult/xcresulttool/legacy/parsing.ts b/packages/reader/src/xcresult/xcresulttool/legacy/parsing.ts index c5bd7b9f..84a5ed43 100644 --- a/packages/reader/src/xcresult/xcresulttool/legacy/parsing.ts +++ b/packages/reader/src/xcresult/xcresulttool/legacy/parsing.ts @@ -1,14 +1,79 @@ -/* eslint no-underscore-dangle: ["error", { "allow": ["_value"] }] */ -import type { Unknown } from "../../../validation.js"; -import { ensureObject, ensureString } from "../../../validation.js"; -import type { XcReference, XcString } from "./model.js"; +/* eslint no-underscore-dangle: ["error", { "allow": ["_name", "_type", "_value", "_values"] }] */ +import type { ShallowKnown, Unknown } from "../../../validation.js"; +import { + ensureArray, + ensureBoolean, + ensureInt, + ensureLiteral, + ensureNumber, + ensureObject, + ensureString, + isDefined, + isObject, +} from "../../../validation.js"; +import type { + XcArray, + XcBool, + XcDate, + XcDouble, + XcInt, + XcObject, + XcReference, + XcString, + XcURL, + XcValue, +} from "./xcModel.js"; -export const getString = (value: Unknown) => { +export const getType = ({ _type }: ShallowKnown>) => + isObject(_type) ? ensureString(_type._name) : undefined; + +export const getUnionType = ( + { _type }: ShallowKnown>, + options: L, +) => (isObject(_type) ? ensureLiteral(_type._name, options) : undefined); + +export const getValue = ( + value: Unknown>, + ensure: (v: Unknown) => Result | undefined, +) => { const obj = ensureObject(value); - return obj ? ensureString(obj._value) : undefined; + return obj ? ensure(obj._value) : undefined; +}; + +export const getBool = (value: Unknown) => getValue(value, ensureBoolean); + +export const getInt = (value: Unknown) => getValue(value, ensureInt); + +export const getDouble = (value: Unknown) => getValue(value, ensureNumber); + +export const getString = (value: Unknown) => getValue(value, ensureString); + +export const getDate = (value: Unknown) => { + const text = getValue(value, ensureString); + return text ? Date.parse(text) : undefined; }; +export const getURL = (value: Unknown) => getValue(value, ensureString); + export const getRef = (ref: Unknown) => { const obj = ensureObject(ref); return obj ? getString(obj.id) : undefined; }; + +export const getArray = >(array: Unknown>) => { + const arrayObject = ensureObject(array); + return arrayObject ? (ensureArray(arrayObject._values) ?? []) : []; +}; + +const getValueArray = >( + array: Unknown>, + getElement: (v: Unknown) => Result | undefined, +) => getArray(array).map(getElement).filter(isDefined); + +export const getStringArray = (array: Unknown>) => getValueArray(array, getString); + +export const getObjectArray = >( + array: Unknown>, +) => { + return getArray(array).filter((v): v is ShallowKnown => isObject(v as any)); +}; diff --git a/packages/reader/src/xcresult/xcresulttool/legacy/utils.ts b/packages/reader/src/xcresult/xcresulttool/legacy/utils.ts new file mode 100644 index 00000000..518b39ae --- /dev/null +++ b/packages/reader/src/xcresult/xcresulttool/legacy/utils.ts @@ -0,0 +1,41 @@ +import type { RawStep, RawTestStatus } from "@allurereport/reader-api"; +import { isDefined } from "../../../validation.js"; +import { getWorstStatus } from "../../utils.js"; +import type { Suite } from "./model.js"; + +export const withNewSuite = (suites: Suite[], uri: string | undefined, name: string) => { + return [...suites.filter(({ uri: parentUri }) => !parentUri || !uri || uri.startsWith(parentUri)), { uri, name }]; +}; + +export const resolveTestStatus = (status: string | undefined, steps: readonly RawStep[]): RawTestStatus => { + switch (status) { + case "Success": + case "Expected Failure": + return "passed"; + case "Failure": + return getWorstStatus(steps) === "broken" ? "broken" : "failed"; + case "Skipped": + return "skipped"; + default: + return "unknown"; + } +}; + +export const resolveFailureStepStatus = (issueType: string | undefined): RawTestStatus => + issueType === "Thrown Error" ? "broken" : "failed"; + +export const convertTraceLine = ( + symbolName: string | undefined, + filename: string | undefined, + line: number | undefined, +) => { + const symbolPart = symbolName ? `In ${symbolName}` : undefined; + const locationPart = filename && isDefined(line) ? `${filename}:${line}` : filename; + return symbolPart + ? locationPart + ? `${symbolName} at ${locationPart}` + : symbolPart + : locationPart + ? `At ${locationPart}` + : undefined; +}; diff --git a/packages/reader/src/xcresult/xcresulttool/legacy/xcModel.ts b/packages/reader/src/xcresult/xcresulttool/legacy/xcModel.ts new file mode 100644 index 00000000..eae1b872 --- /dev/null +++ b/packages/reader/src/xcresult/xcresulttool/legacy/xcModel.ts @@ -0,0 +1,441 @@ +export type XcObject = { + _type: { + _name: T; + }; +}; + +export type XcValue = XcObject & { + _value: Value; +}; + +export type XcArray = XcObject<"Array"> & { + _values: Element[]; +}; + +export type XcSortedKeyValueArrayPair = XcObject<"SortedKeyValueArrayPair"> & { + key: XcString; + value: XcObject; +}; + +export type XcSortedKeyValueArray = XcObject<"SortedKeyValueArray"> & { + storage: XcArray; +}; + +export type XcBool = XcValue<"Bool", boolean>; + +export type XcData = XcValue<"Data", string>; + +export type XcDate = XcValue<"Date", string>; + +export type XcDouble = XcValue<"Double", number>; + +export type XcInt = XcValue<"Int", number>; + +export type XcInt16 = XcValue<"Int16", number>; + +export type XcInt32 = XcValue<"Int32", number>; + +export type XcInt64 = XcValue<"Int64", number>; + +export type XcInt8 = XcValue<"Int8", number>; + +export type XcString = XcValue<"String", string>; + +export type XcUInt16 = XcValue<"UInt16", number>; + +export type XcUInt32 = XcValue<"UInt32", number>; + +export type XcUInt64 = XcValue<"UInt64", number>; + +export type XcUInt8 = XcValue<"UInt8", number>; + +export type XcURL = XcValue<"URL", string>; + +export type XcReference = XcObject<"Reference"> & { + id: XcString; + targetType?: XcTypeDefinition; +}; + +/** + * `xcrun xcresulttool get object` (without --id) + */ +export type XcActionsInvocationRecord = XcObject<"ActionsInvocationRecord"> & { + metadataRef?: XcReference; + metrics: XcResultMetrics; + issues: XcResultIssueSummaries; + actions: XcArray; + archive?: XcArchiveInfo; +}; + +export type XcResultMetrics = XcObject<"ResultMetrics"> & { + analyzerWarningCount: XcInt; + errorCount: XcInt; + testsCount: XcInt; + testsFailedCount: XcInt; + testsSkippedCount: XcInt; + warningCount: XcInt; + totalCoveragePercentage?: XcDouble; +}; + +export type XcResultIssueSummaries = XcObject<"ResultIssueSummaries"> & { + analyzerWarningSummaries: XcArray; + errorSummaries: XcArray; + testFailureSummaries: XcArray; + warningSummaries: XcArray; + testWarningSummaries: XcArray; +}; + +export type XcActionRecord = XcObject<"ActionRecord"> & { + schemeCommandName: XcString; + schemeTaskName: XcString; + title?: XcString; + startedTime: XcDate; + endedTime: XcDate; + runDestination: XcActionRunDestinationRecord; + buildResult: XcActionResult; + actionResult: XcActionResult; + testPlanName?: XcString; +}; + +export type XcArchiveInfo = XcObject<"ArchiveInfo"> & { + path?: XcString; +}; + +export type XcTypeDefinition = XcObject<"TypeDefinition"> & { + name: XcString; + supertype?: XcTypeDefinition; +}; + +export type XcIssueSummary = XcObject & { + issueType: XcString; + message: XcString; + producingTarget?: XcString; + documentLocationInCreatingWorkspace?: XcDocumentLocation; +}; + +export type XcTestFailureIssueSummary = XcIssueSummary<"TestFailureIssueSummary"> & { + testCaseName: XcString; +}; + +export type XcTestIssueSummary = XcIssueSummary<"TestIssueSummary"> & { + testCaseName: XcString; +}; + +export type XcActionRunDestinationRecord = XcObject<"ActionRunDestinationRecord"> & { + displayName: XcString; + targetArchitecture: XcString; + targetDeviceRecord: XcActionDeviceRecord; + localComputerRecord: XcActionDeviceRecord; + targetSDKRecord: XcActionSDKRecord; +}; + +export type XcActionResult = XcObject<"ActionResult"> & { + resultName: XcString; + status: XcString; + metrics: XcResultMetrics; + issues: XcResultIssueSummaries; + coverage: XcCodeCoverageInfo; + timelineRef?: XcReference; + logRef?: XcReference; + testsRef?: XcReference; + diagnosticsRef?: XcReference; + consoleLogRef?: XcReference; +}; + +export type XcDocumentLocation = XcObject<"DocumentLocation"> & { + url: XcString; + concreteTypeName: XcString; +}; + +export type XcActionDeviceRecord = XcObject<"ActionDeviceRecord"> & { + name: XcString; + isConcreteDevice: XcBool; + operatingSystemVersion: XcString; + operatingSystemVersionWithBuildNumber: XcString; + nativeArchitecture: XcString; + modelName: XcString; + modelCode: XcString; + modelUTI: XcString; + identifier: XcString; + isWireless: XcBool; + cpuKind: XcString; + cpuCount?: XcInt; + cpuSpeedInMhz?: XcInt; + busSpeedInMhz?: XcInt; + ramSizeInMegabytes?: XcInt; + physicalCPUCoresPerPackage?: XcInt; + logicalCPUCoresPerPackage?: XcInt; + platformRecord: XcActionPlatformRecord; +}; + +export type XcActionSDKRecord = XcObject<"ActionSDKRecord"> & { + name: XcString; + identifier: XcString; + operatingSystemVersion: XcString; + isInternal: XcBool; +}; + +export type XcCodeCoverageInfo = XcObject<"CodeCoverageInfo"> & { + hasCoverageData: XcBool; + reportRef?: XcReference; + archiveRef?: XcReference; +}; + +export type XcActionPlatformRecord = XcObject<"ActionPlatformRecord"> & { + identifier: XcString; + userDescription: XcString; +}; + +/** + * `xcrun xcresulttool get object --id '...'` with --id of XcActionsInvocationRecord.actions[number].actionResult.testsRef + */ +export type XcActionTestPlanRunSummaries = XcObject<"ActionTestPlanRunSummaries"> & { + summaries: XcArray; +}; + +export type XcActionAbstractTestSummary = XcObject & { + name?: XcString; +}; + +export type XcActionTestPlanRunSummary = XcActionAbstractTestSummary<"ActionTestPlanRunSummary"> & { + testableSummaries: XcArray; +}; + +export type XcActionTestableSummary = XcActionAbstractTestSummary<"ActionTestableSummary"> & { + identifierURL?: XcString; + projectRelativePath?: XcString; + targetName?: XcString; + testKind?: XcString; + tests: XcArray; + diagnosticsDirectoryName?: XcString; + failureSummaries: XcArray; + testLanguage?: XcString; + testRegion?: XcString; +}; + +export type XcActionTestSummaryIdentifiableObjectBase = XcActionAbstractTestSummary & { + identifier?: XcString; + identifierURL?: XcString; +}; + +export type XcActionTestMetadata = XcActionTestSummaryIdentifiableObjectBase<"ActionTestMetadata"> & { + testStatus: XcString; + duration?: XcDouble; + summaryRef?: XcReference; + performanceMetricsCount?: XcInt; + failureSummariesCount?: XcInt; + activitySummariesCount?: XcInt; +}; + +export type XcActionTestSummaryGroup = XcActionTestSummaryIdentifiableObjectBase<"ActionTestSummaryGroup"> & { + duration: XcDouble; + subtests: XcArray; + skipNoticeSummary?: XcActionTestNoticeSummary; + summary?: XcString; + documentation: XcArray; + trackedIssues: XcArray; + tags: XcArray; +}; + +/** + * `xcrun xcresulttool get object --id '...'` with --id of + * XcActionTestPlanRunSummaries.summaries[number].testableSummaries[number].tests[number](.subtests[number])*.summaryRef + */ +export type XcActionTestSummary = XcActionTestSummaryIdentifiableObjectBase<"ActionTestSummary"> & { + testStatus: XcString; + duration: XcDouble; + performanceMetrics: XcArray; + failureSummaries: XcArray; + expectedFailures: XcArray; + skipNoticeSummary?: XcActionTestNoticeSummary; + activitySummaries: XcArray; + repetitionPolicySummary?: XcActionTestRepetitionPolicySummary; + arguments: XcArray; + configuration?: XcActionTestConfiguration; + warningSummaries: XcArray; + summary?: XcString; + documentation: XcArray; + trackedIssues: XcArray; + tags: XcArray; +}; + +export type XcActionTestSummaryIdentifiableObject = + | XcActionTestMetadata + | XcActionTestSummary + | XcActionTestSummaryGroup; + +export const XcActionTestSummaryIdentifiableObjectTypes = [ + "ActionTestMetadata", + "ActionTestSummary", + "ActionTestSummaryGroup", +] as const; + +export type XcActionTestFailureSummary = XcObject<"ActionTestFailureSummary"> & { + message?: XcString; + fileName: XcString; + lineNumber: XcInt; + isPerformanceFailure: XcBool; + uuid: XcString; + issueType?: XcString; + detailedDescription?: XcString; + attachments: XcArray; + associatedError?: XcTestAssociatedError; + sourceCodeContext?: XcSourceCodeContext; + timestamp?: XcDate; + isTopLevelFailure: XcBool; + expression?: XcTestExpression; +}; + +export type XcActionTestAttachment = XcObject<"ActionTestAttachment"> & { + uniformTypeIdentifier: XcString; + name?: XcString; + uuid?: XcString; + timestamp?: XcDate; + userInfo?: XcSortedKeyValueArray; + lifetime: XcString; + inActivityIdentifier: XcInt; + filename?: XcString; + payloadRef?: XcReference; + payloadSize: XcInt; +}; + +export type XcTestAssociatedError = XcObject<"TestAssociatedError"> & { + domain?: XcString; + code?: XcInt; + userInfo?: XcSortedKeyValueArray; +}; + +export type XcSourceCodeContext = XcObject<"SourceCodeContext"> & { + location?: XcSourceCodeLocation; + callStack: XcArray; +}; + +export type XcTestExpression = XcObject<"TestExpression"> & { + sourceCode: XcString; + value?: XcTestValue; + subexpressions: XcArray; +}; + +export type XcSourceCodeLocation = XcObject<"SourceCodeLocation"> & { + filePath?: XcString; + lineNumber?: XcInt; +}; + +export type XcSourceCodeFrame = XcObject<"SourceCodeFrame"> & { + addressString?: XcString; + symbolInfo?: XcSourceCodeSymbolInfo; +}; + +export type XcSourceCodeSymbolInfo = XcObject<"SourceCodeSymbolInfo"> & { + imageName?: XcString; + symbolName?: XcString; + location?: XcSourceCodeLocation; +}; + +export type XcActionTestPerformanceMetricSummary = XcObject<"ActionTestPerformanceMetricSummary"> & { + displayName: XcString; + unitOfMeasurement: XcString; + measurements: XcArray; + identifier?: XcString; + baselineName?: XcString; + baselineAverage?: XcDouble; + maxPercentRegression?: XcDouble; + maxPercentRelativeStandardDeviation?: XcDouble; + maxRegression?: XcDouble; + maxStandardDeviation?: XcDouble; + polarity?: XcString; +}; + +export type XcActionTestExpectedFailure = XcObject<"ActionTestExpectedFailure"> & { + uuid: XcString; + failureReason?: XcString; + failureSummary?: XcActionTestFailureSummary; + isTopLevelFailure: XcBool; +}; + +export type XcActionTestNoticeSummary = XcObject<"ActionTestNoticeSummary"> & { + message?: XcString; + fileName: XcString; + lineNumber: XcInt; + timestamp?: XcDate; +}; + +export type XcActionTestActivitySummary = XcObject<"ActionTestActivitySummary"> & { + title: XcString; + activityType: XcString; + uuid: XcString; + start?: XcDate; + finish?: XcDate; + attachments: XcArray; + subactivities: XcArray; + failureSummaryIDs: XcArray; + expectedFailureIDs: XcArray; + warningSummaryIDs: XcArray; +}; + +export type XcActionTestRepetitionPolicySummary = XcObject<"ActionTestRepetitionPolicySummary"> & { + iteration?: XcInt; + totalIterations?: XcInt; + repetitionMode?: XcString; +}; + +export type XcTestArgument = XcObject<"TestArgument"> & { + parameter?: XcTestParameter; + identifier?: XcString; + description: XcString; + debugDescription?: XcString; + typeName?: XcString; + value: XcTestValue; +}; + +export type XcActionTestConfiguration = XcObject<"ActionTestConfiguration"> & { + values: XcSortedKeyValueArray; +}; + +export type XcActionTestIssueSummary = XcObject<"ActionTestIssueSummary"> & { + message?: XcString; + fileName: XcString; + lineNumber: XcInt; + uuid: XcString; + issueType?: XcString; + detailedDescription?: XcString; + attachments: XcArray; + associatedError?: XcTestAssociatedError; + sourceCodeContext?: XcSourceCodeContext; + timestamp?: XcDate; +}; + +export type XcTestDocumentation = XcObject<"TestDocumentation"> & { + content: XcString; + format: XcString; +}; + +export type XcIssueTrackingMetadata = XcObject<"IssueTrackingMetadata"> & { + identifier: XcString; + url?: XcURL; + comment?: XcString; + summary: XcString; +}; + +export type XcTestTag = XcObject<"TestTag"> & { + identifier: XcString; + name: XcString; + anchors: XcArray; +}; + +export type XcTestParameter = XcObject<"TestParameter"> & { + label: XcString; + name?: XcString; + typeName?: XcString; + fullyQualifiedTypeName?: XcString; +}; + +export type XcTestValue = XcObject<"TestValue"> & { + description: XcString; + debugDescription?: XcString; + typeName?: XcString; + fullyQualifiedTypeName?: XcString; + label?: XcString; + isCollection: XcBool; + children: XcArray; +}; From eeef741606ffb3a72cba13a74c269dae474bb165 Mon Sep 17 00:00:00 2001 From: Maksim Stepanov <17935127+delatrie@users.noreply.github.com> Date: Thu, 13 Feb 2025 01:10:31 +0700 Subject: [PATCH 06/17] fix: use buffered attachment files --- packages/reader/src/xcresult/index.ts | 503 +----------------- packages/reader/src/xcresult/model.ts | 2 +- .../reader/src/xcresult/xcresulttool/cli.ts | 2 +- .../reader/src/xcresult/xcresulttool/index.ts | 483 +++++++++++++++++ .../src/xcresult/xcresulttool/legacy/cli.ts | 2 + .../src/xcresult/xcresulttool/legacy/index.ts | 304 ++++++----- .../src/xcresult/xcresulttool/legacy/model.ts | 29 +- .../reader/src/xcresult/xcresulttool/model.ts | 154 +----- .../reader/src/xcresult/xcresulttool/utils.ts | 38 ++ .../src/xcresult/xcresulttool/xcModel.ts | 143 +++++ 10 files changed, 887 insertions(+), 773 deletions(-) create mode 100644 packages/reader/src/xcresult/xcresulttool/utils.ts create mode 100644 packages/reader/src/xcresult/xcresulttool/xcModel.ts diff --git a/packages/reader/src/xcresult/index.ts b/packages/reader/src/xcresult/index.ts index ec8f1b1a..d7d03501 100644 --- a/packages/reader/src/xcresult/index.ts +++ b/packages/reader/src/xcresult/index.ts @@ -1,57 +1,10 @@ -import type { ResultFile } from "@allurereport/plugin-api"; -import type { - RawStep, - RawTestAttachment, - RawTestLabel, - RawTestParameter, - RawTestResult, - RawTestStatus, - RawTestStepResult, - ResultsReader, -} from "@allurereport/reader-api"; -import { PathResultFile } from "@allurereport/reader-api"; -import * as console from "node:console"; -import { randomUUID } from "node:crypto"; -import { mkdtemp, rm } from "node:fs/promises"; -import path from "node:path"; -import { - ensureArray, - ensureArrayWithItems, - ensureInt, - ensureLiteral, - ensureObject, - ensureString, - isArray, - isLiteral, - isNumber, - isObject, - isString, -} from "../validation.js"; -import type { ShallowKnown, Unknown } from "../validation.js"; -import type { TestDetailsRunData, TestRunCoordinates, TestRunSelector } from "./model.js"; -import { - DEFAULT_BUNDLE_NAME, - DEFAULT_SUITE_NAME, - DEFAULT_TEST_NAME, - createTestRunLookup, - getTargetDetails, - lookupNextTestAttempt, - secondsToMilliseconds, -} from "./utils.js"; -import { exportAttachments, getTestActivities, getTestDetails, getTests } from "./xcresulttool/cli.js"; -import { XcTestNodeTypeValues, XcTestResultValues } from "./xcresulttool/model.js"; -import type { - XcActivityNode, - XcAttachment, - XcDevice, - XcParsingContext, - XcTestNode, - XcTestResult, - XcTestRunArgument, -} from "./xcresulttool/model.js"; - -const DURATION_PATTERN = /\d+\.\d+/; -const ATTACHMENT_NAME_INFIX_PATTERN = /_\d+_[\dA-F]{8}-[\dA-F]{4}-[\dA-F]{4}-[\dA-F]{4}-[\dA-F]{12}/g; +import type { ResultsReader, ResultsVisitor } from "@allurereport/reader-api"; +import console from "node:console"; +import newApi from "./xcresulttool/index.js"; +import { legacyApiUnavailable } from "./xcresulttool/legacy/cli.js"; +import legacyApi from "./xcresulttool/legacy/index.js"; +import type { ApiParseFunction, ParsingContext } from "./xcresulttool/model.js"; +import { parseWithExportedAttachments } from "./xcresulttool/utils.js"; const readerId = "xcresult"; @@ -59,39 +12,24 @@ export const xcresult: ResultsReader = { read: async (visitor, data) => { const originalFileName = data.getOriginalFileName(); if (originalFileName.endsWith(".xfresult")) { - let attachmentsDir: string | undefined; try { - attachmentsDir = await mkdtemp("allure-"); - await exportAttachments(originalFileName, attachmentsDir); - const tests = await getTests(originalFileName); - if (isObject(tests)) { - const { testNodes } = tests; - if (isArray(testNodes)) { - const ctx = { filename: originalFileName, suites: [], attachmentsDir }; - for await (const testResultOrAttachment of processXcNodes(ctx, testNodes)) { - if ("readContent" in testResultOrAttachment) { - await visitor.visitAttachmentFile(testResultOrAttachment, { readerId }); - } else { - await visitor.visitTestResult(testResultOrAttachment, { - readerId, - metadata: { originalFileName }, - }); - } + await parseWithExportedAttachments(originalFileName, async (createAttachmentFile) => { + const context = { xcResultPath: originalFileName, createAttachmentFile }; + + try { + tryApi(visitor, legacyApi, context); + } catch (e) { + if (!legacyApiUnavailable()) { + throw e; } } - } + + tryApi(visitor, newApi, context); + }); + return true; } catch (e) { console.error("error parsing", originalFileName, e); - return false; - } finally { - if (attachmentsDir) { - try { - await rm(attachmentsDir, { recursive: true, force: true }); - } catch (e) { - console.error("when parsing", originalFileName, "- can't remove the tmp dir", attachmentsDir, e); - } - } } } return false; @@ -100,404 +38,13 @@ export const xcresult: ResultsReader = { readerId: () => readerId, }; -const processXcResultNode = async function* ( - ctx: XcParsingContext, - node: ShallowKnown, -): AsyncGenerator { - const { nodeType } = node; - - switch (ensureLiteral(nodeType, XcTestNodeTypeValues)) { - case "Unit test bundle": - case "UI test bundle": - yield* processXcBundleNode(ctx, node); - case "Test Suite": - yield* processXcTestSuiteNode(ctx, node); - case "Test Case": - yield* processXcTestCaseNode(ctx, node); - } -}; - -const processXcBundleNode = async function* (ctx: XcParsingContext, node: ShallowKnown) { - const { children, name } = node; - - yield* processXcNodes({ ...ctx, bundle: ensureString(name) ?? DEFAULT_BUNDLE_NAME }, ensureArray(children) ?? []); -}; - -const processXcTestSuiteNode = async function* (ctx: XcParsingContext, node: ShallowKnown) { - const { children, name } = node; - - yield* processXcNodes( - { ...ctx, suites: [...ctx.suites, ensureString(name) ?? DEFAULT_SUITE_NAME] }, - ensureArray(children) ?? [], - ); -}; - -const processXcTestCaseNode = async function* ( - { filename, bundle, suites, attachmentsDir }: XcParsingContext, - node: ShallowKnown, -) { - const { nodeIdentifier, name: displayName } = node; - if (isString(nodeIdentifier)) { - const testDetails = await getTestDetails(filename, nodeIdentifier); - const testActivities = await getTestActivities(filename, nodeIdentifier); - - if (isObject(testDetails) && isObject(testActivities)) { - const { testName, tags, testRuns: detailsTestRuns, devices: testDetailsDevices } = testDetails; - - const crossDeviceTesting = isArray(testDetailsDevices) && testDetailsDevices.length > 1; - const detailsRunLookup = createTestDetailsRunLookup(detailsTestRuns); - - const name = ensureString(displayName) ?? ensureString(testName) ?? DEFAULT_TEST_NAME; - const fullName = convertFullName(nodeIdentifier, bundle); - const testCaseLabels = convertTestCaseLabels(bundle, suites, nodeIdentifier, tags); - - const { testRuns: activityTestRuns } = testActivities; - for (const activityTestRun of ensureArrayWithItems(activityTestRuns, isObject) ?? []) { - const { - device: activityTestRunDevice, - arguments: activityTestRunArguments, - testPlanConfiguration: activityTestRunTestPlan, - activities, - } = activityTestRun; - const { - labels: deviceLabels, - parameters: deviceParameters, - deviceId, - } = processActivityTestRunDevice(activityTestRunDevice, crossDeviceTesting); - const { configurationId } = ensureObject(activityTestRunTestPlan) ?? {}; - const args = convertActivitiesTestRunArgs(activityTestRunArguments); - - const { - duration, - parameters = [], - result = "unknown", - } = findNextAttemptDataFromTestDetails(detailsRunLookup, { - device: deviceId, - testPlan: ensureString(configurationId), - args, - }) ?? {}; - - const { steps, attachmentFiles } = convertXcActivitiesToAllureSteps(attachmentsDir, activities); - - yield* attachmentFiles; - - yield { - uuid: randomUUID(), - fullName, - name, - start: 0, - duration: duration, - status: convertXcResultToAllureStatus(result), - message: "", - trace: "", - steps, - labels: [...testCaseLabels, ...deviceLabels], - links: [], - parameters: [...deviceParameters, ...pairParameterNamesWithValues(parameters, args)], - } as RawTestResult; - } - } - } -}; - -const convertXcActivitiesToAllureSteps = ( - attachmentsDir: string, - activities: Unknown, - parentActivityAttachments: Iterator<{ potentialNames: Set; uuid: string }> = [].values(), -): { steps: RawStep[] | undefined; attachmentFiles: ResultFile[] } => { - const attachmentFiles: ResultFile[] = []; - let nextAttachmentOfParentActivity = parentActivityAttachments.next(); - return { - steps: ensureArrayWithItems(activities, isObject)?.map( - ({ title: unvalidatedTitle, attachments, childActivities, startTime }) => { - const title = ensureString(unvalidatedTitle); - const start = isNumber(startTime) ? secondsToMilliseconds(startTime) : undefined; - - const { potentialNames: potentialAttachmentNames, uuid: attachmentFileName } = - nextAttachmentOfParentActivity.done ? {} : nextAttachmentOfParentActivity.value; - - const isAttachment = - isString(title) && isAttachmentActivity(potentialAttachmentNames, title, childActivities, attachments); - - if (isAttachment && attachmentFileName) { - const attachmentUuid = randomUUID(); - const attachmentPath = path.join(attachmentsDir, attachmentFileName); - attachmentFiles.push(new PathResultFile(attachmentPath, attachmentUuid)); - - nextAttachmentOfParentActivity = parentActivityAttachments.next(); - - return { - type: "attachment", - start, - name: title, - originalFileName: attachmentUuid, - } as RawTestAttachment; - } - - const stepAttachments = (ensureArrayWithItems(attachments, isObject) ?? []) - .map< - { potentialNames: Set; uuid: string } | undefined - >(({ name, uuid }) => (isString(name) && isString(uuid) ? { potentialNames: getPotentialFileNamesFromXcSuggestedName(name), uuid } : undefined)) - .filter((entry) => typeof entry !== "undefined"); - - const { steps: substeps, attachmentFiles: substepAttachmentFiles } = convertXcActivitiesToAllureSteps( - attachmentsDir, - childActivities, - stepAttachments.values(), - ); - - attachmentFiles.push(...substepAttachmentFiles); - - return { - type: "step", - duration: 0, - message: "", - name: title, - parameters: [], - start, - status: "passed", - steps: substeps, - stop: 0, - trace: "", - } as RawTestStepResult; - }, - ), - attachmentFiles, - }; -}; - -const isAttachmentActivity = ( - potentialAttachmentNames: Set | undefined, - title: string, - childActivities: Unknown, - attachments: Unknown, -) => - typeof childActivities === "undefined" && - typeof attachments === "undefined" && - (potentialAttachmentNames?.has(title) ?? false); - -const getPotentialFileNamesFromXcSuggestedName = (xcSuggestedAttachmentName: string) => - new Set( - [...xcSuggestedAttachmentName.matchAll(ATTACHMENT_NAME_INFIX_PATTERN)].map( - ({ 0: { length }, index }) => - xcSuggestedAttachmentName.slice(0, index) + xcSuggestedAttachmentName.slice(index + length), - ), - ); - -const convertXcResultToAllureStatus = (xcResult: XcTestResult): RawTestStatus => { - switch (xcResult) { - case "Expected Failure": - return "passed"; - case "Failed": - return "failed"; - case "Passed": - return "passed"; - case "Skipped": - return "skipped"; - default: - return "unknown"; - } -}; - -const pairParameterNamesWithValues = ( - names: readonly (string | undefined)[], - values: readonly (string | undefined)[], -): RawTestParameter[] => - names - .slice(0, values.length) - .map((p, i) => { - const value = values[i]; - return typeof p !== "undefined" && typeof value !== "undefined" ? { name: p, value } : undefined; - }) - .filter((p) => typeof p !== "undefined"); - -const convertActivitiesTestRunArgs = (args: Unknown): (string | undefined)[] => - isArray(args) ? args.map((a) => (isObject(a) && isString(a.value) ? a.value : undefined)) : []; - -const createTestDetailsRunLookup = (nodes: Unknown) => - createTestRunLookup(collectRunsFromTestDetails(nodes)); - -const findNextAttemptDataFromTestDetails = ( - lookup: Map>>, - selector: TestRunSelector, -) => { - const attempt = lookupNextTestAttempt(lookup, selector, ({ emitted }) => !emitted); - if (attempt) { - attempt.emitted = true; - } - return attempt; -}; - -const collectRunsFromTestDetails = ( - nodes: Unknown, - coordinates: TestRunCoordinates = {}, -): [TestRunCoordinates, TestDetailsRunData][] => { - return (ensureArrayWithItems(nodes, isObject) ?? []).flatMap((node) => { - const { children, duration, nodeIdentifier, name: nodeName, result } = node; - let coordinateCreated = true; - let repetition: number | undefined; - switch (ensureLiteral(node.nodeType, XcTestNodeTypeValues)) { - case "Device": - if (isString(nodeIdentifier)) { - coordinates = { ...coordinates, device: nodeIdentifier }; - } - case "Repetition": - repetition = ensureInt(nodeIdentifier); - if (repetition) { - coordinates = { ...coordinates, attempt: repetition }; - } - case "Arguments": - // If the test case is parametrized, the test-details/testRuns tree contains nested 'Arguments' nodes. - // We're only interested in the outmost ones; nested nodes can be safely ignored. - if ("args" in coordinates) { - return []; - } - - if (isString(nodeName)) { - coordinates = { ...coordinates, args: extractArguments(children) }; - } - case "Test Plan Configuration": - if (isString(nodeIdentifier)) { - coordinates = { ...coordinates, testPlan: nodeIdentifier }; - } - default: - coordinateCreated = false; - } - - const runs = collectRunsFromTestDetails(children, coordinates); - return runs.length - ? runs - : coordinateCreated - ? [ - coordinates, - { - duration: parseDuration(duration), - parameters: coordinates.args?.map((arg) => arg?.parameter) ?? [], - result: ensureLiteral(result, XcTestResultValues) ?? "unknown", - }, - ] - : []; - }); -}; - -const extractArguments = (nodes: Unknown) => { - if (isArray(nodes)) { - const argumentsNodeIndex = nodes.findIndex((node) => isObject(node) && isLiteral(node.nodeType, ["Arguments"])); - const { children } = nodes.splice(argumentsNodeIndex, 1)[0] as any as ShallowKnown; - return (ensureArrayWithItems(children, isObject) ?? []) - .filter(({ nodeType }) => isLiteral(nodeType, ["Test Value"])) - .map(({ name }) => { - if (isString(name) && name) { - const colonIndex = name.indexOf(":"); - if (colonIndex !== -1) { - return { - parameter: name.slice(0, colonIndex).trim(), - value: name.slice(colonIndex + 1).trim(), - }; - } - } - }); - } - return []; -}; - -const convertFullName = (testId: string, testBundle: string | undefined) => - testBundle ? `${testBundle}/${testId}` : testId; - -const convertTestCaseLabels = ( - bundle: string | undefined, - suites: readonly string[], - testId: string, - tags: Unknown, -) => { - const labels: RawTestLabel[] = []; - - if (bundle) { - labels.push({ name: "package", value: bundle }); - } - - const [testClass, testMethod] = convertTestClassAndMethod(testId); - - if (testClass) { - labels.push({ name: "testClass", value: testClass }); - } - - if (testMethod) { - labels.push({ name: "testMethod", value: testMethod }); - } - - if (suites.length) { - if (suites.length === 1) { - labels.push({ name: "suite", value: suites[0] }); - } - if (suites.length === 2) { - labels.push({ name: "suite", value: suites[0] }, { name: "subSuite", value: suites[1] }); +const tryApi = async (visitor: ResultsVisitor, generator: ApiParseFunction, context: ParsingContext) => { + const { xcResultPath: originalFileName } = context; + for await (const x of generator(context)) { + if ("readContent" in x) { + await visitor.visitAttachmentFile(x, { readerId }); } else { - const [parentSuite, suite, ...subSuites] = suites; - labels.push( - { name: "parentSuite", value: parentSuite }, - { name: "suite", value: suite }, - { name: "subSuite", value: subSuites.join(" > ") }, - ); - } - } - - labels.push(...(ensureArrayWithItems(tags, isString)?.map((t) => ({ name: "tag", value: t })) ?? [])); - - return labels; -}; - -const processActivityTestRunDevice = (device: Unknown, showDevice: boolean) => { - const labels: RawTestLabel[] = []; - const parameters: RawTestParameter[] = []; - - const { architecture, deviceId, deviceName, modelName, osVersion, platform } = ensureObject(device) ?? {}; - - const host = convertHost(device); - if (isString(deviceName) && deviceName) { - labels.push({ name: "host", value: host }); - parameters.push({ name: "Target", value: deviceName, hidden: !showDevice }); - if (showDevice) { - const targetDetails = getTargetDetails({ - architecture: ensureString(architecture), - model: ensureString(modelName), - platform: ensureString(platform), - osVersion: ensureString(osVersion), - }); - if (targetDetails) { - parameters.push({ name: "Target details", value: targetDetails, excluded: true }); - } - } - } - - return { labels, parameters, deviceId: ensureString(deviceId) }; -}; - -const convertHost = (device: Unknown) => { - if (isObject(device)) { - const { deviceName, deviceId } = device; - return ensureString(deviceName) ?? ensureString(deviceId); - } -}; - -const convertTestClassAndMethod = (testId: string) => { - const parts = testId.split("/"); - return [parts.slice(0, -1).join("."), parts.at(-1)]; -}; - -const processXcNodes = async function* (ctx: XcParsingContext, children: readonly Unknown[]) { - for (const child of children) { - if (isObject(child)) { - yield* processXcResultNode(ctx, child); - } - } -}; - -const parseDuration = (duration: Unknown) => { - if (isString(duration)) { - const match = DURATION_PATTERN.exec(duration); - if (match) { - return secondsToMilliseconds(parseFloat(match[0])); + visitor.visitTestResult(x, { readerId, metadata: { originalFileName } }); } } }; diff --git a/packages/reader/src/xcresult/model.ts b/packages/reader/src/xcresult/model.ts index 09d7cd7b..c99ab8b6 100644 --- a/packages/reader/src/xcresult/model.ts +++ b/packages/reader/src/xcresult/model.ts @@ -1,5 +1,5 @@ import type { RawTestLabel, RawTestLink, RawTestParameter } from "@allurereport/reader-api"; -import type { XcTestResult } from "./xcresulttool/model.js"; +import type { XcTestResult } from "./xcresulttool/xcModel.js"; export type XcAttachments = Map; diff --git a/packages/reader/src/xcresult/xcresulttool/cli.ts b/packages/reader/src/xcresult/xcresulttool/cli.ts index 8235c01e..7dc1fe83 100644 --- a/packages/reader/src/xcresult/xcresulttool/cli.ts +++ b/packages/reader/src/xcresult/xcresulttool/cli.ts @@ -1,6 +1,6 @@ import console from "node:console"; import { invokeCliTool, invokeJsonCliTool } from "../../toolRunner.js"; -import type { XcActivities, XcTestDetails, XcTests } from "./model.js"; +import type { XcActivities, XcTestDetails, XcTests } from "./xcModel.js"; export const xcrun = async (utilityName: string, ...args: readonly string[]) => { try { diff --git a/packages/reader/src/xcresult/xcresulttool/index.ts b/packages/reader/src/xcresult/xcresulttool/index.ts index e69de29b..4c079225 100644 --- a/packages/reader/src/xcresult/xcresulttool/index.ts +++ b/packages/reader/src/xcresult/xcresulttool/index.ts @@ -0,0 +1,483 @@ +import type { ResultFile } from "@allurereport/plugin-api"; +import type { + RawStep, + RawTestAttachment, + RawTestLabel, + RawTestParameter, + RawTestResult, + RawTestStatus, + RawTestStepResult, +} from "@allurereport/reader-api"; +import { randomUUID } from "node:crypto"; +import { + ensureArray, + ensureArrayWithItems, + ensureInt, + ensureLiteral, + ensureObject, + ensureString, + isArray, + isLiteral, + isNumber, + isObject, + isString, +} from "../../validation.js"; +import type { ShallowKnown, Unknown } from "../../validation.js"; +import type { TestDetailsRunData, TestRunCoordinates, TestRunSelector } from "../model.js"; +import { + DEFAULT_BUNDLE_NAME, + DEFAULT_SUITE_NAME, + DEFAULT_TEST_NAME, + createTestRunLookup, + getTargetDetails, + lookupNextTestAttempt, + secondsToMilliseconds, +} from "../utils.js"; +import { getTestActivities, getTestDetails, getTests } from "./cli.js"; +import type { ApiParseFunction, AttachmentFileFactory, ParsingContext, ParsingState } from "./model.js"; +import { XcTestNodeTypeValues, XcTestResultValues } from "./xcModel.js"; +import type { XcActivityNode, XcAttachment, XcDevice, XcTestNode, XcTestResult, XcTestRunArgument } from "./xcModel.js"; + +const DURATION_PATTERN = /\d+\.\d+/; +const ATTACHMENT_NAME_INFIX_PATTERN = /_\d+_[\dA-F]{8}-[\dA-F]{4}-[\dA-F]{4}-[\dA-F]{4}-[\dA-F]{12}/g; + +const parse: ApiParseFunction = async function* ( + context: ParsingContext, +): AsyncGenerator { + const { xcResultPath } = context; + const tests = await getTests(xcResultPath); + if (isObject(tests)) { + const { testNodes } = tests; + if (isArray(testNodes)) { + yield* processXcNodes(context, testNodes, { suites: [] }); + } + } +}; + +export default parse; + +const processXcResultNode = async function* ( + ctx: ParsingContext, + node: ShallowKnown, + state: ParsingState, +): AsyncGenerator { + const { nodeType } = node; + + switch (ensureLiteral(nodeType, XcTestNodeTypeValues)) { + case "Unit test bundle": + case "UI test bundle": + yield* processXcBundleNode(ctx, node, state); + case "Test Suite": + yield* processXcTestSuiteNode(ctx, node, state); + case "Test Case": + yield* processXcTestCaseNode(ctx, node, state); + } +}; + +const processXcBundleNode = async function* (ctx: ParsingContext, node: ShallowKnown, state: ParsingState) { + const { children, name } = node; + + yield* processXcNodes(ctx, ensureArray(children) ?? [], { + ...state, + bundle: ensureString(name) ?? DEFAULT_BUNDLE_NAME, + }); +}; + +const processXcTestSuiteNode = async function* ( + ctx: ParsingContext, + node: ShallowKnown, + state: ParsingState, +) { + const { children, name } = node; + const { suites } = state; + + yield* processXcNodes(ctx, ensureArray(children) ?? [], { + ...state, + suites: [...suites, ensureString(name) ?? DEFAULT_SUITE_NAME], + }); +}; + +const processXcTestCaseNode = async function* ( + { xcResultPath, createAttachmentFile }: ParsingContext, + node: ShallowKnown, + { bundle, suites }: ParsingState, +) { + const { nodeIdentifier, name: displayName } = node; + if (isString(nodeIdentifier)) { + const testDetails = await getTestDetails(xcResultPath, nodeIdentifier); + const testActivities = await getTestActivities(xcResultPath, nodeIdentifier); + + if (isObject(testDetails) && isObject(testActivities)) { + const { testName, tags, testRuns: detailsTestRuns, devices: testDetailsDevices } = testDetails; + + const crossDeviceTesting = isArray(testDetailsDevices) && testDetailsDevices.length > 1; + const detailsRunLookup = createTestDetailsRunLookup(detailsTestRuns); + + const name = ensureString(displayName) ?? ensureString(testName) ?? DEFAULT_TEST_NAME; + const fullName = convertFullName(nodeIdentifier, bundle); + const testCaseLabels = convertTestCaseLabels(bundle, suites, nodeIdentifier, tags); + + const { testRuns: activityTestRuns } = testActivities; + for (const activityTestRun of ensureArrayWithItems(activityTestRuns, isObject) ?? []) { + const { + device: activityTestRunDevice, + arguments: activityTestRunArguments, + testPlanConfiguration: activityTestRunTestPlan, + activities, + } = activityTestRun; + const { + labels: deviceLabels, + parameters: deviceParameters, + deviceId, + } = processActivityTestRunDevice(activityTestRunDevice, crossDeviceTesting); + const { configurationId } = ensureObject(activityTestRunTestPlan) ?? {}; + const args = convertActivitiesTestRunArgs(activityTestRunArguments); + + const { + duration, + parameters = [], + result = "unknown", + } = findNextAttemptDataFromTestDetails(detailsRunLookup, { + device: deviceId, + testPlan: ensureString(configurationId), + args, + }) ?? {}; + + const { steps, attachmentFiles } = await convertXcActivitiesToAllureSteps(createAttachmentFile, activities); + + yield* attachmentFiles; + + yield { + uuid: randomUUID(), + fullName, + name, + start: 0, + duration: duration, + status: convertXcResultToAllureStatus(result), + message: "", + trace: "", + steps, + labels: [...testCaseLabels, ...deviceLabels], + links: [], + parameters: [...deviceParameters, ...pairParameterNamesWithValues(parameters, args)], + } as RawTestResult; + } + } + } +}; + +const convertXcActivitiesToAllureSteps = async ( + createAttachmentFile: AttachmentFileFactory, + activities: Unknown, + parentActivityAttachments: Iterator<{ potentialNames: Set; uuid: string; xcName: string }> = [].values(), +): Promise<{ steps: RawStep[] | undefined; attachmentFiles: ResultFile[] }> => { + const attachmentFiles: ResultFile[] = []; + const steps: RawStep[] = []; + let nextAttachmentOfParentActivity = parentActivityAttachments.next(); + for (const { title: unvalidatedTitle, attachments, childActivities, startTime } of ensureArrayWithItems( + activities, + isObject, + ) ?? []) { + const title = ensureString(unvalidatedTitle); + const start = isNumber(startTime) ? secondsToMilliseconds(startTime) : undefined; + + const { + potentialNames: potentialAttachmentNames, + uuid: attachmentFileName, + xcName, + } = nextAttachmentOfParentActivity.done ? {} : nextAttachmentOfParentActivity.value; + + const isAttachment = + isString(title) && isAttachmentActivity(potentialAttachmentNames, title, childActivities, attachments); + + if (isAttachment && attachmentFileName && xcName) { + const file = await createAttachmentFile(attachmentFileName, xcName); + if (file) { + attachmentFiles.push(file); + } + + nextAttachmentOfParentActivity = parentActivityAttachments.next(); + + const attachmentStep = { + type: "attachment", + start, + name: title, + originalFileName: xcName, + } as RawTestAttachment; + + steps.push(attachmentStep); + + continue; + } + + const stepAttachments = (ensureArrayWithItems(attachments, isObject) ?? []) + .map< + { potentialNames: Set; uuid: string; xcName: string } | undefined + >(({ name, uuid }) => (isString(name) && isString(uuid) ? { potentialNames: getPotentialFileNamesFromXcSuggestedName(name), uuid, xcName: name } : undefined)) + .filter((entry) => typeof entry !== "undefined"); + + const { steps: substeps, attachmentFiles: substepAttachmentFiles } = await convertXcActivitiesToAllureSteps( + createAttachmentFile, + childActivities, + stepAttachments.values(), + ); + + const step = { + type: "step", + duration: 0, + message: "", + name: title, + parameters: [], + start, + status: "passed", + steps: substeps, + stop: 0, + trace: "", + } as RawTestStepResult; + + attachmentFiles.push(...substepAttachmentFiles); + steps.push(step); + } + + return { steps, attachmentFiles }; +}; + +const isAttachmentActivity = ( + potentialAttachmentNames: Set | undefined, + title: string, + childActivities: Unknown, + attachments: Unknown, +) => + typeof childActivities === "undefined" && + typeof attachments === "undefined" && + (potentialAttachmentNames?.has(title) ?? false); + +const getPotentialFileNamesFromXcSuggestedName = (xcSuggestedAttachmentName: string) => + new Set( + [...xcSuggestedAttachmentName.matchAll(ATTACHMENT_NAME_INFIX_PATTERN)].map( + ({ 0: { length }, index }) => + xcSuggestedAttachmentName.slice(0, index) + xcSuggestedAttachmentName.slice(index + length), + ), + ); + +const convertXcResultToAllureStatus = (xcResult: XcTestResult): RawTestStatus => { + switch (xcResult) { + case "Expected Failure": + return "passed"; + case "Failed": + return "failed"; + case "Passed": + return "passed"; + case "Skipped": + return "skipped"; + default: + return "unknown"; + } +}; + +const pairParameterNamesWithValues = ( + names: readonly (string | undefined)[], + values: readonly (string | undefined)[], +): RawTestParameter[] => + names + .slice(0, values.length) + .map((p, i) => { + const value = values[i]; + return typeof p !== "undefined" && typeof value !== "undefined" ? { name: p, value } : undefined; + }) + .filter((p) => typeof p !== "undefined"); + +const convertActivitiesTestRunArgs = (args: Unknown): (string | undefined)[] => + isArray(args) ? args.map((a) => (isObject(a) && isString(a.value) ? a.value : undefined)) : []; + +const createTestDetailsRunLookup = (nodes: Unknown) => + createTestRunLookup(collectRunsFromTestDetails(nodes)); + +const findNextAttemptDataFromTestDetails = ( + lookup: Map>>, + selector: TestRunSelector, +) => { + const attempt = lookupNextTestAttempt(lookup, selector, ({ emitted }) => !emitted); + if (attempt) { + attempt.emitted = true; + } + return attempt; +}; + +const collectRunsFromTestDetails = ( + nodes: Unknown, + coordinates: TestRunCoordinates = {}, +): [TestRunCoordinates, TestDetailsRunData][] => { + return (ensureArrayWithItems(nodes, isObject) ?? []).flatMap((node) => { + const { children, duration, nodeIdentifier, name: nodeName, result } = node; + let coordinateCreated = true; + let repetition: number | undefined; + switch (ensureLiteral(node.nodeType, XcTestNodeTypeValues)) { + case "Device": + if (isString(nodeIdentifier)) { + coordinates = { ...coordinates, device: nodeIdentifier }; + } + case "Repetition": + repetition = ensureInt(nodeIdentifier); + if (repetition) { + coordinates = { ...coordinates, attempt: repetition }; + } + case "Arguments": + // If the test case is parametrized, the test-details/testRuns tree contains nested 'Arguments' nodes. + // We're only interested in the outmost ones; nested nodes can be safely ignored. + if ("args" in coordinates) { + return []; + } + + if (isString(nodeName)) { + coordinates = { ...coordinates, args: extractArguments(children) }; + } + case "Test Plan Configuration": + if (isString(nodeIdentifier)) { + coordinates = { ...coordinates, testPlan: nodeIdentifier }; + } + default: + coordinateCreated = false; + } + + const runs = collectRunsFromTestDetails(children, coordinates); + return runs.length + ? runs + : coordinateCreated + ? [ + coordinates, + { + duration: parseDuration(duration), + parameters: coordinates.args?.map((arg) => arg?.parameter) ?? [], + result: ensureLiteral(result, XcTestResultValues) ?? "unknown", + }, + ] + : []; + }); +}; + +const extractArguments = (nodes: Unknown) => { + if (isArray(nodes)) { + const argumentsNodeIndex = nodes.findIndex((node) => isObject(node) && isLiteral(node.nodeType, ["Arguments"])); + const { children } = nodes.splice(argumentsNodeIndex, 1)[0] as any as ShallowKnown; + return (ensureArrayWithItems(children, isObject) ?? []) + .filter(({ nodeType }) => isLiteral(nodeType, ["Test Value"])) + .map(({ name }) => { + if (isString(name) && name) { + const colonIndex = name.indexOf(":"); + if (colonIndex !== -1) { + return { + parameter: name.slice(0, colonIndex).trim(), + value: name.slice(colonIndex + 1).trim(), + }; + } + } + }); + } + return []; +}; + +const convertFullName = (testId: string, testBundle: string | undefined) => + testBundle ? `${testBundle}/${testId}` : testId; + +const convertTestCaseLabels = ( + bundle: string | undefined, + suites: readonly string[], + testId: string, + tags: Unknown, +) => { + const labels: RawTestLabel[] = []; + + if (bundle) { + labels.push({ name: "package", value: bundle }); + } + + const [testClass, testMethod] = convertTestClassAndMethod(testId); + + if (testClass) { + labels.push({ name: "testClass", value: testClass }); + } + + if (testMethod) { + labels.push({ name: "testMethod", value: testMethod }); + } + + if (suites.length) { + if (suites.length === 1) { + labels.push({ name: "suite", value: suites[0] }); + } + if (suites.length === 2) { + labels.push({ name: "suite", value: suites[0] }, { name: "subSuite", value: suites[1] }); + } else { + const [parentSuite, suite, ...subSuites] = suites; + labels.push( + { name: "parentSuite", value: parentSuite }, + { name: "suite", value: suite }, + { name: "subSuite", value: subSuites.join(" > ") }, + ); + } + } + + labels.push(...(ensureArrayWithItems(tags, isString)?.map((t) => ({ name: "tag", value: t })) ?? [])); + + return labels; +}; + +const processActivityTestRunDevice = (device: Unknown, showDevice: boolean) => { + const labels: RawTestLabel[] = []; + const parameters: RawTestParameter[] = []; + + const { architecture, deviceId, deviceName, modelName, osVersion, platform } = ensureObject(device) ?? {}; + + const host = convertHost(device); + if (isString(deviceName) && deviceName) { + labels.push({ name: "host", value: host }); + parameters.push({ name: "Target", value: deviceName, hidden: !showDevice }); + if (showDevice) { + const targetDetails = getTargetDetails({ + architecture: ensureString(architecture), + model: ensureString(modelName), + platform: ensureString(platform), + osVersion: ensureString(osVersion), + }); + if (targetDetails) { + parameters.push({ name: "Target details", value: targetDetails, excluded: true }); + } + } + } + + return { labels, parameters, deviceId: ensureString(deviceId) }; +}; + +const convertHost = (device: Unknown) => { + if (isObject(device)) { + const { deviceName, deviceId } = device; + return ensureString(deviceName) ?? ensureString(deviceId); + } +}; + +const convertTestClassAndMethod = (testId: string) => { + const parts = testId.split("/"); + return [parts.slice(0, -1).join("."), parts.at(-1)]; +}; + +const processXcNodes = async function* ( + ctx: ParsingContext, + children: readonly Unknown[], + state: ParsingState, +) { + for (const child of children) { + if (isObject(child)) { + yield* processXcResultNode(ctx, child, state); + } + } +}; + +const parseDuration = (duration: Unknown) => { + if (isString(duration)) { + const match = DURATION_PATTERN.exec(duration); + if (match) { + return secondsToMilliseconds(parseFloat(match[0])); + } + } +}; diff --git a/packages/reader/src/xcresult/xcresulttool/legacy/cli.ts b/packages/reader/src/xcresult/xcresulttool/legacy/cli.ts index 59452490..bbddac9d 100644 --- a/packages/reader/src/xcresult/xcresulttool/legacy/cli.ts +++ b/packages/reader/src/xcresult/xcresulttool/legacy/cli.ts @@ -7,6 +7,8 @@ import type { XcActionsInvocationRecord, XcReference } from "./xcModel.js"; let legacyRunSucceeded = false; let noLegacyApi = false; +export const legacyApiUnavailable = () => !legacyRunSucceeded && noLegacyApi; + export const xcresulttoolGetLegacy = async ( xcResultPath: string, ...args: readonly string[] diff --git a/packages/reader/src/xcresult/xcresulttool/legacy/index.ts b/packages/reader/src/xcresult/xcresulttool/legacy/index.ts index 563d4728..f74ee48f 100644 --- a/packages/reader/src/xcresult/xcresulttool/legacy/index.ts +++ b/packages/reader/src/xcresult/xcresulttool/legacy/index.ts @@ -1,15 +1,13 @@ import type { ResultFile } from "@allurereport/plugin-api"; import type { + RawStep, RawTestAttachment, RawTestLink, RawTestParameter, RawTestResult, - RawTestStatus, RawTestStepResult, } from "@allurereport/reader-api"; -import { PathResultFile } from "@allurereport/reader-api"; import { randomUUID } from "node:crypto"; -import path from "node:path"; import type { ShallowKnown, Unknown } from "../../../validation.js"; import { ensureObject, ensureString, isDefined, isObject } from "../../../validation.js"; import { @@ -27,14 +25,18 @@ import { secondsToMilliseconds, toSortedSteps, } from "../../utils.js"; +import type { ApiParseFunction, AttachmentFileFactory, ParsingContext } from "../model.js"; import { getById, getRoot } from "./cli.js"; import type { ActionParametersInputData, ActivityProcessingResult, + FailureMap, + FailureMapValue, + FailureOverrides, LegacyActionDiscriminator, LegacyDestinationData, - LegacyParsingContext, LegacyParsingState, + ResolvedStepFailure, } from "./model.js"; import { getBool, @@ -82,8 +84,8 @@ import { XcActionTestSummaryIdentifiableObjectTypes } from "./xcModel.js"; const IDENTIFIER_URL_PREFIX = "test://com.apple.xcode/"; const ACTIVITY_TYPE_ATTACHMENT = "com.apple.dt.xctest.activity-type.attachmentContainer"; -export default async function* ( - context: LegacyParsingContext, +const parse: ApiParseFunction = async function* ( + context: ParsingContext, ): AsyncGenerator { const { xcResultPath } = context; const root = await getRoot(xcResultPath); @@ -115,7 +117,9 @@ export default async function* ( } } } -} +}; + +export default parse; const parseActionDiscriminators = (actions: ShallowKnown[]): LegacyActionDiscriminator[] => { return actions.map(({ runDestination, testPlanName }) => ({ @@ -181,7 +185,7 @@ const parsePlatform = (element: Unknown) => { }; const traverseActionTestSummaries = async function* ( - context: LegacyParsingContext, + context: ParsingContext, array: Unknown>, state: LegacyParsingState, ): AsyncGenerator { @@ -201,7 +205,7 @@ const traverseActionTestSummaries = async function* ( }; const visitActionTestMetadata = async function* ( - context: LegacyParsingContext, + context: ParsingContext, { summaryRef }: ShallowKnown, state: LegacyParsingState, ): AsyncGenerator { @@ -213,7 +217,7 @@ const visitActionTestMetadata = async function* ( }; const visitActionTestSummary = async function* ( - { attachmentsDir }: LegacyParsingContext, + { createAttachmentFile }: ParsingContext, { arguments: args, duration, @@ -246,12 +250,12 @@ const visitActionTestSummary = async function* ( tags: parseTestTags(tags), }); const parameters = getAllTestResultParameters(state, args, repetitionPolicySummary); - const failures = processFailures(attachmentsDir, failureSummaries, expectedFailures); + const failures = await processFailures(createAttachmentFile, failureSummaries, expectedFailures); const { steps: activitySteps, files, apiCalls, - } = processActivities(attachmentsDir, failures, getObjectArray(activitySummaries)); + } = await processActivities(createAttachmentFile, failures, getObjectArray(activitySummaries)); const { message, trace, steps: failureSteps } = resolveTestFailures(failures); const steps = toSortedSteps(activitySteps, failureSteps); const testResult: RawTestResult = { @@ -289,37 +293,58 @@ const parseTrackedIssues = (issues: Unknown>): }) .filter(isDefined); -type FailureMapValue = { - step: RawTestStepResult; - files: ResultFile[]; - isTopLevel?: boolean; +const processFailures = async ( + createAttachmentFile: AttachmentFileFactory, + failures: Unknown>, + expectedFailures: Unknown>, +): Promise => { + const failureEntries = await parseFailureEntries(createAttachmentFile, failures); + const expectedFailureEntries = await parseExpectedFailureEntries(createAttachmentFile, expectedFailures); + return new Map([...failureEntries, ...expectedFailureEntries]); }; -type FailureMap = Map; - -const processFailures = ( - attachmentsDir: string, +const parseFailureEntries = async ( + createAttachmentFile: AttachmentFileFactory, failures: Unknown>, +) => { + const entries: [string, FailureMapValue][] = []; + for (const summary of getObjectArray(failures)) { + const entry = await toFailureMapEntry(createAttachmentFile, summary); + if (entry) { + entries.push(entry); + } + } + return entries; +}; + +const parseExpectedFailureEntries = async ( + createAttachmentFile: AttachmentFileFactory, expectedFailures: Unknown>, -): FailureMap => { - const failureEntries = getObjectArray(failures).map((summary) => toFailureMapEntry(attachmentsDir, summary)); - const expectedFailureEntries = getObjectArray(expectedFailures).map(({ uuid, failureReason, failureSummary }) => - isObject(failureSummary) - ? toFailureMapEntry(attachmentsDir, failureSummary, { - uuid, - status: "passed", - mapMessage: (message) => { - const prefix = getString(failureReason) ?? DEFAULT_EXPECTED_FAILURE_REASON; - return message ? `${prefix}:\n ${message}` : prefix; - }, - }) - : undefined, - ); - return new Map([...failureEntries, ...expectedFailureEntries].filter(isDefined)); +) => { + const entries: [string, FailureMapValue][] = []; + for (const { uuid, failureReason, failureSummary } of getObjectArray(expectedFailures)) { + if (isObject(failureSummary)) { + const mapMessage = (message: string | undefined) => { + const prefix = getString(failureReason) ?? DEFAULT_EXPECTED_FAILURE_REASON; + return message ? `${prefix}:\n ${message}` : prefix; + }; + + const entry = await toFailureMapEntry(createAttachmentFile, failureSummary, { + uuid, + status: "passed", + mapMessage, + }); + + if (entry) { + entries.push(entry); + } + } + } + return entries; }; -const toFailureMapEntry = ( - attachmentsDir: string, +const toFailureMapEntry = async ( + createAttachmentFile: AttachmentFileFactory, { attachments, message: rawMessage, @@ -329,13 +354,9 @@ const toFailureMapEntry = ( isTopLevelFailure, issueType, }: ShallowKnown, - { - uuid: explicitUuid, - mapMessage, - status: explicitStatus, - }: { uuid?: Unknown; mapMessage?: (message: string | undefined) => string; status?: RawTestStatus } = {}, + { uuid: explicitUuid, mapMessage, status: explicitStatus }: FailureOverrides = {}, ) => { - const { steps, files } = parseAttachments(attachmentsDir, getObjectArray(attachments)); + const { steps, files } = await parseAttachments(createAttachmentFile, getObjectArray(attachments)); const message = getString(rawMessage); const status = explicitStatus ?? resolveFailureStepStatus(getString(issueType)); const trace = convertStackTrace(sourceCodeContext); @@ -378,83 +399,77 @@ const convertStackTrace = (sourceCodeContext: Unknown) => { } }; -const processActivities = ( - attachmentsDir: string, +const processActivities = async ( + createAttachmentFile: AttachmentFileFactory, failures: FailureMap, activities: readonly ShallowKnown[], -): ActivityProcessingResult => - mergeActivityProcessingResults( - ...activities.map( - ({ - activityType, - title, - start, - finish, - attachments: rawAttachments, - subactivities: rawSubactivities, - failureSummaryIDs, - }) => { - const attachments = getObjectArray(rawAttachments); - const subactivities = getObjectArray(rawSubactivities); - const failureIds = getStringArray(failureSummaryIDs); - - const parsedAttachments = parseAttachments(attachmentsDir, attachments); - if (getString(activityType) === ACTIVITY_TYPE_ATTACHMENT) { - return parsedAttachments; - } - - const name = getString(title); +): Promise => { + const results: ActivityProcessingResult[] = []; + for (const { + activityType, + title, + start, + finish, + attachments: rawAttachments, + subactivities: rawSubactivities, + failureSummaryIDs, + } of activities) { + const attachments = getObjectArray(rawAttachments); + const subactivities = getObjectArray(rawSubactivities); + const failureIds = getStringArray(failureSummaryIDs); + + const parsedAttachments = await parseAttachments(createAttachmentFile, attachments); + if (getString(activityType) === ACTIVITY_TYPE_ATTACHMENT) { + return parsedAttachments; + } - if (attachments.length === 0 && subactivities.length === 0 && failureIds.length === 0) { - const parsedAllureApiCall = parseAsAllureApiActivity(name); - if (isDefined(parsedAllureApiCall)) { - return { - steps: [], - files: [], - apiCalls: [parsedAllureApiCall], - }; - } - } + const name = getString(title); - const { steps: thisStepAttachmentSteps, files: thisStepFiles } = parsedAttachments; - const { - steps: substeps, - files: substepFiles, - apiCalls, - } = processActivities(attachmentsDir, failures, subactivities); + if (attachments.length === 0 && subactivities.length === 0 && failureIds.length === 0) { + const parsedAllureApiCall = parseAsAllureApiActivity(name); + if (isDefined(parsedAllureApiCall)) { + return { + steps: [], + files: [], + apiCalls: [parsedAllureApiCall], + }; + } + } - const failureSteps = failureIds.map((uuid) => failures.get(uuid)).filter(isDefined); - const { steps: nestedFailureSteps, message, trace } = resolveFailuresOfStep(failureIds, failureSteps); + const { steps: thisStepAttachmentSteps, files: thisStepFiles } = parsedAttachments; + const { + steps: substeps, + files: substepFiles, + apiCalls, + } = await processActivities(createAttachmentFile, failures, subactivities); - const steps = toSortedSteps(thisStepAttachmentSteps, substeps, nestedFailureSteps); + const failureSteps = failureIds.map((uuid) => failures.get(uuid)).filter(isDefined); + const { steps: nestedFailureSteps, message, trace } = resolveFailuresOfStep(failureIds, failureSteps); - return { - steps: [ - { - type: "step", - name: name ?? DEFAULT_STEP_NAME, - start: getDate(start), - stop: getDate(finish), - status: getWorstStatus(steps) ?? "passed", - message, - trace, - steps, - } as RawTestStepResult, - ], - files: [...thisStepFiles, ...substepFiles], - apiCalls, - }; - }, - ), - ); + const steps = toSortedSteps(thisStepAttachmentSteps, substeps, nestedFailureSteps); -type StepFailure = { - message?: string; - trace?: string; - steps: RawTestStepResult[]; + const result = { + steps: [ + { + type: "step", + name: name ?? DEFAULT_STEP_NAME, + start: getDate(start), + stop: getDate(finish), + status: getWorstStatus(steps) ?? "passed", + message, + trace, + steps, + } as RawTestStepResult, + ], + files: [...thisStepFiles, ...substepFiles], + apiCalls, + }; + results.push(result); + } + return mergeActivityProcessingResults(...results); }; -const resolveFailuresOfStep = (failureUids: string[], failures: readonly FailureMapValue[]): StepFailure => +const resolveFailuresOfStep = (failureUids: string[], failures: readonly FailureMapValue[]): ResolvedStepFailure => resolveFailures( failureUids.length > failures.length ? [ @@ -472,10 +487,10 @@ const resolveFailuresOfStep = (failureUids: string[], failures: readonly Failure : failures, ); -const resolveTestFailures = (failures: FailureMap): StepFailure => +const resolveTestFailures = (failures: FailureMap): ResolvedStepFailure => resolveFailures(Array.from(failures.values()).filter(({ isTopLevel }) => isTopLevel)); -const resolveFailures = (failures: readonly FailureMapValue[]): StepFailure => { +const resolveFailures = (failures: readonly FailureMapValue[]): ResolvedStepFailure => { switch (failures.length) { case 0: return { steps: [] }; @@ -490,13 +505,13 @@ const prepareOneFailure = ([ { step: { message, trace }, }, -]: readonly [FailureMapValue]): StepFailure => ({ +]: readonly [FailureMapValue]): ResolvedStepFailure => ({ message, trace, steps: [], }); -const prepareMultipleFailures = (failures: readonly [FailureMapValue, ...FailureMapValue[]]): StepFailure => { +const prepareMultipleFailures = (failures: readonly [FailureMapValue, ...FailureMapValue[]]): ResolvedStepFailure => { const [ { step: { message, trace }, @@ -523,35 +538,38 @@ const mergeActivityProcessingResults = (...results: readonly ActivityProcessingR ); }; -const parseAttachments = (attachmentsDir: string, attachments: readonly ShallowKnown[]) => - attachments - .map(({ name: rawName, timestamp, uuid: rawUuid }) => { - const uuid = getString(rawUuid); - if (uuid) { - const start = getDate(timestamp); - const fileName = randomUUID(); - return { - step: { - type: "attachment", - originalFileName: fileName, - name: getString(rawName) ?? DEFAULT_ATTACHMENT_NAME, - start, - stop: start, - } as RawTestAttachment, - file: new PathResultFile(path.join(attachmentsDir, uuid), fileName), - }; - } - }) - .filter(isDefined) - .reduce( - (parsedAttachments, { step, file }) => { - const { steps, files } = parsedAttachments; - steps.push(step); +const parseAttachments = async ( + createAttachmentFile: AttachmentFileFactory, + attachments: readonly ShallowKnown[], +) => { + const steps: RawStep[] = []; + const files: ResultFile[] = []; + + for (const { name: rawName, timestamp, uuid: rawUuid, filename: rawFileName } of attachments) { + const uuid = getString(rawUuid); + if (uuid) { + const start = getDate(timestamp); + const name = getString(rawName) ?? DEFAULT_ATTACHMENT_NAME; + const fileName = ensureUniqueFileName(rawFileName, name); + const step: RawTestAttachment = { + type: "attachment", + originalFileName: fileName, + name, + start, + stop: start, + }; + const file = await createAttachmentFile(uuid, fileName); + steps.push(step); + if (file) { files.push(file); - return parsedAttachments; - }, - { steps: [], files: [], apiCalls: [] }, - ); + } + } + } + return { steps, files, apiCalls: [] }; +}; + +const ensureUniqueFileName = (byXc: Unknown, byUser: string) => + getString(byXc) ?? `${randomUUID()}-${byUser}`; const getAllTestResultParameters = ( context: LegacyParsingState, @@ -630,7 +648,7 @@ const parseTestTags = (tags: Unknown>): string[] => .filter(isDefined); const visitActionTestSummaryGroup = async function* ( - context: LegacyParsingContext, + context: ParsingContext, { name, identifierURL, summary, subtests }: ShallowKnown, state: LegacyParsingState, ): AsyncGenerator { diff --git a/packages/reader/src/xcresult/xcresulttool/legacy/model.ts b/packages/reader/src/xcresult/xcresulttool/legacy/model.ts index edd3fea8..0da25585 100644 --- a/packages/reader/src/xcresult/xcresulttool/legacy/model.ts +++ b/packages/reader/src/xcresult/xcresulttool/legacy/model.ts @@ -1,6 +1,8 @@ import type { ResultFile } from "@allurereport/plugin-api"; -import type { RawStep } from "@allurereport/reader-api"; +import type { RawStep, RawTestStatus, RawTestStepResult } from "@allurereport/reader-api"; +import type { Unknown } from "../../../validation.js"; import type { AllureApiCall } from "../../model.js"; +import type { XcString } from "./xcModel.js"; export type LegacyTestResultData = { issues: LegacyIssueTrackingMetadata[]; @@ -40,11 +42,6 @@ export type Suite = { uri: string | undefined; }; -export type LegacyParsingContext = { - xcResultPath: string; - attachmentsDir: string; -}; - export type LegacyParsingState = { bundle?: string; suites: Suite[]; @@ -59,3 +56,23 @@ export type ActionParametersInputData = Pick< LegacyParsingState, "destination" | "testPlan" | "multiTarget" | "multiTestPlan" >; + +export type ResolvedStepFailure = { + message?: string; + trace?: string; + steps: RawTestStepResult[]; +}; + +export type FailureMapValue = { + step: RawTestStepResult; + files: ResultFile[]; + isTopLevel?: boolean; +}; + +export type FailureMap = Map; + +export type FailureOverrides = { + uuid?: Unknown; + mapMessage?: (message: string | undefined) => string; + status?: RawTestStatus; +}; diff --git a/packages/reader/src/xcresult/xcresulttool/model.ts b/packages/reader/src/xcresult/xcresulttool/model.ts index a73119bf..a02c0a30 100644 --- a/packages/reader/src/xcresult/xcresulttool/model.ts +++ b/packages/reader/src/xcresult/xcresulttool/model.ts @@ -1,150 +1,16 @@ -/** - * `xcrun xcresulttool get test-results tests` - */ -export type XcTests = { - testPlanConfigurations: XcConfiguration[]; - devices: XcDevice[]; - testNodes: XcTestNode[]; -}; - -export type XcConfiguration = { - configurationId: string; - configurationName: string; -}; - -export type XcDevice = { - deviceId?: string; - deviceName: string; - architecture: string; - modelName: string; - platform?: string; - osVersion: string; -}; - -export type XcTestNode = { - nodeIdentifier?: string; - nodeType: XcTestNodeType; - name: string; - details?: string; - duration?: string; - result?: XcTestResult; - tags?: string[]; - children?: XcTestNode[]; -}; - -export const XcTestNodeTypeValues = [ - "Test Plan", - "Unit test bundle", - "UI test bundle", - "Test Suite", - "Test Case", - "Device", - "Test Plan Configuration", - "Arguments", - "Repetition", - "Test Case Run", - "Failure Message", - "Source Code Reference", - "Attachment", - "Expression", - "Test Value", -] as const; +import type { ResultFile } from "@allurereport/plugin-api"; +import type { RawTestResult } from "@allurereport/reader-api"; -export type XcTestNodeType = (typeof XcTestNodeTypeValues)[number]; - -export const XcTestResultValues = ["Passed", "Failed", "Skipped", "Expected Failure", "unknown"] as const; - -export type XcTestResult = (typeof XcTestResultValues)[number]; - -/** - * `xcrun xcresulttool get test-results test-details --test-id '...'`, where --test-id is the value of - * XcTests.testNodes[number](.children[number])*.nodeIdentifier - */ -export type XcTestDetails = { - testIdentifier: string; - testName: string; - testDescription: string; - duration: string; - startTime?: number; - testPlanConfiguration: XcConfiguration[]; - devices: XcDevice[]; - arguments?: XcTestResultArgument[]; - testRuns: XcTestNode[]; - testResult: XcTestResult; - hasPerformanceMetrics: boolean; - hasMediaAttachments: boolean; - tags?: string[]; - bugs?: XcBug[]; - functionName?: string; -}; - -export type XcTestResultArgument = { - value: string; -}; - -export type XcBug = { - url?: string; - identifier?: string; - title?: string; -}; - -/** - * `xcrun xcresulttool get test-results activities --test-id '...'`, where --test-id is the value of - * XcTests.testNodes[number](.children[number])*.nodeIdentifier - */ -export type XcActivities = { - testIdentifier: string; - testName: string; - testRuns: XcTestRunActivity[]; -}; - -export type XcTestRunActivity = { - device: XcDevice; - testPlanConfiguration: XcConfiguration; - arguments?: XcTestRunArgument[]; - activities: XcActivityNode[]; -}; - -export type XcTestRunArgument = { - value: string; -}; - -export type XcActivityNode = { - title: string; - startTime?: number; - attachments?: XcAttachment[]; - childActivities?: XcActivityNode[]; -}; - -export type XcAttachment = { - name: string; - payloadId?: string; - uuid: string; - timestamp: number; - lifetime?: string; +export type ParsingState = { + suites: readonly string[]; + bundle?: string; }; -/** - * The type of the manifest entry created by `xcrun resulttool export attachments` - */ -export type XcTestAttachmentDetails = { - attachments: XcTestAttachment[]; - testIdentifier: string; +export type ParsingContext = { + xcResultPath: string; + createAttachmentFile: AttachmentFileFactory; }; -export type XcTestAttachment = { - configurationName: string; - deviceId: string; - deviceName: string; - exportedFileName: string; - isAssociatedWithFailure: boolean; - suggestedHumanReadableName: string; - timestamp: number; -}; +export type AttachmentFileFactory = (attachmentUuid: string, uniqueFileName: string) => Promise; -export type XcParsingContext = { - filename: string; - suites: readonly string[]; - bundle?: string; - attachmentsDir: string; -}; +export type ApiParseFunction = (context: ParsingContext) => AsyncGenerator; diff --git a/packages/reader/src/xcresult/xcresulttool/utils.ts b/packages/reader/src/xcresult/xcresulttool/utils.ts new file mode 100644 index 00000000..45c6be87 --- /dev/null +++ b/packages/reader/src/xcresult/xcresulttool/utils.ts @@ -0,0 +1,38 @@ +import { BufferResultFile } from "@allurereport/reader-api"; +import console from "node:console"; +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import path from "node:path"; +import { exportAttachments } from "./cli.js"; +import type { AttachmentFileFactory } from "./model.js"; + +export const parseWithExportedAttachments = async ( + xcResultPath: string, + fn: (createAttachmentFile: AttachmentFileFactory) => Promise, +) => { + let attachmentsDir: string | undefined; + try { + attachmentsDir = await mkdtemp("allure-"); + await exportAttachments(xcResultPath, attachmentsDir); + await fn(createAttachmentFileFactoryFn(attachmentsDir)); + } finally { + if (attachmentsDir) { + try { + await rm(attachmentsDir, { recursive: true, force: true }); + } catch (e) { + console.error("when parsing", xcResultPath, "- can't remove the tmp dir", attachmentsDir, ":", e); + } + } + } +}; + +const createAttachmentFileFactoryFn = + (attachmentsDir: string): AttachmentFileFactory => + async (attachmentUuid, uniqueFileName) => { + const attachmentFilePath = path.join(attachmentsDir, attachmentUuid); + try { + const content = await readFile(attachmentFilePath); + return new BufferResultFile(content, uniqueFileName); + } catch (e) { + console.error("Can't read attachment", attachmentUuid, "in", attachmentsDir, ":", e); + } + }; diff --git a/packages/reader/src/xcresult/xcresulttool/xcModel.ts b/packages/reader/src/xcresult/xcresulttool/xcModel.ts new file mode 100644 index 00000000..d11fd54a --- /dev/null +++ b/packages/reader/src/xcresult/xcresulttool/xcModel.ts @@ -0,0 +1,143 @@ +/** + * `xcrun xcresulttool get test-results tests` + */ +export type XcTests = { + testPlanConfigurations: XcConfiguration[]; + devices: XcDevice[]; + testNodes: XcTestNode[]; +}; + +export type XcConfiguration = { + configurationId: string; + configurationName: string; +}; + +export type XcDevice = { + deviceId?: string; + deviceName: string; + architecture: string; + modelName: string; + platform?: string; + osVersion: string; +}; + +export type XcTestNode = { + nodeIdentifier?: string; + nodeType: XcTestNodeType; + name: string; + details?: string; + duration?: string; + result?: XcTestResult; + tags?: string[]; + children?: XcTestNode[]; +}; + +export const XcTestNodeTypeValues = [ + "Test Plan", + "Unit test bundle", + "UI test bundle", + "Test Suite", + "Test Case", + "Device", + "Test Plan Configuration", + "Arguments", + "Repetition", + "Test Case Run", + "Failure Message", + "Source Code Reference", + "Attachment", + "Expression", + "Test Value", +] as const; + +export type XcTestNodeType = (typeof XcTestNodeTypeValues)[number]; + +export const XcTestResultValues = ["Passed", "Failed", "Skipped", "Expected Failure", "unknown"] as const; + +export type XcTestResult = (typeof XcTestResultValues)[number]; + +/** + * `xcrun xcresulttool get test-results test-details --test-id '...'`, where --test-id is the value of + * XcTests.testNodes[number](.children[number])*.nodeIdentifier + */ +export type XcTestDetails = { + testIdentifier: string; + testName: string; + testDescription: string; + duration: string; + startTime?: number; + testPlanConfiguration: XcConfiguration[]; + devices: XcDevice[]; + arguments?: XcTestResultArgument[]; + testRuns: XcTestNode[]; + testResult: XcTestResult; + hasPerformanceMetrics: boolean; + hasMediaAttachments: boolean; + tags?: string[]; + bugs?: XcBug[]; + functionName?: string; +}; + +export type XcTestResultArgument = { + value: string; +}; + +export type XcBug = { + url?: string; + identifier?: string; + title?: string; +}; + +/** + * `xcrun xcresulttool get test-results activities --test-id '...'`, where --test-id is the value of + * XcTests.testNodes[number](.children[number])*.nodeIdentifier + */ +export type XcActivities = { + testIdentifier: string; + testName: string; + testRuns: XcTestRunActivity[]; +}; + +export type XcTestRunActivity = { + device: XcDevice; + testPlanConfiguration: XcConfiguration; + arguments?: XcTestRunArgument[]; + activities: XcActivityNode[]; +}; + +export type XcTestRunArgument = { + value: string; +}; + +export type XcActivityNode = { + title: string; + startTime?: number; + attachments?: XcAttachment[]; + childActivities?: XcActivityNode[]; +}; + +export type XcAttachment = { + name: string; + payloadId?: string; + uuid: string; + timestamp: number; + lifetime?: string; +}; + +/** + * The type of the manifest entry created by `xcrun resulttool export attachments` + */ +export type XcTestAttachmentDetails = { + attachments: XcTestAttachment[]; + testIdentifier: string; +}; + +export type XcTestAttachment = { + configurationName: string; + deviceId: string; + deviceName: string; + exportedFileName: string; + isAssociatedWithFailure: boolean; + suggestedHumanReadableName: string; + timestamp: number; +}; From 8d35711a849897949255cd73668a3040e2f59333 Mon Sep 17 00:00:00 2001 From: Maksim Stepanov <17935127+delatrie@users.noreply.github.com> Date: Fri, 14 Feb 2025 17:32:06 +0700 Subject: [PATCH 07/17] feat: enable xcresult reader --- packages/core/src/report.ts | 4 ++-- packages/reader/src/index.ts | 1 + packages/reader/src/xcresult/index.ts | 1 + 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/core/src/report.ts b/packages/core/src/report.ts index 54eef217..9a5b094c 100644 --- a/packages/core/src/report.ts +++ b/packages/core/src/report.ts @@ -1,5 +1,5 @@ import type { Plugin, PluginContext, PluginState, ReportFiles, ResultFile } from "@allurereport/plugin-api"; -import { allure1, allure2, attachments, cucumberjson, junitXml } from "@allurereport/reader"; +import { allure1, allure2, attachments, cucumberjson, junitXml, xcresult } from "@allurereport/reader"; import { PathResultFile, type ResultsReader } from "@allurereport/reader-api"; import console from "node:console"; import { randomUUID } from "node:crypto"; @@ -37,7 +37,7 @@ export class AllureReport { constructor(opts: FullConfig) { const { name, - readers = [allure1, allure2, cucumberjson, junitXml, attachments], + readers = [allure1, allure2, cucumberjson, junitXml, xcresult, attachments], plugins = [], history, known, diff --git a/packages/reader/src/index.ts b/packages/reader/src/index.ts index ba2208b6..76b031c2 100644 --- a/packages/reader/src/index.ts +++ b/packages/reader/src/index.ts @@ -2,5 +2,6 @@ export * from "./allure1/index.js"; export * from "./allure2/index.js"; export * from "./cucumberjson/index.js"; export * from "./junitxml/index.js"; +export * from "./xcresult/index.js"; export * from "./attachments/index.js"; export type * from "./model.js"; diff --git a/packages/reader/src/xcresult/index.ts b/packages/reader/src/xcresult/index.ts index d7d03501..d6ceb9fe 100644 --- a/packages/reader/src/xcresult/index.ts +++ b/packages/reader/src/xcresult/index.ts @@ -18,6 +18,7 @@ export const xcresult: ResultsReader = { try { tryApi(visitor, legacyApi, context); + return; } catch (e) { if (!legacyApiUnavailable()) { throw e; From 5727a6e85d841bcea189dde5eaa905ceab964b97 Mon Sep 17 00:00:00 2001 From: Maksim Stepanov <17935127+delatrie@users.noreply.github.com> Date: Fri, 14 Feb 2025 19:54:38 +0700 Subject: [PATCH 08/17] feat: make xcresult a directory reader --- packages/reader/src/xcresult/index.ts | 51 ++++++++++++++------------- 1 file changed, 27 insertions(+), 24 deletions(-) diff --git a/packages/reader/src/xcresult/index.ts b/packages/reader/src/xcresult/index.ts index d6ceb9fe..4c37ceae 100644 --- a/packages/reader/src/xcresult/index.ts +++ b/packages/reader/src/xcresult/index.ts @@ -1,4 +1,5 @@ -import type { ResultsReader, ResultsVisitor } from "@allurereport/reader-api"; +import type { ResultsVisitor } from "@allurereport/reader-api"; +import { DirectoryResultsReader } from "@allurereport/reader-api"; import console from "node:console"; import newApi from "./xcresulttool/index.js"; import { legacyApiUnavailable } from "./xcresulttool/legacy/cli.js"; @@ -6,18 +7,19 @@ import legacyApi from "./xcresulttool/legacy/index.js"; import type { ApiParseFunction, ParsingContext } from "./xcresulttool/model.js"; import { parseWithExportedAttachments } from "./xcresulttool/utils.js"; -const readerId = "xcresult"; +class XcResultReader extends DirectoryResultsReader { + constructor() { + super("xcresult"); + } -export const xcresult: ResultsReader = { - read: async (visitor, data) => { - const originalFileName = data.getOriginalFileName(); - if (originalFileName.endsWith(".xfresult")) { + override async readDirectory(visitor: ResultsVisitor, resultDir: string): Promise { + if (resultDir.endsWith(".xfresult")) { try { - await parseWithExportedAttachments(originalFileName, async (createAttachmentFile) => { - const context = { xcResultPath: originalFileName, createAttachmentFile }; + await parseWithExportedAttachments(resultDir, async (createAttachmentFile) => { + const context = { xcResultPath: resultDir, createAttachmentFile }; try { - tryApi(visitor, legacyApi, context); + this.#tryApi(visitor, legacyApi, context); return; } catch (e) { if (!legacyApiUnavailable()) { @@ -25,27 +27,28 @@ export const xcresult: ResultsReader = { } } - tryApi(visitor, newApi, context); + this.#tryApi(visitor, newApi, context); }); return true; } catch (e) { - console.error("error parsing", originalFileName, e); + console.error("error parsing", resultDir, e); } } return false; - }, - - readerId: () => readerId, -}; + } -const tryApi = async (visitor: ResultsVisitor, generator: ApiParseFunction, context: ParsingContext) => { - const { xcResultPath: originalFileName } = context; - for await (const x of generator(context)) { - if ("readContent" in x) { - await visitor.visitAttachmentFile(x, { readerId }); - } else { - visitor.visitTestResult(x, { readerId, metadata: { originalFileName } }); + #tryApi = async (visitor: ResultsVisitor, generator: ApiParseFunction, context: ParsingContext) => { + const { xcResultPath: originalFileName } = context; + const readerId = this.readerId(); + for await (const x of generator(context)) { + if ("readContent" in x) { + await visitor.visitAttachmentFile(x, { readerId }); + } else { + visitor.visitTestResult(x, { readerId, metadata: { originalFileName } }); + } } - } -}; + }; +} + +export const xcresult = new XcResultReader(); From d21ee2a22584a60c04a366b5103c1ddc0cb0d0a7 Mon Sep 17 00:00:00 2001 From: Maksim Stepanov <17935127+delatrie@users.noreply.github.com> Date: Tue, 25 Feb 2025 01:11:13 +0700 Subject: [PATCH 09/17] fix: invalid exit code check in tool runner --- packages/reader/src/toolRunner.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/reader/src/toolRunner.ts b/packages/reader/src/toolRunner.ts index 71feaa9c..0845f4db 100644 --- a/packages/reader/src/toolRunner.ts +++ b/packages/reader/src/toolRunner.ts @@ -49,7 +49,7 @@ export const invokeCliTool = async ( return; } - if (typeof expectedExitCode === "number" ? code === expectedExitCode : expectedExitCode(code!)) { + if (typeof expectedExitCode === "number" ? code !== expectedExitCode : expectedExitCode(code!)) { onError(new Error(`${executable} finished with an unexpected exit code ${code}`)); return; } From 3a5ff132d81d620a411c14214a1abc5a65ddc959 Mon Sep 17 00:00:00 2001 From: Maksim Stepanov <17935127+delatrie@users.noreply.github.com> Date: Tue, 25 Feb 2025 01:22:54 +0700 Subject: [PATCH 10/17] feat: check xcresulttool availibility --- packages/reader/src/validation.ts | 2 +- packages/reader/src/xcresult/index.ts | 67 +++++++++++++------ .../reader/src/xcresult/xcresulttool/cli.ts | 11 ++- 3 files changed, 59 insertions(+), 21 deletions(-) diff --git a/packages/reader/src/validation.ts b/packages/reader/src/validation.ts index 8bc3e6a6..4c4c1315 100644 --- a/packages/reader/src/validation.ts +++ b/packages/reader/src/validation.ts @@ -295,7 +295,7 @@ export const ensureArrayWithItems = >> /** * If the value is an object (but not an array; for arrays, see `ensureArrayWithItems`), returns a new object of - * the same shape as the original one but with only proeprties that confirms to the provided type guard. + * the same shape as the original one but with only those properties that conform to a type guard. * @example * ```ts * const raw: Unknown<{ name: string }> = JSON.parse('{ "name": "foo" }'); diff --git a/packages/reader/src/xcresult/index.ts b/packages/reader/src/xcresult/index.ts index 4c37ceae..fb2232cf 100644 --- a/packages/reader/src/xcresult/index.ts +++ b/packages/reader/src/xcresult/index.ts @@ -1,6 +1,7 @@ import type { ResultsVisitor } from "@allurereport/reader-api"; import { DirectoryResultsReader } from "@allurereport/reader-api"; import console from "node:console"; +import { version } from "./xcresulttool/cli.js"; import newApi from "./xcresulttool/index.js"; import { legacyApiUnavailable } from "./xcresulttool/legacy/cli.js"; import legacyApi from "./xcresulttool/legacy/index.js"; @@ -13,30 +14,58 @@ class XcResultReader extends DirectoryResultsReader { } override async readDirectory(visitor: ResultsVisitor, resultDir: string): Promise { - if (resultDir.endsWith(".xfresult")) { - try { - await parseWithExportedAttachments(resultDir, async (createAttachmentFile) => { - const context = { xcResultPath: resultDir, createAttachmentFile }; - - try { - this.#tryApi(visitor, legacyApi, context); - return; - } catch (e) { - if (!legacyApiUnavailable()) { - throw e; - } + if (resultDir.endsWith(".xcresult")) { + if (await this.#xcResultToolAvailable()) { + return await this.#parseBundleWithXcResultTool(visitor, resultDir); + } + } + return false; + } + + #xcResultToolAvailable = async () => { + try { + await version(); + return true; + } catch (e) { + console.error( + "xcresulttool is unavailable on this machine. Please, make sure XCode is installed. The original error:", + e, + ); + } + + return false; + }; + + #parseBundleWithXcResultTool = async (visitor: ResultsVisitor, xcResultPath: string) => { + try { + await parseWithExportedAttachments(xcResultPath, async (createAttachmentFile) => { + const context = { xcResultPath: xcResultPath, createAttachmentFile }; + + try { + await this.#tryApi(visitor, legacyApi, context); + return; + } catch (e) { + console.error(e); + if (!legacyApiUnavailable()) { + // The legacy API available but some other error has occured. We should not attempt using the new API in + // that case because the results may've been partially created. + throw e; } + } - this.#tryApi(visitor, newApi, context); - }); + // The legacy API is not available. Fallback to the new API (as paradoxical as it may sound; the new API is + // much less convenient to consume, lacks some important information, and hides test results that share the + // same test id. + await this.#tryApi(visitor, newApi, context); + }); - return true; - } catch (e) { - console.error("error parsing", resultDir, e); - } + return true; + } catch (e) { + console.error("error parsing", xcResultPath, e); } + return false; - } + }; #tryApi = async (visitor: ResultsVisitor, generator: ApiParseFunction, context: ParsingContext) => { const { xcResultPath: originalFileName } = context; diff --git a/packages/reader/src/xcresult/xcresulttool/cli.ts b/packages/reader/src/xcresult/xcresulttool/cli.ts index 7dc1fe83..da821eea 100644 --- a/packages/reader/src/xcresult/xcresulttool/cli.ts +++ b/packages/reader/src/xcresult/xcresulttool/cli.ts @@ -1,5 +1,5 @@ import console from "node:console"; -import { invokeCliTool, invokeJsonCliTool } from "../../toolRunner.js"; +import { invokeCliTool, invokeJsonCliTool, invokeStdoutCliTool } from "../../toolRunner.js"; import type { XcActivities, XcTestDetails, XcTests } from "./xcModel.js"; export const xcrun = async (utilityName: string, ...args: readonly string[]) => { @@ -12,6 +12,15 @@ export const xcrun = async (utilityName: string, ...args: readonly string[]) export const xcresulttool = async (...args: readonly string[]) => await xcrun("xcresulttool", ...args); +export const version = async () => { + const stdout = invokeStdoutCliTool("xcrun", ["xcresulttool", "--version"], { timeout: 1000 }); + const lines: string[] = []; + for await (const line of stdout) { + lines.push(line); + } + return lines.join("\n"); +}; + export const getTests = async (xcResultPath: string) => await xcresulttool("get", "test-results", "tests", "--path", xcResultPath); From daad2549e871471aa7a41e45ba5d77d0996ee682 Mon Sep 17 00:00:00 2001 From: Maksim Stepanov <17935127+delatrie@users.noreply.github.com> Date: Tue, 25 Feb 2025 01:25:57 +0700 Subject: [PATCH 11/17] fix: invalid map entries usage in groupBy --- packages/reader/src/xcresult/utils.ts | 34 +++++++++++++++------------ 1 file changed, 19 insertions(+), 15 deletions(-) diff --git a/packages/reader/src/xcresult/utils.ts b/packages/reader/src/xcresult/utils.ts index 86da32ad..455dce0d 100644 --- a/packages/reader/src/xcresult/utils.ts +++ b/packages/reader/src/xcresult/utils.ts @@ -52,15 +52,15 @@ export const getArgsKeyByValues = (values: readonly (string | undefined)[]) => v export const getArgsKey = (args: TestRunArgs) => getArgsKeyByValues(args.map((arg) => arg?.value)); export const createTestRunLookup = (entries: readonly (readonly [TestRunCoordinates, T])[]): TestRunLookup => - groupByMap( + mappedGroupBy( entries, ([{ device }]) => device ?? SURROGATE_DEVICE_ID, (deviceRuns) => - groupByMap( + mappedGroupBy( deviceRuns, ([{ testPlan }]) => testPlan ?? SURROGATE_TEST_PLAN_ID, (configRuns) => - groupByMap( + mappedGroupBy( configRuns, ([{ args }]) => (args && args.length ? getArgsKey(args) : SURROGATE_ARGS_ID), (argRuns) => { @@ -102,16 +102,17 @@ export const groupBy = (values: readonly T[], keyFn: (v: T) => K): Map()); -export const groupByMap = ( +export const mappedGroupBy = ( values: readonly T[], keyFn: (v: T) => K, groupMapFn: (group: T[]) => G, -): Map => - new Map( - groupBy(values, keyFn) - .entries() - .map(([k, g]) => [k, groupMapFn(g)]), - ); +): Map => { + const result = new Map(); + for (const [k, g] of groupBy(values, keyFn)) { + result.set(k, groupMapFn(g)); + } + return result; +}; export const getTargetDetails = ({ architecture, model, platform, osVersion }: TargetDescriptor = {}) => { const osPart = platform ? (osVersion ? `${platform} ${osVersion}` : platform) : undefined; @@ -164,14 +165,17 @@ export const parseAsAllureApiActivity = (title: string | undefined): AllureApiCa } }; -export const applyApiCalls = (testResult: RawTestResult, apiCalls: readonly AllureApiCall[]) => - groupByMap( +export const applyApiCalls = (testResult: RawTestResult, apiCalls: readonly AllureApiCall[]) => { + const groupedApiCalls = mappedGroupBy( apiCalls, (v) => v.type, (g) => g.map(({ value }) => value), - ) - .entries() - .forEach(([type, values]) => applyApiCallGroup(testResult, type, values)); + ); + + for (const [type, values] of groupedApiCalls) { + applyApiCallGroup(testResult, type, values); + } +}; const applyApiCallGroup = ( testResult: RawTestResult, From b9dd249fb8d3f30df7e470162f3992e4ca235118 Mon Sep 17 00:00:00 2001 From: Maksim Stepanov <17935127+delatrie@users.noreply.github.com> Date: Tue, 25 Feb 2025 01:26:26 +0700 Subject: [PATCH 12/17] fix: attachments export and lookup --- .../reader/src/xcresult/xcresulttool/utils.ts | 39 ++++++++++++++++--- 1 file changed, 33 insertions(+), 6 deletions(-) diff --git a/packages/reader/src/xcresult/xcresulttool/utils.ts b/packages/reader/src/xcresult/xcresulttool/utils.ts index 45c6be87..d2f57625 100644 --- a/packages/reader/src/xcresult/xcresulttool/utils.ts +++ b/packages/reader/src/xcresult/xcresulttool/utils.ts @@ -1,6 +1,7 @@ import { BufferResultFile } from "@allurereport/reader-api"; import console from "node:console"; import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; import path from "node:path"; import { exportAttachments } from "./cli.js"; import type { AttachmentFileFactory } from "./model.js"; @@ -11,7 +12,7 @@ export const parseWithExportedAttachments = async ( ) => { let attachmentsDir: string | undefined; try { - attachmentsDir = await mkdtemp("allure-"); + attachmentsDir = await mkdtemp(path.join(tmpdir(), "allure-reader-xcresult-")); await exportAttachments(xcResultPath, attachmentsDir); await fn(createAttachmentFileFactoryFn(attachmentsDir)); } finally { @@ -28,11 +29,37 @@ export const parseWithExportedAttachments = async ( const createAttachmentFileFactoryFn = (attachmentsDir: string): AttachmentFileFactory => async (attachmentUuid, uniqueFileName) => { + const fileExtension = path.extname(uniqueFileName); const attachmentFilePath = path.join(attachmentsDir, attachmentUuid); - try { - const content = await readFile(attachmentFilePath); - return new BufferResultFile(content, uniqueFileName); - } catch (e) { - console.error("Can't read attachment", attachmentUuid, "in", attachmentsDir, ":", e); + + const [firstAttemptOk, firstAttemptResult] = await tryReadFile(attachmentFilePath, uniqueFileName); + if (firstAttemptOk) { + return firstAttemptResult; + } + + const errors: unknown[] = [firstAttemptResult]; + + if ((firstAttemptResult as any)?.code === "ENOENT" && fileExtension) { + const attachmentPathWithExt = `${attachmentFilePath}${fileExtension}`; + const [secondAttemptOk, secondAttemptResult] = await tryReadFile(attachmentPathWithExt, uniqueFileName); + + if (secondAttemptOk) { + return secondAttemptResult; + } + + errors.push(secondAttemptResult); } + + console.error("Can't read attachment", attachmentUuid, "in", attachmentsDir, ":", ...errors); }; + +const tryReadFile = async ( + attachmentPath: string, + attachmentName: string, +): Promise<[true, BufferResultFile] | [false, unknown]> => { + try { + return [true, new BufferResultFile(await readFile(attachmentPath), attachmentName)]; + } catch (e) { + return [false, e]; + } +}; From c5ba378b8100c8ccff2a1aef15c76a0df7aff239 Mon Sep 17 00:00:00 2001 From: Maksim Stepanov <17935127+delatrie@users.noreply.github.com> Date: Tue, 25 Feb 2025 01:27:44 +0700 Subject: [PATCH 13/17] fix: invalid parameter parsing with legacy API --- packages/reader/src/xcresult/xcresulttool/legacy/index.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/reader/src/xcresult/xcresulttool/legacy/index.ts b/packages/reader/src/xcresult/xcresulttool/legacy/index.ts index f74ee48f..c475ae6a 100644 --- a/packages/reader/src/xcresult/xcresulttool/legacy/index.ts +++ b/packages/reader/src/xcresult/xcresulttool/legacy/index.ts @@ -9,7 +9,7 @@ import type { } from "@allurereport/reader-api"; import { randomUUID } from "node:crypto"; import type { ShallowKnown, Unknown } from "../../../validation.js"; -import { ensureObject, ensureString, isDefined, isObject } from "../../../validation.js"; +import { ensureObject, isDefined, isObject } from "../../../validation.js"; import { DEFAULT_ATTACHMENT_NAME, DEFAULT_BUNDLE_NAME, @@ -665,7 +665,7 @@ const visitActionTestSummaryGroup = async function* ( }; const getParameterName = (parameter: Unknown) => - isObject(parameter) ? ensureString(parameter.name) : undefined; + isObject(parameter) ? getString(parameter.name) : undefined; const getArgumentValue = (parameter: Unknown) => - isObject(parameter) ? ensureString(parameter.description) : undefined; + isObject(parameter) ? getString(parameter.description) : undefined; From 9e78dd6a84487f10b80b59f11402a4d7a4647a73 Mon Sep 17 00:00:00 2001 From: Maksim Stepanov <17935127+delatrie@users.noreply.github.com> Date: Tue, 25 Feb 2025 01:29:19 +0700 Subject: [PATCH 14/17] fix: inconsistent effect of target on historyId --- packages/reader/src/xcresult/xcresulttool/legacy/index.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/reader/src/xcresult/xcresulttool/legacy/index.ts b/packages/reader/src/xcresult/xcresulttool/legacy/index.ts index c475ae6a..96db5397 100644 --- a/packages/reader/src/xcresult/xcresulttool/legacy/index.ts +++ b/packages/reader/src/xcresult/xcresulttool/legacy/index.ts @@ -589,9 +589,9 @@ const convertActionParameters = ({ destination, testPlan, multiTarget, multiTest if (destination) { const { name, targetDetails } = destination; if (isDefined(name)) { - parameters.push({ name: "Target", value: name, excluded: !multiTarget }); + parameters.push({ name: "Target", value: name }); if (multiTarget && targetDetails) { - parameters.push({ name: "Target details", value: targetDetails }); + parameters.push({ name: "Target details", value: targetDetails, excluded: true }); } } } From 50b0e81809ce9d68d2080f6f89f16d70a05dceb4 Mon Sep 17 00:00:00 2001 From: Maksim Stepanov <17935127+delatrie@users.noreply.github.com> Date: Tue, 25 Feb 2025 01:31:22 +0700 Subject: [PATCH 15/17] fix: rename target parameter to device --- packages/reader/src/xcresult/xcresulttool/legacy/index.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/reader/src/xcresult/xcresulttool/legacy/index.ts b/packages/reader/src/xcresult/xcresulttool/legacy/index.ts index 96db5397..94116018 100644 --- a/packages/reader/src/xcresult/xcresulttool/legacy/index.ts +++ b/packages/reader/src/xcresult/xcresulttool/legacy/index.ts @@ -589,9 +589,9 @@ const convertActionParameters = ({ destination, testPlan, multiTarget, multiTest if (destination) { const { name, targetDetails } = destination; if (isDefined(name)) { - parameters.push({ name: "Target", value: name }); + parameters.push({ name: "Device", value: name }); if (multiTarget && targetDetails) { - parameters.push({ name: "Target details", value: targetDetails, excluded: true }); + parameters.push({ name: "Device details", value: targetDetails, excluded: true }); } } } From dec6f075fd2bb07288a168aa8fc464760a379ae3 Mon Sep 17 00:00:00 2001 From: Maksim Stepanov <17935127+delatrie@users.noreply.github.com> Date: Tue, 25 Feb 2025 01:45:30 +0700 Subject: [PATCH 16/17] fix: revert reader abc changes --- packages/reader/src/xcresult/index.ts | 120 +++++++++++++------------- 1 file changed, 60 insertions(+), 60 deletions(-) diff --git a/packages/reader/src/xcresult/index.ts b/packages/reader/src/xcresult/index.ts index fb2232cf..06ec6d86 100644 --- a/packages/reader/src/xcresult/index.ts +++ b/packages/reader/src/xcresult/index.ts @@ -1,5 +1,5 @@ -import type { ResultsVisitor } from "@allurereport/reader-api"; -import { DirectoryResultsReader } from "@allurereport/reader-api"; +import type { ResultFile } from "@allurereport/plugin-api"; +import type { ResultsReader, ResultsVisitor } from "@allurereport/reader-api"; import console from "node:console"; import { version } from "./xcresulttool/cli.js"; import newApi from "./xcresulttool/index.js"; @@ -8,76 +8,76 @@ import legacyApi from "./xcresulttool/legacy/index.js"; import type { ApiParseFunction, ParsingContext } from "./xcresulttool/model.js"; import { parseWithExportedAttachments } from "./xcresulttool/utils.js"; -class XcResultReader extends DirectoryResultsReader { - constructor() { - super("xcresult"); - } +const readerId = "xcresult"; + +export const xcresult: ResultsReader = { + read: async (visitor: ResultsVisitor, data: ResultFile): Promise => { + const resultDir = data.getOriginalFileName(); - override async readDirectory(visitor: ResultsVisitor, resultDir: string): Promise { + // TODO: move the check to core; replace with structural check if (resultDir.endsWith(".xcresult")) { - if (await this.#xcResultToolAvailable()) { - return await this.#parseBundleWithXcResultTool(visitor, resultDir); + if (await xcResultToolAvailable()) { + return await parseBundleWithXcResultTool(visitor, resultDir); } } return false; - } + }, - #xcResultToolAvailable = async () => { - try { - await version(); - return true; - } catch (e) { - console.error( - "xcresulttool is unavailable on this machine. Please, make sure XCode is installed. The original error:", - e, - ); - } + readerId: () => readerId, +}; - return false; - }; +const xcResultToolAvailable = async () => { + try { + await version(); + return true; + } catch (e) { + console.error( + "xcresulttool is unavailable on this machine. Please, make sure XCode is installed. The original error:", + e, + ); + } + + return false; +}; - #parseBundleWithXcResultTool = async (visitor: ResultsVisitor, xcResultPath: string) => { - try { - await parseWithExportedAttachments(xcResultPath, async (createAttachmentFile) => { - const context = { xcResultPath: xcResultPath, createAttachmentFile }; +const parseBundleWithXcResultTool = async (visitor: ResultsVisitor, xcResultPath: string) => { + try { + await parseWithExportedAttachments(xcResultPath, async (createAttachmentFile) => { + const context = { xcResultPath: xcResultPath, createAttachmentFile }; - try { - await this.#tryApi(visitor, legacyApi, context); - return; - } catch (e) { - console.error(e); - if (!legacyApiUnavailable()) { - // The legacy API available but some other error has occured. We should not attempt using the new API in - // that case because the results may've been partially created. - throw e; - } + try { + await tryApi(visitor, legacyApi, context); + return; + } catch (e) { + console.error(e); + if (!legacyApiUnavailable()) { + // The legacy API available but some other error has occured. We should not attempt using the new API in + // that case because the results may've been partially created. + throw e; } + } - // The legacy API is not available. Fallback to the new API (as paradoxical as it may sound; the new API is - // much less convenient to consume, lacks some important information, and hides test results that share the - // same test id. - await this.#tryApi(visitor, newApi, context); - }); + // The legacy API is not available. Fallback to the new API (as paradoxical as it may sound; the new API is + // much less convenient to consume, lacks some important information, and hides test results that share the + // same test id. + await tryApi(visitor, newApi, context); + }); - return true; - } catch (e) { - console.error("error parsing", xcResultPath, e); - } + return true; + } catch (e) { + console.error("error parsing", xcResultPath, e); + } - return false; - }; + return false; +}; - #tryApi = async (visitor: ResultsVisitor, generator: ApiParseFunction, context: ParsingContext) => { - const { xcResultPath: originalFileName } = context; - const readerId = this.readerId(); - for await (const x of generator(context)) { - if ("readContent" in x) { - await visitor.visitAttachmentFile(x, { readerId }); - } else { - visitor.visitTestResult(x, { readerId, metadata: { originalFileName } }); - } +const tryApi = async (visitor: ResultsVisitor, generator: ApiParseFunction, context: ParsingContext) => { + const { xcResultPath: originalFileName } = context; + for await (const x of generator(context)) { + if ("readContent" in x) { + await visitor.visitAttachmentFile(x, { readerId }); + } else { + visitor.visitTestResult(x, { readerId, metadata: { originalFileName } }); } - }; -} - -export const xcresult = new XcResultReader(); + } +}; From 3e2e5c731d254221fb5807603d64564798b9c3cf Mon Sep 17 00:00:00 2001 From: Maksim Stepanov <17935127+delatrie@users.noreply.github.com> Date: Tue, 25 Feb 2025 05:05:29 +0700 Subject: [PATCH 17/17] use xcresulttool as a special case reader in readDirectory --- packages/core/src/report.ts | 9 ++- packages/reader/src/xcresult/bundle.ts | 83 ++++++++++++++++++++++++++ packages/reader/src/xcresult/index.ts | 37 ++++++------ 3 files changed, 110 insertions(+), 19 deletions(-) create mode 100644 packages/reader/src/xcresult/bundle.ts diff --git a/packages/core/src/report.ts b/packages/core/src/report.ts index 9a5b094c..132c1012 100644 --- a/packages/core/src/report.ts +++ b/packages/core/src/report.ts @@ -1,5 +1,5 @@ import type { Plugin, PluginContext, PluginState, ReportFiles, ResultFile } from "@allurereport/plugin-api"; -import { allure1, allure2, attachments, cucumberjson, junitXml, xcresult } from "@allurereport/reader"; +import { allure1, allure2, attachments, cucumberjson, junitXml, readXcResultBundle } from "@allurereport/reader"; import { PathResultFile, type ResultsReader } from "@allurereport/reader-api"; import console from "node:console"; import { randomUUID } from "node:crypto"; @@ -37,7 +37,7 @@ export class AllureReport { constructor(opts: FullConfig) { const { name, - readers = [allure1, allure2, cucumberjson, junitXml, xcresult, attachments], + readers = [allure1, allure2, cucumberjson, junitXml, attachments], plugins = [], history, known, @@ -88,6 +88,11 @@ export class AllureReport { } const resultsDirPath = resolve(resultsDir); + + if (await readXcResultBundle(this.#store, resultsDirPath)) { + return; + } + const dir = await opendir(resultsDirPath); try { diff --git a/packages/reader/src/xcresult/bundle.ts b/packages/reader/src/xcresult/bundle.ts new file mode 100644 index 00000000..6cfceac8 --- /dev/null +++ b/packages/reader/src/xcresult/bundle.ts @@ -0,0 +1,83 @@ +import { lstat } from "node:fs/promises"; +import { platform } from "node:os"; +import path from "node:path"; +import { invokeStdoutCliTool } from "../toolRunner.js"; +import { isDefined } from "../validation.js"; + +const XCODE_INSTALL_URL = + "https://developer.apple.com/documentation/safari-developer-tools/installing-xcode-and-simulators"; + +const XCODE_SWITCH_COMMAND = "sudo xcode-select -s /Applications/Xcode.app/Contents/Developer"; + +export const XCRESULTTOOL_MISSING_MESSAGE = `xcresulttool is required to parse Xcode Result bundles, but we can't access it on this machine. +This tool is a part of Xcode. Please make sure Xcode is installed. Visit this page to learn more about the installation:\n + + ${XCODE_INSTALL_URL} + +Note that xcresulttool doesn't come with Command Line Tools for Xcode. You need the full Xcode package to get it. If you have both +installed, make sure the full installation is selected. If it's not, switch it with xcode-select. For example: + + ${XCODE_SWITCH_COMMAND} + +The original error:`; + +const bundleInfoFilePaths = new Set([ + "Info.plist", + "Contents/Info.plist", + "Support Files/Info.plist", + "Resources/Info.plist", +]); + +export const IS_MAC = platform() === "darwin"; + +/** + * On Mac OS returns `true` if and only if the path points to a directory that has the `"com.apple.xcode.resultbundle"` + * uniform type identifier in its content type tree. + * On other platforms return `false`. + */ +export const isXcResultBundle = async (directory: string) => { + const hasXcResultUti = IS_MAC ? await checkUniformTypeIdentifier(directory, "com.apple.xcode.resultbundle") : false; + return hasXcResultUti || isMostProbablyXcResultBundle(directory); +}; + +/** + * Returns `true` if and only if the path points to an item that has the specified uniform type identifier in its + * content type tree. + * Requires Mac OS. + */ +export const checkUniformTypeIdentifier = async (itemPath: string, uti: string) => { + const mdlsArgs = ["-raw", "-attr", "kMDItemContentTypeTree", itemPath]; + const stringToSearch = `"${uti}"`; + + for await (const line of invokeStdoutCliTool("mdls", mdlsArgs)) { + if (line.indexOf(stringToSearch) !== -1) { + return true; + } + } + + return false; +}; + +export const isMostProbablyXcResultBundle = (directory: string) => + isDefined(findBundleInfoFile(directory)) || followsXcResultNaming(directory); + +export const followsXcResultNaming = (directory: string) => directory.endsWith(".xcresult"); + +/** + * If the provided directory contains an Info.plist file in one of the well-known locations, returns the absolute path + * of that file. Otherwise, returns `undefined`. + * Directories with such files are most probably Mac OS bundles and should be treated accordingly. + * + * NOTE: not all bundles contain an Info.plist file. But the ones we're interested in (XCResult bundles, specifically) + * does. + */ +export const findBundleInfoFile = async (directory: string) => { + for (const infoFilePath of bundleInfoFilePaths) { + const infoFileAbsPath = path.join(directory, infoFilePath); + const stat = await lstat(infoFileAbsPath); + + if (stat.isFile()) { + return infoFileAbsPath; + } + } +}; diff --git a/packages/reader/src/xcresult/index.ts b/packages/reader/src/xcresult/index.ts index 06ec6d86..e1d34e31 100644 --- a/packages/reader/src/xcresult/index.ts +++ b/packages/reader/src/xcresult/index.ts @@ -1,6 +1,6 @@ -import type { ResultFile } from "@allurereport/plugin-api"; -import type { ResultsReader, ResultsVisitor } from "@allurereport/reader-api"; +import type { ResultsVisitor } from "@allurereport/reader-api"; import console from "node:console"; +import { IS_MAC, XCRESULTTOOL_MISSING_MESSAGE, isXcResultBundle } from "./bundle.js"; import { version } from "./xcresulttool/cli.js"; import newApi from "./xcresulttool/index.js"; import { legacyApiUnavailable } from "./xcresulttool/legacy/cli.js"; @@ -10,20 +10,26 @@ import { parseWithExportedAttachments } from "./xcresulttool/utils.js"; const readerId = "xcresult"; -export const xcresult: ResultsReader = { - read: async (visitor: ResultsVisitor, data: ResultFile): Promise => { - const resultDir = data.getOriginalFileName(); +export const readXcResultBundle = async (visitor: ResultsVisitor, directory: string) => { + if (await isXcResultBundle(directory)) { + if (!IS_MAC) { + console.warn( + `It looks like ${directory} is a Mac OS bundle. Allure 3 can only parse such bundles on a Mac OS machine.`, + ); - // TODO: move the check to core; replace with structural check - if (resultDir.endsWith(".xcresult")) { - if (await xcResultToolAvailable()) { - return await parseBundleWithXcResultTool(visitor, resultDir); - } + // There is a small chance we're dealing with a proper allure results directory here. + // In such a case, allow the directory to be read (if it's really a bundle, the user will see an empty report). + return false; + } + + if (await xcResultToolAvailable()) { + return await parseBundleWithXcResultTool(visitor, directory); } - return false; - }, - readerId: () => readerId, + return true; + } + + return false; }; const xcResultToolAvailable = async () => { @@ -31,10 +37,7 @@ const xcResultToolAvailable = async () => { await version(); return true; } catch (e) { - console.error( - "xcresulttool is unavailable on this machine. Please, make sure XCode is installed. The original error:", - e, - ); + console.error(XCRESULTTOOL_MISSING_MESSAGE, e); } return false;