From fd1ae7b288347a836ac41a1d8cf49f17f929e031 Mon Sep 17 00:00:00 2001 From: Rob Herley Date: Fri, 19 Jan 2024 17:04:39 -0500 Subject: [PATCH] add tests for upload-artifact --- __tests__/upload.test.ts | 231 ++++++++++++++++++++++++++++++++++ src/merge/index.ts | 103 +-------------- src/merge/merge-artifact.ts | 101 +++++++++++++++ src/upload/index.ts | 85 +------------ src/upload/upload-artifact.ts | 77 ++++++++++++ 5 files changed, 417 insertions(+), 180 deletions(-) create mode 100644 __tests__/upload.test.ts create mode 100644 src/merge/merge-artifact.ts create mode 100644 src/upload/upload-artifact.ts diff --git a/__tests__/upload.test.ts b/__tests__/upload.test.ts new file mode 100644 index 0000000..0b74e66 --- /dev/null +++ b/__tests__/upload.test.ts @@ -0,0 +1,231 @@ +import * as core from '@actions/core' +import * as github from '@actions/github' +import artifact, {ArtifactNotFoundError} from '@actions/artifact' +import {run} from '../src/upload/upload-artifact' +import {Inputs} from '../src/upload/constants' +import * as search from '../src/shared/search' + +const fixtures = { + artifactName: 'artifact-name', + rootDirectory: '/some/artifact/path', + filesToUpload: [ + '/some/artifact/path/file1.txt', + '/some/artifact/path/file2.txt' + ] +} + +jest.mock('@actions/github', () => ({ + context: { + repo: { + owner: 'actions', + repo: 'toolkit' + }, + runId: 123, + serverUrl: 'https://github.com' + } +})) + +jest.mock('@actions/core') + +/* eslint-disable no-unused-vars */ +const mockInputs = (overrides?: Partial<{[K in Inputs]?: any}>) => { + const inputs = { + [Inputs.Name]: 'artifact-name', + [Inputs.Path]: '/some/artifact/path', + [Inputs.IfNoFilesFound]: 'warn', + [Inputs.RetentionDays]: 0, + [Inputs.CompressionLevel]: 6, + [Inputs.Overwrite]: false, + ...overrides + } + + ;(core.getInput as jest.Mock).mockImplementation((name: string) => { + return inputs[name] + }) + ;(core.getBooleanInput as jest.Mock).mockImplementation((name: string) => { + return inputs[name] + }) + + return inputs +} + +describe('upload', () => { + beforeEach(async () => { + mockInputs() + + jest.spyOn(search, 'findFilesToUpload').mockResolvedValue({ + filesToUpload: fixtures.filesToUpload, + rootDirectory: fixtures.rootDirectory + }) + + jest.spyOn(artifact, 'uploadArtifact').mockResolvedValue({ + size: 123, + id: 1337 + }) + }) + + it('uploads a single file', async () => { + jest.spyOn(search, 'findFilesToUpload').mockResolvedValue({ + filesToUpload: [fixtures.filesToUpload[0]], + rootDirectory: fixtures.rootDirectory + }) + + await run() + + expect(artifact.uploadArtifact).toHaveBeenCalledWith( + fixtures.artifactName, + [fixtures.filesToUpload[0]], + fixtures.rootDirectory, + {compressionLevel: 6} + ) + }) + + it('uploads multiple files', async () => { + await run() + + expect(artifact.uploadArtifact).toHaveBeenCalledWith( + fixtures.artifactName, + fixtures.filesToUpload, + fixtures.rootDirectory, + {compressionLevel: 6} + ) + }) + + it('sets outputs', async () => { + await run() + + expect(core.setOutput).toHaveBeenCalledWith('artifact-id', 1337) + expect(core.setOutput).toHaveBeenCalledWith( + 'artifact-url', + `${github.context.serverUrl}/${github.context.repo.owner}/${ + github.context.repo.repo + }/actions/runs/${github.context.runId}/artifacts/${1337}` + ) + }) + + it('supports custom compression level', async () => { + mockInputs({ + [Inputs.CompressionLevel]: 2 + }) + + await run() + + expect(artifact.uploadArtifact).toHaveBeenCalledWith( + fixtures.artifactName, + fixtures.filesToUpload, + fixtures.rootDirectory, + {compressionLevel: 2} + ) + }) + + it('supports custom retention days', async () => { + mockInputs({ + [Inputs.RetentionDays]: 7 + }) + + await run() + + expect(artifact.uploadArtifact).toHaveBeenCalledWith( + fixtures.artifactName, + fixtures.filesToUpload, + fixtures.rootDirectory, + {retentionDays: 7, compressionLevel: 6} + ) + }) + + it('supports warn if-no-files-found', async () => { + mockInputs({ + [Inputs.IfNoFilesFound]: 'warn' + }) + + jest.spyOn(search, 'findFilesToUpload').mockResolvedValue({ + filesToUpload: [], + rootDirectory: fixtures.rootDirectory + }) + + await run() + + expect(core.warning).toHaveBeenCalledWith( + `No files were found with the provided path: ${fixtures.rootDirectory}. No artifacts will be uploaded.` + ) + }) + + it('supports error if-no-files-found', async () => { + mockInputs({ + [Inputs.IfNoFilesFound]: 'error' + }) + + jest.spyOn(search, 'findFilesToUpload').mockResolvedValue({ + filesToUpload: [], + rootDirectory: fixtures.rootDirectory + }) + + await run() + + expect(core.setFailed).toHaveBeenCalledWith( + `No files were found with the provided path: ${fixtures.rootDirectory}. No artifacts will be uploaded.` + ) + }) + + it('supports ignore if-no-files-found', async () => { + mockInputs({ + [Inputs.IfNoFilesFound]: 'ignore' + }) + + jest.spyOn(search, 'findFilesToUpload').mockResolvedValue({ + filesToUpload: [], + rootDirectory: fixtures.rootDirectory + }) + + await run() + + expect(core.info).toHaveBeenCalledWith( + `No files were found with the provided path: ${fixtures.rootDirectory}. No artifacts will be uploaded.` + ) + }) + + it('supports overwrite', async () => { + mockInputs({ + [Inputs.Overwrite]: true + }) + + jest.spyOn(artifact, 'deleteArtifact').mockResolvedValue({ + id: 1337 + }) + + await run() + + expect(artifact.uploadArtifact).toHaveBeenCalledWith( + fixtures.artifactName, + fixtures.filesToUpload, + fixtures.rootDirectory, + {compressionLevel: 6} + ) + + expect(artifact.deleteArtifact).toHaveBeenCalledWith(fixtures.artifactName) + }) + + it('supports overwrite and continues if not found', async () => { + mockInputs({ + [Inputs.Overwrite]: true + }) + + jest + .spyOn(artifact, 'deleteArtifact') + .mockRejectedValue(new ArtifactNotFoundError('not found')) + + await run() + + expect(artifact.uploadArtifact).toHaveBeenCalledWith( + fixtures.artifactName, + fixtures.filesToUpload, + fixtures.rootDirectory, + {compressionLevel: 6} + ) + + expect(artifact.deleteArtifact).toHaveBeenCalledWith(fixtures.artifactName) + expect(core.debug).toHaveBeenCalledWith( + `Skipping deletion of '${fixtures.artifactName}', it does not exist` + ) + }) +}) diff --git a/src/merge/index.ts b/src/merge/index.ts index 44560f2..f085dbc 100644 --- a/src/merge/index.ts +++ b/src/merge/index.ts @@ -1,101 +1,6 @@ -import * as path from 'path' -import {mkdtemp, rm} from 'fs/promises' import * as core from '@actions/core' -import {Minimatch} from 'minimatch' -import artifactClient, {UploadArtifactOptions} from '@actions/artifact' -import {getInputs} from './input-helper' -import {uploadArtifact} from '../shared/upload-artifact' -import {findFilesToUpload} from '../shared/search' +import {run} from './merge-artifact' -const PARALLEL_DOWNLOADS = 5 - -export const chunk = (arr: T[], n: number): T[][] => - arr.reduce((acc, cur, i) => { - const index = Math.floor(i / n) - acc[index] = [...(acc[index] || []), cur] - return acc - }, [] as T[][]) - -async function run(): Promise { - try { - const inputs = getInputs() - const tmpDir = await mkdtemp('merge-artifact') - - const listArtifactResponse = await artifactClient.listArtifacts({ - latest: true - }) - const matcher = new Minimatch(inputs.pattern) - const artifacts = listArtifactResponse.artifacts.filter(artifact => - matcher.match(artifact.name) - ) - core.debug( - `Filtered from ${listArtifactResponse.artifacts.length} to ${artifacts.length} artifacts` - ) - - if (artifacts.length === 0) { - throw new Error(`No artifacts found matching pattern '${inputs.pattern}'`) - } - - core.info(`Preparing to download the following artifacts:`) - artifacts.forEach(artifact => { - core.info( - `- ${artifact.name} (ID: ${artifact.id}, Size: ${artifact.size})` - ) - }) - - const downloadPromises = artifacts.map(artifact => - artifactClient.downloadArtifact(artifact.id, { - path: inputs.separateDirectories - ? path.join(tmpDir, artifact.name) - : tmpDir - }) - ) - - const chunkedPromises = chunk(downloadPromises, PARALLEL_DOWNLOADS) - for (const chunk of chunkedPromises) { - await Promise.all(chunk) - } - - const options: UploadArtifactOptions = {} - if (inputs.retentionDays) { - options.retentionDays = inputs.retentionDays - } - - if (typeof inputs.compressionLevel !== 'undefined') { - options.compressionLevel = inputs.compressionLevel - } - - const searchResult = await findFilesToUpload(tmpDir) - - await uploadArtifact( - inputs.into, - searchResult.filesToUpload, - searchResult.rootDirectory, - options - ) - - core.info( - `The ${artifacts.length} artifact(s) have been successfully merged!` - ) - - if (inputs.deleteMerged) { - const deletePromises = artifacts.map(artifact => - artifactClient.deleteArtifact(artifact.name) - ) - await Promise.all(deletePromises) - core.info(`The ${artifacts.length} artifact(s) have been deleted`) - } - - try { - await rm(tmpDir, {recursive: true}) - } catch (error) { - core.warning( - `Unable to remove temporary directory: ${(error as Error).message}` - ) - } - } catch (error) { - core.setFailed((error as Error).message) - } -} - -run() +run().catch(error => { + core.setFailed((error as Error).message) +}) diff --git a/src/merge/merge-artifact.ts b/src/merge/merge-artifact.ts new file mode 100644 index 0000000..89bd295 --- /dev/null +++ b/src/merge/merge-artifact.ts @@ -0,0 +1,101 @@ +import * as path from 'path' +import {mkdtemp, rm} from 'fs/promises' +import * as core from '@actions/core' +import {Minimatch} from 'minimatch' +import artifactClient, {UploadArtifactOptions} from '@actions/artifact' +import {getInputs} from './input-helper' +import {uploadArtifact} from '../shared/upload-artifact' +import {findFilesToUpload} from '../shared/search' + +const PARALLEL_DOWNLOADS = 5 + +export const chunk = (arr: T[], n: number): T[][] => + arr.reduce((acc, cur, i) => { + const index = Math.floor(i / n) + acc[index] = [...(acc[index] || []), cur] + return acc + }, [] as T[][]) + +export async function run(): Promise { + try { + const inputs = getInputs() + const tmpDir = await mkdtemp('merge-artifact') + + const listArtifactResponse = await artifactClient.listArtifacts({ + latest: true + }) + const matcher = new Minimatch(inputs.pattern) + const artifacts = listArtifactResponse.artifacts.filter(artifact => + matcher.match(artifact.name) + ) + core.debug( + `Filtered from ${listArtifactResponse.artifacts.length} to ${artifacts.length} artifacts` + ) + + if (artifacts.length === 0) { + throw new Error(`No artifacts found matching pattern '${inputs.pattern}'`) + } + + core.info(`Preparing to download the following artifacts:`) + artifacts.forEach(artifact => { + core.info( + `- ${artifact.name} (ID: ${artifact.id}, Size: ${artifact.size})` + ) + }) + + const downloadPromises = artifacts.map(artifact => + artifactClient.downloadArtifact(artifact.id, { + path: inputs.separateDirectories + ? path.join(tmpDir, artifact.name) + : tmpDir + }) + ) + + const chunkedPromises = chunk(downloadPromises, PARALLEL_DOWNLOADS) + for (const chunk of chunkedPromises) { + await Promise.all(chunk) + } + + const options: UploadArtifactOptions = {} + if (inputs.retentionDays) { + options.retentionDays = inputs.retentionDays + } + + if (typeof inputs.compressionLevel !== 'undefined') { + options.compressionLevel = inputs.compressionLevel + } + + const searchResult = await findFilesToUpload(tmpDir) + + await uploadArtifact( + inputs.into, + searchResult.filesToUpload, + searchResult.rootDirectory, + options + ) + + core.info( + `The ${artifacts.length} artifact(s) have been successfully merged!` + ) + + if (inputs.deleteMerged) { + const deletePromises = artifacts.map(artifact => + artifactClient.deleteArtifact(artifact.name) + ) + await Promise.all(deletePromises) + core.info(`The ${artifacts.length} artifact(s) have been deleted`) + } + + try { + await rm(tmpDir, {recursive: true}) + } catch (error) { + core.warning( + `Unable to remove temporary directory: ${(error as Error).message}` + ) + } + } catch (error) { + core.setFailed((error as Error).message) + } +} + +run() diff --git a/src/upload/index.ts b/src/upload/index.ts index 98f229e..16f4e8c 100644 --- a/src/upload/index.ts +++ b/src/upload/index.ts @@ -1,83 +1,6 @@ import * as core from '@actions/core' -import artifact, { - UploadArtifactOptions, - ArtifactNotFoundError -} from '@actions/artifact' -import {findFilesToUpload} from '../shared/search' -import {getInputs} from './input-helper' -import {NoFileOptions} from './constants' -import {uploadArtifact} from '../shared/upload-artifact' +import {run} from './upload-artifact' -async function deleteArtifactIfExists(artifactName: string): Promise { - try { - await artifact.deleteArtifact(artifactName) - } catch (error) { - if (error instanceof ArtifactNotFoundError) { - core.debug(`Skipping deletion of '${artifactName}', it does not exist`) - return - } - - // Best effort, we don't want to fail the action if this fails - core.debug(`Unable to delete artifact: ${(error as Error).message}`) - } -} - -async function run(): Promise { - try { - const inputs = getInputs() - const searchResult = await findFilesToUpload(inputs.searchPath) - if (searchResult.filesToUpload.length === 0) { - // No files were found, different use cases warrant different types of behavior if nothing is found - switch (inputs.ifNoFilesFound) { - case NoFileOptions.warn: { - core.warning( - `No files were found with the provided path: ${inputs.searchPath}. No artifacts will be uploaded.` - ) - break - } - case NoFileOptions.error: { - core.setFailed( - `No files were found with the provided path: ${inputs.searchPath}. No artifacts will be uploaded.` - ) - break - } - case NoFileOptions.ignore: { - core.info( - `No files were found with the provided path: ${inputs.searchPath}. No artifacts will be uploaded.` - ) - break - } - } - } else { - const s = searchResult.filesToUpload.length === 1 ? '' : 's' - core.info( - `With the provided path, there will be ${searchResult.filesToUpload.length} file${s} uploaded` - ) - core.debug(`Root artifact directory is ${searchResult.rootDirectory}`) - - if (inputs.overwrite) { - await deleteArtifactIfExists(inputs.artifactName) - } - - const options: UploadArtifactOptions = {} - if (inputs.retentionDays) { - options.retentionDays = inputs.retentionDays - } - - if (typeof inputs.compressionLevel !== 'undefined') { - options.compressionLevel = inputs.compressionLevel - } - - await uploadArtifact( - inputs.artifactName, - searchResult.filesToUpload, - searchResult.rootDirectory, - options - ) - } - } catch (error) { - core.setFailed((error as Error).message) - } -} - -run() +run().catch(error => { + core.setFailed((error as Error).message) +}) diff --git a/src/upload/upload-artifact.ts b/src/upload/upload-artifact.ts new file mode 100644 index 0000000..8c77543 --- /dev/null +++ b/src/upload/upload-artifact.ts @@ -0,0 +1,77 @@ +import * as core from '@actions/core' +import artifact, { + UploadArtifactOptions, + ArtifactNotFoundError +} from '@actions/artifact' +import {findFilesToUpload} from '../shared/search' +import {getInputs} from './input-helper' +import {NoFileOptions} from './constants' +import {uploadArtifact} from '../shared/upload-artifact' + +async function deleteArtifactIfExists(artifactName: string): Promise { + try { + await artifact.deleteArtifact(artifactName) + } catch (error) { + if (error instanceof ArtifactNotFoundError) { + core.debug(`Skipping deletion of '${artifactName}', it does not exist`) + return + } + + // Best effort, we don't want to fail the action if this fails + core.debug(`Unable to delete artifact: ${(error as Error).message}`) + } +} + +export async function run(): Promise { + const inputs = getInputs() + const searchResult = await findFilesToUpload(inputs.searchPath) + if (searchResult.filesToUpload.length === 0) { + // No files were found, different use cases warrant different types of behavior if nothing is found + switch (inputs.ifNoFilesFound) { + case NoFileOptions.warn: { + core.warning( + `No files were found with the provided path: ${inputs.searchPath}. No artifacts will be uploaded.` + ) + break + } + case NoFileOptions.error: { + core.setFailed( + `No files were found with the provided path: ${inputs.searchPath}. No artifacts will be uploaded.` + ) + break + } + case NoFileOptions.ignore: { + core.info( + `No files were found with the provided path: ${inputs.searchPath}. No artifacts will be uploaded.` + ) + break + } + } + } else { + const s = searchResult.filesToUpload.length === 1 ? '' : 's' + core.info( + `With the provided path, there will be ${searchResult.filesToUpload.length} file${s} uploaded` + ) + core.debug(`Root artifact directory is ${searchResult.rootDirectory}`) + + if (inputs.overwrite) { + await deleteArtifactIfExists(inputs.artifactName) + } + + const options: UploadArtifactOptions = {} + if (inputs.retentionDays) { + options.retentionDays = inputs.retentionDays + } + + if (typeof inputs.compressionLevel !== 'undefined') { + options.compressionLevel = inputs.compressionLevel + } + + await uploadArtifact( + inputs.artifactName, + searchResult.filesToUpload, + searchResult.rootDirectory, + options + ) + } +}