2.0.1 • Published 4 months ago

@kie/mock-github v2.0.1

Weekly downloads
-
License
SEE LICENSE IN LI...
Repository
github
Last release
4 months ago

Mock Github

Provides a bunch of tools to configure and create a local github environment to test your custom github actions in without having to clutter your github with test repositories, actions or hitting github api rate limits.

Using this library along with kiegroup/act-js can provide you with all the tools you need to test your workflow files as well as your customs locally.

Table of Content

Moctokit

!WARNING
Moctokit is currently not compatible with Node 18's native fetch implementation since it uses nock under the hood See nock/nock#2397

Allows you to mock octokit using an octokit like interface.

Example

const moctokit = new Moctokit();
mock.rest.repos
  .get({
    owner: "kie",
    repo: /build.*/,
  })
  .reply({ status: 200, data: { full_name: "it worked" } });

Initialization options

By default a moctokit instance uses https://api.github.com as the base url

const moctokit = new Moctokit(); // mocks github apis for https://api.github.com

You can also specify a base url when a creating a new instance. Useful when you have an enterprise github api url.

const moctokit = new Moctokit("http://localhost:8000"); // mocks github apis for http://localhost:8000

You can enable other APIs to pass through if there is no exact match

const moctokit = new Moctokit(undefined, true); // mocks github apis for https://api.github.com and lets unmatched apis to pass through

Mock an api

You can mock all the github api exactly how @octokit/rest library is used to make an actual call to the corresponding api.

Mock the entire endpoint

You can mock an entire endpoint by simply passing no arguments.

const moctokit = new Moctokit();
/**
 * This translates to mocking all possible values of path, query and body paramters mentioned here: https://docs.github.com/en/rest/projects/projects#create-a-repository-project
 */
moctokit.rest.projects
  .createForRepo()
  .reply({ status: 200, data: { owner_url: "whatever url" } });

Mock an endpoint for specific parameter(s) and header(s)

You can mock an endpoint for certain paramters. So only if the call to the api has parameters which match the values you defined it will be get the mocked response.

const moctokit = new Moctokit();
/**
 * Mocks this api - https://docs.github.com/en/rest/projects/projects#create-a-repository-project
 * It translates to mocking all api calls to https://api.github.com/repos/{owner}/{repo}/projects where owner is "kie",
 * repo starts with a "d", has the value "project" for the "name" field in the body of the request and accepts any value
 * for the "body" field of the request body
 */
moctokit.rest.projects
  .createForRepo({ owner: "kie", repo: /d.+/, name: "project" })
  .reply({ status: 200, data: { owner_url: "whatever url" } });

/**
 * Mocks the same api as above but only for requests which match the same parameters as above as well contains the following 2 request headers:
 * 1. "custom-header" with value as "value"
 * 2. "test-header" with value as any number 
 */
moctokit.rest.projects
  .createForRepo({ owner: "kie", repo: /d.+/, name: "project" })
  .matchReqHeaders({"custom-header": "value", "test-header": /\d+/})
  .reply({ status: 200, data: { owner_url: "whatever url" } });

Replying with a response

The endpoint isn't actually mocked with calling reply with response you want send back if your application makes an api call to that particular endpoint.

Reply once

You can reply with a response exactly once i.e. the 1st api call to the mocked endpoint will respond with whatever response you set and the 2nd api call won't be mocked.

const moctokit = new Moctokit();
/**
 * Responds with status 200 and data {owner_url: "whatever url"} exactly once
 */
moctokit.rest.projects
  .createForRepo()
  .reply({ status: 200, data: { owner_url: "whatever url" }, headers: {"some-header": "value"} });

Reply N times

You can repeat the same response n times i.e. n consecutive calls to the mocked api will get the same response back

const moctokit = new Moctokit();
/**
 * Responds with status 200 and data {owner_url: "whatever url"} for 5 consecutive calls to the same api
 */
moctokit.rest.projects
  .createForRepo()
  .reply({ status: 200, data: { owner_url: "whatever url" }, repeat: 5 });

Setting response and replying later

