Implement isolated Build and Refresh with immutable leased snapshots
This commit is contained in:
+14
-3
@@ -55,6 +55,15 @@ public final class FileDocuments implements HostServices.Documents, AutoCloseabl
|
||||
});
|
||||
}
|
||||
|
||||
public CompletionStage<List<DocumentSnapshot>> unsaved() {
|
||||
return submit(
|
||||
() ->
|
||||
documents.values().stream()
|
||||
.map(FileDocuments::snapshot)
|
||||
.filter(DocumentSnapshot::dirty)
|
||||
.toList());
|
||||
}
|
||||
|
||||
private static DocumentSnapshot snapshot(State s) {
|
||||
return new DocumentSnapshot(
|
||||
s.file.toUri(), s.revision, s.text, Files.isWritable(s.file), !s.text.equals(s.saved));
|
||||
@@ -126,9 +135,11 @@ public final class FileDocuments implements HostServices.Documents, AutoCloseabl
|
||||
Path temp = Files.createTempFile(s.file.getParent(), "designer-save-", ".tmp");
|
||||
try {
|
||||
Files.writeString(temp, s.text);
|
||||
if (Files.getFileAttributeView(s.file, java.nio.file.attribute.PosixFileAttributeView.class) != null) {
|
||||
Files.setPosixFilePermissions(temp, Files.getPosixFilePermissions(s.file));
|
||||
}
|
||||
if (Files.getFileAttributeView(
|
||||
s.file, java.nio.file.attribute.PosixFileAttributeView.class)
|
||||
!= null) {
|
||||
Files.setPosixFilePermissions(temp, Files.getPosixFilePermissions(s.file));
|
||||
}
|
||||
Files.move(
|
||||
temp,
|
||||
s.file,
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
package de.neitzel.fxdesigner.build;
|
||||
|
||||
import de.neitzel.fxdesigner.host.*;
|
||||
import de.neitzel.fxdesigner.standalone.*;
|
||||
import java.nio.file.*;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.*;
|
||||
|
||||
/** Executed by check-bootstrap after the helper plugin and shared artifacts are installed. */
|
||||
public final class BuildRefreshSmoke {
|
||||
private static int checks;
|
||||
|
||||
private static void check(boolean condition, String message) {
|
||||
if (!condition) throw new AssertionError(message);
|
||||
checks++;
|
||||
}
|
||||
|
||||
private static void write(Path root, String name, String text) throws Exception {
|
||||
Path p = root.resolve(name);
|
||||
Files.createDirectories(p.getParent());
|
||||
Files.writeString(p, text);
|
||||
}
|
||||
|
||||
private static String child(String name, String extra) {
|
||||
return "<project><modelVersion>4.0.0</modelVersion><parent><groupId>fixture</groupId><artifactId>parent</artifactId><version>1</version></parent><artifactId>"
|
||||
+ name
|
||||
+ "</artifactId>"
|
||||
+ extra
|
||||
+ "</project>";
|
||||
}
|
||||
|
||||
private static final String DEP =
|
||||
"<dependencies><dependency><groupId>fixture</groupId><artifactId>dep</artifactId><version>1</version></dependency></dependencies>";
|
||||
|
||||
public static void main(String[] args) throws Exception {
|
||||
Path repository = Path.of(args[0]).toRealPath(), javaHome = Path.of(args[1]).toRealPath();
|
||||
Path root = Files.createTempDirectory("designer-maven-smoke-").toRealPath();
|
||||
var logs = new java.util.concurrent.CopyOnWriteArrayList<String>();
|
||||
try {
|
||||
write(
|
||||
root,
|
||||
"pom.xml",
|
||||
"""
|
||||
<project><modelVersion>4.0.0</modelVersion><groupId>fixture</groupId><artifactId>parent</artifactId><version>1</version><packaging>pom</packaging><modules><module>dep</module><module>app</module><module>unrelated</module></modules><properties><maven.compiler.release>21</maven.compiler.release></properties><profiles><profile><id>designer-preview</id><modules><module>preview</module></modules></profile></profiles><build><pluginManagement><plugins><plugin><groupId>org.apache.maven.plugins</groupId><artifactId>maven-compiler-plugin</artifactId><version>3.14.1</version></plugin><plugin><groupId>org.apache.maven.plugins</groupId><artifactId>maven-resources-plugin</artifactId><version>3.3.1</version></plugin><plugin><groupId>org.apache.maven.plugins</groupId><artifactId>maven-surefire-plugin</artifactId><version>3.5.4</version></plugin><plugin><groupId>org.apache.maven.plugins</groupId><artifactId>maven-jar-plugin</artifactId><version>3.4.2</version></plugin></plugins></pluginManagement></build></project>
|
||||
""");
|
||||
write(root, "dep/pom.xml", child("dep", ""));
|
||||
write(
|
||||
root,
|
||||
"dep/src/main/java/fixture/Model.java",
|
||||
"package fixture; public class Model { public static String value(){return \"first\";}"
|
||||
+ " }");
|
||||
write(root, "app/pom.xml", child("app", DEP));
|
||||
write(
|
||||
root,
|
||||
"app/src/main/java/fixture/Main.java",
|
||||
"package fixture; public class Main {public static void main(String[]"
|
||||
+ " a){System.out.print(Model.value());}}");
|
||||
write(root, "app/src/main/resources/data.txt", "resource-v1");
|
||||
write(
|
||||
root,
|
||||
"app/src/test/java/fixture/TestPreview.java",
|
||||
"package fixture; public class TestPreview {public static void main(String[]"
|
||||
+ " a){System.out.print(\"test-\"+Model.value());}}");
|
||||
write(
|
||||
root,
|
||||
"preview/pom.xml",
|
||||
child(
|
||||
"preview",
|
||||
"<dependencies><dependency><groupId>fixture</groupId><artifactId>app</artifactId><version>1</version></dependency></dependencies>"));
|
||||
write(
|
||||
root,
|
||||
"preview/src/main/java/fixture/Preview.java",
|
||||
"package fixture; public class Preview {public static void main(String[]"
|
||||
+ " a){System.out.print(\"preview-\"+Model.value());}}");
|
||||
write(root, "unrelated/pom.xml", child("unrelated", ""));
|
||||
write(root, "unrelated/src/main/java/Broken.java", "THIS MUST NEVER COMPILE");
|
||||
Files.copy(
|
||||
repository.resolve("mvnw"), root.resolve("mvnw"), StandardCopyOption.COPY_ATTRIBUTES);
|
||||
Files.createDirectories(root.resolve(".mvn/wrapper"));
|
||||
Files.copy(
|
||||
repository.resolve(".mvn/wrapper/maven-wrapper.properties"),
|
||||
root.resolve(".mvn/wrapper/maven-wrapper.properties"));
|
||||
Path config = root.resolve("neitzel-fx-designer.json");
|
||||
write(root, "neitzel-fx-designer.json", configuration(true));
|
||||
var runtime = new HostServices.RuntimeInstallation(javaHome, 21);
|
||||
var mapping =
|
||||
new ConfiguredRuntimes(
|
||||
Map.of(
|
||||
new ConfiguredRuntimes.Key("java", HostServices.RuntimeRole.BUILD),
|
||||
runtime,
|
||||
new ConfiguredRuntimes.Key("java", HostServices.RuntimeRole.TARGET),
|
||||
runtime));
|
||||
try (var host =
|
||||
new StandaloneHost(
|
||||
mapping,
|
||||
l -> CompletableFuture.completedFuture(null),
|
||||
d -> logs.add(d.message()));
|
||||
var refresh =
|
||||
new BuildRefresh(
|
||||
host.services(),
|
||||
new SnapshotStore(root.resolve(".neitzel-fx-designer/snapshots")))) {
|
||||
var request = new BuildRefresh.Request(config, "desktop", null);
|
||||
var first =
|
||||
refresh
|
||||
.start(request, Map.of(), o -> logs.add(o.line()))
|
||||
.completion()
|
||||
.toCompletableFuture()
|
||||
.get(180, TimeUnit.SECONDS);
|
||||
check(
|
||||
first.status() == BuildRefresh.Status.READY,
|
||||
"Module preview build failed: " + first.message());
|
||||
check(
|
||||
first.reactor().modules().stream().noneMatch(m -> m.directory().endsWith("unrelated")),
|
||||
"Unrelated module entered scope");
|
||||
check(!Files.exists(root.resolve("app/target")), "Build touched original target");
|
||||
try (var old = refresh.previewCurrent(request)) {
|
||||
check(
|
||||
run(javaHome, old, "fixture.Preview").equals("preview-first"),
|
||||
"Preview module runtime incorrect");
|
||||
check(
|
||||
old.runtime().stream().anyMatch(p -> Files.exists(p.resolve("data.txt"))),
|
||||
"Resources missing from snapshot");
|
||||
write(
|
||||
root,
|
||||
"dep/src/main/java/fixture/Model.java",
|
||||
"package fixture; public class Model { public static String value(){return"
|
||||
+ " \"second\";} }");
|
||||
write(root, "neitzel-fx-designer.json", configuration(false));
|
||||
var second =
|
||||
refresh
|
||||
.start(request, Map.of(), o -> logs.add(o.line()))
|
||||
.completion()
|
||||
.toCompletableFuture()
|
||||
.get(180, TimeUnit.SECONDS);
|
||||
check(
|
||||
second.status() == BuildRefresh.Status.READY,
|
||||
"Test preview build failed: " + second.message());
|
||||
try (var current = refresh.previewCurrent(request)) {
|
||||
check(
|
||||
run(javaHome, current, "fixture.TestPreview").equals("test-second"),
|
||||
"Test source arrangement missing");
|
||||
}
|
||||
check(
|
||||
run(javaHome, old, "fixture.Preview").equals("preview-first"),
|
||||
"Rebuild mutated active snapshot");
|
||||
write(root, "app/src/main/java/fixture/Main.java", "INVALID JAVA");
|
||||
var failed =
|
||||
refresh
|
||||
.start(request, Map.of(), o -> logs.add(o.line()))
|
||||
.completion()
|
||||
.toCompletableFuture()
|
||||
.get(180, TimeUnit.SECONDS);
|
||||
check(
|
||||
failed.status() == BuildRefresh.Status.FAILED,
|
||||
"Compiler failure incorrectly activated");
|
||||
try (var retained = refresh.previewLastSuccessful("desktop")) {
|
||||
check(
|
||||
run(javaHome, retained, "fixture.TestPreview").equals("test-second"),
|
||||
"Last successful build lost");
|
||||
}
|
||||
}
|
||||
}
|
||||
System.out.println(
|
||||
"PASS real Maven Build and Refresh: "
|
||||
+ checks
|
||||
+ " checks (Java 21, profile/module preview, test preview, scope, resources,"
|
||||
+ " isolation, failed build retention)");
|
||||
} finally {
|
||||
Path log = repository.resolve("designer-standalone/target/build-refresh-smoke.log");
|
||||
Files.createDirectories(log.getParent());
|
||||
Files.write(log, logs);
|
||||
try (var files = Files.walk(root)) {
|
||||
for (Path file : files.sorted(Comparator.reverseOrder()).toList())
|
||||
Files.deleteIfExists(file);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static String configuration(boolean module) {
|
||||
return "{\"schemaVersion\":\"1.0\",\"contexts\":{\"desktop\":{\"applicationModule\":\"app\",\"mavenProfiles\":[\"designer-preview\"],\"launchMode\":\"classpath\",\"buildRuntime\":\"java\",\"targetRuntime\":\"java\",\"preview\":{\"sourceSet\":\""
|
||||
+ (module ? "module" : "test")
|
||||
+ "\","
|
||||
+ (module ? "\"module\":\"preview\"," : "")
|
||||
+ "\"provider\":\"fixture.Preview\"}}}}";
|
||||
}
|
||||
|
||||
private static String run(Path home, SnapshotStore.Lease lease, String main) throws Exception {
|
||||
String cp =
|
||||
String.join(
|
||||
java.io.File.pathSeparator, lease.runtime().stream().map(Path::toString).toList());
|
||||
var process =
|
||||
new ProcessBuilder(home.resolve("bin/java").toString(), "-cp", cp, main)
|
||||
.redirectErrorStream(true)
|
||||
.start();
|
||||
if (!process.waitFor(15, TimeUnit.SECONDS)) {
|
||||
process.destroyForcibly();
|
||||
throw new AssertionError("Target did not exit");
|
||||
}
|
||||
String output =
|
||||
new String(
|
||||
process.getInputStream().readAllBytes(), java.nio.charset.StandardCharsets.UTF_8);
|
||||
check(process.exitValue() == 0, output);
|
||||
return output;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
package de.neitzel.fxdesigner.build;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import de.neitzel.fxdesigner.contracts.*;
|
||||
import de.neitzel.fxdesigner.host.*;
|
||||
import de.neitzel.fxdesigner.standalone.*;
|
||||
import java.nio.file.*;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.*;
|
||||
import java.util.concurrent.atomic.*;
|
||||
import org.junit.jupiter.api.*;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class BuildRefreshTest {
|
||||
@TempDir Path root;
|
||||
private Path config;
|
||||
private final AtomicInteger starts = new AtomicInteger();
|
||||
private final AtomicBoolean failure = new AtomicBoolean();
|
||||
private Runnable duringBuild = () -> {};
|
||||
|
||||
private HostServices host(FileDocuments documents) {
|
||||
var runtime =
|
||||
new HostServices.RuntimeInstallation(Path.of(System.getProperty("java.home")), 25);
|
||||
return new HostServices(
|
||||
documents,
|
||||
(request, output) -> {
|
||||
starts.incrementAndGet();
|
||||
duringBuild.run();
|
||||
try {
|
||||
Path classes = Files.createDirectories(request.directory().resolve("target/classes"));
|
||||
Files.writeString(
|
||||
classes.resolve("A.class"),
|
||||
Files.readString(request.directory().resolve("A.java")));
|
||||
Path meta =
|
||||
Path.of(
|
||||
request.command().stream()
|
||||
.filter(s -> s.startsWith("-Ddesigner.captureDir="))
|
||||
.findFirst()
|
||||
.orElseThrow()
|
||||
.substring("-Ddesigner.captureDir=".length()));
|
||||
Files.createDirectories(meta);
|
||||
var p = new Properties();
|
||||
p.setProperty("format", "2");
|
||||
p.setProperty("artifact.count", "0");
|
||||
p.setProperty("module", request.directory().toString());
|
||||
p.setProperty("coordinates", "test:app:1");
|
||||
p.setProperty("packaging", "jar");
|
||||
for (String list : List.of("runtime", "test")) {
|
||||
p.setProperty(list + ".count", "1");
|
||||
p.setProperty(list + ".0", classes.toString());
|
||||
}
|
||||
for (String list : List.of("sources", "dependencies", "profiles"))
|
||||
p.setProperty(list + ".count", "0");
|
||||
try (var out = Files.newOutputStream(meta.resolve("app.properties"))) {
|
||||
p.store(out, "");
|
||||
}
|
||||
} catch (Exception e) {
|
||||
throw new java.io.IOException(e);
|
||||
}
|
||||
return new HostServices.BuildRun() {
|
||||
public CompletionStage<HostServices.BuildResult> completion() {
|
||||
return CompletableFuture.completedFuture(
|
||||
new HostServices.BuildResult(failure.get() ? 1 : 0, false));
|
||||
}
|
||||
|
||||
public void cancel() {}
|
||||
};
|
||||
},
|
||||
(n, r) -> CompletableFuture.completedFuture(runtime),
|
||||
l -> CompletableFuture.completedFuture(null),
|
||||
d -> {},
|
||||
new SessionScope());
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
void setup() throws Exception {
|
||||
Files.writeString(root.resolve("pom.xml"), "<project/>");
|
||||
Files.writeString(root.resolve("A.java"), "first");
|
||||
Files.writeString(root.resolve("View.fxml"), "view");
|
||||
Path wrapper = Files.writeString(root.resolve("mvnw"), "#!/bin/sh\nexit 0\n");
|
||||
wrapper.toFile().setExecutable(true);
|
||||
config = root.resolve("neitzel-fx-designer.json");
|
||||
Files.writeString(
|
||||
config,
|
||||
"""
|
||||
{"schemaVersion":"1.0","contexts":{"desktop":{"applicationModule":".","launchMode":"classpath","buildRuntime":"build","targetRuntime":"target","preview":{"sourceSet":"test","provider":"Example"}}}}
|
||||
""");
|
||||
root = root.toRealPath();
|
||||
config = config.toRealPath();
|
||||
}
|
||||
|
||||
private BuildRefresh.Request request() {
|
||||
return new BuildRefresh.Request(config, "desktop", null);
|
||||
}
|
||||
|
||||
private BuildRefresh.Outcome build(BuildRefresh refresh, Map<java.net.URI, Long> approvals)
|
||||
throws Exception {
|
||||
return refresh
|
||||
.start(request(), approvals, o -> {})
|
||||
.completion()
|
||||
.toCompletableFuture()
|
||||
.get(15, TimeUnit.SECONDS);
|
||||
}
|
||||
|
||||
@Test
|
||||
void unsavedInputRequiresExactApprovalAndFxmlDraftDoesNotForceJavaBuild() throws Exception {
|
||||
try (var docs = new FileDocuments()) {
|
||||
var host = host(docs);
|
||||
var store = new SnapshotStore(root.resolve(".neitzel-fx-designer/snapshots"));
|
||||
try (var refresh = new BuildRefresh(host, store);
|
||||
var java = docs.open(root.resolve("A.java").toUri()).toCompletableFuture().get();
|
||||
var fxml = docs.open(root.resolve("View.fxml").toUri()).toCompletableFuture().get()) {
|
||||
java.apply(new EditBatch(0, "edit", List.of(new EditBatch.Replacement(0, 5, "draft"))))
|
||||
.toCompletableFuture()
|
||||
.get();
|
||||
var prompt = build(refresh, Map.of());
|
||||
assertEquals(BuildRefresh.Status.NEEDS_SAVE, prompt.status());
|
||||
assertEquals(0, starts.get());
|
||||
assertEquals("first", Files.readString(root.resolve("A.java")));
|
||||
var approved = prompt.unsaved().getFirst();
|
||||
assertEquals(
|
||||
BuildRefresh.Status.READY,
|
||||
build(refresh, Map.of(approved.document(), approved.revision())).status());
|
||||
assertEquals("draft", Files.readString(root.resolve("A.java")));
|
||||
fxml.apply(
|
||||
new EditBatch(
|
||||
0, "FXML edit", List.of(new EditBatch.Replacement(0, 4, "draft view"))))
|
||||
.toCompletableFuture()
|
||||
.get();
|
||||
try (var lease = refresh.previewCurrent(request())) {
|
||||
assertEquals("draft", Files.readString(lease.runtime().getFirst().resolve("A.class")));
|
||||
}
|
||||
assertEquals(1, starts.get());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void failedBuildRetainsExplicitLastSuccessAndNeverFallsBackAutomatically() throws Exception {
|
||||
try (var docs = new FileDocuments();
|
||||
var refresh =
|
||||
new BuildRefresh(
|
||||
host(docs), new SnapshotStore(root.resolve(".neitzel-fx-designer/snapshots")))) {
|
||||
assertEquals(BuildRefresh.Status.READY, build(refresh, Map.of()).status());
|
||||
failure.set(true);
|
||||
assertEquals(BuildRefresh.Status.FAILED, build(refresh, Map.of()).status());
|
||||
assertThrows(IllegalStateException.class, () -> refresh.previewCurrent(request()));
|
||||
try (var lease = refresh.previewLastSuccessful("desktop")) {
|
||||
assertEquals("first", Files.readString(lease.runtime().getFirst().resolve("A.class")));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void inputEditDuringBuildRejectsActivationEvenThoughBuildUsedIsolatedSources() throws Exception {
|
||||
duringBuild =
|
||||
() -> {
|
||||
try {
|
||||
Files.writeString(root.resolve("A.java"), "second");
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
};
|
||||
var store = new SnapshotStore(root.resolve(".neitzel-fx-designer/snapshots"));
|
||||
try (var docs = new FileDocuments();
|
||||
var refresh = new BuildRefresh(host(docs), store)) {
|
||||
assertEquals(BuildRefresh.Status.STALE, build(refresh, Map.of()).status());
|
||||
assertNull(store.state("desktop").lastSuccessful());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void cancellationPreventsPublication() throws Exception {
|
||||
var entered = new CountDownLatch(1);
|
||||
var release = new CountDownLatch(1);
|
||||
duringBuild =
|
||||
() -> {
|
||||
entered.countDown();
|
||||
try {
|
||||
release.await(5, TimeUnit.SECONDS);
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
};
|
||||
var store = new SnapshotStore(root.resolve(".neitzel-fx-designer/snapshots"));
|
||||
try (var docs = new FileDocuments();
|
||||
var refresh = new BuildRefresh(host(docs), store)) {
|
||||
var run = refresh.start(request(), Map.of(), o -> {});
|
||||
assertTrue(entered.await(5, TimeUnit.SECONDS));
|
||||
run.cancel();
|
||||
release.countDown();
|
||||
assertEquals(
|
||||
BuildRefresh.Status.CANCELLED,
|
||||
run.completion().toCompletableFuture().get(10, TimeUnit.SECONDS).status());
|
||||
assertNull(store.state("desktop").lastSuccessful());
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user