Repository: codecov/uploader
Pull request #715 (2022-04-14 17:52:30)

Feat xcode
Note that `xcode.ts` is basically a re-write from our [poc](https://github.com/codecov/xcode-poc/blob/master/generate-codecov-json.js)


Patch:
diff --git a/src/ci_providers/provider_azurepipelines.ts b/src/ci_providers/provider_azurepipelines.ts
index 3f48e65..85355ab 100644
--- a/src/ci_providers/provider_azurepipelines.ts
+++ b/src/ci_providers/provider_azurepipelines.ts
@@ -66,7 +66,7 @@ function _getSHA(inputs: UploaderInputs): string {
     if (mergeCommitRegex.exec(mergeCommitMessage)) {
       const mergeCommit = mergeCommitMessage.split(' ')[1]
       info(`    Fixing merge commit SHA ${commit} -> ${mergeCommit}`)
-      commit = mergeCommit
+      commit = mergeCommit || ''
     }
   }
 
diff --git a/src/ci_providers/provider_circleci.ts b/src/ci_providers/provider_circleci.ts
index 282ba89..3aae069 100644
--- a/src/ci_providers/provider_circleci.ts
+++ b/src/ci_providers/provider_circleci.ts
@@ -43,7 +43,7 @@ function _getSlug(inputs: UploaderInputs): string {
   }
 
   if (isSetAndNotEmpty(envs.CIRCLE_REPOSITORY_URL)) {
-    return `${envs.CIRCLE_REPOSITORY_URL?.split(':')[1].split('.git')[0]}`
+    return `${envs.CIRCLE_REPOSITORY_URL?.split(':')[1]?.split('.git')[0]}`
   }
   return slug
 }
diff --git a/src/helpers/cli.ts b/src/helpers/cli.ts
index b77d589..a67c6b2 100644
--- a/src/helpers/cli.ts
+++ b/src/helpers/cli.ts
@@ -185,6 +185,19 @@ const args: ICLIArgument[] = [
     type: 'string',
     description: 'Extra arguments to pass to gcov',
   },
+  {
+    alias: 'xc',
+    name: 'xcode',
+    type: 'boolean',
+    default: false,
+    description: 'Run with xcode support',
+  },
+  {
+    alias: 'xp',
+    name: 'xcodeArchivePath',
+    type: 'string',
+    description: 'Specify the xcode archive path. Likely specified as the -resultBundlePath and should end in .xcresult',
+  },
 ]
 
 export interface IYargsObject {
diff --git a/src/helpers/files.ts b/src/helpers/files.ts
index 4395670..b2ac57e 100644
--- a/src/helpers/files.ts
+++ b/src/helpers/files.ts
@@ -217,7 +217,7 @@ export function fetchGitRoot(): string {
     return (
       runExternalProgram('git', ['rev-parse', '--show-toplevel'])) || process.cwd()
   } catch (error) {
-    throw new Error('Error fetching git root. Please try using the -R flag.')
+    throw new Error(`Error fetching git root. Please try using the -R flag. ${error}`)
   }
 }
 
@@ -259,7 +259,7 @@ export function getAllFiles(
   if (args.networkPrefix) {
     files = files.map(file => String(args.networkPrefix) + file)
   }
-  
+
   return files
 }
 
@@ -348,7 +348,7 @@ export function cleanCoverageFilePaths(projectRoot: string, paths: string[]): st
 
   if (coverageFilePaths.length === 0) {
     logError(`None of the following appear to exist as files: ${paths.toString()}`)
-    throw new Error('Error while cleaning paths. No paths matched existing files!')  
+    throw new Error('Error while cleaning paths. No paths matched existing files!')
   }
 
   return coverageFilePaths
diff --git a/src/helpers/git.ts b/src/helpers/git.ts
index 9c58164..8ee9db4 100644
--- a/src/helpers/git.ts
+++ b/src/helpers/git.ts
@@ -9,14 +9,14 @@ export function parseSlug(slug: string): string {
 
   if (slug.match('http')) {
     // Type is http(s)
-    const phaseOne = slug.split('//')[1].replace('.git', '')
-    const phaseTwo = phaseOne.split('/')
+    const phaseOne = slug.split('//')[1]?.replace('.git', '') || ''
+    const phaseTwo = phaseOne?.split('/') || ''
     const cleanSlug = `${phaseTwo[1]}/${phaseTwo[2]}`
     return cleanSlug
   } else if (slug.match('@')) {
     // Type is git
-    const cleanSlug = slug.split(':')[1].replace('.git', '')
-    return cleanSlug
+    const cleanSlug = slug.split(':')[1]?.replace('.git', '')
+    return cleanSlug || ''
   }
   throw new Error(`Unable to parse slug URL: ${slug}`)
 }
