diff --git a/packages/core/src/report.ts b/packages/core/src/report.ts index 54eef217..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 } 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"; @@ -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/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/toolRunner.ts b/packages/reader/src/toolRunner.ts new file mode 100644 index 00000000..0845f4db --- /dev/null +++ b/packages/reader/src/toolRunner.ts @@ -0,0 +1,190 @@ +import { spawn } from "node:child_process"; +import type { Unknown } from "./validation.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/validation.ts b/packages/reader/src/validation.ts new file mode 100644 index 00000000..4c4c1315 --- /dev/null +++ b/packages/reader/src/validation.ts @@ -0,0 +1,340 @@ +// 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 : 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 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 + * ```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 a 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 those properties that conform to a 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/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 new file mode 100644 index 00000000..e1d34e31 --- /dev/null +++ b/packages/reader/src/xcresult/index.ts @@ -0,0 +1,86 @@ +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"; +import legacyApi from "./xcresulttool/legacy/index.js"; +import type { ApiParseFunction, ParsingContext } from "./xcresulttool/model.js"; +import { parseWithExportedAttachments } from "./xcresulttool/utils.js"; + +const readerId = "xcresult"; + +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.`, + ); + + // 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 true; + } + + return false; +}; + +const xcResultToolAvailable = async () => { + try { + await version(); + return true; + } catch (e) { + console.error(XCRESULTTOOL_MISSING_MESSAGE, e); + } + + return false; +}; + +const parseBundleWithXcResultTool = async (visitor: ResultsVisitor, xcResultPath: string) => { + try { + await parseWithExportedAttachments(xcResultPath, async (createAttachmentFile) => { + const context = { xcResultPath: xcResultPath, createAttachmentFile }; + + 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 tryApi(visitor, newApi, context); + }); + + return true; + } catch (e) { + console.error("error parsing", xcResultPath, e); + } + + return false; +}; + +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 } }); + } + } +}; diff --git a/packages/reader/src/xcresult/model.ts b/packages/reader/src/xcresult/model.ts new file mode 100644 index 00000000..c99ab8b6 --- /dev/null +++ b/packages/reader/src/xcresult/model.ts @@ -0,0 +1,80 @@ +import type { RawTestLabel, RawTestLink, RawTestParameter } from "@allurereport/reader-api"; +import type { XcTestResult } from "./xcresulttool/xcModel.js"; + +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?: 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..455dce0d --- /dev/null +++ b/packages/reader/src/xcresult/utils.ts @@ -0,0 +1,347 @@ +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 => + mappedGroupBy( + entries, + ([{ device }]) => device ?? SURROGATE_DEVICE_ID, + (deviceRuns) => + mappedGroupBy( + deviceRuns, + ([{ testPlan }]) => testPlan ?? SURROGATE_TEST_PLAN_ID, + (configRuns) => + mappedGroupBy( + 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 mappedGroupBy = ( + values: readonly T[], + keyFn: (v: T) => K, + groupMapFn: (group: T[]) => 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; + + 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[]) => { + const groupedApiCalls = mappedGroupBy( + apiCalls, + (v) => v.type, + (g) => g.map(({ value }) => value), + ); + + for (const [type, values] of groupedApiCalls) { + 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/cli.ts b/packages/reader/src/xcresult/xcresulttool/cli.ts new file mode 100644 index 00000000..da821eea --- /dev/null +++ b/packages/reader/src/xcresult/xcresulttool/cli.ts @@ -0,0 +1,43 @@ +import console from "node:console"; +import { invokeCliTool, invokeJsonCliTool, invokeStdoutCliTool } from "../../toolRunner.js"; +import type { XcActivities, XcTestDetails, XcTests } from "./xcModel.js"; + +export const xcrun = async (utilityName: string, ...args: readonly string[]) => { + 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 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); + +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/src/xcresult/xcresulttool/index.ts b/packages/reader/src/xcresult/xcresulttool/index.ts new file mode 100644 index 00000000..4c079225 --- /dev/null +++ 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 new file mode 100644 index 00000000..bbddac9d --- /dev/null +++ b/packages/reader/src/xcresult/xcresulttool/legacy/cli.ts @@ -0,0 +1,39 @@ +import console from "node:console"; +import type { Unknown } from "../../../validation.js"; +import { xcresulttool } from "../cli.js"; +import { getRef } from "./parsing.js"; +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[] +): 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(xcResultPath, "--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..94116018 --- /dev/null +++ b/packages/reader/src/xcresult/xcresulttool/legacy/index.ts @@ -0,0 +1,671 @@ +import type { ResultFile } from "@allurereport/plugin-api"; +import type { + RawStep, + RawTestAttachment, + RawTestLink, + RawTestParameter, + RawTestResult, + RawTestStepResult, +} from "@allurereport/reader-api"; +import { randomUUID } from "node:crypto"; +import type { ShallowKnown, Unknown } from "../../../validation.js"; +import { ensureObject, 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 type { ApiParseFunction, AttachmentFileFactory, ParsingContext } from "../model.js"; +import { getById, getRoot } from "./cli.js"; +import type { + ActionParametersInputData, + ActivityProcessingResult, + FailureMap, + FailureMapValue, + FailureOverrides, + LegacyActionDiscriminator, + LegacyDestinationData, + LegacyParsingState, + ResolvedStepFailure, +} 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"; + +const parse: ApiParseFunction = async function* ( + context: ParsingContext, +): 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, + }); + } + } + } + } + } + } +}; + +export default parse; + +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: ParsingContext, + 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: ParsingContext, + { 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* ( + { createAttachmentFile }: ParsingContext, + { + 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 = await processFailures(createAttachmentFile, failureSummaries, expectedFailures); + const { + steps: activitySteps, + files, + apiCalls, + } = await processActivities(createAttachmentFile, 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); + +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]); +}; + +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>, +) => { + 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 = async ( + createAttachmentFile: AttachmentFileFactory, + { + attachments, + message: rawMessage, + sourceCodeContext, + timestamp, + uuid: rawUuid, + isTopLevelFailure, + issueType, + }: ShallowKnown, + { uuid: explicitUuid, mapMessage, status: explicitStatus }: FailureOverrides = {}, +) => { + const { steps, files } = await parseAttachments(createAttachmentFile, 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 = async ( + createAttachmentFile: AttachmentFileFactory, + failures: FailureMap, + activities: readonly ShallowKnown[], +): 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; + } + + 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, + } = await processActivities(createAttachmentFile, 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); + + 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[]): ResolvedStepFailure => + 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): ResolvedStepFailure => + resolveFailures(Array.from(failures.values()).filter(({ isTopLevel }) => isTopLevel)); + +const resolveFailures = (failures: readonly FailureMapValue[]): ResolvedStepFailure => { + 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]): ResolvedStepFailure => ({ + message, + trace, + steps: [], +}); + +const prepareMultipleFailures = (failures: readonly [FailureMapValue, ...FailureMapValue[]]): ResolvedStepFailure => { + 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 = 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 { steps, files, apiCalls: [] }; +}; + +const ensureUniqueFileName = (byXc: Unknown, byUser: string) => + getString(byXc) ?? `${randomUUID()}-${byUser}`; + +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: "Device", value: name }); + if (multiTarget && targetDetails) { + parameters.push({ name: "Device details", value: targetDetails, excluded: true }); + } + } + } + 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: ParsingContext, + { 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) ? getString(parameter.name) : undefined; + +const getArgumentValue = (parameter: Unknown) => + isObject(parameter) ? getString(parameter.description) : undefined; 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..0da25585 --- /dev/null +++ b/packages/reader/src/xcresult/xcresulttool/legacy/model.ts @@ -0,0 +1,78 @@ +import type { ResultFile } from "@allurereport/plugin-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[]; + trace?: string; + steps: []; +}; + +export type LegacyIssueTrackingMetadata = { + url: string; + title?: string; +}; + +export type LegacyStepResultData = { + trace?: string; + steps: []; +}; + +export type LegacyActionDiscriminator = { + destination: LegacyDestinationData | undefined; + testPlan: string | undefined; +}; + +export type LegacyDestinationData = { + name?: string; + targetDetails?: string; + hostName?: string; +}; + +export type ActivityProcessingResult = { + steps: RawStep[]; + files: ResultFile[]; + apiCalls: AllureApiCall[]; +}; + +export type Suite = { + name: string; + uri: string | undefined; +}; + +export type LegacyParsingState = { + bundle?: string; + suites: Suite[]; + className?: string; + destination?: LegacyDestinationData; + testPlan?: string; + multiTarget: boolean; + multiTestPlan: boolean; +}; + +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/legacy/parsing.ts b/packages/reader/src/xcresult/xcresulttool/legacy/parsing.ts new file mode 100644 index 00000000..84a5ed43 --- /dev/null +++ b/packages/reader/src/xcresult/xcresulttool/legacy/parsing.ts @@ -0,0 +1,79 @@ +/* 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 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 ? 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; +}; diff --git a/packages/reader/src/xcresult/xcresulttool/model.ts b/packages/reader/src/xcresult/xcresulttool/model.ts new file mode 100644 index 00000000..a02c0a30 --- /dev/null +++ b/packages/reader/src/xcresult/xcresulttool/model.ts @@ -0,0 +1,16 @@ +import type { ResultFile } from "@allurereport/plugin-api"; +import type { RawTestResult } from "@allurereport/reader-api"; + +export type ParsingState = { + suites: readonly string[]; + bundle?: string; +}; + +export type ParsingContext = { + xcResultPath: string; + createAttachmentFile: AttachmentFileFactory; +}; + +export type AttachmentFileFactory = (attachmentUuid: string, uniqueFileName: string) => Promise; + +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..d2f57625 --- /dev/null +++ b/packages/reader/src/xcresult/xcresulttool/utils.ts @@ -0,0 +1,65 @@ +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"; + +export const parseWithExportedAttachments = async ( + xcResultPath: string, + fn: (createAttachmentFile: AttachmentFileFactory) => Promise, +) => { + let attachmentsDir: string | undefined; + try { + attachmentsDir = await mkdtemp(path.join(tmpdir(), "allure-reader-xcresult-")); + 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 fileExtension = path.extname(uniqueFileName); + const attachmentFilePath = path.join(attachmentsDir, attachmentUuid); + + 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]; + } +}; 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; +}; 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])); + }); +});