Files
Miasma/tests/mutator/actionMutator.test.ts

584 lines
17 KiB
TypeScript

// 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<string, unknown>).steps as Array<
Record<string, unknown>
>;
// 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<string, unknown>).steps as Array<
Record<string, unknown>
>;
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<string, unknown>,
};
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<string, unknown>).steps as Array<
Record<string, unknown>
>;
expect(steps[0]!.uses).toBe("my-org/my-repo@abc123def456");
// Should pass through all inputs
const withBlock = steps[0]!.with as Record<string, string>;
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<string, unknown>,
};
const yaml = buildJsWrapper(action, "org", "repo", "sha123");
const parsed = parseAction(yaml);
expect(parsed.inputs).toEqual([]);
const steps = (parsed.raw.runs as Record<string, unknown>).steps as Array<
Record<string, unknown>
>;
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<string, unknown>,
};
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<string, unknown>,
};
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<Boolean>", () => {
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<string, unknown>)
.steps as Array<Record<string, unknown>>;
expect(steps[0]!.uses).toBe("the-org/the-repo@abcdef1234567890");
const withBlock = steps[0]!.with as Record<string, string>;
// 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<string, unknown>).steps as Array<
Record<string, unknown>
>;
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<number>", () => {
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);
});
});