diff --git a/src/helpers/web.ts b/src/helpers/web.ts
index 4481bbb..2727832 100644
--- a/src/helpers/web.ts
+++ b/src/helpers/web.ts
@@ -53,7 +53,7 @@ export function getPackage(source: string): string {
 
 
 export async function uploadToCodecovPUT(
-  putAndResultUrlPair: PostResults, 
+  putAndResultUrlPair: PostResults,
   uploadFile: string | Buffer,
   args: UploaderArgs
 ): Promise<PutResults> {
@@ -133,6 +133,11 @@ export function parsePOSTResults(putAndResultUrlPair: string): PostResults {
     )
   }
 
+  if (matches[0] === undefined || matches[1] === undefined) {
+    throw new Error(
+      `Invalid URLs received when parsing results from POST: ${matches[0]},${matches[1]}`
+    )
+  }
   const resultURL = new URL(matches[0].trimEnd())
   const putURL = new URL(matches[1])
    // This match may have trailing 0x0A and 0x0D that must be trimmed
@@ -215,5 +220,5 @@ export function generateRequestHeadersPUT(
       }
 
     }
-  
+
 }
diff --git a/src/helpers/xcode.ts b/src/helpers/xcode.ts
new file mode 100644
index 0000000..5188ce4
--- /dev/null
+++ b/src/helpers/xcode.ts
@@ -0,0 +1,63 @@
+import fs from 'fs/promises'
+
+import { info, UploadLogger } from '../helpers/logger'
+import {
+  XcodeCoverageFileReport,
+  XcodeCoverageReport,
+} from '../types'
+import { isProgramInstalled, runExternalProgram } from "./util"
+
+export async function generateXcodeCoverageFiles(archivePath: string): Promise<string> {
+  if (!isProgramInstalled('xcrun')) {
+      throw new Error('xcrun is not installed, cannot process files')
+  }
+  info('Running xcode coversion...')
+
+  const coverage: XcodeCoverageReport = {}
+  const report = { coverage: coverage }
+
+  getFileList(archivePath).forEach(repoFilePath => {
+    UploadLogger.verbose(`Converting ${repoFilePath}...`)
+    const coverageInfo = getCoverageInfo(archivePath, repoFilePath)
+    const coverageJson = convertCoverage(coverageInfo)
+    report.coverage[repoFilePath] = coverageJson
+  })
+
+  let pathFilename = archivePath.split('/').pop()
+  if (pathFilename) {
+    pathFilename = pathFilename.split('.xcresult')[0]
+  }
+  const filename = `./coverage-report-${pathFilename}.json`
+  UploadLogger.verbose(`Writing coverage to ${filename}`)
+  await fs.writeFile(filename, JSON.stringify(report))
+  return filename
+}
+
+function getFileList(archivePath: string): string[] {
+  const fileList = runExternalProgram('xcrun', ['xccov', 'view', '--file-list', '--archive', archivePath]);
+  return fileList.split('\n').filter(i => i !== '')
+}
+
+function getCoverageInfo(archivePath: string, filePath: string): string {
+  return runExternalProgram('xcrun', ['xccov', 'view', '--archive', archivePath, '--file', filePath])
+}
+
+function convertCoverage(coverageInfo: string): XcodeCoverageFileReport {
+  const coverageInfoArr = coverageInfo.split('\n')
+  const obj: XcodeCoverageFileReport = {}
+  coverageInfoArr.forEach(line => {
+    const [lineNum, lineInfo] = line.split(':')
+    if (lineNum && Number.isInteger(Number(lineNum))) {
+      const lineHits = lineInfo?.trimStart().split(' ')[0]?.trim()
+      if (typeof lineHits !== 'string') {
+        return
+      }
+      if (lineHits === '*') {
+        obj[String(lineNum.trim())] = null
+      } else {
+        obj[String(lineNum.trim())] = lineHits
+      }
+    }
+  })
+  return obj
+}
diff --git a/src/index.ts b/src/index.ts
index 9573b96..53517d5 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -24,6 +24,7 @@ import {
 } from './helpers/files'
 import { generateCoveragePyFile } from './helpers/coveragepy'
 import { generateGcovCoverageFiles } from './helpers/gcov'
