mirror of
https://github.com/morluto/rea
synced 2026-10-26 06:16:17 -04:00
1b3d57ca8a
* docs(ci): add a repeatable static analysis example * fix(ci): isolate example reports and repair the guide link --------- Co-authored-by: gongfpp <22683417+gongfpp@users.noreply.github.com>
243 lines
8.5 KiB
TypeScript
243 lines
8.5 KiB
TypeScript
import {
|
|
appendFile,
|
|
mkdir,
|
|
readFile,
|
|
readdir,
|
|
symlink,
|
|
writeFile,
|
|
} from "node:fs/promises";
|
|
import { delimiter, dirname, join, resolve } from "node:path";
|
|
|
|
import { describe, expect, it } from "vitest";
|
|
import { parse } from "yaml";
|
|
import { z } from "zod";
|
|
|
|
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
|
import { cliTest } from "../../support/cli/cliFixture.js";
|
|
|
|
const example = async () =>
|
|
z
|
|
.object({
|
|
on: z.record(z.string(), z.null()),
|
|
permissions: z.record(z.string(), z.string()),
|
|
jobs: z.object({
|
|
analyze: z.object({
|
|
"runs-on": z.string(),
|
|
env: z.object({ ARTIFACT_PATH: z.string() }),
|
|
steps: z.array(
|
|
z.object({
|
|
id: z.string().optional(),
|
|
name: z.string().optional(),
|
|
run: z.string().optional(),
|
|
uses: z.string().optional(),
|
|
if: z.string().optional(),
|
|
env: z.record(z.string(), z.string()).optional(),
|
|
"continue-on-error": z.boolean().optional(),
|
|
with: z.record(z.string(), z.unknown()).optional(),
|
|
}),
|
|
),
|
|
}),
|
|
}),
|
|
})
|
|
.parse(
|
|
parse(
|
|
await readFile(
|
|
new URL(
|
|
"../../../examples/github-actions/rea-analysis.yml",
|
|
import.meta.url,
|
|
),
|
|
"utf8",
|
|
),
|
|
),
|
|
);
|
|
|
|
it("keeps the example manually triggered, least-privileged, pinned and failure-preserving", async () => {
|
|
const workflow = await example();
|
|
expect(workflow.on).toEqual({ workflow_dispatch: null });
|
|
expect(workflow.permissions).toEqual({ contents: "read" });
|
|
const job = workflow.jobs.analyze;
|
|
expect(job["runs-on"]).toBe("ubuntu-24.04");
|
|
expect(
|
|
job.steps.find((step) => step.uses?.startsWith("actions/setup-node@"))
|
|
?.with,
|
|
).toMatchObject({
|
|
"node-version": "24.22.1",
|
|
});
|
|
for (const step of job.steps) {
|
|
expect(step["continue-on-error"]).toBeUndefined();
|
|
if (step.uses !== undefined) expect(step.uses).toMatch(/@[a-f0-9]{40}$/u);
|
|
}
|
|
expect(
|
|
job.steps.find((step) => step.name === "Install pinned REA")?.run,
|
|
).toContain("--save-exact rea-agents@6.7.0");
|
|
const upload = job.steps.find((step) =>
|
|
step.uses?.startsWith("actions/upload-artifact@"),
|
|
);
|
|
expect(job.steps[0]?.id).toBe("reports");
|
|
expect(job.steps.find((step) => step.id === "analyze")?.env).toEqual({
|
|
REPORT_DIR: "${{ steps.reports.outputs.directory }}",
|
|
});
|
|
expect(upload?.if).toBe(
|
|
"${{ always() && steps.reports.outputs.directory != '' }}",
|
|
);
|
|
expect(upload?.with).toMatchObject({
|
|
path: "${{ steps.reports.outputs.directory }}",
|
|
"if-no-files-found": "warn",
|
|
"retention-days": 7,
|
|
});
|
|
});
|
|
|
|
it("links to the workflow asset outside the documentation site's base", async () => {
|
|
const guide = await readFile(
|
|
new URL("../../../docs/ci-analysis.md", import.meta.url),
|
|
"utf8",
|
|
);
|
|
expect(guide).toContain(
|
|
"[minimal workflow](https://github.com/morluto/rea/blob/main/examples/github-actions/rea-analysis.yml)",
|
|
);
|
|
});
|
|
|
|
// This example explicitly targets an Ubuntu/Bash runner, not Windows shells.
|
|
describe.skipIf(process.platform === "win32")(
|
|
"GitHub Actions example with the compiled CLI",
|
|
() => {
|
|
for (const kind of [
|
|
"complete",
|
|
"partial",
|
|
"missing",
|
|
"unsupported",
|
|
"pre-analysis-failure",
|
|
] as const) {
|
|
cliTest(
|
|
`retains the real ${kind} result and exit status`,
|
|
async ({ processes, cliCompileCache }) => {
|
|
const job = (await example()).jobs.analyze;
|
|
const build = job.steps.find((step) => step.id === "build")?.run;
|
|
const analyze = job.steps.find((step) => step.id === "analyze")?.run;
|
|
if (build === undefined || analyze === undefined)
|
|
throw new Error("Example omitted build or analysis");
|
|
const root = await createTestTempDirectory("rea-ci-example-");
|
|
const workspace = join(root, "checkout");
|
|
const runner = join(root, "runner");
|
|
const home = join(root, "home");
|
|
const bin = join(root, "bin");
|
|
const outputs = join(root, "step-outputs");
|
|
for (const directory of [workspace, runner, home, bin])
|
|
await mkdir(directory);
|
|
await writeFile(outputs, "");
|
|
// Use the real executable, not a stub that invents a successful report.
|
|
await symlink(resolve("scripts/rea.mjs"), join(bin, "rea"));
|
|
const env = {
|
|
PATH: [bin, dirname(process.execPath), "/usr/bin", "/bin"].join(
|
|
delimiter,
|
|
),
|
|
HOME: home,
|
|
TMPDIR: root,
|
|
NODE_COMPILE_CACHE: cliCompileCache,
|
|
GITHUB_WORKSPACE: workspace,
|
|
GITHUB_OUTPUT: outputs,
|
|
RUNNER_TEMP: runner,
|
|
REPORT_DIR: "",
|
|
ARTIFACT_PATH: job.env.ARTIFACT_PATH,
|
|
};
|
|
const run = (script: string) =>
|
|
processes.run(
|
|
"bash",
|
|
["--noprofile", "--norc", "-e", "-o", "pipefail", "-c", script],
|
|
{
|
|
cwd: workspace,
|
|
env,
|
|
timeoutMs: 30_000,
|
|
},
|
|
);
|
|
const checkoutReports = join(workspace, "reports", "rea");
|
|
await mkdir(checkoutReports, { recursive: true });
|
|
await writeFile(
|
|
join(checkoutReports, "analysis.json"),
|
|
'{"ok":true,"stale":true}\n',
|
|
);
|
|
await writeFile(
|
|
join(checkoutReports, "unrelated.txt"),
|
|
"Not a run report.\n",
|
|
);
|
|
let reports = checkoutReports;
|
|
const prepare = job.steps.find((step) => step.id === "reports")?.run;
|
|
if (prepare !== undefined) {
|
|
const prepared = await run(prepare);
|
|
expect(prepared.exitCode, prepared.stdout + prepared.stderr).toBe(
|
|
0,
|
|
);
|
|
const output = (await readFile(outputs, "utf8"))
|
|
.split("\n")
|
|
.find((line) => line.startsWith("directory="));
|
|
if (output === undefined)
|
|
throw new Error("Report step omitted its directory output");
|
|
reports = output.slice("directory=".length);
|
|
env.REPORT_DIR = reports;
|
|
}
|
|
if (kind === "pre-analysis-failure") {
|
|
const failed = await run("exit 23");
|
|
expect(failed.exitCode).toBe(23);
|
|
expect(await readdir(reports)).toEqual([]);
|
|
expect(
|
|
await readFile(join(checkoutReports, "analysis.json"), "utf8"),
|
|
).toContain('"stale":true');
|
|
return;
|
|
}
|
|
const built = await run(build);
|
|
expect(built.exitCode, built.stdout + built.stderr).toBe(0);
|
|
if (kind === "partial")
|
|
await appendFile(
|
|
join(workspace, env.ARTIFACT_PATH, "index.js"),
|
|
"\nexport const broken = ;\n",
|
|
);
|
|
if (kind === "missing") env.ARTIFACT_PATH = "build/missing-artifact";
|
|
if (kind === "unsupported")
|
|
env.ARTIFACT_PATH = "build/rea-ci-demo/index.js";
|
|
const result = await run(analyze);
|
|
expect(result.signal).toBeNull();
|
|
const raw = await readFile(join(reports, "analysis.json"), "utf8");
|
|
expect(await readdir(reports)).toEqual([
|
|
"analysis.json",
|
|
"analysis.stderr",
|
|
"exit-code.txt",
|
|
]);
|
|
const diagnostics = await readFile(
|
|
join(reports, "analysis.stderr"),
|
|
"utf8",
|
|
);
|
|
expect(await readFile(join(reports, "exit-code.txt"), "utf8")).toBe(
|
|
`${result.exitCode}\n`,
|
|
);
|
|
expect(diagnostics).not.toContain(
|
|
"This artifact must not be executed.",
|
|
);
|
|
const envelope: unknown = JSON.parse(raw);
|
|
if (kind === "complete" || kind === "partial") {
|
|
expect(result.exitCode).toBe(0);
|
|
expect(envelope).toMatchObject({
|
|
ok: true,
|
|
data: {
|
|
normalized_result: {
|
|
statistics: { parse_failures: kind === "partial" ? 1 : 0 },
|
|
},
|
|
},
|
|
});
|
|
if (kind === "partial")
|
|
expect(raw).toContain("Graph coverage is incomplete");
|
|
} else {
|
|
expect(result.exitCode).toBe(1);
|
|
expect(envelope).toMatchObject({
|
|
ok: false,
|
|
error: {
|
|
category:
|
|
kind === "missing" ? "invalid_input" : "unsupported_target",
|
|
},
|
|
});
|
|
}
|
|
},
|
|
);
|
|
}
|
|
},
|
|
);
|