Repository: codecov/uploader
Pull request #968 (2023-03-21 03:27:09)

fix: add swift coverage conversions
Fixes https://github.com/codecov/uploader/issues/803

Based off of [codecov-bash](https://github.com/codecov/codecov-bash/blob/master/codecov#L219-L239)

codecov[bot]: ## [Codecov](https://app.codecov.io/gh/codecov/uploader/compare/968?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=codecov) Report
> Merging [#968](https://app.codecov.io/gh/codecov/uploader/compare/968?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=codecov) (5eff59a) into [main](https://codecov.io/gh/codecov/uploader/commit/fa9f041f029ab13f432a1d3ece5a782fab7bff8a?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=codecov) (fa9f041) will **increase** coverage by `0.20%`.
> The diff coverage is `96.61%`.

```diff
@@            Coverage Diff             @@
##             main     #968      +/-   ##
==========================================
+ Coverage   92.14%   92.34%   +0.20%     
==========================================
  Files          35       36       +1     
  Lines        1235     1294      +59     
  Branches      253      262       +9     
==========================================
+ Hits         1138     1195      +57     
- Misses         65       67       +2     
  Partials       32       32              
```

| Flag | Coverage Δ | |
|---|---|---|
| alpine | `92.27% <96.61%> (+0.20%)` | :arrow_up: |
| alpine-proxy | `92.27% <96.61%> (+0.20%)` | :arrow_up: |
| alpine-without-git | `92.27% <96.61%> (+0.20%)` | :arrow_up: |
| linux | `92.27% <96.61%> (+0.20%)` | :arrow_up: |
| linux-without-git | `92.27% <96.61%> (+0.20%)` | :arrow_up: |
| macos | `92.27% <96.61%> (+0.20%)` | :arrow_up: |
| macos-without-git | `92.27% <96.61%> (+0.20%)` | :arrow_up: |
| windows | `91.65% <96.61%> (+0.23%)` | :arrow_up: |
| windows-without-git | `91.65% <96.61%> (+0.23%)` | :arrow_up: |

Flags with carried forward coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=codecov#carryforward-flags-in-the-pull-request-comment) to find out more.

| [Impacted Files](https://app.codecov.io/gh/codecov/uploader/compare/968?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=codecov) | Coverage Δ | |
|---|---|---|
| [src/helpers/cli.ts](https://app.codecov.io/gh/codecov/uploader/compare/968?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=codecov#diff-c3JjL2hlbHBlcnMvY2xpLnRz) | `100.00% <ø> (ø)` | |
| [src/index.ts](https://app.codecov.io/gh/codecov/uploader/compare/968?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=codecov#diff-c3JjL2luZGV4LnRz) | `75.88% <33.33%> (-0.77%)` | :arrow_down: |
| [src/helpers/swift.ts](https://app.codecov.io/gh/codecov/uploader/compare/968?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=codecov#diff-c3JjL2hlbHBlcnMvc3dpZnQudHM=) | `100.00% <100.00%> (ø)` | |


Patch:
diff --git a/src/helpers/cli.ts b/src/helpers/cli.ts
index 779ed90..4ade19e 100644
--- a/src/helpers/cli.ts
+++ b/src/helpers/cli.ts
@@ -200,13 +200,34 @@ const args: ICLIArgument[] = [
     name: 'xcode',
     type: 'boolean',
     default: false,
-    description: 'Run with xcode support',
+    description: '[Deprecating, please use xs] 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',
+    description: '[Deprecating, please use xs] Specify the xcode archive path. Likely specified as the -resultBundlePath and should end in .xcresult',
+  },
+  {
+    alias: 'xs',
+    name: 'swift',
+    type: 'boolean',
+    default: false,
+    description: 'Run with swift support',
+  },
+  {
+    alias: 'xsp',
+    name: 'swiftProject',
+    type: 'string',
+    default: '',
+    description: 'Specify the swift project'
+  },
+  {
+    alias: 'xc',
+    name: 'xcode',
+    type: 'boolean',
+    default: false,
+    description: 'Run with xcode support',
   },
   {
     alias: 'Z',
diff --git a/src/helpers/swift.ts b/src/helpers/swift.ts
new file mode 100644
index 0000000..2085611
--- /dev/null
+++ b/src/helpers/swift.ts
@@ -0,0 +1,103 @@
+import fs from 'fs'
+import * as fsPromise from 'fs/promises'
+import glob from 'fast-glob'
+import os from 'os'
+import path from 'path'
+
+import { info, UploadLogger } from '../helpers/logger'
+import { isProgramInstalled, runExternalProgram } from "./util"
+
+export async function generateSwiftCoverageFiles(project: string): Promise<string[]> {
+  if (!isProgramInstalled('xcrun')) {
+      throw new Error('xcrun is not installed, cannot process files')
+  }
+  info('==> Processing Xcode reports via llvm-cov...')
+
+  const derivedDataDir = `${os.homedir()}/Library/Developer/Xcode/DerivedData`
+  UploadLogger.verbose(`  DerivedData folder: ${derivedDataDir}`)
+
+  if (project == "") {
+    info("   hint: Speed up Swift processing by using use -J 'AppName'")
+  }
+
+  const profDataFiles = await glob.sync(['**/*.profdata'], {
+      cwd: derivedDataDir,
+      absolute: true,
+      onlyFiles: true,
+    })
+
+  if (profDataFiles.length == 0) {
+    info('  -> No swift coverage found.')
+  } else {
+    info(`Found ${profDataFiles.length} profdata files:`)
+  }
+
+  for (const profDataFile of profDataFiles) {
+    info(`  ${profDataFile}`)
+  }
+
+  let outputFiles: string[] = []
+  for (const profDataFile of profDataFiles) {
+    const projOutputFiles = await convertSwiftFile(profDataFile, project)
+    for (const projOutputFile of projOutputFiles) {
+      info(`${profDataFile} ${projOutputFile}`)
+    }
+    outputFiles = outputFiles.concat(projOutputFiles)
+  }
+  return outputFiles
+}
+
+async function convertSwiftFile(profDataFile: string, project: string): Promise<string[]> {
+  UploadLogger.verbose(`Starting conversion of ${profDataFile}`)
+  let dirName = path.dirname(profDataFile)
+  const BUILD = 'Build'
+  if (profDataFile.includes(BUILD)) {
+    dirName = dirName.substr(0, dirName.indexOf(BUILD) + (BUILD.length))
+  }
+
+  const outputFiles: string[] = []
+
+  for (const fileType of ['app', 'framework', 'xctest']) {
+    const reportDirs = await glob.sync([`**/*.${fileType}`], {
+        cwd: dirName,
+        absolute: true,
+        onlyFiles: false
+      })
+
+    if (reportDirs.length == 0) {
+      continue
+    }
+
+    for (const reportDir of reportDirs) {
+      const proj = path.basename(reportDir, `.${fileType}`)
+
+      if (project != "" && proj != project) {
+        UploadLogger.verbose(`  Skipping ${proj} as it does not match project ${project}`)
+        continue
+      }
+      info(`  + Building reports for ${proj} ${fileType}`)
+      UploadLogger.verbose(`  Reports sourced from ${reportDir}`)
+
+      let dest = path.join(reportDir, proj)
+      if (!fs.existsSync(dest)) {
+        dest = path.join(reportDir, 'Contents', 'MacOS', proj)
+      }
+
+      const outputFile = `${proj.replace(/\s/g,'')}.${fileType}.coverage.txt`
+      try {
+        await fsPromise.writeFile(
+          outputFile,
+          runExternalProgram(
+            'xcrun',
+            ['llvm-cov', 'show', '-instr-profile', profDataFile, dest]
+          )
+        )
+        info(`  Coverage report written to ${outputFile}`)
+        outputFiles.push(outputFile)
+      } catch (error) {
+        info(`  Could not write coverage report to ${outputFile}: ${error}`)
+      }
+    }
+  }
+  return outputFiles
+}
diff --git a/src/index.ts b/src/index.ts
index 347694a..7d07d51 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -25,6 +25,7 @@ import {
 import { generateCoveragePyFile } from './helpers/coveragepy'
 import { generateFixes, FIXES_HEADER } from './helpers/fixes'
 import { generateGcovCoverageFiles } from './helpers/gcov'
+import { generateSwiftCoverageFiles } from './helpers/swift'
 import { generateXcodeCoverageFiles } from './helpers/xcode'
 import { argAsArray } from './helpers/util'
 import { checkSlug } from './helpers/checkSlug'
@@ -71,6 +72,8 @@ function dryRun(
  * @param {string} args.pr Specify the pull request number manually
  * @param {string} args.token Codecov upload token
  * @param {string} args.tag Specify the git tag
+ * @param {boolean} args.swift Specify whether to use swift conversion
+ * @param {string} args.swiftProject Specific swift project to convert
  * @param {boolean} args.verbose Run with verbose logging
  * @param {string} args.rootDir Specify the project root directory when not in a git repo
  * @param {boolean} args.nonZero Should errors exit with a non-zero (default: false)
@@ -183,6 +186,10 @@ export async function main(
     UploadLogger.verbose(`${gcovLogs}`)
   }
 
+  if (args.swift) {
+    await generateSwiftCoverageFiles(args.swiftProject || '')
+  }
+
   if (args.xcode) {
     if (!args.xcodeArchivePath) {
       throw new Error('Please specify xcodeArchivePath to run the Codecov uploader with xcode support')
diff --git a/src/types.ts b/src/types.ts
index 7070645..a8bd3f0 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -26,6 +26,8 @@ export interface UploaderArgs {
   sha?: string // Specify the commit SHA manually
   slug: string // Specify the slug manually
   source?: string // Track wrappers of the uploader
+  swift?: string // Run with swift support
+  swiftProject?: string // Specify the swift project
   tag?: string // Specify the git tag
   token?: string // Codecov upload token
   upstream: string // Upstream proxy to connect to


Test patch:
diff --git a/test/fixtures/Library/Developer/Xcode/DerivedData/Build/Dir2/Project2.profdata b/test/fixtures/Library/Developer/Xcode/DerivedData/Build/Dir2/Project2.profdata
new file mode 100644
index 0000000..e69de29
diff --git a/test/fixtures/Library/Developer/Xcode/DerivedData/Build/Project2.xctest b/test/fixtures/Library/Developer/Xcode/DerivedData/Build/Project2.xctest
new file mode 100644
index 0000000..e69de29
diff --git a/test/fixtures/Library/Developer/Xcode/DerivedData/Dir/Project.profdata b/test/fixtures/Library/Developer/Xcode/DerivedData/Dir/Project.profdata
new file mode 100644
index 0000000..e69de29
diff --git a/test/fixtures/Library/Developer/Xcode/DerivedData/Dir/Project/Project.app b/test/fixtures/Library/Developer/Xcode/DerivedData/Dir/Project/Project.app
new file mode 100644
index 0000000..e69de29
diff --git a/test/helpers/swift.test.ts b/test/helpers/swift.test.ts
new file mode 100644
index 0000000..e4212c8
--- /dev/null
+++ b/test/helpers/swift.test.ts
@@ -0,0 +1,155 @@
+import td from 'testdouble'
+import childProcess from 'child_process'
+
+import * as fsPromise from 'fs/promises'
+import os from 'os'
+import { SPAWNPROCESSBUFFERSIZE } from '../../src/helpers/util'
+import { generateSwiftCoverageFiles } from '../../src/helpers/swift'
+
+describe('generateSwiftCoverageFiles()', () => {
+    afterEach(() => {
+        jest.clearAllMocks()
+        td.reset()
+    })
+
+    it('should convert all files without a project given', async () => {
+        const homedir = td.replace(os, 'homedir')
+        td.when(homedir()).thenReturn('test/fixtures/')
+        expect(os.homedir()).toBe('test/fixtures/')
+
+        const spawnSync = td.replace(childProcess, 'spawnSync')
+        td.when(spawnSync('xcrun')).thenReturn({
+            stdout: 'xcrun installed',
+            error: null
+        })
+        td.when(spawnSync('xcrun', td.matchers.contains('llvm-cov'), { maxBuffer: SPAWNPROCESSBUFFERSIZE })).thenReturn({
+            stdout: '',
+            error: null
+        })
+
+        expect(await generateSwiftCoverageFiles('')).toEqual([
+           'Project.app.coverage.txt',
+           'Project2.xctest.coverage.txt'
+        ])
+        expect(console.log).toHaveBeenCalledWith(
+            expect.stringMatching(/Found 2 profdata files:/),
+        )
+        expect(console.log).toHaveBeenCalledWith(
+            expect.stringMatching(/ {2}\+ Building reports for Project app/),
+        )
+        expect(console.log).toHaveBeenCalledWith(
+            expect.stringMatching(/ {2}\+ Building reports for Project2 xctest/),
+        )
+    })
+
+    it('should convert all files with a project given', async () => {
+        const homedir = td.replace(os, 'homedir')
+        td.when(homedir()).thenReturn('test/fixtures/')
+        expect(os.homedir()).toBe('test/fixtures/')
+
+        const spawnSync = td.replace(childProcess, 'spawnSync')
+        td.when(spawnSync('xcrun')).thenReturn({
+            stdout: 'xcrun installed',
+            error: null
+        })
+        td.when(spawnSync('xcrun', td.matchers.contains('llvm-cov'), { maxBuffer: SPAWNPROCESSBUFFERSIZE })).thenReturn({
+            stdout: '',
+            error: null
+        })
+
+        expect(await generateSwiftCoverageFiles('Project')).toEqual([
+           'Project.app.coverage.txt',
+        ])
+        expect(console.log).toHaveBeenCalledWith(
+            expect.stringMatching(/Found 2 profdata files:/),
+        )
+        expect(console.log).toHaveBeenCalledWith(
+            expect.stringMatching(/ {2}\+ Building reports for Project app/),
+        )
+        expect(console.log).not.toHaveBeenCalledWith(
+            expect.stringMatching(/ {2}\+ Building reports for Project2 xctest/),
+        )
+    })
+
+    it('should convert all files with a project given', async () => {
+        const homedir = td.replace(os, 'homedir')
+        td.when(homedir()).thenReturn('test/fixtures/')
+        expect(os.homedir()).toBe('test/fixtures/')
+
+        const spawnSync = td.replace(childProcess, 'spawnSync')
+        td.when(spawnSync('xcrun')).thenReturn({
+            stdout: 'xcrun installed',
+            error: null
+        })
+        td.when(spawnSync('xcrun', td.matchers.contains('llvm-cov'), { maxBuffer: SPAWNPROCESSBUFFERSIZE })).thenReturn({
+            stdout: '',
+            error: null
+        })
+
+        expect(await generateSwiftCoverageFiles('Project')).toEqual([
+           'Project.app.coverage.txt',
+        ])
+        expect(console.log).toHaveBeenCalledWith(
+            expect.stringMatching(/Found 2 profdata files:/),
+        )
+        expect(console.log).toHaveBeenCalledWith(
+            expect.stringMatching(/ {2}\+ Building reports for Project app/),
+        )
+        expect(console.log).not.toHaveBeenCalledWith(
+            expect.stringMatching(/ {2}\+ Building reports for Project2 xctest/),
+        )
+    })
+
+    it('should gracefully handle failed conversions', async () => {
+        const homedir = td.replace(os, 'homedir')
+        td.when(homedir()).thenReturn('test/fixtures/')
+        expect(os.homedir()).toBe('test/fixtures/')
+
+        const spawnSync = td.replace(childProcess, 'spawnSync')
+        td.when(spawnSync('xcrun')).thenReturn({
+            stdout: 'xcrun installed',
+            error: null
+        })
+        td.when(spawnSync('xcrun', td.matchers.contains('llvm-cov'), { maxBuffer: SPAWNPROCESSBUFFERSIZE })).thenReturn({
+            stdout: '',
+            error: 'could not convert'
+        })
+
+        expect(await generateSwiftCoverageFiles('')).toEqual([])
+        expect(console.log).toHaveBeenCalledWith(
+            expect.stringMatching(/Found 2 profdata files:/),
+        )
+        expect(console.log).toHaveBeenCalledWith(
+            expect.stringMatching(/ {2}\+ Building reports for Project app/),
+        )
+        expect(console.log).toHaveBeenCalledWith(
+            expect.stringMatching(/ {2}\+ Building reports for Project2 xctest/),
+        )
+        expect(console.log).toHaveBeenCalledWith(
+            expect.stringMatching(/ {2}Could not write coverage report to /),
+        )
+    })
+
+    it('should exit gracefully if no swift coverage is found', async () => {
+        const spawnSync = td.replace(childProcess, 'spawnSync')
+        td.when(spawnSync('xcrun')).thenReturn({
+            stdout: 'xcrun installed',
+            error: null
+        })
+        const homedir = td.replace(os, 'homedir')
+        td.when(homedir()).thenReturn('test/fixtures/yaml')
+        expect(os.homedir()).toBe('test/fixtures/yaml')
+
+        expect(await generateSwiftCoverageFiles('')).toEqual([])
+        expect(console.log).toHaveBeenCalledWith(
+            expect.stringMatching(/ {2}-> No swift coverage found./),
+        )
+    })
+
+    it('should return an error when xcrun is not installed', async () => {
+        const spawnSync = td.replace(childProcess, 'spawnSync')
+        td.when(spawnSync('xcrun')).thenReturn({ error: "Command 'xcrun' not found" })
+
+        await expect(generateSwiftCoverageFiles('')).rejects.toThrowError(/xcrun is not installed/)
+    })
+})