Validate preview sessions against real classpath and module-path fixtures

This commit is contained in:
2026-11-12 09:47:59 +01:00
parent e97af99412
commit 28c1e47c95
52 changed files with 1867 additions and 9 deletions
+11
View File
@@ -34,6 +34,17 @@
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-jar-plugin</artifactId>
<configuration>
<archive>
<manifestEntries>
<Automatic-Module-Name>de.neitzel.fxdesigner.target</Automatic-Module-Name>
</manifestEntries>
</archive>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-enforcer-plugin</artifactId>
@@ -0,0 +1,75 @@
package de.neitzel.fxdesigner.target;
import de.neitzel.fxdesigner.preview.ExampleDescriptor;
import java.io.*;
import java.net.URL;
import java.util.*;
/** Reads only bounded metadata resources, never ServiceLoader providers or application classes. */
public final class ExampleCatalog {
public static final String RESOURCE = "META-INF/neitzel-fx-designer/examples.properties";
private ExampleCatalog() {}
public static List<ExampleDescriptor> discover(ClassLoader loader) throws IOException {
var examples = new TreeMap<String, ExampleDescriptor>();
var resources = loader.getResources(RESOURCE);
int files = 0;
while (resources.hasMoreElements()) {
if (++files > 128) throw new IOException("Too many example catalogs");
for (var e : read(resources.nextElement()))
if (examples.putIfAbsent(e.id(), e) != null)
throw new IOException("Duplicate example ID: " + e.id());
if (examples.size() > 1024) throw new IOException("Too many examples");
}
return List.copyOf(examples.values());
}
public static List<ExampleDescriptor> read(URL resource) throws IOException {
var p =
new Properties() {
@Override
public synchronized Object put(Object key, Object value) {
if (containsKey(key)) throw new IllegalArgumentException("Duplicate descriptor key");
return super.put(key, value);
}
};
try (var in = resource.openStream()) {
byte[] bytes = in.readNBytes(1024 * 1024 + 1);
if (bytes.length > 1024 * 1024) throw new IOException("Example catalog exceeds 1 MiB");
p.load(
new StringReader(
java.nio.charset.StandardCharsets.UTF_8
.newDecoder()
.onMalformedInput(java.nio.charset.CodingErrorAction.REPORT)
.decode(java.nio.ByteBuffer.wrap(bytes))
.toString()));
if (!"1".equals(p.getProperty("format")))
throw new IOException("Unsupported example catalog format");
int count = Integer.parseInt(p.getProperty("count"));
if (count < 1 || count > 1024) throw new IOException("Invalid example count");
var result = new ArrayList<ExampleDescriptor>();
var keys = new HashSet<>(Set.of("format", "count"));
for (int i = 0; i < count; i++) {
String prefix = "example." + i + ".";
String[] fields = {
"id", "label", "modelType", "providerClass", "viewResource", "componentPackage"
};
for (String field : fields) keys.add(prefix + field);
result.add(
new ExampleDescriptor(
p.getProperty(prefix + "id"),
p.getProperty(prefix + "label"),
p.getProperty(prefix + "modelType"),
p.getProperty(prefix + "providerClass"),
p.getProperty(prefix + "viewResource"),
p.getProperty(prefix + "componentPackage")));
}
if (!keys.equals(p.stringPropertyNames()))
throw new IOException("Unknown or missing example fields");
return List.copyOf(result);
} catch (IllegalArgumentException e) {
throw new IOException("Invalid example catalog", e);
}
}
}
@@ -0,0 +1,29 @@
package de.neitzel.fxdesigner.target;
/** Structured target diagnostic; a host can display it without transporting application objects. */
public final class PreviewFailure extends RuntimeException {
public enum Code {
SESSION_REUSE,
MODULE_ACCESS,
PROVIDER,
MODEL_TYPE,
CONTEXT_TYPE,
RESOURCE,
LOAD,
READINESS,
TIMEOUT,
CANCELLED,
CLEANUP
}
private final Code code;
public PreviewFailure(Code code, String message, Throwable cause) {
super(message, cause);
this.code = code;
}
public Code code() {
return code;
}
}
@@ -0,0 +1,244 @@
package de.neitzel.fxdesigner.target;
import de.neitzel.fx.view.*;
import de.neitzel.fxdesigner.preview.*;
import de.neitzel.injection.ApplicationContext;
import java.time.Duration;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicBoolean;
import javafx.application.Platform;
import javafx.geometry.Rectangle2D;
import javafx.scene.*;
import javafx.scene.image.WritableImage;
import javafx.scene.paint.Color;
/**
* One preview session per target JVM: the library retains process-wide Model/ViewModel identities.
*/
public final class TargetPreview implements AutoCloseable {
private static final AtomicBoolean CONSUMED = new AtomicBoolean();
private PreviewSession<?, ?> session;
private LoadedView view;
private Scene scene;
private boolean closed;
private final CompletableFuture<TargetPreview> ready = new CompletableFuture<>();
private final CompletableFuture<Void> cleanup = new CompletableFuture<>();
private final ScheduledExecutorService timer =
Executors.newSingleThreadScheduledExecutor(
r -> {
var t = new Thread(r, "preview-deadline");
t.setDaemon(true);
return t;
});
public static TargetPreview open(
ExampleDescriptor descriptor, ClassLoader loader, Duration timeout) {
if (timeout.isNegative() || timeout.isZero() || timeout.compareTo(Duration.ofMinutes(5)) > 0)
throw new IllegalArgumentException("Invalid readiness timeout");
if (!CONSUMED.compareAndSet(false, true))
throw new PreviewFailure(
PreviewFailure.Code.SESSION_REUSE,
"Start a fresh target JVM for every preview session",
null);
var preview = new TargetPreview();
preview.timer.schedule(
() ->
preview.failOnFx(
new PreviewFailure(
PreviewFailure.Code.TIMEOUT, "Preview readiness timed out", null)),
timeout.toMillis(),
TimeUnit.MILLISECONDS);
try {
Platform.runLater(() -> preview.load(descriptor, loader));
} catch (RuntimeException e) {
preview.timer.shutdownNow();
preview.ready.completeExceptionally(e);
preview.cleanup.complete(null);
}
return preview;
}
public CompletionStage<TargetPreview> ready() {
return ready;
}
public CompletionStage<Void> cleanup() {
return cleanup;
}
/** Target-thread access for interactive preview and the adapter tests, never sent over IPC. */
public LoadedView loadedView() {
fx();
if (closed || view == null) throw new IllegalStateException("Preview not loaded");
return view;
}
private void load(ExampleDescriptor d, ClassLoader loader) {
fx();
if (closed) return;
var previous = Thread.currentThread().getContextClassLoader();
Thread.currentThread().setContextClassLoader(loader);
try {
// Named modules need this one package open for Reflections' ClassLoader resource enumeration.
for (Module module : ModuleLayer.boot().modules())
if (module.getPackages().contains(d.componentPackage())
&& !module.isOpen(d.componentPackage()))
throw failure(
PreviewFailure.Code.MODULE_ACCESS,
"Open component package " + d.componentPackage() + " for NeitzelLib scanning",
null);
Class<?> type = Class.forName(d.providerClass(), false, loader);
if (!PreviewProvider.class.isAssignableFrom(type))
throw failure(
PreviewFailure.Code.PROVIDER,
"Configured class does not implement PreviewProvider",
null);
var provider = (PreviewProvider<?, ?>) type.getConstructor().newInstance();
Class<?> modelType = Class.forName(d.modelType(), false, loader);
if (provider.modelType() != modelType)
throw failure(
PreviewFailure.Code.MODEL_TYPE, "Descriptor/provider Model types differ", null);
session = provider.open(d.id());
if (session == null || session.model() == null || !modelType.isInstance(session.model()))
throw failure(
PreviewFailure.Code.MODEL_TYPE, "Provider returned an incompatible Model", null);
if (!(session.context() instanceof ApplicationContext context))
throw failure(
PreviewFailure.Code.CONTEXT_TYPE,
"NeitzelLib preview requires an ApplicationContext",
null);
var resource = loader.getResource(d.viewResource());
if (resource == null)
throw failure(
PreviewFailure.Code.RESOURCE,
"View resource unavailable: " + d.viewResource() + "; check module resource openness",
null);
view = ViewLoader.load(resource, session.model(), context);
scene = new Scene(view.getRoot(), 420, 240);
layout(420, 240);
var wait = session.ready();
if (wait == null)
throw failure(PreviewFailure.Code.READINESS, "Provider returned no readiness stage", null);
wait.whenComplete(
(v, e) ->
Platform.runLater(
() -> {
if (closed) return;
if (e != null) {
failOnFx(
failure(PreviewFailure.Code.READINESS, "Example readiness failed", e));
return;
}
try {
layout(420, 240);
timer.shutdownNow();
ready.complete(this);
} catch (Throwable error) {
failOnFx(classify(error));
}
}));
} catch (Throwable e) {
failOnFx(classify(e));
} finally {
Thread.currentThread().setContextClassLoader(previous);
}
}
private static PreviewFailure failure(PreviewFailure.Code code, String text, Throwable e) {
return new PreviewFailure(code, text, e);
}
private static PreviewFailure classify(Throwable e) {
if (e instanceof PreviewFailure f) return f;
for (Throwable t = e; t != null; t = t.getCause())
if (t instanceof java.lang.reflect.InaccessibleObjectException
|| t instanceof IllegalAccessException
|| t instanceof IllegalAccessError)
return failure(
PreviewFailure.Code.MODULE_ACCESS,
"Target module access denied; inspect declared exports/opens",
e);
return failure(PreviewFailure.Code.LOAD, "NeitzelLib View load failed", e);
}
private void layout(int width, int height) {
view.getRoot().resize(width, height);
view.getRoot().applyCss();
view.getRoot().layout();
}
/** Raw, row-major, straight-alpha RGBA8 matching the wire contract. Must run on FX. */
public byte[] render(int width, int height) {
fx();
if (closed || !ready.isDone() || ready.isCompletedExceptionally())
throw new IllegalStateException("Preview not ready");
if (width < 1
|| height < 1
|| width > 8192
|| height > 8192
|| (long) width * height * 4 > 16 * 1024 * 1024)
throw new IllegalArgumentException("Image exceeds protocol limits");
layout(width, height);
var parameters = new SnapshotParameters();
parameters.setFill(Color.TRANSPARENT);
parameters.setViewport(new Rectangle2D(0, 0, width, height));
var image = view.getRoot().snapshot(parameters, new WritableImage(width, height));
byte[] bytes = new byte[width * height * 4];
var pixels = image.getPixelReader();
for (int y = 0; y < height; y++)
for (int x = 0; x < width; x++) {
int argb = pixels.getArgb(x, y), i = (y * width + x) * 4;
bytes[i] = (byte) (argb >>> 16);
bytes[i + 1] = (byte) (argb >>> 8);
bytes[i + 2] = (byte) argb;
bytes[i + 3] = (byte) (argb >>> 24);
}
return bytes;
}
private static void fx() {
if (!Platform.isFxApplicationThread())
throw new IllegalStateException("Must run on target FX thread");
}
private void failOnFx(PreviewFailure failure) {
if (!Platform.isFxApplicationThread()) {
Platform.runLater(() -> failOnFx(failure));
return;
}
if (closed || (failure.code() == PreviewFailure.Code.TIMEOUT && ready.isDone())) return;
closeOnFx(failure);
ready.completeExceptionally(failure);
}
private void closeOnFx(Throwable failure) {
fx();
if (closed) return;
closed = true;
timer.shutdownNow();
try {
if (session != null) session.close();
cleanup.complete(null);
} catch (Throwable e) {
if (failure != null) failure.addSuppressed(e);
cleanup.completeExceptionally(
failure(PreviewFailure.Code.CLEANUP, "Example cleanup failed", e));
} finally {
session = null;
view = null;
scene = null;
}
}
public void cancel() {
failOnFx(failure(PreviewFailure.Code.CANCELLED, "Preview cancelled", null));
}
@Override
public void close() {
if (Platform.isFxApplicationThread()) {
if (!ready.isDone()) cancel();
else closeOnFx(null);
} else Platform.runLater(this::close);
}
}
@@ -0,0 +1,110 @@
package de.neitzel.fxdesigner.target;
import static org.junit.jupiter.api.Assertions.*;
import java.io.IOException;
import java.net.URL;
import java.nio.file.*;
import java.util.*;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class ExampleCatalogTest {
@TempDir Path temp;
private static final String VALID =
"""
format=1
count=1
example.0.id=person
example.0.label=Person
example.0.modelType=missing.Person
example.0.providerClass=missing.Provider
example.0.viewResource=views/Person.fxml
example.0.componentPackage=missing.components
""";
URL resource(String text) throws Exception {
Path file = Files.createTempFile(temp, "catalog", ".properties");
Files.writeString(file, text);
return file.toUri().toURL();
}
@Test
void readsMetadataWithoutLoadingClasses() throws Exception {
var examples = ExampleCatalog.read(resource(VALID));
assertEquals("missing.Provider", examples.getFirst().providerClass());
assertThrows(UnsupportedOperationException.class, () -> examples.clear());
}
@Test
void rejectsUnknownDuplicateAndMissingFields() throws Exception {
for (String value :
List.of(
VALID + "extra=value\n",
VALID + "format=1\n",
VALID.replace("example.0.id=person\n", ""))) {
URL url = resource(value);
assertThrows(IOException.class, () -> ExampleCatalog.read(url));
}
}
@Test
void rejectsVersionsCountsAndUnsafeResources() throws Exception {
for (String value :
List.of(
VALID.replace("format=1", "format=2"),
VALID.replace("count=1", "count=0"),
VALID.replace("count=1", "count=1025"),
VALID.replace("views/Person.fxml", "../Person.fxml"))) {
URL url = resource(value);
assertThrows(IOException.class, () -> ExampleCatalog.read(url));
}
}
@Test
void rejectsOversizeAndMalformedUtf8() throws Exception {
URL oversized = resource("x".repeat(1024 * 1024 + 1));
assertThrows(IOException.class, () -> ExampleCatalog.read(oversized));
Path malformed = temp.resolve("malformed");
Files.write(malformed, new byte[] {(byte) 0xc3, 0x28});
assertThrows(IOException.class, () -> ExampleCatalog.read(malformed.toUri().toURL()));
}
@Test
void rejectsDuplicateIdsAcrossResources() throws Exception {
var urls = List.of(resource(VALID), resource(VALID));
var loader =
new ClassLoader(null) {
public Enumeration<URL> getResources(String name) {
assertEquals(ExampleCatalog.RESOURCE, name);
return Collections.enumeration(urls);
}
};
assertThrows(IOException.class, () -> ExampleCatalog.discover(loader));
}
@Test
void discoveryIsSortedAndBounded() throws Exception {
var urls =
List.of(
resource(VALID.replace("id=person", "id=z")),
resource(VALID.replace("id=person", "id=a")));
var loader =
new ClassLoader(null) {
public Enumeration<URL> getResources(String name) {
return Collections.enumeration(urls);
}
};
assertEquals(
List.of("a", "z"), ExampleCatalog.discover(loader).stream().map(e -> e.id()).toList());
var many = new ArrayList<URL>();
for (int i = 0; i < 129; i++) many.add(resource(VALID.replace("id=person", "id=p" + i)));
var excessive =
new ClassLoader(null) {
public Enumeration<URL> getResources(String name) {
return Collections.enumeration(many);
}
};
assertThrows(IOException.class, () -> ExampleCatalog.discover(excessive));
}
}