Skip to content

Commit

Permalink
Add Support for @actions/artifact (#136)
Browse files Browse the repository at this point in the history
This PR introduces support for stubbing the functionality of the
[`@actions/artifact`](https://github.com/actions/toolkit/blob/main/packages/artifact/README.md)
package. This is accomplished via the following:

- Copies all required components of the `@actions/artifact` package.
- Adds a new environment variable, `LOCAL_ACTION_ARTIFACT_PATH`, which
can be used to set the local directory where `@actions/artifact` will
_"upload"_ and _"download"_ artifacts.
- Adds a check for the `LOCAL_ACTION_ARTIFACT_PATH` environment variable
whenever `@actions/artifact` operations are performed.
- Adds new properties to environment metadata for tracking artifacts
that have been created as part of a `local-action` run (currently no
cleanup of those artifacts is performed).

> [!IMPORTANT]
>
> Operations on artifacts in external repositories is left implemented
**as-is**. If you specify the `findBy` options as noted
[here](https://github.com/actions/toolkit/blob/main/packages/artifact/README.md#downloading-from-other-workflow-runs-or-repos),
the corresponding GitHub API will be called.
  • Loading branch information
ncalteen authored Jan 7, 2025
2 parents 9c84a45 + 45c064a commit 1b5783f
Show file tree
Hide file tree
Showing 45 changed files with 4,208 additions and 92 deletions.
7 changes: 7 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,13 @@ ACTIONS_STEP_DEBUG=true
# Hyphens should not be converted to underscores!
INPUT_MILLISECONDS=2400

# Environment variables specific to the @github/local-action tool.
#
# LOCAL_ACTION_ARTIFACT_PATH: Local path where any artifacts will be saved. Will
# throw an error if the action attempts to use the
# @actions/artifact package without setting this.
LOCAL_ACTION_ARTIFACT_PATH=""

# GitHub Actions default environment variables. These are set for every run of a
# workflow and can be used in your actions. Setting the value here will override
# any value set by the local-action tool.
Expand Down
7 changes: 7 additions & 0 deletions __fixtures__/crypto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { jest } from '@jest/globals'

export const createHash = jest.fn()

export default {
createHash
}
15 changes: 15 additions & 0 deletions __fixtures__/fs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { jest } from '@jest/globals'

export const accessSync = jest.fn()
export const createWriteStream = jest.fn()
export const createReadStream = jest.fn()
export const mkdirSync = jest.fn()
export const rmSync = jest.fn()

export default {
accessSync,
createWriteStream,
createReadStream,
mkdirSync,
rmSync
}
3 changes: 3 additions & 0 deletions __fixtures__/stream/promises.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { jest } from '@jest/globals'

export const finished = jest.fn()
4 changes: 2 additions & 2 deletions __tests__/command.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { jest } from '@jest/globals'
import { Command } from 'commander'
import { ResetCoreMetadata } from '../src/stubs/core-stubs.js'
import { ResetEnvMetadata } from '../src/stubs/env-stubs.js'
import { ResetCoreMetadata } from '../src/stubs/core.js'
import { ResetEnvMetadata } from '../src/stubs/env.js'

const action = jest.fn()

Expand Down
4 changes: 2 additions & 2 deletions __tests__/commands/run.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { jest } from '@jest/globals'
import * as core from '../../__fixtures__/core.js'
import { ResetCoreMetadata } from '../../src/stubs/core-stubs.js'
import { EnvMeta, ResetEnvMetadata } from '../../src/stubs/env-stubs.js'
import { ResetCoreMetadata } from '../../src/stubs/core.js'
import { EnvMeta, ResetEnvMetadata } from '../../src/stubs/env.js'

const quibbleEsm = jest.fn().mockImplementation(() => {})
const quibbleDefault = jest.fn().mockImplementation(() => {})
Expand Down
4 changes: 2 additions & 2 deletions __tests__/index.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { jest } from '@jest/globals'
import { ResetCoreMetadata } from '../src/stubs/core-stubs.js'
import { ResetEnvMetadata } from '../src/stubs/env-stubs.js'
import { ResetCoreMetadata } from '../src/stubs/core.js'
import { ResetEnvMetadata } from '../src/stubs/env.js'

const makeProgram = jest.fn().mockResolvedValue({
parse: jest.fn()
Expand Down
288 changes: 288 additions & 0 deletions __tests__/stubs/artifact/internal/client.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,288 @@
import { jest } from '@jest/globals'
import * as core from '../../../../__fixtures__/core.js'
import { ResetCoreMetadata } from '../../../../src/stubs/core.js'
import { EnvMeta, ResetEnvMetadata } from '../../../../src/stubs/env.js'

const isGhes = jest.fn().mockReturnValue(false)
const getGitHubWorkspaceDir = jest.fn().mockReturnValue('/github/workspace')
const getUploadChunkSize = jest.fn().mockReturnValue(8 * 1024 * 1024)
const uploadArtifact = jest.fn()
const downloadArtifactInternal = jest.fn()
const downloadArtifactPublic = jest.fn()
const listArtifactsInternal = jest.fn()
const listArtifactsPublic = jest.fn()
const getArtifactInternal = jest.fn()
const getArtifactPublic = jest.fn()
const deleteArtifactInternal = jest.fn()
const deleteArtifactPublic = jest.fn()

jest.unstable_mockModule(
'../../../../src/stubs/artifact/internal/shared/config.js',
() => ({
isGhes,
getGitHubWorkspaceDir,
getUploadChunkSize
})
)
jest.unstable_mockModule(
'../../../../src/stubs/artifact/internal/upload/upload-artifact.js',
() => ({
uploadArtifact
})
)
jest.unstable_mockModule(
'../../../../src/stubs/artifact/internal/download/download-artifact.js',
() => ({
downloadArtifactPublic,
downloadArtifactInternal
})
)
jest.unstable_mockModule(
'../../../../src/stubs/artifact/internal/find/list-artifacts.js',
() => ({
listArtifactsInternal,
listArtifactsPublic
})
)
jest.unstable_mockModule(
'../../../../src/stubs/artifact/internal/find/get-artifact.js',
() => ({
getArtifactInternal,
getArtifactPublic
})
)
jest.unstable_mockModule(
'../../../../src/stubs/artifact/internal/delete/delete-artifact.js',
() => ({
deleteArtifactInternal,
deleteArtifactPublic
})
)
jest.unstable_mockModule('../../../../src/stubs/core.js', () => core)

const { DefaultArtifactClient } = await import(
'../../../../src/stubs/artifact/internal/client.js'
)

describe('DefaultArtifactClient', () => {
beforeEach(() => {
// Set environment variables
process.env.LOCAL_ACTION_ARTIFACT_PATH = '/tmp/artifacts'

// Reset metadata
ResetEnvMetadata()
ResetCoreMetadata()

EnvMeta.artifacts = [{ name: 'artifact-name', id: 1, size: 0 }]
})

afterEach(() => {
// Reset all spies
jest.resetAllMocks()

// Unset environment variables
delete process.env.LOCAL_ACTION_ARTIFACT_PATH
})

describe('uploadArtifact', () => {
it('Throws if LOCAL_ACTION_ARTIFACT_PATH is not set', async () => {
delete process.env.LOCAL_ACTION_ARTIFACT_PATH

const client = new DefaultArtifactClient()

await expect(
client.uploadArtifact('artifact-name', ['file1', 'file2'], 'root')
).rejects.toThrow()
})

it('Throws if running on GHES', async () => {
isGhes.mockReturnValue(true)

const client = new DefaultArtifactClient()

await expect(
client.uploadArtifact('artifact-name', ['file1', 'file2'], 'root')
).rejects.toThrow()
})

it('Uploads an artifact', async () => {
const client = new DefaultArtifactClient()

await client.uploadArtifact('artifact-name', ['file1', 'file2'], 'root')

expect(uploadArtifact).toHaveBeenCalled()
})
})

describe('downloadArtifact', () => {
it('Throws if LOCAL_ACTION_ARTIFACT_PATH is not set', async () => {
delete process.env.LOCAL_ACTION_ARTIFACT_PATH

const client = new DefaultArtifactClient()

await expect(client.downloadArtifact(1)).rejects.toThrow()
})

it('Throws if running on GHES', async () => {
isGhes.mockReturnValue(true)

const client = new DefaultArtifactClient()

await expect(client.downloadArtifact(1)).rejects.toThrow()
})

it('Downloads an artifact (internal)', async () => {
const client = new DefaultArtifactClient()

await client.downloadArtifact(1)

expect(downloadArtifactInternal).toHaveBeenCalled()
expect(downloadArtifactPublic).not.toHaveBeenCalled()
})

it('Downloads an artifact (public)', async () => {
const client = new DefaultArtifactClient()

await client.downloadArtifact(1, {
findBy: {
repositoryOwner: 'owner',
repositoryName: 'repo',
workflowRunId: 1,
token: 'token'
}
})

expect(downloadArtifactInternal).not.toHaveBeenCalled()
expect(downloadArtifactPublic).toHaveBeenCalled()
})
})

describe('listArtifacts', () => {
it('Throws if LOCAL_ACTION_ARTIFACT_PATH is not set', async () => {
delete process.env.LOCAL_ACTION_ARTIFACT_PATH

const client = new DefaultArtifactClient()

await expect(client.listArtifacts()).rejects.toThrow()
})

it('Throws if running on GHES', async () => {
isGhes.mockReturnValue(true)

const client = new DefaultArtifactClient()

await expect(client.listArtifacts()).rejects.toThrow()
})

it('Lists artifacts (internal)', async () => {
const client = new DefaultArtifactClient()

await client.listArtifacts()

expect(listArtifactsInternal).toHaveBeenCalled()
expect(listArtifactsPublic).not.toHaveBeenCalled()
})

it('Lists artifacts (public)', async () => {
const client = new DefaultArtifactClient()

await client.listArtifacts({
findBy: {
repositoryOwner: 'owner',
repositoryName: 'repo',
workflowRunId: 1,
token: 'token'
}
})

expect(listArtifactsInternal).not.toHaveBeenCalled()
expect(listArtifactsPublic).toHaveBeenCalled()
})
})

describe('getArtifact', () => {
it('Throws if LOCAL_ACTION_ARTIFACT_PATH is not set', async () => {
delete process.env.LOCAL_ACTION_ARTIFACT_PATH

const client = new DefaultArtifactClient()

await expect(client.getArtifact('artifact-name')).rejects.toThrow()
})

it('Throws if running on GHES', async () => {
isGhes.mockReturnValue(true)

const client = new DefaultArtifactClient()

await expect(client.getArtifact('artifact-name')).rejects.toThrow()
})

it('Gets an artifact (internal)', async () => {
const client = new DefaultArtifactClient()

await client.getArtifact('artifact-name')

expect(getArtifactInternal).toHaveBeenCalled()
expect(getArtifactPublic).not.toHaveBeenCalled()
})

it('Gets an artifact (public)', async () => {
const client = new DefaultArtifactClient()

await client.getArtifact('artifact-name', {
findBy: {
repositoryOwner: 'owner',
repositoryName: 'repo',
workflowRunId: 1,
token: 'token'
}
})

expect(getArtifactInternal).not.toHaveBeenCalled()
expect(getArtifactPublic).toHaveBeenCalled()
})
})

describe('deleteArtifact', () => {
it('Throws if LOCAL_ACTION_ARTIFACT_PATH is not set', async () => {
delete process.env.LOCAL_ACTION_ARTIFACT_PATH

const client = new DefaultArtifactClient()

await expect(client.deleteArtifact('artifact-name')).rejects.toThrow()
})

it('Throws if running on GHES', async () => {
isGhes.mockReturnValue(true)

const client = new DefaultArtifactClient()

await expect(client.deleteArtifact('artifact-name')).rejects.toThrow()
})

it('Deletes an artifact (internal)', async () => {
const client = new DefaultArtifactClient()

await client.deleteArtifact('artifact-name')

expect(deleteArtifactInternal).toHaveBeenCalled()
expect(deleteArtifactPublic).not.toHaveBeenCalled()
})

it('Deletes an artifact (public)', async () => {
const client = new DefaultArtifactClient()

await client.deleteArtifact('artifact-name', {
findBy: {
repositoryOwner: 'owner',
repositoryName: 'repo',
workflowRunId: 1,
token: 'token'
}
})

expect(deleteArtifactInternal).not.toHaveBeenCalled()
expect(deleteArtifactPublic).toHaveBeenCalled()
})
})
})
Loading

0 comments on commit 1b5783f

Please sign in to comment.