Connect minimal hosts to snapshots, target workers and shared Kit editing

This commit is contained in:
2026-11-12 11:07:45 +01:00
parent 28c1e47c95
commit 7db7218a6e
52 changed files with 4236 additions and 29 deletions
+1
View File
@@ -40,6 +40,7 @@
<configuration>
<includes>
<include>**/ConfigurationHostContract.class</include>
<include>**/ConnectedWorkflowContract.class</include>
</includes>
</configuration>
</execution>
@@ -194,6 +194,15 @@ public final class BuildRefresh implements AutoCloseable {
details.put("buildJava", Integer.toString(build.javaFeature()));
details.put("targetJavaHome", target.javaHome().toString());
details.put("targetJava", Integer.toString(target.javaFeature()));
details.put(
"sourceRoots",
new com.fasterxml.jackson.databind.ObjectMapper()
.writeValueAsString(
captured.reactor().modules().stream()
.flatMap(m -> m.sourceRoots().stream())
.map(Path::toString)
.distinct()
.toList()));
details.put("wireVersion", "1.0");
details.put("manifestVersion", "1");
details.put("coordinates", captured.coordinates().toString());
@@ -359,6 +368,14 @@ public final class BuildRefresh implements AutoCloseable {
}
public SnapshotStore.Lease previewCurrent(Request request) throws Exception {
return previewCurrent(request, Set.of());
}
/**
* Saved FXML may differ only when that exact resource is supplied by the immutable draft overlay.
*/
public SnapshotStore.Lease previewCurrent(Request request, Set<URI> overlaidFXML)
throws Exception {
var state = store.state(request.context());
if (state.outdated() || state.lastSuccessful() == null)
throw new IllegalStateException("Choose Preview with last successful build explicitly");
@@ -371,7 +388,7 @@ public final class BuildRefresh implements AutoCloseable {
.get("configuration")
.equals(request.configuration().toRealPath().toUri().toString())
|| !runtimeMatches(request, lease.snapshot())
|| !lease.snapshot().inputs().equals(fingerprint(root))
|| !inputsMatch(lease.snapshot().inputs(), fingerprint(root), root, overlaidFXML)
|| !dirty(root).isEmpty())
throw new IllegalStateException(
"Build is outdated; choose last successful build explicitly");
@@ -383,6 +400,23 @@ public final class BuildRefresh implements AutoCloseable {
}
}
private static boolean inputsMatch(
Map<String, String> captured, Map<String, String> current, Path root, Set<URI> overlays)
throws IOException {
var before = new TreeMap<>(captured);
var after = new TreeMap<>(current);
for (URI uri : overlays) {
Path file = Path.of(uri).toRealPath();
if (!file.startsWith(root) || !file.toString().endsWith(".fxml")) continue;
String key = root.relativize(file).toString();
if (before.containsKey(key) && after.containsKey(key)) {
before.remove(key);
after.remove(key);
}
}
return before.equals(after);
}
private boolean runtimeMatches(Request request, SnapshotStore.Snapshot snapshot)
throws Exception {
var context =
@@ -26,6 +26,15 @@ public record HostServices(
public interface Documents {
CompletionStage<DocumentSession> open(URI document);
/** Fired synchronously with host edits; listeners must not block the host thread. */
default AutoCloseable onChange(Consumer<URI> listener) {
return () -> {};
}
default CompletionStage<List<de.neitzel.fxdesigner.contracts.DocumentSnapshot>> snapshots() {
return unsaved();
}
default CompletionStage<List<de.neitzel.fxdesigner.contracts.DocumentSnapshot>> unsaved() {
return java.util.concurrent.CompletableFuture.failedFuture(
new UnsupportedOperationException("Host must enumerate known unsaved build inputs"));
@@ -0,0 +1,288 @@
package de.neitzel.fxdesigner.workflow;
import static de.neitzel.fxdesigner.protocol.WireProtocol.*;
import de.neitzel.fxdesigner.build.*;
import de.neitzel.fxdesigner.contracts.*;
import de.neitzel.fxdesigner.host.*;
import de.neitzel.fxdesigner.protocol.*;
import java.net.URI;
import java.nio.file.*;
import java.time.*;
import java.util.*;
import java.util.concurrent.*;
import java.util.function.*;
/**
* Shared build, discovery and render owner. Hosts supply only documents, runtimes and presentation.
*/
public final class DesignerWorkflow implements AutoCloseable {
public record Example(String id, String label, String viewResource, String componentPackage) {}
public record Image(
String node,
int width,
int height,
byte[] rgba,
Map<String, Long> revisions,
String snapshot) {
public Image {
rgba = rgba.clone();
revisions = Map.copyOf(revisions);
}
public byte[] rgba() {
return rgba.clone();
}
}
private final HostServices host;
private final BuildRefresh build;
private final BuildRefresh.Request request;
private final List<Path> tools;
private final Consumer<String> log;
private PreviewWorker active;
private long generation;
private boolean closed;
public DesignerWorkflow(
HostServices host,
Path configuration,
String context,
Path maven,
List<Path> tools,
Consumer<String> log)
throws Exception {
this.host = host;
this.tools = List.copyOf(tools);
this.log = log;
request = new BuildRefresh.Request(configuration, context, maven);
build =
new BuildRefresh(
host,
new SnapshotStore(
configuration
.toAbsolutePath()
.getParent()
.resolve(".neitzel-fx-designer/snapshots")));
host.lifecycle().own(this);
}
public CompletionStage<URI> componentSource(String type) {
if (!type.matches("[A-Za-z_$][A-Za-z0-9_$]*(\\.[A-Za-z_$][A-Za-z0-9_$]*)*"))
return CompletableFuture.failedFuture(
new IllegalArgumentException("Invalid Component class name"));
return CompletableFuture.supplyAsync(
() -> {
try (var lease = build.previewLastSuccessful(request.context())) {
var roots =
new com.fasterxml.jackson.databind.ObjectMapper()
.readValue(lease.snapshot().details().get("sourceRoots"), String[].class);
var matches = new LinkedHashSet<Path>();
for (String root : roots) {
Path path = Path.of(root).resolve(type.replace('.', '/') + ".fxml");
if (Files.isRegularFile(path)) matches.add(path.toRealPath());
}
if (matches.size() != 1)
throw new IllegalStateException(
"Component has no unique editable FXML source in the captured reactor: " + type);
return matches.iterator().next().toUri();
} catch (Exception e) {
throw new CompletionException(e);
}
});
}
public BuildRefresh.Run build(Map<URI, Long> approved) {
cancel();
return build.start(request, approved, line -> log.accept(line.line()));
}
public CompletionStage<List<Example>> discover(boolean lastSuccessful) {
return execute(
lastSuccessful,
List.of(),
worker -> {
var packet =
worker.request(
Operation.DISCOVER, Map.of(), 0, 0, new byte[0], Duration.ofSeconds(15));
var data = PreviewMessages.decode(packet.payload());
if (!"1".equals(data.get("format")))
throw new IllegalArgumentException("Unsupported metadata version");
int count = Integer.parseInt(data.get("count"));
if (count < 0 || count > 1024)
throw new IllegalArgumentException("Invalid example count");
var result = new ArrayList<Example>();
var config = worker.context();
for (int i = 0; i < count; i++) {
String p = "example." + i + ".";
String id = data.get(p + "id");
if (config.preview().provider().equals(data.get(p + "providerClass"))
&& (config.preview().examples().isEmpty()
|| config.preview().examples().contains(id)))
result.add(
new Example(
id,
data.get(p + "label"),
data.get(p + "viewResource"),
data.get(p + "componentPackage")));
}
return List.copyOf(result);
});
}
public record Size(int width, int height) {}
public CompletionStage<Image> render(
boolean lastSuccessful,
String example,
String node,
int width,
int height,
List<DocumentSnapshot> drafts) {
return renderImages(lastSuccessful, example, Map.of(node, new Size(width, height)), drafts)
.thenApply(images -> images.getFirst());
}
/** All Component images in this batch come from one loaded parent and one shared Model graph. */
public CompletionStage<List<Image>> renderImages(
boolean lastSuccessful,
String example,
Map<String, Size> nodes,
List<DocumentSnapshot> drafts) {
var requested = new LinkedHashMap<>(nodes);
var captured = List.copyOf(drafts);
if (requested.isEmpty() || requested.size() > 128)
return CompletableFuture.failedFuture(
new IllegalArgumentException("Invalid render node count"));
long bytes = 0;
for (var size : requested.values()) {
if (size.width() < 1
|| size.height() < 1
|| size.width() > 8192
|| size.height() > 8192
|| (long) size.width() * size.height() * 4 > MAX_IMAGE)
return CompletableFuture.failedFuture(
new IllegalArgumentException("Invalid render dimensions"));
bytes += (long) size.width() * size.height() * 4;
}
if (bytes > 64L * 1024 * 1024)
return CompletableFuture.failedFuture(
new IllegalArgumentException("Render batch exceeds 64 MiB"));
return execute(
lastSuccessful,
captured,
worker -> {
var revisions = new TreeMap<String, Long>();
captured.forEach(d -> revisions.put(d.document().toString(), d.revision()));
var images = new ArrayList<Image>();
for (var entry : requested.entrySet()) {
var size = entry.getValue();
String node = entry.getKey();
var result =
worker.request(
Operation.RENDER,
revisions,
size.width(),
size.height(),
PreviewMessages.encode(Map.of("example", example, "node", node)),
Duration.ofSeconds(30));
images.add(
new Image(
node,
size.width(),
size.height(),
result.payload(),
revisions,
result.identity().snapshot()));
}
for (var draft : captured) {
try (var doc = host.documents().open(draft.document()).toCompletableFuture().get()) {
if (doc.snapshot().toCompletableFuture().get().revision() != draft.revision())
throw new CancellationException("Draft changed during rendering");
}
}
return List.copyOf(images);
});
}
private interface Work<T> {
T run(PreviewWorker worker) throws Exception;
}
private <T> CompletionStage<T> execute(
boolean last, List<DocumentSnapshot> drafts, Work<T> work) {
cancel();
long ticket;
synchronized (this) {
if (closed)
return CompletableFuture.failedFuture(new IllegalStateException("Designer closed"));
ticket = generation;
}
var result = new CompletableFuture<T>();
Thread.ofVirtual()
.start(
() -> {
PreviewWorker worker = null;
try {
var lease =
last
? build.previewLastSuccessful(request.context())
: build.previewCurrent(
request,
drafts.stream()
.map(DocumentSnapshot::document)
.collect(java.util.stream.Collectors.toSet()));
worker = new PreviewWorker(new DraftOverlay(lease, drafts));
synchronized (this) {
if (closed || ticket != generation) throw new CancellationException();
active = worker;
}
var owned = worker;
// Hard deadline also covers unresponsive FX, construction and handshake.
var watchdog =
Thread.ofVirtual()
.start(
() -> {
try {
Thread.sleep(45000);
owned.close();
} catch (InterruptedException ignored) {
}
});
try {
worker.start(tools, log);
T value = work.run(worker);
synchronized (this) {
if (closed || ticket != generation) throw new CancellationException();
result.complete(value);
}
} finally {
watchdog.interrupt();
}
} catch (Throwable e) {
result.completeExceptionally(e);
} finally {
if (worker != null) worker.close();
synchronized (this) {
if (active == worker) active = null;
}
}
});
return result;
}
public synchronized void cancel() {
generation++;
var old = active;
active = null;
if (old != null) Thread.ofVirtual().start(old::close);
}
public synchronized void close() {
if (closed) return;
closed = true;
cancel();
build.close();
}
}
@@ -0,0 +1,156 @@
package de.neitzel.fxdesigner.workflow;
import de.neitzel.fxdesigner.build.SnapshotStore;
import de.neitzel.fxdesigner.contracts.DocumentSnapshot;
import java.io.*;
import java.nio.file.*;
import java.util.*;
import java.util.zip.*;
/** Private runtime copies, retaining their immutable snapshot lease until the worker exits. */
public final class DraftOverlay implements AutoCloseable {
private final SnapshotStore.Lease lease;
private final Path directory;
private final List<Path> runtime = new ArrayList<>();
private final Map<String, Long> revisions = new TreeMap<>();
public DraftOverlay(SnapshotStore.Lease lease, List<DocumentSnapshot> drafts) throws Exception {
this.lease = lease;
directory = Files.createTempDirectory("neitzel-preview-");
try {
if (drafts.size() > 128) throw new IOException("Too many draft overlays");
var roots =
new com.fasterxml.jackson.databind.ObjectMapper()
.readValue(
lease.snapshot().details().getOrDefault("sourceRoots", "[]"), String[].class);
var resources = new TreeMap<String, byte[]>();
for (var draft : drafts) {
Path file = Path.of(draft.document()).toAbsolutePath().normalize();
if (!file.toString().endsWith(".fxml")) continue;
var matches = new HashSet<String>();
for (String root : roots) {
Path base = Path.of(root);
if (file.startsWith(base))
matches.add(base.relativize(file).toString().replace(File.separatorChar, '/'));
}
if (matches.size() != 1)
throw new IOException("FXML draft has no unambiguous captured source root: " + file);
String name = matches.iterator().next();
byte[] bytes = draft.text().getBytes(java.nio.charset.StandardCharsets.UTF_8);
if (bytes.length > 1024 * 1024) throw new IOException("FXML draft exceeds 1 MiB");
if (resources.putIfAbsent(name, bytes) != null)
throw new IOException("Ambiguous FXML resource: " + name);
revisions.put(draft.document().toString(), draft.revision());
}
var found = new HashSet<String>();
int i = 0;
for (Path source : lease.runtime()) {
boolean affected;
if (Files.isDirectory(source))
affected =
resources.keySet().stream()
.anyMatch(name -> Files.isRegularFile(source.resolve(name)));
else
try (var jar = new ZipFile(source.toFile())) {
affected = resources.keySet().stream().anyMatch(name -> jar.getEntry(name) != null);
}
if (!affected) {
runtime.add(source);
continue;
}
Path dest = directory.resolve(Integer.toString(i++)).resolve(source.getFileName());
Files.createDirectories(dest.getParent());
if (Files.isDirectory(source)) {
try (var paths = Files.walk(source)) {
for (Path p : paths.toList()) {
Path target = dest.resolve(source.relativize(p));
if (Files.isDirectory(p)) Files.createDirectories(target);
else Files.copy(p, target);
}
}
for (var e : resources.entrySet()) {
Path file = dest.resolve(e.getKey());
if (Files.isRegularFile(file)) {
Files.write(file, e.getValue());
found.add(e.getKey());
}
}
} else {
try (var in = new ZipFile(source.toFile());
var out = new ZipOutputStream(Files.newOutputStream(dest))) {
var entries = in.entries();
while (entries.hasMoreElements()) {
var entry = entries.nextElement();
String name = entry.getName();
if (!resources.isEmpty()
&& name.startsWith("META-INF/")
&& (name.endsWith(".SF") || name.endsWith(".RSA")))
throw new IOException("Signed runtime JAR cannot be overlaid");
out.putNextEntry(new ZipEntry(name));
if (resources.containsKey(name)) {
out.write(resources.get(name));
found.add(name);
} else
try (var stream = in.getInputStream(entry)) {
stream.transferTo(out);
}
out.closeEntry();
}
}
}
runtime.add(dest);
}
if (!found.containsAll(resources.keySet()))
throw new IOException("Draft resource absent from captured runtime");
} catch (Exception e) {
close();
throw e;
}
}
public List<Path> copyTools(List<Path> tools) throws Exception {
var result = new ArrayList<Path>();
int index = 0;
for (Path source : tools) {
Path target =
directory
.resolve("tools")
.resolve(Integer.toString(index++))
.resolve(source.getFileName());
Files.createDirectories(target.getParent());
byte[] before =
java.security.MessageDigest.getInstance("SHA-256").digest(Files.readAllBytes(source));
Files.copy(source, target);
byte[] copied =
java.security.MessageDigest.getInstance("SHA-256").digest(Files.readAllBytes(target));
byte[] after =
java.security.MessageDigest.getInstance("SHA-256").digest(Files.readAllBytes(source));
if (!Arrays.equals(before, copied) || !Arrays.equals(before, after))
throw new IOException("Target tooling changed during launch");
result.add(target);
}
return List.copyOf(result);
}
public List<Path> runtime() {
return List.copyOf(runtime);
}
public Map<String, Long> revisions() {
return Map.copyOf(revisions);
}
public SnapshotStore.Snapshot snapshot() {
return lease.snapshot();
}
public void close() throws IOException {
try {
try (var paths = Files.walk(directory)) {
for (Path p : paths.sorted(Comparator.reverseOrder()).toList()) Files.deleteIfExists(p);
}
} finally {
lease.close();
}
}
}
@@ -0,0 +1,42 @@
package de.neitzel.fxdesigner.workflow;
import de.neitzel.fxdesigner.config.LocalSettings;
import java.nio.file.*;
import java.util.*;
/** Explicit machine-local launch setup, independent of application configuration. */
public record HostLaunch(
Path configuration, String context, Path maven, List<Path> tools, LocalSettings runtimes) {
public static HostLaunch load(Path file) throws Exception {
var p = new Properties();
try (var reader = Files.newBufferedReader(file)) {
p.load(reader);
}
Path base = file.toAbsolutePath().getParent();
Path config = resolve(base, p.getProperty("configuration"));
var contexts = de.neitzel.fxdesigner.config.ConfigurationFiles.load(config).contexts();
String context = p.getProperty("context");
if (context == null && contexts.size() == 1) context = contexts.keySet().iterator().next();
if (context == null || !contexts.containsKey(context))
throw new IllegalArgumentException("Select a configured context in " + file);
var tools = new ArrayList<Path>();
for (String item : p.getProperty("tools", "").split(java.io.File.pathSeparator)) {
Path path = resolve(base, item);
if (!Files.isRegularFile(path))
throw new IllegalArgumentException("Target tooling JAR missing: " + path);
tools.add(path);
}
return new HostLaunch(
config,
context,
p.containsKey("maven") ? resolve(base, p.getProperty("maven")) : null,
List.copyOf(tools),
LocalSettings.load(resolve(base, p.getProperty("settings"))));
}
private static Path resolve(Path base, String value) {
if (value == null || value.isBlank())
throw new IllegalArgumentException("Incomplete local designer launch settings");
return base.resolve(value).normalize().toAbsolutePath();
}
}
@@ -0,0 +1,192 @@
package de.neitzel.fxdesigner.workflow;
import static de.neitzel.fxdesigner.protocol.WireProtocol.*;
import de.neitzel.fxdesigner.config.DesignerConfiguration;
import de.neitzel.fxdesigner.protocol.*;
import java.io.*;
import java.net.*;
import java.nio.file.*;
import java.time.*;
import java.util.*;
import java.util.concurrent.*;
import java.util.function.Consumer;
/**
* One target process, authenticated socket and reserved owner pipe. All blocking calls run off UI.
*/
public final class PreviewWorker implements AutoCloseable {
private final DraftOverlay overlay;
private Process process;
private Socket socket;
private ServerSocket server;
private ProtocolSession protocol;
private volatile boolean closed;
public PreviewWorker(DraftOverlay overlay) {
this.overlay = overlay;
}
public synchronized long pid() {
return process == null ? -1 : process.pid();
}
public DesignerConfiguration.Context context() throws Exception {
return new com.fasterxml.jackson.databind.ObjectMapper()
.readValue(
overlay.snapshot().details().get("contextSpec"), DesignerConfiguration.Context.class);
}
public void start(List<Path> tools, Consumer<String> log) throws Exception {
var snapshot = overlay.snapshot();
var config = context();
var command = new ArrayList<String>();
command.add(Path.of(snapshot.details().get("targetJavaHome"), "bin", "java").toString());
ArrayList<Path> paths;
synchronized (this) {
if (closed) throw new CancellationException();
paths = new ArrayList<>(overlay.copyTools(tools));
}
paths.addAll(overlay.runtime());
boolean modular = config.launchMode().equals("module-path");
if (modular) {
var modules = new LinkedHashMap<String, Path>();
for (Path path : paths) {
var refs = java.lang.module.ModuleFinder.of(path).findAll();
if (refs.size() != 1) throw new IOException("Not a single module: " + path);
String name = refs.iterator().next().descriptor().name();
modules.putIfAbsent(name, path);
}
if (!modules.containsKey(config.modulePath().entryModule()))
throw new IOException("Configured entry module absent from snapshot");
command.addAll(
List.of("--module-path", join(modules.values()), "--add-modules", "ALL-MODULE-PATH"));
for (String value : config.modulePath().addReads())
command.addAll(List.of("--add-reads", value));
for (String value : config.modulePath().addOpens())
command.addAll(List.of("--add-opens", value));
command.addAll(
List.of("-m", "de.neitzel.fxdesigner.target/de.neitzel.fxdesigner.target.WorkerMain"));
} else command.addAll(List.of("-cp", join(paths), "de.neitzel.fxdesigner.target.WorkerMain"));
String id = UUID.randomUUID().toString(), token = ProtocolSession.newToken();
synchronized (this) {
if (closed) throw new CancellationException();
server = new ServerSocket(0, 1, InetAddress.getLoopbackAddress());
server.setSoTimeout(15000);
command.add(Integer.toString(server.getLocalPort()));
command.add(id);
process = new ProcessBuilder(command).start();
protocol =
new ProtocolSession(
id,
process.pid(),
token,
Set.of("discover-v1", "render-rgba-v1"),
config.requiredCapabilities());
}
drain(process.getInputStream(), log);
drain(process.getErrorStream(), log);
var owner = new DataOutputStream(process.getOutputStream());
owner.writeUTF(token);
owner.flush();
Socket connection = server.accept();
synchronized (this) {
if (closed) {
connection.close();
throw new CancellationException();
}
socket = connection;
}
socket.setSoTimeout(15000);
var hello = FrameCodec.read(socket.getInputStream());
if (!(hello instanceof Hello h)) throw new IOException("Worker did not send HELLO");
protocol.authenticate(socket.getInetAddress(), h);
FrameCodec.write(socket.getOutputStream(), protocol.welcome());
socket.getOutputStream().flush();
server.close();
}
private static String join(Collection<Path> paths) {
return String.join(File.pathSeparator, paths.stream().map(Path::toString).toList());
}
private static void drain(InputStream input, Consumer<String> log) {
Thread.ofVirtual()
.start(
() -> {
try (var reader = new BufferedReader(new InputStreamReader(input))) {
char[] chunk = new char[2048];
int n;
while ((n = reader.read(chunk)) >= 0) log.accept(new String(chunk, 0, n));
} catch (IOException ignored) {
}
});
}
public Packet request(
Operation operation,
Map<String, Long> revisions,
int width,
int height,
byte[] payload,
Duration timeout)
throws Exception {
if (closed) throw new CancellationException();
var snapshot = overlay.snapshot();
var packet =
protocol.request(
operation,
snapshot.context(),
snapshot.id(),
revisions,
width,
height,
payload,
Instant.now(),
timeout);
socket.setSoTimeout(Math.toIntExact(timeout.toMillis()));
FrameCodec.write(socket.getOutputStream(), packet);
socket.getOutputStream().flush();
var response = FrameCodec.read(socket.getInputStream());
if (!(response instanceof Packet result) || !protocol.accept(result, Instant.now()))
throw new IOException("Stale or unrelated worker response");
if (result.kind() == Kind.ERROR)
throw new IOException(new String(result.payload(), java.nio.charset.StandardCharsets.UTF_8));
return result;
}
@Override
public void close() {
Process child;
synchronized (this) {
if (closed) return;
closed = true;
child = process;
try {
if (server != null) server.close();
} catch (IOException ignored) {
}
try {
if (socket != null) socket.close();
} catch (IOException ignored) {
}
if (protocol != null) protocol.close();
}
if (child != null) {
try {
child.getOutputStream().close();
if (!child.waitFor(2, TimeUnit.SECONDS)) {
child.descendants().forEach(ProcessHandle::destroyForcibly);
child.destroyForcibly();
child.waitFor(3, TimeUnit.SECONDS);
}
} catch (Exception e) {
child.destroyForcibly();
}
}
try {
overlay.close();
} catch (IOException ignored) {
}
}
}
@@ -0,0 +1,175 @@
package de.neitzel.fxdesigner.workflow;
import de.neitzel.fxdesigner.build.*;
import de.neitzel.fxdesigner.contracts.*;
import de.neitzel.fxdesigner.host.*;
import java.nio.file.*;
import java.util.*;
import java.util.concurrent.*;
public final class ConnectedWorkflowContract {
private int checks;
private void check(boolean value, String message) {
if (!value) throw new AssertionError(message);
checks++;
}
private static <T> T await(CompletionStage<T> stage) throws Exception {
return stage.toCompletableFuture().get(120, TimeUnit.SECONDS);
}
public int run(HostServices host, Path root, Path fixture) throws Exception {
var tools =
List.of(
root.resolve("designer-target-tools/target/designer-target-tools-0.1.0-SNAPSHOT.jar"),
root.resolve("designer-contracts/target/designer-contracts-0.1.0-SNAPSHOT.jar"),
root.resolve("designer-preview-api/target/designer-preview-api-0.1.0-SNAPSHOT.jar"));
var workflow =
new DesignerWorkflow(
host,
fixture.resolve("neitzel-fx-designer.json"),
"fixture",
root.resolve("mvnw"),
tools,
System.out::println);
var built = await(workflow.build(Map.of()).completion());
check(built.status() == BuildRefresh.Status.READY, "Build failed: " + built.message());
var examples = await(workflow.discover(false));
check(examples.size() == 9, "Discovery from snapshot");
Path file = fixture.resolve("app/src/main/resources/fixture/components/Person.fxml"),
child = fixture.resolve("app/src/main/resources/fixture/components/Card.fxml");
try (var doc = await(host.documents().open(file.toUri()));
var component = await(host.documents().open(child.toUri()))) {
var initial = await(doc.snapshot());
var first = await(workflow.render(false, "standard", "card", 310, 220, List.of(initial)));
check(first.rgba().length == 310 * 220 * 4, "Image frame");
var batch =
await(
workflow.renderImages(
false,
"standard",
Map.of(
"card",
new DesignerWorkflow.Size(310, 220),
"second",
new DesignerWorkflow.Size(310, 220)),
List.of(initial)));
check(
batch.size() == 2
&& batch.stream().allMatch(image -> image.snapshot().equals(first.snapshot())),
"Shared parent render batch");
check(
Path.of(await(workflow.componentSource("fixture.components.Card")))
.equals(child.toRealPath()),
"Component navigation source");
var changed =
await(
doc.apply(
new EditBatch(
initial.revision(),
"Caption",
List.of(
new EditBatch.Replacement(
0,
initial.text().length(),
initial.text().replace("FXML caption", "Draft caption"))))));
var second = await(workflow.render(false, "standard", "card", 310, 220, List.of(changed)));
check(!Arrays.equals(first.rgba(), second.rgba()), "Parent draft not rendered");
check(first.snapshot().equals(second.snapshot()), "FXML edit rebuilt Java");
var childInitial = await(component.snapshot());
var childDraft =
await(
component.apply(
new EditBatch(
childInitial.revision(),
"Component",
List.of(
new EditBatch.Replacement(
0,
childInitial.text().length(),
childInitial
.text()
.replace(
"<TextField",
"<TextField style=\"-fx-background-color: red;\""))))));
var third =
await(workflow.render(false, "standard", "card", 310, 220, List.of(changed, childDraft)));
check(!Arrays.equals(second.rgba(), third.rgba()), "Component draft not rendered");
var undo = await(doc.undo());
check(undo.text().equals(initial.text()), "Native host undo");
var redo = await(doc.redo());
check(redo.text().equals(changed.text()), "Native host redo");
var obsolete = workflow.render(false, "standard", "card", 310, 220, List.of(redo));
var newer =
await(
doc.apply(
new EditBatch(
redo.revision(),
"Concurrent edit",
List.of(
new EditBatch.Replacement(
0,
redo.text().length(),
redo.text().replace("Draft caption", "Concurrent caption"))))));
try {
await(obsolete);
throw new AssertionError("Obsolete render accepted");
} catch (Exception expected) {
checks++;
}
redo = await(doc.undo());
var bad =
await(
doc.apply(
new EditBatch(
redo.revision(),
"Broken source",
List.of(
new EditBatch.Replacement(
0,
redo.text().length(),
redo.text()
.replace("prefWidth=\"310\"", "prefWidth=\"invalid\""))))));
try {
await(workflow.render(false, "standard", "card", 310, 220, List.of(bad)));
throw new AssertionError("Invalid draft accepted");
} catch (ExecutionException expected) {
check(
expected.getCause().getMessage().contains("LOAD_FAILED"), "Missing target diagnostic");
}
check(await(doc.snapshot()).writable(), "Failure disabled source");
await(doc.undo());
var saved = await(doc.save(await(doc.snapshot()).revision()));
var savedChild = await(component.save(childDraft.revision()));
var savedRender =
await(workflow.render(false, "standard", "card", 310, 220, List.of(saved, savedChild)));
check(
savedRender.snapshot().equals(first.snapshot()),
"Saving overlaid FXML required Java rebuild");
var store = new SnapshotStore(fixture.resolve(".neitzel-fx-designer/snapshots"));
try (var lease = store.lease(built.snapshot())) {
check(lease.snapshot().id().equals(built.snapshot()), "Overlay mutated snapshot");
}
var pending =
workflow.render(
false,
"timeout",
"card",
310,
220,
List.of(await(doc.snapshot()), await(component.snapshot())));
Thread.sleep(500);
workflow.cancel();
try {
await(pending);
throw new AssertionError("Cancellation accepted result");
} catch (Exception expected) {
checks++;
}
}
workflow.close();
return checks;
}
}