import { describe, test, expect, beforeEach, afterEach } from "vitest";
import { mkdtempSync, writeFileSync, rmSync, existsSync, readFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { migrateCommand } from "./migrate";
import type { LexiconPlugin, MigrationResult } from "../../lexicon";
function stubPlugin(behavior: Partial<{ supports: string[]; detect: boolean; result: MigrationResult }>): LexiconPlugin {
return {
name: "stub",
serializer: { name: "stub", rulePrefix: "STB", serialize: () => "" },
async generate() {},
async validate() {},
async coverage() {},
async package() {},
migrationSource(from: string) {
if (!(behavior.supports ?? ["github"]).includes(from)) return undefined;
return {
detect: () => behavior.detect ?? true,
async transform() {
return behavior.result ?? { output: "stub-output", provenance: [], diagnostics: [] };
},
};
},
};
}
describe("migrateCommand", () => {
let testDir: string;
beforeEach(() => {
testDir = mkdtempSync(join(tmpdir(), "chant-migrate-test-"));
});
afterEach(() => {
rmSync(testDir, { recursive: true, force: true });
});
test("fails when target lexicon is not installed", async () => {
const file = join(testDir, "ci.yml");
writeFileSync(file, "jobs:\n x:\n runs-on: ubuntu-latest\n");
const r = await migrateCommand({
sourceFile: file, from: "github", to: "missing",
emit: "yaml", strict: false, validate: false, useComposites: false,
plugins: [],
});
expect(r.exitCode).toBe(1);
expect(r.error).toContain("not installed");
});
test("fails when target lexicon does not support migration", async () => {
const file = join(testDir, "ci.yml");
writeFileSync(file, "jobs:\n x:\n runs-on: ubuntu-latest\n");
const plugin: LexiconPlugin = {
name: "no-migrate",
serializer: { name: "no-migrate", rulePrefix: "NM", serialize: () => "" },
async generate() {},
async validate() {},
async coverage() {},
async package() {},
};
const r = await migrateCommand({
sourceFile: file, from: "github", to: "no-migrate",
emit: "yaml", strict: false, validate: false, useComposites: false,
plugins: [plugin],
});
expect(r.exitCode).toBe(1);
expect(r.error).toContain("does not support migration");
});
test("fails when source content is not recognised", async () => {
const file = join(testDir, "ci.yml");
writeFileSync(file, "not a workflow");
const r = await migrateCommand({
sourceFile: file, from: "github", to: "stub",
emit: "yaml", strict: false, validate: false, useComposites: false,
plugins: [stubPlugin({ detect: false })],
});
expect(r.exitCode).toBe(1);
expect(r.error).toContain("does not look like github");
});
test("writes output to --output file", async () => {
const file = join(testDir, "ci.yml");
const out = join(testDir, "out.yml");
writeFileSync(file, "jobs:\n x:\n runs-on: ubuntu-latest\n");
const r = await migrateCommand({
sourceFile: file, from: "github", to: "stub", output: out,
emit: "yaml", strict: false, validate: false, useComposites: false,
plugins: [stubPlugin({ result: { output: "hello-yaml\n", provenance: [], diagnostics: [] } })],
});
expect(r.exitCode).toBe(0);
expect(existsSync(out)).toBe(true);
expect(readFileSync(out, "utf-8")).toBe("hello-yaml\n");
});
test("--strict escalates error diagnostics to non-zero exit", async () => {
const file = join(testDir, "ci.yml");
writeFileSync(file, "jobs:\n x:\n runs-on: ubuntu-latest\n");
const r = await migrateCommand({
sourceFile: file, from: "github", to: "stub", output: join(testDir, "out.yml"),
emit: "yaml", strict: true, validate: false, useComposites: false,
plugins: [stubPlugin({
result: {
output: "out",
provenance: [],
diagnostics: [{ severity: "error", ruleId: "TEST-001", message: "bad", file: "", line: 1, column: 1 }],
},
})],
});
expect(r.exitCode).toBe(1);
});
test("--report writes valid SARIF v2.1.0 JSON", async () => {
const file = join(testDir, "ci.yml");
const out = join(testDir, "out.yml");
const report = join(testDir, "r.sarif");
writeFileSync(file, "jobs:\n x:\n runs-on: ubuntu-latest\n");
await migrateCommand({
sourceFile: file, from: "github", to: "stub", output: out, reportFile: report,
emit: "yaml", strict: false, validate: false, useComposites: false,
plugins: [stubPlugin({
result: {
output: "out",
provenance: [],
diagnostics: [{ severity: "warning", ruleId: "TEST-W", message: "warn", file: "", line: 2, column: 1 }],
},
})],
});
expect(existsSync(report)).toBe(true);
const sarif = JSON.parse(readFileSync(report, "utf-8"));
expect(sarif.version).toBe("2.1.0");
expect(sarif.runs?.[0]?.results?.length).toBe(1);
expect(sarif.runs[0].results[0].ruleId).toBe("TEST-W");
});
test("--strict off keeps exit 0 even with error diagnostics", async () => {
const file = join(testDir, "ci.yml");
writeFileSync(file, "jobs:\n x:\n runs-on: ubuntu-latest\n");
const r = await migrateCommand({
sourceFile: file, from: "github", to: "stub", output: join(testDir, "out.yml"),
emit: "yaml", strict: false, validate: false, useComposites: false,
plugins: [stubPlugin({
result: {
output: "out",
provenance: [],
diagnostics: [{ severity: "error", ruleId: "TEST-001", message: "bad", file: "", line: 1, column: 1 }],
},
})],
});
expect(r.exitCode).toBe(0);
});
});
describe("tryValidateExternal", () => {
test("returns ran=false when neither glci nor glab is on PATH", async () => {
const { tryValidateExternal } = await import("./migrate");
const r = tryValidateExternal("stages:\n - build\n");
if (!r.ran) {
expect(r.ok).toBe(false);
expect(r.backend).toBeUndefined();
} else {
expect(["glci", "glab"]).toContain(r.backend);
}
});
});