Files
engineering/resources/ci/CiTests.java

359 lines
16 KiB
Java

import java.nio.file.*;
import java.time.Duration;
import java.util.*;
/** Dependency-free behavior tests: java ci/Ci.java test. */
class CiTests {
static final String SHA = "a".repeat(40);
static int count;
interface Test {
void run() throws Exception;
}
static void test(String name, Test test) throws Exception {
test.run();
count++;
System.out.println("PASS " + name);
}
static void equal(Object expected, Object actual) {
if (!Objects.equals(expected, actual)) throw new AssertionError("Expected " + expected + ", got " + actual);
}
static void failure(Test action) throws Exception {
try {
action.run();
} catch (Exception expected) {
return;
}
throw new AssertionError("Expected failure");
}
static void run() throws Exception {
count = 0;
test("recipe supports backend-only and frontend-only projects", () -> {
equal(List.of("backend"), Recipe.roles(Map.of("BACKEND_MODULE", "server")));
equal(List.of("frontend"), Recipe.roles(Map.of("FRONTEND_MODULE", "web")));
failure(() -> Recipe.roles(Map.of()));
failure(() -> Recipe.project(Map.of("PROJECT_NAME", "../unsafe")));
});
test("project configuration is overridden by environment and rejects escaping modules", () -> {
final Path root = Files.createTempDirectory("recipe-config-");
try {
Files.writeString(
root.resolve("engineering.properties"), "PROJECT_NAME=sample\nBACKEND_MODULE=server\n");
equal(
"override",
Recipe.environment(root, Map.of("PROJECT_NAME", "override"))
.get("PROJECT_NAME"));
failure(() -> Recipe.environment(root, Map.of("BACKEND_MODULE", "../outside")));
} finally {
Pipeline.deleteTree(root);
}
});
test("JSON preserves configuration values and escaped text", () -> {
String json = "{\"text\":\"line\\nquote\\\"\\u20ac\",\"values\":[null,true,false,1,-2.5e2],\"nested\":{}}";
Object data = Json.parse(json);
equal(data, Json.parse(Json.write(data)));
equal("line\nquote\"€", Json.object(data).get("text"));
});
test("JSON rejects malformed/ambiguous input", () -> {
for (String value : List.of(
"{\"a\":null,\"a\":1}", "{} []", "[1,]", "01", "\"\\uZZZZ\"", "\"line\n\"", "truefalse", "+1"))
failure(() -> Json.parse(value));
});
test("Docker configuration merge is idempotent and preserves backup", () -> {
Path dir = Files.createTempDirectory("ci-docker-test-");
try {
Path file = dir.resolve("daemon.json");
String original = "{\"features\":{\"buildkit\":true},\"insecure-registries\":[\"existing:5000\"]}";
Files.writeString(file, original);
Setup.configureDocker(file, "registry.example:3000");
String first = Files.readString(file);
Setup.configureDocker(file, "registry.example:3000");
equal(first, Files.readString(file));
equal(
List.of("existing:5000", "registry.example:3000"),
Json.object(Json.parse(first)).get("insecure-registries"));
equal(original, Files.readString(dir.resolve("daemon.json.before-engineering")));
} finally {
Pipeline.deleteTree(dir);
}
});
test("Invalid Docker configuration is unchanged", () -> {
Path dir = Files.createTempDirectory("ci-docker-test-");
try {
Path file = dir.resolve("daemon.json");
String original = "{\"insecure-registries\":\"invalid\"}";
Files.writeString(file, original);
failure(() -> Setup.configureDocker(file, "registry.example:3000"));
equal(original, Files.readString(file));
} finally {
Pipeline.deleteTree(dir);
}
});
test("Feature branches never contact registry", () -> {
try (Fixture f = new Fixture()) {
f.pipeline.env.put("CI_REF", "refs/heads/feature/test");
f.pipeline.publish();
equal(0L, f.calls("login"));
equal(0L, f.calls("push"));
}
});
test("Unverified commit cannot publish", () -> {
try (Fixture f = new Fixture()) {
Files.delete(f.root.resolve("ci/artifacts/verified-commit"));
failure(f.pipeline::publish);
equal(0L, f.calls("push"));
}
});
test("Publication pullback and rerun preserve commit tags", () -> {
try (Fixture f = new Fixture()) {
f.pipeline.publish();
equal(4L, f.calls("push"));
f.pipeline.publish();
equal(6L, f.calls("push"));
equal(8L, f.calls("pull"));
}
});
test("Partial publication is completed without overwriting backend", () -> {
try (Fixture f = new Fixture()) {
f.fake.pushFailure = "frontend:" + SHA;
failure(f.pipeline::publish);
equal(
0L,
f.fake.calls.stream()
.filter(a -> a.contains("push") && a.getLast().endsWith(":main"))
.count());
f.fake.pushFailure = "";
f.pipeline.publish();
equal(
1L,
f.fake.calls.stream()
.filter(a -> a.contains("push") && a.getLast().endsWith("backend:" + SHA))
.count());
}
});
test("Authentication error is not a missing tag", () -> {
try (Fixture f = new Fixture()) {
f.fake.manifestError = true;
failure(f.pipeline::publish);
equal(0L, f.calls("push"));
}
});
test("Pull failure blocks moving tags", () -> {
try (Fixture f = new Fixture()) {
f.fake.pullFailure = "backend:" + SHA;
failure(f.pipeline::publish);
equal(
0L,
f.fake.calls.stream()
.filter(a -> a.contains("push") && a.getLast().endsWith(":main"))
.count());
}
});
test("Older commit cannot move main", () -> {
try (Fixture f = new Fixture()) {
f.fake.head = "c".repeat(40);
f.pipeline.publish();
equal(2L, f.calls("push"));
}
});
test("Unavailable Docker fails before build with a concrete cause", () -> {
try (Fixture f = new Fixture()) {
f.fake.dockerAvailable = false;
try {
f.pipeline.verify();
throw new AssertionError("Expected failure");
} catch (IllegalArgumentException error) {
equal(true, error.getMessage().contains("Docker is unavailable"));
}
equal(
0L,
f.fake.calls.stream()
.filter(a -> a.getFirst().equals("mvn"))
.count());
}
});
test("Architecture mismatch blocks publication", () -> {
try (Fixture f = new Fixture()) {
f.pipeline.env.put("TARGET_PLATFORM", "linux/arm64");
failure(f.pipeline::publish);
equal(0L, f.calls("push"));
}
});
test("Failed verification removes success marker and preserves exit status", () -> {
try (Fixture f = new Fixture()) {
f.fake.mavenFailure = true;
try {
f.pipeline.verify();
throw new AssertionError("Expected failure");
} catch (Commands.Failure error) {
equal(42, error.exitCode);
}
equal(
List.of("mvn", "-B", "-ntp", "-Dformat.mode=check", "clean", "verify"),
f.fake.calls.stream()
.filter(a -> a.getFirst().equals("mvn"))
.findFirst()
.orElseThrow());
equal(false, Files.exists(f.root.resolve("ci/artifacts/verified-commit")));
equal(0L, f.calls("build"));
equal(0L, f.calls("push"));
}
});
test("Frontend lint runs after install and blocks tests and build on failure", () -> {
try (Fixture f = new Fixture()) {
f.fake.lintFailure = true;
try {
f.pipeline.verifyFrontend();
throw new AssertionError("Expected failure");
} catch (Commands.Failure error) {
equal(1, error.exitCode);
}
equal(List.of(List.of("npm", "ci"), List.of("npm", "run", "lint")), f.fake.calls);
}
});
test("Frontend verification checks before testing and building", () -> {
try (Fixture f = new Fixture()) {
f.pipeline.verifyFrontend();
equal(
List.of(
"ci",
"run lint",
"test -- --reporters=default --reporters=junit --output-file=unit-results/junit.xml",
"run build"),
f.fake.calls.stream()
.map(a -> String.join(" ", a.subList(1, a.size())))
.toList());
}
});
test("Process execution enforces its deadline", () -> {
Path dir = Files.createTempDirectory("ci-process-test-");
try {
Path file = dir.resolve("Sleep.java");
Files.writeString(
file,
"class Sleep { public static void main(String[] args) throws Exception { Thread.sleep(10000);"
+ " } }");
Commands runner = new Commands(Duration.ofMillis(100));
try {
runner.execute(
List.of(System.getProperty("java.home") + "/bin/java", file.toString()),
dir,
System.getenv(),
null,
false);
throw new AssertionError("Expected timeout");
} catch (Commands.Failure error) {
equal(124, error.exitCode);
}
} finally {
Pipeline.deleteTree(dir);
}
});
System.out.println(count + " CI tests passed");
}
static class Fixture implements AutoCloseable {
final Path root = Files.createTempDirectory("ci-publication-test-");
final FakeCommands fake = new FakeCommands();
final Pipeline pipeline;
Fixture() throws Exception {
Map<String, String> env = new HashMap<>(System.getenv());
env.putAll(Map.of(
"CI_COMMIT",
SHA,
"CI_RUN_ID",
"test",
"CI_RUN_ATTEMPT",
"1",
"CI_REF",
"refs/heads/main",
"CI_REPOSITORY",
"owner/recipe-demo",
"TARGET_PLATFORM",
"linux/amd64",
"GITEA_SERVER_URL",
"http://registry.example:3000",
"REGISTRY_USER",
"test",
"REGISTRY_TOKEN",
"dummy-token",
"CI_PUBLISH_LOCK_DIR",
root.resolve("lock").toString()));
env.put("PROJECT_NAME", "recipe-demo");
env.put("BACKEND_MODULE", "backend");
env.put("FRONTEND_MODULE", "frontend");
pipeline = new Pipeline(root, env, fake) {
@Override
String mainHead() {
return fake.head;
}
};
Files.writeString(root.resolve("ci/artifacts/verified-commit"), SHA + "\n");
}
long calls(String verb) {
return fake.calls.stream()
.filter(a -> a.size() > 1
&& a.getFirst().equals("docker")
&& a.get(1).equals(verb))
.count();
}
public void close() throws Exception {
pipeline.closeDockerConfig();
Pipeline.deleteTree(root);
}
}
static class FakeCommands extends Commands {
final List<List<String>> calls = new ArrayList<>();
final Map<String, String> images = new HashMap<>(), tags = new HashMap<>();
String head = SHA, pushFailure = "", pullFailure = "";
boolean manifestError, mavenFailure, lintFailure;
boolean dockerAvailable = true;
FakeCommands() {
super(Duration.ofMinutes(1));
}
@Override
Result execute(List<String> args, Path cwd, Map<String, String> env, String input, boolean echo) {
calls.add(List.copyOf(args));
if (args.getFirst().equals("git")) return new Result(0, SHA);
if (args.getFirst().equals("mvn") && mavenFailure) return new Result(42, "deliberate failure");
if (args.equals(List.of("npm", "run", "lint")) && lintFailure)
return new Result(1, "deliberate lint failure");
if (!args.getFirst().equals("docker")) return new Result(0, "");
String command = args.get(1), ref = args.getLast();
if (command.equals("info"))
return new Result(dockerAvailable ? 0 : 1, args.contains("{{.Architecture}}") ? "amd64" : "linux");
if (command.equals("context")) return new Result(0, "unix:///tmp/test-docker.sock");
if (command.equals("manifest")) {
if (manifestError) return new Result(1, "unauthorized");
return images.containsKey(ref) ? new Result(0, "{}") : new Result(1, "manifest unknown");
}
if (command.equals("tag")) tags.put(ref, images.getOrDefault(args.get(2), SHA));
if (command.equals("push")) {
if (!pushFailure.isEmpty() && ref.contains(pushFailure)) return new Result(1, "push failed");
images.put(ref, tags.getOrDefault(ref, SHA));
}
if (command.equals("pull") && !pullFailure.isEmpty() && ref.contains(pullFailure))
return new Result(1, "pull failed");
if (command.equals("image"))
return new Result(
0,
args.stream().anyMatch(a -> a.contains("RepoDigests"))
? ref.substring(0, ref.lastIndexOf(':')) + "@sha256:" + "b".repeat(64)
: images.getOrDefault(ref, "missing"));
if (command.equals("container") || command.equals("network") && args.contains("inspect"))
return new Result(1, "");
return new Result(0, "");
}
}
}