import { describe, test, expect } from "vitest"; import { Workflow, Job, Step, Checkout, SetupNode, PrPlanReport } from "@intentius/chant-lexicon-github"; import type { Declarable } from "@intentius/chant/declarable"; import type { SerializerResult } from "@intentius/chant/serializer"; import { forgejoSerializer } from "./serializer"; import { DEFAULT_ACTIONS_ROOT } from "./actions"; function asResult(out: string | SerializerResult): SerializerResult { return typeof out === "string" ? { primary: out } : out; } describe("forgejoSerializer identity", () => { test("claims the github partition with a distinct rule prefix", () => { // name is "github" on purpose — it serializes github-lexicon entities. expect(forgejoSerializer.name).toBe("github"); expect(forgejoSerializer.rulePrefix).toBe("WFJ"); }); }); describe("forgejoSerializer — github-style source roundtrip", () => { function buildSource(): Map { const workflow = new Workflow({ name: "CI", on: { push: { branches: ["main"] } }, permissions: { contents: "read" }, }) as unknown as Declarable; const build = new Job({ "runs-on": "ubuntu-latest", "continue-on-error": true, steps: [ new Step({ name: "Build", run: "npm run build", "continue-on-error": true }), new Step({ name: "Test", run: "npm test" }), ], }) as unknown as Declarable; return new Map([ ["workflow", workflow], ["build", build], ]); } test("emits on/jobs/steps from reused github entities", () => { const { primary } = asResult(forgejoSerializer.serialize(buildSource())); expect(primary).toContain("on:"); expect(primary).toContain("jobs:"); expect(primary).toContain("push:"); expect(primary).toContain("Build"); expect(primary).toContain("npm run build"); }); test("drops permissions and continue-on-error, warning on each", () => { const result = asResult(forgejoSerializer.serialize(buildSource())); expect(result.primary).not.toContain("permissions"); expect(result.primary).not.toContain("continue-on-error"); // workflow permissions + job continue-on-error + step continue-on-error expect((result.warnings ?? []).filter((w) => w.includes("permissions"))).toHaveLength(1); expect((result.warnings ?? []).filter((w) => w.includes("continue-on-error"))).toHaveLength(2); }); test("maps ubuntu-latest to the default Forgejo runner label", () => { const { primary } = asResult(forgejoSerializer.serialize(buildSource())); expect(primary).toContain("runs-on: docker"); expect(primary).not.toContain("ubuntu-latest"); }); test("a chant.config forgejo.runnerLabels override changes the label", () => { const out = forgejoSerializer.serialize(buildSource(), undefined, { config: { forgejo: { runnerLabels: { "ubuntu-latest": "big-runner" } } }, }); expect(asResult(out).primary).toContain("runs-on: big-runner"); }); test("an unmapped label passes through (WFJ011 reports it post-synth, not the serializer)", () => { const job = new Job({ "runs-on": "windows-latest", steps: [new Step({ run: "echo hi" })] }) as unknown as Declarable; const out = forgejoSerializer.serialize(new Map([["build", job]])); const result = asResult(out); expect(result.primary).toContain("runs-on: windows-latest"); expect((result.warnings ?? []).filter((w) => w.includes("windows-latest"))).toHaveLength(0); }); }); describe("forgejoSerializer — uses: action resolution", () => { function withSteps(...steps: unknown[]): Map { const job = new Job({ "runs-on": "ubuntu-latest", steps }) as unknown as Declarable; return new Map([["build", job]]); } test("rewrites composite-emitted action refs under the actions root", () => { const { primary } = asResult( forgejoSerializer.serialize(withSteps(Checkout({}).step, SetupNode({ nodeVersion: "22" }).step)), ); expect(primary).toContain(`uses: ${DEFAULT_ACTIONS_ROOT}/actions/checkout@v4`); expect(primary).toContain(`uses: ${DEFAULT_ACTIONS_ROOT}/actions/setup-node@v4`); }); test("forgejo.actionsRoot override changes the rewritten base", () => { const out = forgejoSerializer.serialize(withSteps(Checkout({}).step), undefined, { config: { forgejo: { actionsRoot: "https://codeberg.org" } }, }); expect(asResult(out).primary).toContain("uses: https://codeberg.org/actions/checkout@v4"); }); test("an unmapped action ref is passed through (WFJ010 reports it post-synth)", () => { const step = new Step({ name: "Custom", uses: "some-org/custom-action@v1" }); const result = asResult(forgejoSerializer.serialize(withSteps(step))); expect(result.primary).toContain("uses: some-org/custom-action@v1"); expect((result.warnings ?? []).filter((w) => w.includes("some-org/custom-action@v1"))).toHaveLength(0); }); }); describe("forgejoSerializer — multi-workflow output", () => { test("additional workflows become separate files", () => { const a = new Workflow({ name: "A", on: { push: {} } }) as unknown as Declarable; const b = new Workflow({ name: "B", on: { push: {} } }) as unknown as Declarable; const out = forgejoSerializer.serialize( new Map([ ["ci", a], ["release", b], ]), ); const result = asResult(out); expect(result.primary).toContain("name: A"); expect(Object.keys(result.files ?? {})).toContain("release.yml"); }); }); // #1983 — the github lexicon's `PrPlanReport` composite reaches forgejo purely // through the re-export (`src/index.ts`'s `export * from // "@intentius/chant-lexicon-github"`); nothing forgejo-specific is written for // it. This is the functional check that inheritance actually holds, not just // that the type is importable: the dialect still drops the job's // `permissions` and remaps its runner label, and the sticky-comment step — a // plain `gh api` script with no `uses:` at all — passes through the dialect // transform untouched, because the dialect only ever rewrites job-level // `permissions:`/`environment:`/action refs and never looks at step content. // // That passing-through is a fact about serialization, not about whether the // script would work if run: chant #2291 settled, against a real Forgejo // 12.0.4+gitea-1.22.0 instance, that Forgejo's `/api/v1` does accept the same // GET/POST/PATCH calls this script makes — but only when they target a full // URL. Until chant #2305, this script's `gh api "repos/$REPO/issues/…"` took // a bare relative path, which `gh` resolves against `/api/v3` on any host but // github.com, and Forgejo does not serve `/api/v3`. `reconcilePr`'s // `postOrUpdateComment` got the URL fix #2291 made // (`packages/core/src/op/activities/reconcile.ts`), and `PrPlanReport`'s own // script (`lexicons/github/src/composites/pr-plan-report.ts`) — out of // #2291's scope, being the component composite rather than the Op generator — // got the equivalent fix in #2305: every call now targets `$api_base`, built // from `$GITHUB_API_URL` inline in the shell script. The dialect still leaves // step content untouched, so that base-URL construction crosses over exactly // as written, which is what makes the untouched script below a working // Forgejo comment rather than just a passed-through one. describe("forgejoSerializer — inherits PrPlanReport from github (#1983)", () => { test("dialect still applies to a github-lexicon composite's job", () => { const workflow = new Workflow({ name: "pr-plan", on: { pull_request: { types: ["opened", "synchronize"] } }, }) as unknown as Declarable; const { job } = PrPlanReport({ environment: "prod" }); const out = forgejoSerializer.serialize( new Map([["workflow", workflow], ["plan", job as unknown as Declarable]]), ); const result = asResult(out); expect(result.primary).toContain("runs-on: docker"); expect(result.primary).not.toContain("ubuntu-latest"); // The job carries `permissions` — dropped by the dialect, warned once. expect(result.primary).not.toContain("permissions:"); expect((result.warnings ?? []).filter((w) => w.includes("permissions"))).toHaveLength(1); // The sticky-comment mechanism is a raw script, nothing to remap or drop. expect(result.primary).toContain("Post or update PR comment"); expect(result.primary).toContain("gh api"); // Including the `-F` that reads the body from plan.md — forgejo renders // the same script, so the #2236 regression would show up here as well. expect(result.primary).toContain("-F body=@plan.md"); // And the #2305 fix: the resolved-base construction and every `gh api` // call built from it cross the dialect untouched too. expect(result.primary).toContain('api_base="${GITHUB_API_URL%/}"'); expect(result.primary).not.toMatch(/gh api[^\n]*"repos\//); expect(result.primary).not.toContain("-f body=@"); }); });