+import { generateXcodeCoverageFiles } from './helpers/xcode'
 import { argAsArray } from './helpers/util'
 
 /**
@@ -167,6 +168,16 @@ export async function main(
     UploadLogger.verbose(`${gcovLogs}`)
   }
 
+  if (args.xcode) {
+    if (!args.xcodeArchivePath) {
+      throw new Error('Please specify xcodeArchivePath to run the Codecov uploader with xcode support')
+    } else {
+      const xcodeArchivePath: string = args.xcodeArchivePath
+      const xcodeLogs = await generateXcodeCoverageFiles(xcodeArchivePath)
+      UploadLogger.verbose(`${xcodeLogs}`)
+    }
+  }
+
   try {
     await generateCoveragePyFile()
   } catch (error) {
diff --git a/src/types.ts b/src/types.ts
index 8b98272..9dad4f9 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -30,6 +30,8 @@ export interface UploaderArgs {
   upstream: string // Upstream proxy to connect to
   url?: string // Change the upload host (Enterprise use)
   verbose?: string // Run with verbose logging
+  xcode?: string // Run with xcode support
+  xcodeArchivePath?: string // Specify the xcode archive path. Likely specified as the -resultBundlePath and should end in .xcresult
 }
 
 export type UploaderEnvs = NodeJS.Dict<string>
@@ -82,3 +84,6 @@ export interface PutResults {
   status: string
   resultURL: URL
 }
+
+export type XcodeCoverageFileReport = Record<string, string | null>
+export type XcodeCoverageReport = Record<string, XcodeCoverageFileReport>
diff --git a/tsconfig.json b/tsconfig.json
index 9e53fb3..cf45c4e 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -11,7 +11,8 @@
     "moduleResolution": "node",
     "esModuleInterop": true,
     "resolveJsonModule": true,
-    "skipLibCheck": true
+    "skipLibCheck": true,
+    "noUncheckedIndexedAccess": true
   },
   "exclude": ["dist/*", "test/*"]
 }


Test patch:
diff --git a/test/fixtures/xcode/UI/View1.swift b/test/fixtures/xcode/UI/View1.swift
new file mode 100644
index 0000000..e69de29
diff --git a/test/fixtures/xcode/UI/View2.swift b/test/fixtures/xcode/UI/View2.swift
new file mode 100644
index 0000000..e69de29
diff --git a/test/fixtures/xcode/test.xcresult/Info.plist b/test/fixtures/xcode/test.xcresult/Info.plist
new file mode 100644
index 0000000..e69de29
diff --git a/test/helpers/xcode.test.ts b/test/helpers/xcode.test.ts
new file mode 100644
index 0000000..9c6b2ef
--- /dev/null
+++ b/test/helpers/xcode.test.ts
@@ -0,0 +1,35 @@
+import td from 'testdouble'
+import childProcess from 'child_process'
+import { SPAWNPROCESSBUFFERSIZE } from '../../src/helpers/util'
+import { generateXcodeCoverageFiles } from '../../src/helpers/xcode'
+
+describe('generateXcodeCoverageFiles()', () => {
+    afterEach(() => {
+        td.reset()
+    })
+
+    it('should find all files in fixtures', async () => {
+        const spawnSync = td.replace(childProcess, 'spawnSync')
+        td.when(spawnSync('xcrun')).thenReturn({
+            stdout: 'xcrun installed',
+            error: null
+        })
+        td.when(spawnSync('xcrun', td.matchers.contains('--file-list'), { maxBuffer: SPAWNPROCESSBUFFERSIZE })).thenReturn({
+            stdout: '../fixtures/xcode/UI/View1.swift',
+            error: null
+        })
+        td.when(spawnSync('xcrun', td.matchers.contains('--file'), { maxBuffer: SPAWNPROCESSBUFFERSIZE })).thenReturn({
+            stdout: '  1: *\n  2: 0',
+            error: null
+        })
+
+        expect(await generateXcodeCoverageFiles('../fixtures/xcode/test.xcresult')).toBe('./coverage-report-test.json')
+    })
+
+    it('should return an error when xcode is not installed', async () => {
+        const spawnSync = td.replace(childProcess, 'spawnSync')
+        td.when(spawnSync('xcrun')).thenReturn({ error: "Command 'xcrun' not found" })
+
+        await expect(generateXcodeCoverageFiles('')).rejects.toThrowError(/xcrun is not installed/)
+    })
+})
diff --git a/test/index.test.ts b/test/index.test.ts
index 7c280cd..0cfe689 100644
--- a/test/index.test.ts
+++ b/test/index.test.ts
@@ -2,10 +2,12 @@ import Module from 'module'
 
 import * as app from '../src'
 
-import { version } from '../package.json'
-import nock from 'nock'
+import childProcess from 'child_process'
 import fs from 'fs'
+import nock from 'nock'
+import td from 'testdouble'
 import { UploadLogger } from '../src/helpers/logger'
+import { version } from '../package.json'
 
 // Backup the env
 const realEnv = { ...process.env }
@@ -30,6 +32,7 @@ describe('Uploader Core', () => {
   afterEach(() => {
     process.env = env
     jest.restoreAllMocks()
+    td.reset()
   })
 
   it('Can return version', () => {