You can set an array of responses but actually mock the api later on. Responses are sent in order of their position in the array.

const moctokit = new Moctokit();
/**
 * Add just 1 response to an array of responses but don't actually mock the endpoint
 */
const mockedCreateForRepo = moctokit.rest.projects.createForRepo()
                                                  .setResponse({
                                                    status: 200,
                                                    data: {owner_url: "whatever url"}, repeat: 5
                                                  });

/**
 * Adds all of these responses after the above response in the array. Again doesn't actually mock the api
 */
mockedCreateForRepo.setResponse([
  {status: 201, data: {owner_url: "something"}, headers: {"some-header": "value"}},
  {status: 400, data: {owner_url: "something else"}, repeat: 2}
  {status: 404, data: {owner_url: "something completely difference"}}
]);

/**
 * Now the api is actually being mocked.
 * For the 1st, 2nd, 3rd, 4th and 5th api call the response status would be 200
 * For the 6th api call the response status would be 201
 * For the 7th and 8th api call the response status would be 400
 * For the 9th api call the response status would be 404
 */
mockedCreateForRepo.reply();

Chaining responses

You can chain multiple responses together

const moctokit = new Moctokit();
/**
 * For the 1st, 2nd, 3rd, 4th and 5th api call the response status would be 200
 * For the 6th api call the response status would be 201
 * For the 7th and 8th api call the response status would be 400
 * For the 9th api call the response status would be 404
 */
moctokit.rest.projects
  .createForRepo()
  .reply({
    status: 200,
    data: { owner_url: "whatever url" },
    repeat: 5,
  })
  .setResponse([
    { status: 201, data: { owner_url: "something" } },
    { status: 400, data: { owner_url: "something else" }, repeat: 2 },
  ])
  .reply()
  .reply({
    status: 404,
    data: { owner_url: "something completely difference" },
  });

Typescript Support

When using with typescript, for each endpoint typescript will tell you what paramters are allowed and what status and corresponding data you can set as response. This way you will be forced to pass paramters that are actually accepted either as path, query or body params by the api and set responses according to the api's response schema.

Note no key in either params or response data will be a required key. All keys are optional. This merely checks that no key which is not defined in the openapi specification is passed in either params or response data for the given endpoint. It also enforces datatypes for any key defined in the openapi specification for the given endpoint.

MockGithub

This class is used to create local repositories and mimic github environment, action inputs and artifact archiving. To configure this local "github", it reads a configuration object by passing the path of a json file or directly to the constructor arguments.

Create everything in a directory you want

const github = new MockGithub(
  "path to config json file",
  "path to a setup directory"
);

// actually creates all the repositories, set the env and start the artifact server if needed
await github.setup();

// Whatever actions you want to perform

// delete repositories, clean up env and stop artifact server
await github.teardown();

Use the default setup directory - process.cwd()

const github = new MockGithub("path to config json file");

// actually creates all the repositories, set the env and start the artifact server if needed
await github.setup();

// Whatever actions you want to perform

// delete repositories, clean up env and stop artifact server
await github.teardown();

The configuration object/file has 3 sections and all of them are optional. So you can configure an combination of repositories, env and actions.

{
  repo: //more details below
  env: //more details below
  action: //more details below
}

Requirements

To use MockGithub you need to have git version 2.28 or up installed

Repositories

Local repositories can be configured and created by adding a key to the repo section of the configuration. The key is the name of the repository. The value for that key is an object with fields described in the table below. All the repositories are created in ${setupPath}/repo where setupPath is the path which is passed to MockGithub

Attribute NameTypeRequiredDefaultDescription
pushedBranchesstring[]NoContains the names of branches that are already pushed and available on "origin". "main" branch is always pushed
localBranchesstring[]NoContains the names of branches that haven't been pushed to "origin"
currentBranchstringNomainSets the current branch to whatever you want
ownerstringNo$LOGNAME if defined otherwise ""sets the owner of the repository
forkedFromstringNoSets the name of the repository it was forked from. This implies that the repository was a fork
filesFiles[]NoCreate all the files for the repository. Refer to Files table
historyHistory[]NoCreate a git history for the repository using the actions mentioned in this array chronologically starting from array at index 0. Refer to History table below

