Validate preview sessions against real classpath and module-path fixtures
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
+110
@@ -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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user