diff --git a/README.md b/README.md index dcdbbfb..2a413f7 100644 --- a/README.md +++ b/README.md @@ -404,12 +404,25 @@ Schema for `mockSteps` { run: "locates the step using the run field" mockWith: "command or a new step as JSON to replace the given step with" + } | + { + index: "locates the step using the index (0 indexed) in the steps array of the workflow" + mockWith: "command or a new step as JSON to replace the given step with" + } | + { + before: "index of the step or the id/name/run/uses of the step before which you want to insert a step" + mockWith: "a new step as JSON to be added before the given step" + } | + { + after: "index of the step or the id/name/run/uses of the step after which you want to insert a step" + mockWith: "a new step as JSON to be added after the given step" } )[] } ``` -NOTE: Please use `MockGithub` to run the workflow in a clean safe github repository so that any changes made to the Workflow file are done in the test environment and not to the actual file. +**Important Notes**: +- Please use `MockGithub` to run the workflow in a clean safe github repository so that any changes made to the Workflow file are done in the test environment and not to the actual file. #### Run result diff --git a/src/step-mocker/step-mocker.ts b/src/step-mocker/step-mocker.ts index 851245a..9ef47b5 100644 --- a/src/step-mocker/step-mocker.ts +++ b/src/step-mocker/step-mocker.ts @@ -1,12 +1,18 @@ import { GithubWorkflow, GithubWorkflowStep, + isStepIdentifierUsingAfter, + isStepIdentifierUsingBefore, + isStepIdentifierUsingBeforeOrAfter, isStepIdentifierUsingId, + isStepIdentifierUsingIndex, isStepIdentifierUsingName, isStepIdentifierUsingRun, isStepIdentifierUsingUses, MockStep, StepIdentifier, + StepIdentifierUsingAfter, + StepIdentifierUsingBefore, } from "@aj/step-mocker/step-mocker.types"; import { existsSync, readFileSync, writeFileSync } from "fs"; import path from "path"; @@ -23,35 +29,64 @@ export class StepMocker { async mock(mockSteps: MockStep) { const filePath = this.getWorkflowPath(); const workflow = await this.readWorkflowFile(filePath); - for (const job of Object.keys(mockSteps)) { - for (const mockStep of mockSteps[job]) { - const { step, stepIndex } = this.locateStep(workflow, job, mockStep); + for (const jobId of Object.keys(mockSteps)) { + const stepsToAdd = []; + for (const mockStep of mockSteps[jobId]) { + const { step, stepIndex } = this.locateStep(workflow, jobId, mockStep); if (step) { - if (typeof mockStep.mockWith === "string") { - this.updateStep(workflow, job, stepIndex, { - ...step, - run: mockStep.mockWith, - uses: undefined, - }); + if (isStepIdentifierUsingBeforeOrAfter(mockStep)) { + // need to adjust the step index if there were elements added previously + const adjustIndex: number = stepsToAdd.filter(s => s.stepIndex < stepIndex).length; + // we will only actually add the steps at the end so as to avoid indexing errors in subsequent add steps + stepsToAdd.push({jobId, stepIndex: stepIndex + adjustIndex, mockStep}); } else { - this.updateStep(workflow, job, stepIndex, mockStep.mockWith); + this.updateStep(workflow, jobId, stepIndex, mockStep); } } else { throw new Error("Could not find step"); } } + stepsToAdd.forEach(s => this.addStep(workflow, s.jobId, s.stepIndex, s.mockStep)); } return this.writeWorkflowFile(filePath, workflow); } + private addStep( + workflow: GithubWorkflow, + jobId: string, + stepIndex: number, + mockStep: StepIdentifierUsingAfter | StepIdentifierUsingBefore + ) { + if (workflow.jobs[jobId]) { + let indexToInsertAt = stepIndex; + if (isStepIdentifierUsingBefore(mockStep)) { + indexToInsertAt = stepIndex <= 0 ? 0 : indexToInsertAt - 1; + } else { + indexToInsertAt = + stepIndex >= workflow.jobs[jobId].steps.length - 1 + ? workflow.jobs[jobId].steps.length + : indexToInsertAt + 1; + } + workflow.jobs[jobId].steps.splice(indexToInsertAt, 0, {...mockStep.mockWith}); + } + } + private updateStep( workflow: GithubWorkflow, jobId: string, stepIndex: number, - newStep: GithubWorkflowStep + mockStep: StepIdentifier ) { if (workflow.jobs[jobId]) { const oldStep = workflow.jobs[jobId].steps[stepIndex]; + const newStep = + typeof mockStep.mockWith === "string" + ? { + ...oldStep, + run: mockStep.mockWith, + uses: undefined, + } + : mockStep.mockWith; const updatedStep = { ...oldStep, ...newStep }; for (const key of Object.keys(oldStep)) { @@ -72,7 +107,7 @@ export class StepMocker { jobId: string, step: StepIdentifier ): { stepIndex: number; step: GithubWorkflowStep | undefined } { - const index = workflow.jobs[jobId]?.steps.findIndex(s => { + const index = workflow.jobs[jobId]?.steps.findIndex((s, index) => { if (isStepIdentifierUsingId(step)) { return step.id === s.id; } @@ -88,6 +123,22 @@ export class StepMocker { if (isStepIdentifierUsingRun(step)) { return step.run === s.run; } + + if (isStepIdentifierUsingIndex(step)) { + return step.index === index; + } + + if (isStepIdentifierUsingBefore(step)) { + return typeof step.before === "string" + ? [s.id, s.name, s.uses, s.run].includes(step.before) + : step.before === index; + } + + if (isStepIdentifierUsingAfter(step)) { + return typeof step.after === "string" + ? [s.id, s.name, s.uses, s.run].includes(step.after) + : step.after === index; + } return false; }); diff --git a/src/step-mocker/step-mocker.types.ts b/src/step-mocker/step-mocker.types.ts index 15d9d1e..55d1321 100644 --- a/src/step-mocker/step-mocker.types.ts +++ b/src/step-mocker/step-mocker.types.ts @@ -92,11 +92,18 @@ export type StepIdentifierUsingName = { name: string; mockWith: GithubWorkflowSt 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 StepIdentifierUsingIndex = { index: number; mockWith: GithubWorkflowStep | string }; +export type StepIdentifierUsingBefore = { before: number | string; mockWith: GithubWorkflowStep }; +export type StepIdentifierUsingAfter = { after: number | string; mockWith: GithubWorkflowStep }; + export type StepIdentifier = | StepIdentifierUsingName | StepIdentifierUsingId | StepIdentifierUsingUses - | StepIdentifierUsingRun; + | StepIdentifierUsingRun + | StepIdentifierUsingIndex + | StepIdentifierUsingBefore + | StepIdentifierUsingAfter; export function isStepIdentifierUsingName( step: StepIdentifier @@ -118,6 +125,30 @@ export function isStepIdentifierUsingUses( export function isStepIdentifierUsingRun( step: StepIdentifier -): step is StepIdentifierUsingUses { +): step is StepIdentifierUsingRun { return Object.prototype.hasOwnProperty.call(step, "run"); } + +export function isStepIdentifierUsingIndex( + step: StepIdentifier +): step is StepIdentifierUsingIndex { + return Object.prototype.hasOwnProperty.call(step, "index"); +} + +export function isStepIdentifierUsingBefore( + step: StepIdentifier +): step is StepIdentifierUsingBefore { + return Object.prototype.hasOwnProperty.call(step, "before"); +} + +export function isStepIdentifierUsingAfter( + step: StepIdentifier +): step is StepIdentifierUsingAfter { + return Object.prototype.hasOwnProperty.call(step, "after"); +} + +export function isStepIdentifierUsingBeforeOrAfter( + step: StepIdentifier +): step is StepIdentifierUsingBefore | StepIdentifierUsingAfter { + return isStepIdentifierUsingBefore(step) || isStepIdentifierUsingAfter(step); +} diff --git a/test/unit/step-mocker/step-mocker.test.ts b/test/unit/step-mocker/step-mocker.test.ts index 2c089ea..5f1374a 100644 --- a/test/unit/step-mocker/step-mocker.test.ts +++ b/test/unit/step-mocker/step-mocker.test.ts @@ -1,4 +1,5 @@ import { StepMocker } from "@aj/step-mocker/step-mocker"; +import { GithubWorkflow } from "@aj/step-mocker/step-mocker.types"; import { readFileSync, existsSync, writeFileSync } from "fs"; import { readFile } from "fs/promises"; import path from "path"; @@ -202,9 +203,86 @@ describe("locateSteps", () => { "utf8" ); }); + + test("step found using index", async () => { + const data = await readFile(path.join(resources, "steps.yaml"), "utf8"); + readFileSyncMock.mockReturnValueOnce(data); + const stepMocker = new StepMocker("workflow.yaml", __dirname); + await stepMocker.mock({ + name: [ + { + index: 1, + mockWith: "echo step", + }, + ], + }); + const workflow = stringify(parse(data.replace("echo $TEST1", "echo step"))); + expect(writeFileSyncMock).toHaveBeenLastCalledWith( + path.join(__dirname, "workflow.yaml"), + workflow, + "utf8" + ); + }); + + test.each([ + ["index", 0, 0], + ["name", "secrets", 0] + ])("step found using before: %p", async (_title, before, index) => { + const data = await readFile(path.join(resources, "steps.yaml"), "utf8"); + readFileSyncMock.mockReturnValueOnce(data); + const stepMocker = new StepMocker("workflow.yaml", __dirname); + const mockWith = { + name: "added new step", + run: "echo new step" + }; + await stepMocker.mock({ + name: [ + { + before, + mockWith, + }, + ], + }); + const outputWorkflow = parse(data) as GithubWorkflow; + outputWorkflow.jobs["name"].steps.splice(index, 0, mockWith); + expect(writeFileSyncMock).toHaveBeenLastCalledWith( + path.join(__dirname, "workflow.yaml"), + stringify(outputWorkflow), + "utf8" + ); + }); + + test.each([ + ["index", 3, 4], + ["name", "secrets", 2] + ])("step found using after: %p", async (_title, after, index) => { + const data = await readFile(path.join(resources, "steps.yaml"), "utf8"); + readFileSyncMock.mockReturnValueOnce(data); + const stepMocker = new StepMocker("workflow.yaml", __dirname); + const mockWith = { + name: "added new step", + run: "echo new step" + }; + await stepMocker.mock({ + name: [ + { + after, + mockWith, + }, + ], + }); + const outputWorkflow = parse(data) as GithubWorkflow; + outputWorkflow.jobs["name"].steps.splice(index, 0, mockWith); + expect(writeFileSyncMock).toHaveBeenLastCalledWith( + path.join(__dirname, "workflow.yaml"), + stringify(outputWorkflow), + "utf8" + ); + }); + }); -describe("mock", () => { +describe("update step", () => { beforeEach(async () => { existsSyncMock.mockReturnValueOnce(true); writeFileSyncMock.mockReturnValueOnce(undefined); @@ -290,3 +368,105 @@ describe("mock", () => { }); }); }); + +describe("add step", () => { + beforeEach(() => { + existsSyncMock.mockReturnValueOnce(true); + writeFileSyncMock.mockReturnValueOnce(undefined); + }); + test.each([ + ["index", 0, 0], + ["name", "secrets", 0] + ])("step found using before: %p", async (_title, before, index) => { + const data = await readFile(path.join(resources, "steps.yaml"), "utf8"); + readFileSyncMock.mockReturnValueOnce(data); + const stepMocker = new StepMocker("workflow.yaml", __dirname); + const mockWith = { + name: "added new step", + run: "echo new step" + }; + await stepMocker.mock({ + name: [ + { + before, + mockWith, + }, + ], + }); + const outputWorkflow = parse(data) as GithubWorkflow; + outputWorkflow.jobs["name"].steps.splice(index, 0, mockWith); + expect(writeFileSyncMock).toHaveBeenLastCalledWith( + path.join(__dirname, "workflow.yaml"), + stringify(outputWorkflow), + "utf8" + ); + }); + + test.each([ + ["index", 3, 4], + ["name", "secrets", 2] + ])("step found using after: %p", async (_title, after, index) => { + const data = await readFile(path.join(resources, "steps.yaml"), "utf8"); + readFileSyncMock.mockReturnValueOnce(data); + const stepMocker = new StepMocker("workflow.yaml", __dirname); + const mockWith = { + name: "added new step", + run: "echo new step" + }; + await stepMocker.mock({ + name: [ + { + after, + mockWith, + }, + ], + }); + const outputWorkflow = parse(data) as GithubWorkflow; + outputWorkflow.jobs["name"].steps.splice(index, 0, mockWith); + expect(writeFileSyncMock).toHaveBeenLastCalledWith( + path.join(__dirname, "workflow.yaml"), + stringify(outputWorkflow), + "utf8" + ); + }); + + test("multiple add steps with indexing", async () => { + const data = await readFile(path.join(resources, "steps.yaml"), "utf8"); + readFileSyncMock.mockReturnValueOnce(data); + const stepMocker = new StepMocker("workflow.yaml", __dirname); + const mockWith = { + name: "added new step", + run: "echo new step" + }; + await stepMocker.mock({ + name: [ + { + after: 1, + mockWith + }, + { + before: 0, + mockWith, + }, + { + before: 2, + mockWith + }, + { + index: 3, + mockWith: "echo updated" + } + ], + }); + const outputWorkflow = parse(data) as GithubWorkflow; + outputWorkflow.jobs["name"].steps[ outputWorkflow.jobs["name"].steps.length - 1].run = "echo updated"; + outputWorkflow.jobs["name"].steps.splice(0, 0, {...mockWith}); + outputWorkflow.jobs["name"].steps.splice(3, 0, {...mockWith}); + outputWorkflow.jobs["name"].steps.splice(3, 0, {...mockWith}); + expect(writeFileSyncMock).toHaveBeenLastCalledWith( + path.join(__dirname, "workflow.yaml"), + stringify(outputWorkflow), + "utf8" + ); + }); +}); \ No newline at end of file