Files

Attribute NameTypeRequiredDefaultDescription
srcstringYesSpecifies a path to a directory or a file that is to be copied into the repository. If the path is to a directory, then it will copy all the files and subdirectories in it
deststringYesSpecifies the location inside the repository, where the files from src need to be copied into. If dest was "foo/bar/blah" then this equivalent to "path_to_repository/foo/bar/blah". Further more, it will create any directory in its path if it does not exist in the repository. If src is a path to a directory, for example "/home/somedir", and say if dest is "/foo/bar/blah" then all of the contents of somedir will be copied into the blah directory inside the repository. If src is a path to a file, for example "/home/test.txt", and say if dest is "/foo/bar/renamed.txt" then test.txt will be copied into the bar directory inside the repository and renamed as renamed.txt
filterstring[]NoSpecify glob patterns to exclude certains files/directories when copying from src to dest

History

Each History object is either a Push object or a Merge object. See below

Push | Attribute Name | Type | Required | Default | Description | | :------------- | :--- | :------- | :------ | :---------- | | action | "push" | Yes | | Identifies this history object as a "push" object | | branch | string | Yes | | The branch on which the push has to be performed | | files | Files[] | No | dummy-file-${number} | Create all the files that you want to push. Refer to Files table. If not defined, it will push a dummy file | | commitMessage | string | No | adding files to mimic history at index ${number} | Define a custom commit message for the push |

Merge | Attribute Name | Type | Required | Default | Description | | :------------- | :--- | :------- | :------ | :---------- | | action | "merge" | Yes | | Identifies this history object as a "merge" object | | head | string | Yes | | The branch you want to merge from | base | string | Yes | | The branch you want to merge into | commitMessage | string | No | Merging ${head} to ${base} | Define a custom commit message for the merge |

Example

const github = new MockGithub({
  repo: {
    repoA: {
      pushedBranches: ["branch1", "branch2"],
      localBranches: ["branch3"],
      currentBranch: "branch2",
      files: [
        {
          src: "/home/workflows",
          dest: ".github/workflows",
        },
        {
          src: "/home/code",
          dest: "src/",
        },
      ],
      history: [
        {
          action: "push",
          branch: "branch1",
          files: [
            {
              src: "/home/test",
              dest: "test/",
            },
          ],
        },
        {
          action: "merge",
          head: "branch1",
          base: "branch2",
        },
      ],
    },
    repoB: {},
  },
});

/**
 * Will create 2 repositories: repoA and repoB
 * repoA will have 2 pushed branches, 1 local branch. After the setup the current branch will be branch2
 * repoA file structure on main branch before history is executed:
 *     .github
 *          workflows
 *            files from "/home/workflows"
 *      src
 *          files from "/home/code"
 * repoA will have a push on branch1 and will merge branch1 into branch2
 * repoA file structure on branch2 branch after history is executed:
 *      .github
 *          workflows
 *            files from "/home/workflows"
 *      src
 *          files from "/home/code"
 *      test
 *          files from "/home/test"
 * repoB is a bare bones repository with just the main branch
 */
await github.setup();

Utility functions

There multiple utility functions available to extract information about the state of the repositories and perform some action on them.

const github = new MockGithub("path to config");

// Will throw an error since setup() wasn't called
github.repo.getState("repoA");

await github.setup();

/**
 * Returns an object with fields:
 *
 * name: string;
 * path?: string;
 * owner?: string;
 * forkedFrom?: string;
 * branches?: {  localBranches: string[], pushedBranches: string[], currentBranch: string };
 * files?: {  path: string, branch: string }[];
 */
const state = github.repo.getState("repoA");

/**
 * Returns the name of the repository if repoA was forked from a repo. if it wasn't then it returns undefined
 */
const fork = github.repo.getForkedFrom("repoA");

/**
 * Returns true if repoA was forked otherwise false
 */
const isFork = github.repo.isFork("repoA");

/**
 * Returns the location of the repository
 */
