mirror of
https://github.com/morluto/rea.git
synced 2026-10-30 19:35:56 +00:00
251 lines
9.3 KiB
TypeScript
251 lines
9.3 KiB
TypeScript
import { mkdir, readdir, readFile, stat, writeFile } from "node:fs/promises";
|
|
import { join, sep } from "node:path";
|
|
|
|
import { describe, expect, it } from "vitest";
|
|
|
|
import {
|
|
environmentServeFixture,
|
|
environmentServeSource,
|
|
writeEnvironmentServeFiles,
|
|
} from "../../../tests/fixtures/environmentServe.js";
|
|
import { createTestTempDirectory } from "../../../tests/fixtures/temporaryDirectory.js";
|
|
import { projectAnalysisError } from "../../domain/analysisErrorProjection.js";
|
|
import { PACKAGE_METADATA } from "../../generatedPackageMetadata.js";
|
|
import {
|
|
exportEnvironmentBundle,
|
|
readEnvironmentExportInputs,
|
|
} from "./environmentExport.js";
|
|
|
|
const listFiles = async (root: string): Promise<string[]> =>
|
|
(await readdir(root, { recursive: true, withFileTypes: true }))
|
|
.filter((entry) => entry.isFile())
|
|
.map((entry) =>
|
|
join(entry.parentPath, entry.name)
|
|
.slice(root.length + 1)
|
|
.split(sep)
|
|
.join("/"),
|
|
)
|
|
.sort();
|
|
|
|
const prepare = async (prefix: string) => {
|
|
const root = await createTestTempDirectory(prefix);
|
|
const fixture = environmentServeFixture();
|
|
const files = await writeEnvironmentServeFiles(root, fixture);
|
|
const inputs = await readEnvironmentExportInputs(
|
|
files.manifestPath,
|
|
files.capturePath,
|
|
);
|
|
if (!inputs.ok) throw inputs.error;
|
|
return {
|
|
root,
|
|
files,
|
|
input: { ...inputs.value, source: environmentServeSource(fixture) },
|
|
directory: join(root, "bundle"),
|
|
};
|
|
};
|
|
|
|
describe("readEnvironmentExportInputs", () => {
|
|
it("reads each file once as the bytes on disk and the value they parse to", async () => {
|
|
const root = await createTestTempDirectory("rea-environment-inputs-");
|
|
const fixture = environmentServeFixture();
|
|
const files = await writeEnvironmentServeFiles(root, fixture);
|
|
const inputs = await readEnvironmentExportInputs(
|
|
files.manifestPath,
|
|
files.capturePath,
|
|
);
|
|
if (!inputs.ok) throw inputs.error;
|
|
expect(inputs.value.manifest.path).toBe(files.manifestPath);
|
|
expect(inputs.value.manifest.bytes).toEqual(
|
|
await readFile(files.manifestPath),
|
|
);
|
|
expect(inputs.value.manifest.value).toEqual(fixture.manifest);
|
|
expect(inputs.value.evidence.bytes).toEqual(
|
|
await readFile(files.capturePath),
|
|
);
|
|
expect(inputs.value.evidence.value).toEqual(fixture.capture);
|
|
});
|
|
|
|
it("refuses inline JSON, a directory and a file that is not JSON by role", async () => {
|
|
const root = await createTestTempDirectory("rea-environment-inputs-bad-");
|
|
const files = await writeEnvironmentServeFiles(root);
|
|
const inline = await readEnvironmentExportInputs(
|
|
await readFile(files.manifestPath, "utf8"),
|
|
files.capturePath,
|
|
);
|
|
if (inline.ok) throw new Error("Expected the inline manifest refusal");
|
|
expect(projectAnalysisError(inline.error)).toMatchObject({
|
|
code: "invalid_request",
|
|
details: {
|
|
operation: "environment-export",
|
|
issues: [{ path: ["manifest"], reason: "invalid_value" }],
|
|
},
|
|
});
|
|
const directory = await readEnvironmentExportInputs(
|
|
files.manifestPath,
|
|
root,
|
|
);
|
|
if (directory.ok) throw new Error("Expected the directory refusal");
|
|
expect(projectAnalysisError(directory.error).details?.issues).toEqual([
|
|
expect.objectContaining({ path: ["evidence"], reason: "invalid_value" }),
|
|
]);
|
|
const broken = join(root, "broken.json");
|
|
await writeFile(broken, '{"evidence_id": ');
|
|
const notJson = await readEnvironmentExportInputs(
|
|
files.manifestPath,
|
|
broken,
|
|
);
|
|
if (notJson.ok) throw new Error("Expected the malformed JSON refusal");
|
|
expect(projectAnalysisError(notJson.error)).toMatchObject({
|
|
code: "invalid_request",
|
|
details: {
|
|
issues: [
|
|
{ path: ["evidence"], reason: "invalid_format", expected: "JSON" },
|
|
],
|
|
},
|
|
});
|
|
expect(projectAnalysisError(notJson.error).message).toContain(broken);
|
|
});
|
|
});
|
|
|
|
describe("exportEnvironmentBundle", () => {
|
|
it("writes the bundle into an absent directory with the read bytes copied unchanged", async () => {
|
|
const { root, files, input, directory } = await prepare(
|
|
"rea-environment-export-",
|
|
);
|
|
const exported = await exportEnvironmentBundle({ ...input, directory });
|
|
if (!exported.ok) throw exported.error;
|
|
expect(exported.value).toMatchObject({
|
|
status: "exported",
|
|
directory,
|
|
name: "grafana-example",
|
|
// The bundle serves the state the export derived: the manifest's read tools.
|
|
tool_count: 14,
|
|
evidence_id: input.source.evidenceId,
|
|
manifest_evidence_id: input.source.manifestEvidenceId,
|
|
state: { served: true, evidence_id: expect.stringMatching(/^ev_/u) },
|
|
package_source: {
|
|
kind: "registry",
|
|
specifier: `rea-agents@${PACKAGE_METADATA.version}`,
|
|
},
|
|
});
|
|
const written = exported.value.files.map((file) => file.path).sort();
|
|
expect(written).toEqual(await listFiles(directory));
|
|
expect(written).toEqual([
|
|
"README.md",
|
|
"artifacts/check_grafana-example/verify.py",
|
|
"envs/grafana-example/Dockerfile",
|
|
"envs/grafana-example/env.toml",
|
|
"envs/grafana-example/evidence.json",
|
|
"envs/grafana-example/manifest.json",
|
|
"tasks/smoke.json",
|
|
]);
|
|
for (const file of exported.value.files)
|
|
expect(file.bytes).toBe(
|
|
(await readFile(join(directory, file.path))).length,
|
|
);
|
|
expect(
|
|
await readFile(join(directory, "envs/grafana-example/manifest.json")),
|
|
).toEqual(await readFile(files.manifestPath));
|
|
expect(
|
|
await readFile(join(directory, "envs/grafana-example/evidence.json")),
|
|
).toEqual(input.evidence.bytes);
|
|
expect(await listFiles(root)).toEqual(
|
|
expect.arrayContaining(["capture.json", "manifest.json"]),
|
|
);
|
|
});
|
|
|
|
it("copies the package tarball beside the Dockerfile and reports it as the package source", async () => {
|
|
const { root, input, directory } = await prepare(
|
|
"rea-environment-export-tarball-",
|
|
);
|
|
const tarball = join(root, "rea-agents-0.0.0.tgz");
|
|
await writeFile(tarball, Buffer.from([0x1f, 0x8b, 8, 0, 1, 2, 3]));
|
|
const exported = await exportEnvironmentBundle({
|
|
...input,
|
|
directory,
|
|
packageTarball: tarball,
|
|
name: "replay-copy",
|
|
});
|
|
if (!exported.ok) throw exported.error;
|
|
expect(exported.value.package_source).toEqual({
|
|
kind: "tarball",
|
|
path: tarball,
|
|
});
|
|
expect(exported.value.files).toContainEqual({
|
|
path: "envs/replay-copy/rea-agents.tgz",
|
|
bytes: 7,
|
|
});
|
|
expect(
|
|
await readFile(join(directory, "envs/replay-copy/rea-agents.tgz")),
|
|
).toEqual(await readFile(tarball));
|
|
expect(
|
|
await readFile(join(directory, "envs/replay-copy/Dockerfile"), "utf8"),
|
|
).toContain("COPY rea-agents.tgz /tmp/");
|
|
});
|
|
|
|
it("refuses a non-empty directory, a file path and a missing tarball before writing anything", async () => {
|
|
const { root, input, directory } = await prepare(
|
|
"rea-environment-export-refusals-",
|
|
);
|
|
await mkdir(directory);
|
|
await writeFile(join(directory, "keep.txt"), "existing");
|
|
const nonEmpty = await exportEnvironmentBundle({ ...input, directory });
|
|
if (nonEmpty.ok)
|
|
throw new Error("Expected the non-empty directory refusal");
|
|
expect(projectAnalysisError(nonEmpty.error)).toMatchObject({
|
|
code: "invalid_request",
|
|
details: {
|
|
operation: "environment-export",
|
|
issues: [{ path: ["directory"], reason: "invalid_value" }],
|
|
},
|
|
});
|
|
expect(projectAnalysisError(nonEmpty.error).message).toContain("not empty");
|
|
expect(await listFiles(directory)).toEqual(["keep.txt"]);
|
|
const asFile = await exportEnvironmentBundle({
|
|
...input,
|
|
directory: join(directory, "keep.txt"),
|
|
});
|
|
if (asFile.ok) throw new Error("Expected the file path refusal");
|
|
expect(projectAnalysisError(asFile.error).details?.issues).toEqual([
|
|
expect.objectContaining({ path: ["directory"] }),
|
|
]);
|
|
const missingTarball = await exportEnvironmentBundle({
|
|
...input,
|
|
directory: join(root, "fresh"),
|
|
packageTarball: join(root, "absent.tgz"),
|
|
});
|
|
if (missingTarball.ok) throw new Error("Expected the tarball refusal");
|
|
expect(projectAnalysisError(missingTarball.error).details?.issues).toEqual([
|
|
expect.objectContaining({ path: ["package_tarball"] }),
|
|
]);
|
|
expect(await listFiles(root)).not.toContain("fresh/README.md");
|
|
});
|
|
|
|
it("accepts an existing empty directory", async () => {
|
|
const { input, directory } = await prepare("rea-environment-export-empty-");
|
|
await mkdir(directory);
|
|
const exported = await exportEnvironmentBundle({ ...input, directory });
|
|
expect(exported.ok).toBe(true);
|
|
expect(await listFiles(directory)).toHaveLength(7);
|
|
});
|
|
});
|
|
|
|
describe("exportEnvironmentBundle file modes", () => {
|
|
it.skipIf(process.platform === "win32")(
|
|
"writes the captured manifest and Evidence copies readable by their owner only",
|
|
async () => {
|
|
const { input, directory } = await prepare(
|
|
"rea-environment-export-mode-",
|
|
);
|
|
const exported = await exportEnvironmentBundle({ ...input, directory });
|
|
if (!exported.ok) throw exported.error;
|
|
for (const copy of ["manifest.json", "evidence.json"]) {
|
|
const { mode } = await stat(
|
|
join(directory, "envs/grafana-example", copy),
|
|
);
|
|
expect(mode & 0o777).toBe(0o600);
|
|
}
|
|
},
|
|
);
|
|
});
|