Skip to content

Commit

Permalink
feat: add ability to change env, with and other fields of a step (#…
Browse files Browse the repository at this point in the history
…56)

* update step mocker to allow mocking of fields such as env and with

* updated tests

* updated docs
  • Loading branch information
shubhbapna authored Aug 17, 2023
1 parent bb09768 commit d068a51
Show file tree
Hide file tree
Showing 4 changed files with 177 additions and 22 deletions.
20 changes: 14 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -333,7 +333,7 @@ Examples to help you get started:

#### Mocking steps

There are cases where some of the steps have to be directly skipped or mocked because it is not feasible to execute them in a test env and might even be redundant to test them (npm publish for instance), so `mockSteps` mechanism is provided to overcome those cases.
There are cases where some of the steps have to be skipped or mocked because it is not feasible to execute them in a test env and might even be redundant to test them (npm publish for instance). In such cases, we can use the `mockSteps` option when executing `act`.

Let's suppose this is the workflow to test

Expand All @@ -355,14 +355,22 @@ jobs:
NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}}
```
The two final steps has to be skipped since the package shouldn't be really published (and most probably it will fail due to NPM_TOKEN or already existing version on the registry). In order to do skip or mock this step we can do the following while running the workflow
Now while testing this, we probably don't want to actually publish a package, so we will use `mockSteps` to change the `npm publish --access public` command to something else. Moreover, say the registry url was behind a vpn and not really accessible locally. We can change this registry url as well using `mockSteps`. In particular, for any step you can replace it with any new step you want. The new step doesn't override the old step completely, it just updates the values that are defined in the new step. To delete a particular field, you will have to set it to `undefined` when passing the new step to `mockSteps`.

```typescript
const act = new Act();
let result = await act.runJob("job_id", {
mockSteps: {
// job name
publish: [
{
uses: "actions/setup-node@v3",
mockWith: {
with: {
"registry-url": "local-regsitry"
}
}
},
{
name: "publish step",
mockWith: "echo published",
Expand All @@ -380,19 +388,19 @@ Schema for `mockSteps`
[jobName: string]: (
{
name: "locates the step using the name field"
mockWith: "command to replace the given step with"
mockWith: "command or a new step as JSON to replace the given step with"
} |
{
id: "locates the step using the id field"
mockWith: "command to replace the given step with"
mockWith: "command or a new step as JSON to replace the given step with"
} |
{
uses: "locates the step using the uses field"
mockWith: "command to replace the given step with"
mockWith: "command or a new step as JSON to replace the given step with"
} |
{
run: "locates the step using the run field"
mockWith: "command to replace the given step with"
mockWith: "command or a new step as JSON to replace the given step with"
}
)[]
}
Expand Down
45 changes: 39 additions & 6 deletions src/step-mocker/step-mocker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,12 +25,17 @@ export class StepMocker {
const workflow = await this.readWorkflowFile(filePath);
for (const job of Object.keys(mockSteps)) {
for (const mockStep of mockSteps[job]) {
const step = this.locateStep(workflow, job, mockStep);
const { step, stepIndex } = this.locateStep(workflow, job, mockStep);
if (step) {
if (step.uses) {
delete step["uses"];
if (typeof mockStep.mockWith === "string") {
this.updateStep(workflow, job, stepIndex, {
...step,
run: mockStep.mockWith,
uses: undefined,
});
} else {
this.updateStep(workflow, job, stepIndex, mockStep.mockWith);
}
step.run = mockStep.mockWith;
} else {
throw new Error("Could not find step");
}
Expand All @@ -39,12 +44,35 @@ export class StepMocker {
return this.writeWorkflowFile(filePath, workflow);
}

private updateStep(
workflow: GithubWorkflow,
jobId: string,
stepIndex: number,
newStep: GithubWorkflowStep
) {
if (workflow.jobs[jobId]) {
const oldStep = workflow.jobs[jobId].steps[stepIndex];
const updatedStep = { ...oldStep, ...newStep };

for (const key of Object.keys(oldStep)) {
if (key === "env" || key === "with") {
updatedStep[key] = {
...oldStep[key],
...(newStep[key] ?? {}),
};
}
}

workflow.jobs[jobId].steps[stepIndex] = updatedStep;
}
}

private locateStep(
workflow: GithubWorkflow,
jobId: string,
step: StepIdentifier
): GithubWorkflowStep | undefined {
return workflow.jobs[jobId]?.steps.find(s => {
): { stepIndex: number; step: GithubWorkflowStep | undefined } {
const index = workflow.jobs[jobId]?.steps.findIndex(s => {
if (isStepIdentifierUsingId(step)) {
return step.id === s.id;
}
Expand All @@ -62,6 +90,11 @@ export class StepMocker {
}
return false;
});

return {
stepIndex: index,
step: index > -1 ? workflow.jobs[jobId]?.steps[index] : undefined,
};
}

private getWorkflowPath(): string {
Expand Down
39 changes: 35 additions & 4 deletions src/step-mocker/step-mocker.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,36 @@ export type GithubWorkflowStep = {
*/
run?: string;

/**
* A map of the input parameters defined by the action.
*/
with?: Record<string, string | number | boolean | undefined>;

/**
* Sets variables for the step to use during execution.
*/
env?: Record<string, string | number | boolean | undefined>;

/**
* Override the default shell settings in the runner's operating system using the shell keyword.
*/
shell?: string

/**
* Specify the working directory of where to run the command.
*/
"working-directory"?: string;

/**
* Prevents a job from failing when a step fails. Set to true to allow a job to pass when this step fails.
*/
"continue-on-error"?: string;

/**
* The maximum number of minutes to run the step before killing the process.
*/
"timeout-minutes"?: string;

[k: string]: unknown;
};

Expand Down Expand Up @@ -57,10 +87,11 @@ export type MockStep = {
[job: string]: StepIdentifier[];
};

export type StepIdentifierUsingName = { name: string; mockWith: string };
export type StepIdentifierUsingId = { id: string; mockWith: string };
export type StepIdentifierUsingUses = { uses: string; mockWith: string };
export type StepIdentifierUsingRun = { run: string; mockWith: string };
// added string type to mockWith for backward compatibility
export type StepIdentifierUsingName = { name: string; mockWith: GithubWorkflowStep | string };
export type StepIdentifierUsingId = { id: string; mockWith: GithubWorkflowStep | string };
export type StepIdentifierUsingUses = { uses: string; mockWith: GithubWorkflowStep | string };
export type StepIdentifierUsingRun = { run: string; mockWith: GithubWorkflowStep | string };
export type StepIdentifier =
| StepIdentifierUsingName
| StepIdentifierUsingId
Expand Down
95 changes: 89 additions & 6 deletions test/unit/step-mocker/step-mocker.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,9 +153,7 @@ describe("locateSteps", () => {
},
],
});
const workflow = stringify(
parse(data.replace("echo $TEST1", "echo step"))
);
const workflow = stringify(parse(data.replace("echo $TEST1", "echo step")));
expect(writeFileSyncMock).toHaveBeenLastCalledWith(
path.join(__dirname, "workflow.yaml"),
workflow,
Expand Down Expand Up @@ -197,13 +195,98 @@ describe("locateSteps", () => {
},
],
});
const workflow = stringify(
parse(data.replace("echo run", "echo step"))
);
const workflow = stringify(parse(data.replace("echo run", "echo step")));
expect(writeFileSyncMock).toHaveBeenLastCalledWith(
path.join(__dirname, "workflow.yaml"),
workflow,
"utf8"
);
});
});

describe("mock", () => {
beforeEach(async () => {
existsSyncMock.mockReturnValueOnce(true);
writeFileSyncMock.mockReturnValueOnce(undefined);
readFileSyncMock.mockReturnValueOnce(
await readFile(path.join(resources, "steps.yaml"), "utf8")
);
});

test("mockWith is a string", async () => {
const stepMocker = new StepMocker("workflow.yaml", __dirname);
await stepMocker.mock({
name: [
{
run: "echo run",
mockWith: "echo step",
},
{
uses: "actions/checkout@v3",
mockWith: "echo checkout"
}
],
});

expect(parse(writeFileSyncMock.mock.calls[0][1])).toMatchObject({
jobs: {
name: {
steps: expect.arrayContaining([
{
run: "echo checkout",
},
{
run: "echo step",
},
]),
},
},
});
});

test("mockWith is a Step", async () => {
const stepMocker = new StepMocker("workflow.yaml", __dirname);
await stepMocker.mock({
name: [
{
uses: "actions/checkout@v3",
mockWith: {
uses: "actions/setup-node",
with: {
nodeVersion: 16
}
}
},
{
name: "secrets",
mockWith: {
env: {
TEST1: "val",
},
name: undefined // try deleting the field
}
}
],
});
expect(parse(writeFileSyncMock.mock.calls[0][1])).toMatchObject({
jobs: {
name: {
steps: expect.arrayContaining([
{
run: "echo $TEST1",
env: {
TEST1: "val"
}
},
{
uses: "actions/setup-node",
with: {
nodeVersion: 16
}
},
]),
},
},
});
});
});

0 comments on commit d068a51

Please sign in to comment.