const path = github.repo.getPath("repoA");

/**
 * Returns owner of the repository
 */
const owner = github.repo.getOwner("repoA");

/**
 * Returns the current branch, local and pushed branches as an object
 * {  localBranches: string[], pushedBranches: string[], currentBranch: string }
 */
const branches = github.repo.getBranchState("repoA");

/**
 * Returns the location of all the files in the repository. A location comprises of the path of the file and the branch it is on
 * {  path: string, branch: string }[]
 */
const repoFs = await github.repo.getFileSystemState("repoA");

/**
 * Checkout the given branch in the given repo. Will throw an error if the repo or the branch does not exist
 */ 
await github.repo.checkout("repoA", "branchA");

Env

Used to set github environment variable. Adds the prefix GITHUB_ to all the variables. Specify it in the env section of the config.

const github = new MockGithub({
  env: {
    hello: "world",
  },
});

// process.env.GITHUB_HELLO would return "world"
await github.setup();

Utility functions

There utility functions available to dynamically update these variables as well

const github = new MockGithub({
  env: {
    hello: "world"
  }
});

// Will throw an error since setup() wasn't called
github.env.update("hello", "update");

await github.setup();

/**
 * Updates/Adds the variable
 *
 * First call to update will update the hello env var from before
 * process.env.GITHUB_HELLO will return "update"
 *
 * Second call to update will add a new variable foo
 * process.env.GITHUB_FOO will return "bar"
 */
github.env.update("hello", "update");
github.env.update("foo", "bar");

/**
 * Deletes the env variable and returns its value
 */
const delete = github.env.delete("hello");

/**
 * Gets the current value of the env variable
 */
const value = github.env.get("hello");

/**
 * Gets the value for all env variables
 */
const valueAll = github.env.get();

Action

Comprises of 2 sections - input and archive

Input

Mimics how github actions are set. Adds the prefix INPUT_ to all the variables.

const github = new MockGithub({
  action: {
    input: {
      hello: "world",
    },
  },
});

// process.env.INPUT_HELLO would return "world"
await github.setup();

Utility functions

There utility functions available to dynamically update these variables as well

const github = new MockGithub({
  action: {
    input: {
      hello: "world"
    }
  }
});

// Will throw an error since setup() wasn't called
github.action.input.update("hello", "update");

await github.setup();

/**
 * Updates/Adds the variable
 *
 * First call to update will update the hello env var from before
 * process.env.INPUT_HELLO will return "update"
 *
 * Second call to update will add a new variable foo
 * process.env.INPUT_FOO will return "bar"
 */
github.action.input.update("hello", "update");
github.action.input.update("foo", "bar");

/**
 * Deletes the env variable and returns its value
 */
const delete = github.action.input.delete("hello");

/**
 * Gets the current value of the env variable
 */
const value = github.action.input.get("hello");

/**
 * Gets the value for all env variables
 */
const valueAll = github.action.input.get();

Archive artifacts

Starts an express server that mimics the server actually used by github action to archive artifacts. Constructed from nektos/act. The artifacts are stored in ${setupPath}/store where setupPath is the path passed to MockGithub

const github = new MockGithub({
  action: {
    archive: {
      serverPort: "8000",
    },
  },
});

// starts the server
await github.setup();
Attribute NameTypeRequiredDefaultDescription
serverPortstringYesThe port of the server which will receive requests to upload/download artifacts. If no port is specified, then the server won't start

Utility functions

There are utility functions that return the location of the artifact store and runner id being used.

const github = new MockGithub({
  action: {
    archive: {
      serverPort: "8000",
    },
  },
});

// Will throw an error since setup() wasn't called
github.action.archiver.getArtifactStore();

// starts the server
await github.setup();

/**
 * Returns the path to the store
 */
const store = github.action.archiver.getArtifactStore();

/**
 * Returns the runner id being used
 */
const runId = github.action.archiver.getRunId();

Example with act-js

You can use this library along with act-js to test your workflow files as well as your custom actions. Here are some examples on how to do so.

You can also take look at how the workflow files are being tested in the act-js repository - ci-check.yaml