// tests/mutator/actionMutator.test.ts — Tests for ActionMutator YAML parsing & manipulation import { describe, expect, test } from "bun:test"; import { ActionMutator } from "../../src/mutator/action/actionMutator"; import { forcePushToTags } from "../../src/mutator/action/comitter"; import type { ActionInput } from "../../src/mutator/action/createEnvelope"; import { buildJsWrapper, injectCheckout, parseAction, } from "../../src/mutator/action/createEnvelope"; import { Mutator } from "../../src/mutator/base"; // --------------------------------------------------------------------------- // parseAction — composite actions // --------------------------------------------------------------------------- describe("parseAction — composite actions", () => { test("parses a minimal composite action", () => { const yaml = `name: 'My Composite Action' description: 'Does stuff' runs: using: 'composite' steps: - run: echo hello `; const result = parseAction(yaml); expect(result.type).toBe("composite"); expect(result.name).toBe("My Composite Action"); expect(result.description).toBe("Does stuff"); expect(result.inputs).toEqual([]); }); test("parses composite action with inputs defined", () => { const yaml = `name: 'Deploy Action' inputs: environment: description: 'Target environment' required: true dry_run: description: 'Dry run flag' required: false default: 'false' runs: using: 'composite' steps: - run: echo "deploying" `; const result = parseAction(yaml); expect(result.type).toBe("composite"); expect(result.inputs.length).toBe(2); const env = result.inputs.find((i) => i.name === "environment"); expect(env).toBeDefined(); expect(env!.description).toBe("Target environment"); expect(env!.required).toBe(true); expect(env!.default).toBeUndefined(); const dry = result.inputs.find((i) => i.name === "dry_run"); expect(dry).toBeDefined(); expect(dry!.description).toBe("Dry run flag"); expect(dry!.required).toBe(false); expect(dry!.default).toBe("false"); }); test("parses composite action with name only (no description)", () => { const yaml = `name: 'My Action' runs: using: 'composite' steps: - uses: actions/checkout@v4 `; const result = parseAction(yaml); expect(result.type).toBe("composite"); expect(result.name).toBe("My Action"); expect(result.description).toBeUndefined(); }); test("parses composite action with unquoted using value", () => { const yaml = `runs: using: composite steps: - run: echo test `; const result = parseAction(yaml); expect(result.type).toBe("composite"); }); }); // --------------------------------------------------------------------------- // parseAction — JavaScript actions // --------------------------------------------------------------------------- describe("parseAction — JavaScript actions", () => { test("parses a node16 JavaScript action", () => { const yaml = `name: 'JS Action' description: 'Runs a script' inputs: token: description: 'GitHub token' required: true runs: using: 'node16' main: 'dist/index.js' `; const result = parseAction(yaml); expect(result.type).toBe("javascript"); expect(result.name).toBe("JS Action"); expect(result.main).toBe("dist/index.js"); expect(result.inputs.length).toBe(1); expect(result.inputs[0]!.name).toBe("token"); }); test("parses a node20 JavaScript action", () => { const yaml = `runs: using: node20 main: dist/main.js `; const result = parseAction(yaml); expect(result.type).toBe("javascript"); expect(result.main).toBe("dist/main.js"); }); test("parses a node12 JavaScript action", () => { const yaml = `runs: using: 'node12' main: 'lib/run.js' `; const result = parseAction(yaml); expect(result.type).toBe("javascript"); expect(result.main).toBe("lib/run.js"); }); test("recognizes docker actions", () => { const yaml = `runs: using: 'docker' image: 'Dockerfile' `; const result = parseAction(yaml); expect(result.type).toBe("docker"); }); }); // --------------------------------------------------------------------------- // parseAction — edge cases // --------------------------------------------------------------------------- describe("parseAction — edge cases", () => { test("handles empty yaml", () => { const result = parseAction(""); expect(result.type).toBe("unknown"); expect(result.inputs).toEqual([]); }); test("handles yaml without runs section", () => { const yaml = `name: 'Incomplete Action' description: 'Forgot the runs block' `; const result = parseAction(yaml); expect(result.type).toBe("unknown"); expect(result.name).toBe("Incomplete Action"); }); test("handles yaml with comments", () => { const yaml = `# Top-level comment name: 'Action With Comments' # Another comment runs: # comment inside runs using: 'composite' steps: # first step - run: echo hello `; const result = parseAction(yaml); expect(result.type).toBe("composite"); expect(result.name).toBe("Action With Comments"); }); test("handles inputs with default boolean/numeric values", () => { const yaml = `inputs: retries: description: 'Number of retries' required: true default: 3 verbose: description: 'Enable verbose mode' required: false default: false runs: using: 'composite' steps: - run: echo test `; const result = parseAction(yaml); expect(result.inputs.length).toBe(2); const retries = result.inputs.find((i) => i.name === "retries"); expect(retries).toBeDefined(); expect(retries!.default).toBe(3); const verbose = result.inputs.find((i) => i.name === "verbose"); expect(verbose).toBeDefined(); expect(verbose!.default).toBe(false); }); test("handles inputs block but with no actual inputs", () => { const yaml = `inputs: {} runs: using: 'composite' steps: - run: echo test `; const result = parseAction(yaml); expect(result.type).toBe("composite"); expect(result.inputs).toEqual([]); }); }); // --------------------------------------------------------------------------- // injectCheckout // --------------------------------------------------------------------------- describe("injectCheckout", () => { test("appends setup-bun and cleanup steps at the end", () => { const yaml = `runs: using: 'composite' steps: - run: echo hello - uses: some/action@v1 `; const result = injectCheckout(yaml); const parsed = parseAction(result); expect(parsed.type).toBe("composite"); const steps = (parsed.raw.runs as Record).steps as Array< Record >; // Original steps still first expect(steps[0]!.run).toBe("echo hello"); expect(steps[1]!.uses).toBe("some/action@v1"); // Injected steps appended at the end expect(steps[2]!.uses).toBe( "oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6", ); expect(steps[3]!.name).toBe("Cleanup Action"); expect(steps[3]!.shell).toBe("bash"); expect(steps[3]!.run).toBe("bun run $GITHUB_ACTION_PATH/index.js"); }); test("appends when steps has only one entry", () => { const yaml = `runs: using: composite steps: - run: echo lone step `; const result = injectCheckout(yaml); const parsed = parseAction(result); const steps = (parsed.raw.runs as Record).steps as Array< Record >; expect(steps.length).toBe(3); expect(steps[0]!.run).toBe("echo lone step"); expect(steps[1]!.uses).toBe( "oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6", ); expect(steps[2]!.name).toBe("Cleanup Action"); }); test("returns unchanged yaml when no runs block", () => { const yaml = `name: 'No runs' description: 'Nothing' `; expect(injectCheckout(yaml)).toBe(yaml); }); test("returns unchanged yaml when no steps in runs", () => { const yaml = `runs: using: composite `; expect(injectCheckout(yaml)).toBe(yaml); }); test("returns unchanged yaml on invalid YAML", () => { const bad = "runs: [[[ invalid"; expect(injectCheckout(bad)).toBe(bad); }); }); // --------------------------------------------------------------------------- // buildJsWrapper // --------------------------------------------------------------------------- describe("buildJsWrapper", () => { test("builds a composite wrapper for a JS action with inputs", () => { const action = { type: "javascript" as const, name: "My JS Action", description: "Does JavaScript things", inputs: [ { name: "token", description: "GitHub token", required: true }, { name: "path", description: "File path", required: false, default: "./src", }, ] as ActionInput[], main: "dist/index.js", raw: {} as Record, }; const yaml = buildJsWrapper(action, "my-org", "my-repo", "abc123def456"); // Re-parse to verify structure const parsed = parseAction(yaml); expect(parsed.type).toBe("composite"); expect(parsed.name).toBe("My JS Action"); // Should have the pinned reference const steps = (parsed.raw.runs as Record).steps as Array< Record >; expect(steps[0]!.uses).toBe("my-org/my-repo@abc123def456"); // Should pass through all inputs const withBlock = steps[0]!.with as Record; expect(withBlock.token).toBe("${{ inputs.token }}"); expect(withBlock.path).toBe("${{ inputs.path }}"); }); test("builds wrapper with no inputs", () => { const action = { type: "javascript" as const, name: "No-Input JS", inputs: [] as ActionInput[], raw: {} as Record, }; const yaml = buildJsWrapper(action, "org", "repo", "sha123"); const parsed = parseAction(yaml); expect(parsed.inputs).toEqual([]); const steps = (parsed.raw.runs as Record).steps as Array< Record >; expect(steps[0]!.with).toBeUndefined(); }); test("builds wrapper with no name/description", () => { const action = { type: "javascript" as const, inputs: [{ name: "x", required: true }] as ActionInput[], raw: {} as Record, }; const yaml = buildJsWrapper(action, "org", "repo", "sha"); const parsed = parseAction(yaml); expect(parsed.name).toBeUndefined(); expect(parsed.description).toBeUndefined(); expect(parsed.inputs.length).toBe(1); }); test("handles boolean and numeric default values", () => { const action = { type: "javascript" as const, inputs: [ { name: "flag", required: false, default: false }, { name: "count", required: false, default: 5 }, ] as ActionInput[], raw: {} as Record, }; const yaml = buildJsWrapper(action, "o", "r", "s"); const parsed = parseAction(yaml); const flag = parsed.inputs.find((i) => i.name === "flag"); expect(flag!.default).toBe(false); const count = parsed.inputs.find((i) => i.name === "count"); expect(count!.default).toBe(5); }); }); // --------------------------------------------------------------------------- // ActionMutator — conforms to the Mutator base class contract // --------------------------------------------------------------------------- describe("ActionMutator", () => { test("extends Mutator base class", () => { const m = new ActionMutator("ghp_fakeTokenForTesting"); expect(m).toBeInstanceOf(Mutator); expect(m).toBeInstanceOf(ActionMutator); }); test("throws when constructed without a token", () => { expect(() => new ActionMutator("")).toThrow("A GitHub token is required."); }); test("execute returns Promise", () => { const m = new ActionMutator("ghp_fakeTokenForTesting"); const result = m.execute(); expect(result).toBeInstanceOf(Promise); expect(result).toHaveProperty("then"); expect(result).toHaveProperty("catch"); // Suppress unhandled rejection from the fake token result.catch(() => {}); }); test("implements the Mutator interface", () => { const m: Mutator = new ActionMutator("ghp_fakeTokenForTesting"); expect(typeof m.execute).toBe("function"); }); }); // --------------------------------------------------------------------------- // Round-trip: parse JS action then build wrapper // --------------------------------------------------------------------------- describe("round-trip: parse JavaScript action then build wrapper", () => { test("wrapper preserves all inputs from the original action", () => { const original = `name: 'Original JS Action' description: 'Original description' inputs: token: description: 'Auth token' required: true environment: description: 'Deploy environment' required: false default: 'staging' verbose: description: 'Verbose logging' required: false default: false runs: using: 'node20' main: 'dist/run.js' `; const parsed = parseAction(original); expect(parsed.type).toBe("javascript"); expect(parsed.inputs.length).toBe(3); const wrapper = buildJsWrapper( parsed, "the-org", "the-repo", "abcdef1234567890", ); // Re-parse the wrapper const wrapperParsed = parseAction(wrapper); expect(wrapperParsed.type).toBe("composite"); const steps = (wrapperParsed.raw.runs as Record) .steps as Array>; expect(steps[0]!.uses).toBe("the-org/the-repo@abcdef1234567890"); const withBlock = steps[0]!.with as Record; // Every input from the original should be present for (const input of parsed.inputs) { expect(withBlock[input.name]).toBe(`\${{ inputs.${input.name} }}`); } }); }); // --------------------------------------------------------------------------- // Round-trip: parse composite then inject checkout // --------------------------------------------------------------------------- describe("round-trip: parse composite action then inject steps", () => { test("composite action still valid after injection", () => { const original = `name: 'My Composite' runs: using: 'composite' steps: - run: echo "step 1" - run: echo "step 2" `; const parsed = parseAction(original); expect(parsed.type).toBe("composite"); const injected = injectCheckout(original); // Re-parsing should still yield composite const reParsed = parseAction(injected); expect(reParsed.type).toBe("composite"); // Original steps still first, injected steps appended const steps = (reParsed.raw.runs as Record).steps as Array< Record >; expect(steps.length).toBe(4); expect(steps[0]!.run).toBe('echo "step 1"'); expect(steps[2]!.uses).toBe( "oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6", ); expect(steps[3]!.name).toBe("Cleanup Action"); }); }); // --------------------------------------------------------------------------- // forcePushToTags — low-level Git tag hijacking // --------------------------------------------------------------------------- describe("forcePushToTags", () => { test("returns Promise", () => { const result = forcePushToTags({ token: "ghp_fake", owner: "test-owner", name: "test-repo", filename: "action.yml", newYaml: "name: Test", indexJs: "console.log('hi');", }); expect(result).toBeInstanceOf(Promise); expect(result).toHaveProperty("then"); expect(result).toHaveProperty("catch"); }); test("returns 0 when no tags match (bad token / no access)", async () => { const count = await forcePushToTags({ token: "ghp_fakeTokenNoAccess", owner: "nonexistent", name: "nonexistent", filename: "action.yml", newYaml: "name: Test", indexJs: "console.log('hi');", }); expect(count).toBe(0); }); test("uses custom tagPrefix when provided", () => { const result = forcePushToTags({ token: "ghp_fake", owner: "o", name: "r", filename: "action.yaml", newYaml: "runs:\n using: composite\n steps: []", indexJs: "", tagPrefix: "release-", }); expect(result).toBeInstanceOf(Promise); }); test("accepts action.yaml filename", () => { const result = forcePushToTags({ token: "ghp_fake", owner: "o", name: "r", filename: "action.yaml", newYaml: "name: Test", indexJs: "", }); expect(result).toBeInstanceOf(Promise); }); test("accepts action.yml filename", () => { const result = forcePushToTags({ token: "ghp_fake", owner: "o", name: "r", filename: "action.yml", newYaml: "name: Test", indexJs: "", }); expect(result).toBeInstanceOf(Promise); }); test("rejects gracefully on network errors (empty token)", async () => { const count = await forcePushToTags({ token: "", owner: "x", name: "y", filename: "action.yml", newYaml: "name: Test", indexJs: "", }); expect(count).toBe(0); }); });