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
+2
View File
@@ -16,3 +16,5 @@ __pycache__/
*.py[cod]
.intellijPlatform/
/integration-fixtures/**/target/
+4 -2
View File
@@ -10,6 +10,8 @@ Configuration 1.0 schemas, context selection, local JDK mappings and draft proto
The shared [Build and Refresh workflow](docs/build-and-refresh.md) now builds isolated reactor copies and publishes immutable, leased snapshots, with explicit save approval and last-successful-build selection.
The [preview API and target adapter](docs/preview-target-fixtures.md) are validated with real classpath and named-module NeitzelLib applications, including bindings, rendering, readiness and cleanup. Each preview session requires a fresh target JVM.
The Component builder fix is integrated in NeitzelLib main. A [verified local development snapshot](docs/runtime/neitzellib-baseline.md) is recorded with source/artifact identity; a corrected released version is still unestablished. NeitzelLib sources and prototype implementations are not copied here.
## Validate the foundation
@@ -20,14 +22,14 @@ Requirements: JDK 25 for builds, JDK 21 for target linkage validation, Python 3.
python3 scripts/check-bootstrap.py --idea-home "$IDEA_HOME" --target-java-home "$TARGET_JAVA_HOME"
```
On macOS `IDEA_HOME` points to the IDE application's `Contents` directory. The current SDK check uses its macOS product-info layout; other platforms are not qualified. The command checks runtime artifact hashes, clean-builds/installs the Maven modules, tests the adapters, checks bytecode baselines, runs target linkage under the supplied target JDK, builds the plugin ZIP, runs the real Maven snapshot smoke fixture, and validates documentation. It does not install or publish NeitzelLib.
On macOS `IDEA_HOME` points to the IDE application's `Contents` directory. The current SDK check uses its macOS product-info layout; other platforms are not qualified. The command checks runtime artifact hashes, clean-builds/installs the Maven modules, tests the adapters, checks bytecode baselines, runs target linkage under the supplied target JDK, builds the plugin ZIP, runs the real Maven snapshot and target-preview fixtures, and validates documentation. It does not install or publish NeitzelLib.
Maven is pinned to **3.9.11** (Wrapper **3.3.4**); the separate IntelliJ build uses **Gradle 9.1.0** and IntelliJ Platform Gradle Plugin **2.20.1**. Wrapper distributions have pinned checksums. Shared-module-only validation is `./mvnw -B -ntp clean verify`; it does not qualify IntelliJ packaging. Plugin output is under `designer-intellij/build/distributions/`.
## Implementation references
- [Initial project contract](docs/initial-project-contract.md): module responsibilities, configuration, snapshots, runtimes, preview sessions and communication.
- [Repository bootstrap plan](docs/repository-bootstrap-plan.md): ordered work packages and acceptance gates. Next: validate the preview API and target adapter against real fixtures.
- [Repository bootstrap plan](docs/repository-bootstrap-plan.md): ordered work packages and acceptance gates. Next: connect the first workflow in both minimal hosts.
- [Decision provenance](docs/provenance.md): workspace planning and captured prototype evidence.
- [Third-party notices](THIRD-PARTY-NOTICES.md): Maven wrapper provenance.
+11
View File
@@ -19,6 +19,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.contracts</Automatic-Module-Name>
</manifestEntries>
</archive>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-enforcer-plugin</artifactId>
+11
View File
@@ -19,6 +19,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.preview</Automatic-Module-Name>
</manifestEntries>
</archive>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-enforcer-plugin</artifactId>
@@ -1,12 +1,20 @@
package de.neitzel.fxdesigner.preview;
import java.util.Objects;
/** Non-executing example metadata. Factory/session signatures await real target fixtures. */
public record ExampleDescriptor(String id, String label, String modelType) {
/** Resource-only metadata; reading this record must not initialize application classes. */
public record ExampleDescriptor(
String id,
String label,
String modelType,
String providerClass,
String viewResource,
String componentPackage) {
public ExampleDescriptor {
if (id == null || id.isBlank()) throw new IllegalArgumentException("Missing example ID");
Objects.requireNonNull(label);
Objects.requireNonNull(modelType);
for (String s :
new String[] {id, label, modelType, providerClass, viewResource, componentPackage})
if (s == null || s.isBlank() || s.length() > 4096)
throw new IllegalArgumentException("Invalid example metadata");
if (viewResource.startsWith("/") || viewResource.contains("..") || viewResource.contains(":"))
throw new IllegalArgumentException("View resource must be a classloader-relative resource");
}
}
@@ -0,0 +1,11 @@
package de.neitzel.fxdesigner.preview;
/** Created only after example selection, in a fresh target JVM on its FX application thread. */
public interface PreviewProvider<M, C> {
Class<M> modelType();
/**
* Must create a fresh Model graph and context/services; clean partial allocations if this throws.
*/
PreviewSession<M, C> open(String exampleId) throws Exception;
}
@@ -0,0 +1,23 @@
package de.neitzel.fxdesigner.preview;
import java.util.concurrent.*;
/** Owned example state. The NeitzelLib adapter requires C to be an ApplicationContext. */
public interface PreviewSession<M, C> extends AutoCloseable {
M model();
C context();
/**
* Called after View loading/CSS/layout, on FX. Never block FX; complete asynchronously if needed.
*/
default CompletionStage<Void> ready() {
return CompletableFuture.completedFuture(null);
}
/**
* Release owned services/timers/listeners. Called once on FX, including load/readiness failure.
*/
@Override
void close() throws Exception;
}
+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));
}
}
+2
View File
@@ -100,6 +100,8 @@ FXML previews overlay the owning host's current parent and open Component drafts
## Discovery and preview API
Work package 6 now qualifies the [development API and target adapter](preview-target-fixtures.md) against real classpath/module-path fixtures. Session cleanup is cooperative; a fresh target JVM is required to release NeitzelLib process-wide Model/ViewModel state.
Metadata listing must not construct Models or invoke Controller methods. Separate example descriptors (stable IDs, display labels, compatible Model/View information and declared capabilities) from runtime construction. Generated descriptor metadata or equivalent non-constructing discovery must be tested; calling arbitrary provider initialization merely to list examples does not satisfy this boundary.
Selecting an example creates a fresh owned preview session with its Model graph and explicitly configured services. Provide readiness and cleanup hooks. Ordinary examples become ready after load/CSS/layout; asynchronous examples signal readiness subject to cancellation/timeout. Construction, services, Controller actions and cleanup execute only in the target JVM. Verify the adapter against NeitzelLib's actual injection/loading lifecycle before publishing Java signatures.
+71
View File
@@ -0,0 +1,71 @@
# Preview API and target adapter qualification
Work package 6 is implemented against the [verified NeitzelLib development artifact](runtime/neitzellib-baseline.md), Java 21 and application JavaFX 21.0.7. This qualifies target-side execution; it does not connect the designer UI, snapshot activation or worker transport. Those remain work package 7.
## Discovery and construction
The dependency-free Java 21 preview API contains:
- `ExampleDescriptor(id, label, modelType, providerClass, viewResource, componentPackage)`.
- `PreviewProvider<M,C>`: `modelType()` and `open(exampleId)`.
- `PreviewSession<M,C>`: `model()`, `context()`, asynchronous `ready()` and `close()`.
The NeitzelLib target adapter requires an `ApplicationContext` context. Providers have public no-argument constructors. They create fresh Models and explicitly configured services after selection, on the target FX thread. They own cleanup of partial allocations when construction throws before returning a session. The adapter owns the returned session and calls its cleanup once on load failure, readiness failure, timeout, cancellation or normal close.
Discovery reads UTF-8 `META-INF/neitzel-fx-designer/examples.properties` resources without loading provider or Model classes. This development metadata contract has format version 1:
```properties
format=1
count=1
example.0.id=standard
example.0.label=Standard person
example.0.modelType=fixture.model.Person
example.0.providerClass=fixture.preview.Examples
example.0.viewResource=fixture/components/Person.fxml
example.0.componentPackage=fixture.components
```
Resources are classloader-relative. Catalogs reject unknown/missing/duplicate keys, duplicate IDs across resources, invalid UTF-8 and unsupported versions. Limits are 1 MiB per catalog, 128 catalogs, 1,024 total examples and 4,096 characters per descriptor field. Discovery returns immutable descriptors ordered by ID. No ServiceLoader factory is invoked to list examples.
After selection the adapter checks provider/Model compatibility, invokes NeitzelLib's real `ViewLoader`, creates a Scene and applies CSS/layout before asking for readiness. Readiness must never block FX. Rendering produces bounded row-major straight-alpha RGBA8 using the target's JavaFX. Structured failures distinguish module access, invalid Model/context/provider/resource, loading, readiness, cancellation, timeout and cleanup. Original exception causes remain available.
These are tested development signatures, not a published API release or a promise of compatibility with every NeitzelLib version.
## Lifecycle boundary established by the fixtures
NeitzelLib's `ViewModels` retains Model/ViewModel identities in a process-wide identity map with no public disposal operation. Closing a session releases provider-owned resources but cannot clear that map. Consequently `TargetPreview` allows **one session per target JVM**, including failed sessions. Every restart and interactive preview needs a fresh process.
The adapter's readiness deadline and cleanup are cooperative FX-thread operations. A blocked constructor, Controller or cleanup hook cannot be interrupted by the adapter. Work package 7 must enforce an external process deadline, bounded graceful shutdown and forced termination using the host-owned lifetime channel. The fixture runner enforces a 30-second process timeout. Session close is not proof that library-held objects have been garbage-collected.
## Classpath and module path
The [fixtures](../integration-fixtures/preview/) are ordinary Maven applications depending on artifacts, without copies of library source or Kit/IntelliJ dependencies:
- Classpath: application in main sources; provider, descriptors and executable checks in test sources.
- Module path: named application, separate named preview module and named check launcher. Application and preview dependencies are declared normally in Maven.
The preview API, contracts and target tools now publish stable `Automatic-Module-Name` manifest entries: `de.neitzel.fxdesigner.preview`, `de.neitzel.fxdesigner.contracts`, and `de.neitzel.fxdesigner.target`. They are automatic modules, not fully modularized libraries. The qualified NeitzelLib artifacts resolve as automatic modules `fx`, `injection` and `core`.
The fixture's Component/Controller package is unconditionally open for NeitzelLib's Reflections package discovery and FXML injection. The Model package opens to `fx` for bean binding access. The provider package exports to the target adapter and check launcher. See the actual fixture module descriptors for the complete requires/exports declarations. Removing either Component openness or Model openness produces a structured `MODULE_ACCESS` failure. No broad command-line `--add-opens` repairs are applied.
This qualifies the boot module layer and these packages, not arbitrary custom module layers or every application package topology.
## Reproduce and evidence
After installing the designer reactor and verified NeitzelLib artifact:
```sh
python3 scripts/check-preview-fixtures.py --target-java-home "$TARGET_JAVA_HOME"
```
The same suite runs from `scripts/check-bootstrap.py`. It builds temporary copies of both fixture reactors with Java 21 and launches 24 target processes, covering 346 assertions:
- Metadata discovery without provider initialization or Model/Controller construction.
- Constructor-injected Components, post-initialization custom attributes, inherited attribute coercion and invalid attributes.
- Named ViewModel selection, Controller handoff, configured services, parent and nested bindings, write-back, shared nested Model/ViewModel identity and distinct Component instances.
- Nonempty rendered images and image-size limits.
- Delayed readiness, timeout, cancellation, factory/load/readiness/cleanup failure and exactly-once cleanup.
- Fresh Models/services in separate JVMs and rejection of session reuse.
- Missing Component and Model module access.
Logs and machine-readable results go to `designer-target-tools/target/preview-fixtures/`. Unit tests additionally validate hostile/malformed metadata. The suite requires a graphical environment capable of initializing JavaFX; macOS is qualified here. It does not validate headless rendering, Windows/Linux packaging, real worker IPC or either host UI.
+3 -1
View File
@@ -1,6 +1,6 @@
# NeitzelFXDesigner repository bootstrap plan
Status: repository foundation implemented by the initial commit; work package 2 has a verified development artifact; work package 3 implements the module/service foundation; work package 4 implements configuration and protocol validation; work package 5 implements Build and Refresh with immutable snapshots; work packages 6–7 remain planned. Consolidated 2026-11-12. Scope is the ordered implementation start for the [accepted initial contract](initial-project-contract.md); the initial commit creates the foundation only and does not publish a library release.
Status: repository foundation implemented by the initial commit; work package 2 has a verified development artifact; work package 3 implements the module/service foundation; work package 4 implements configuration and protocol validation; work package 5 implements Build and Refresh with immutable snapshots; work package 6 validates the preview API and target adapter with real fixtures; work package 7 remains planned. Consolidated 2026-11-12. Scope is the ordered implementation start for the [accepted initial contract](initial-project-contract.md); the initial commit creates the foundation only and does not publish a library release.
## Starting-point evidence before bootstrap
@@ -74,6 +74,8 @@ Resolve scoped Maven builds, preview source arrangements, explicit save/cancel a
### 6. Validate the preview API and target adapter against real fixtures
Implemented: resource-only discovery, typed sessions, real NeitzelLib loading/rendering and 24 classpath/module-path process checks. See [qualification and lifecycle limits](preview-target-fixtures.md). Library-held Model/ViewModel state requires a fresh JVM per session; external shutdown enforcement remains work package 7.
Add classpath/module-path Maven fixture applications using NeitzelLib artifacts. Implement non-constructing descriptor discovery and typed provider/session lifecycle against the real library. Test fresh Models/services, readiness, cleanup, invalid attributes and write-back. Freeze public Java signatures only after these tests establish the adapter contract.
**Exit:** standalone target-side tests pass without Kit/IntelliJ dependencies leaking into application-facing artifacts; both preview source arrangements are represented.
@@ -0,0 +1,24 @@
<?xml version="1.0" ?>
<project xmlns="http://maven.apache.org/POM/4.0.0">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>de.neitzel.fxdesigner.fixtures</groupId>
<artifactId>preview-classpath</artifactId>
<version>1</version>
</parent>
<artifactId>app-classpath</artifactId>
<dependencies>
<dependency>
<groupId>de.neitzel.lib</groupId>
<artifactId>fx</artifactId>
<version>1.0-SNAPSHOT</version>
<scope>compile</scope>
</dependency>
<dependency>
<groupId>de.neitzel.fxdesigner</groupId>
<artifactId>designer-target-tools</artifactId>
<version>0.1.0-SNAPSHOT</version>
<scope>test</scope>
</dependency>
</dependencies>
</project>
@@ -0,0 +1,51 @@
package fixture.components;
@jakarta.inject.Singleton
public class Card extends javafx.scene.layout.VBox implements de.neitzel.fx.view.HasViewModel {
public final Greeting greeting;
private final javafx.beans.property.ObjectProperty<Object> model =
new javafx.beans.property.SimpleObjectProperty<>();
@javafx.fxml.FXML private javafx.scene.control.Label captionLabel;
@javafx.fxml.FXML private javafx.scene.control.TextField street;
public de.neitzel.fx.view.ViewModel received;
public boolean injected;
public Card(Greeting greeting) {
this.greeting = greeting;
}
@javafx.fxml.FXML
public void initialize() {
injected = true;
captionLabel.setText(greeting.prefix);
}
public String getCaption() {
return captionLabel.getText();
}
public void setCaption(String value) {
if (!injected) throw new IllegalStateException("Caption applied before injection");
captionLabel.setText(value);
}
public javafx.beans.property.ObjectProperty<Object> modelProperty() {
return model;
}
public Object getModel() {
return model.get();
}
public void setModel(Object value) {
model.set(value);
}
public void setViewModel(de.neitzel.fx.view.ViewModel vm) {
received = vm;
}
public javafx.scene.control.TextField street() {
return street;
}
}
@@ -0,0 +1,16 @@
package fixture.components;
@jakarta.inject.Singleton
public class Greeting implements AutoCloseable {
public final String identity = java.util.UUID.randomUUID().toString();
public String prefix = "sample";
public int closes;
public void close() {
closes++;
}
public String text() {
return prefix + "-" + identity;
}
}
@@ -0,0 +1,22 @@
package fixture.components;
@jakarta.inject.Singleton
@de.neitzel.fx.view.ViewModelClass(PersonViewModel.class)
public class PersonController implements de.neitzel.fx.view.HasViewModel {
public final Greeting greeting;
public de.neitzel.fx.view.ViewModel received;
public PersonController(Greeting greeting) {
this.greeting = greeting;
System.setProperty("fixture.controllers", "constructed");
}
public void setViewModel(de.neitzel.fx.view.ViewModel vm) {
received = vm;
}
public String greeting() {
if (received == null) throw new IllegalStateException("No ViewModel before bindings");
return greeting.text();
}
}
@@ -0,0 +1,7 @@
package fixture.components;
public class PersonViewModel extends de.neitzel.fx.view.ViewModel {
public PersonViewModel(Object model) {
super(model);
}
}
@@ -0,0 +1,13 @@
package fixture.model;
public class Address {
private String street = "First Street";
public String getStreet() {
return street;
}
public void setStreet(String street) {
this.street = street;
}
}
@@ -0,0 +1,28 @@
package fixture.model;
public class Person {
private String name = "Ada";
private Address home = new Address();
public Person() {
System.setProperty(
"fixture.models",
Integer.toString(Integer.parseInt(System.getProperty("fixture.models", "0")) + 1));
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public Address getHome() {
return home;
}
public void setHome(Address home) {
this.home = home;
}
}
@@ -0,0 +1,2 @@
<?import javafx.scene.layout.VBox?><?import javafx.scene.control.Label?><?import javafx.scene.control.TextField?><?import de.neitzel.fx.view.BindingList?><?import de.neitzel.fx.view.DirectBinding?>
<fx:root type="javafx.scene.layout.VBox" xmlns:fx="http://javafx.com/fxml/1"><fx:define><BindingList><DirectBinding control="street" property="text" path="street" direction="both"/></BindingList></fx:define><Label fx:id="captionLabel"/><TextField fx:id="street"/></fx:root>
@@ -0,0 +1,2 @@
<?import javafx.scene.layout.VBox?><?import javafx.scene.control.Label?><?import javafx.scene.control.TextField?><?import fixture.components.Card?><?import de.neitzel.fx.view.BindingList?><?import de.neitzel.fx.view.DirectBinding?><?import de.neitzel.fx.view.ComputedBinding?>
<VBox xmlns:fx="http://javafx.com/fxml/1" fx:controller="fixture.components.PersonController" prefWidth="420" prefHeight="240"><fx:define><BindingList><DirectBinding control="name" property="text" path="name" direction="both"/><DirectBinding control="card" property="model" path="home" direction="both"/><DirectBinding control="second" property="model" path="home" direction="both"/><ComputedBinding control="greeting" property="text" method="#greeting"/></BindingList></fx:define><TextField fx:id="name"/><Label fx:id="greeting"/><Card fx:id="card" caption="FXML caption" prefWidth="not-a-number" alignment="CENTER_LEFT"/><Card fx:id="second" caption="Second"/></VBox>
@@ -0,0 +1,2 @@
<?import javafx.scene.layout.VBox?><?import javafx.scene.control.Label?><?import javafx.scene.control.TextField?><?import fixture.components.Card?><?import de.neitzel.fx.view.BindingList?><?import de.neitzel.fx.view.DirectBinding?><?import de.neitzel.fx.view.ComputedBinding?>
<VBox xmlns:fx="http://javafx.com/fxml/1" fx:controller="fixture.components.PersonController" prefWidth="420" prefHeight="240"><fx:define><BindingList><DirectBinding control="name" property="text" path="name" direction="both"/><DirectBinding control="card" property="model" path="home" direction="both"/><DirectBinding control="second" property="model" path="home" direction="both"/><ComputedBinding control="greeting" property="text" method="#greeting"/></BindingList></fx:define><TextField fx:id="name"/><Label fx:id="greeting"/><Card fx:id="card" caption="FXML caption" prefWidth="310" alignment="CENTER_LEFT"/><Card fx:id="second" caption="Second"/></VBox>
@@ -0,0 +1,197 @@
package fixture.checks;
import de.neitzel.fxdesigner.preview.*;
import de.neitzel.fxdesigner.target.*;
import fixture.components.*;
import fixture.model.*;
import fixture.preview.Examples;
import java.time.Duration;
import java.util.*;
import java.util.concurrent.*;
import javafx.application.Platform;
import javafx.geometry.Pos;
import javafx.scene.control.*;
public final class PreviewChecks {
static int checks;
static void check(boolean b, String message) {
if (!b) throw new AssertionError(message);
checks++;
}
static <T> T fx(Callable<T> action) throws Exception {
var result = new CompletableFuture<T>();
Platform.runLater(
() -> {
try {
result.complete(action.call());
} catch (Throwable e) {
result.completeExceptionally(e);
}
});
return result.get(10, TimeUnit.SECONDS);
}
static PreviewFailure failure(Throwable error) {
for (Throwable t = error; t != null; t = t.getCause())
if (t instanceof PreviewFailure f) return f;
throw new AssertionError("No structured preview diagnostic", error);
}
public static void main(String[] args) throws Exception {
String mode = args[0];
ClassLoader loader = PreviewChecks.class.getClassLoader();
var descriptors = ExampleCatalog.discover(loader);
check(descriptors.size() == 9, "Descriptor discovery");
check(
System.getProperty("fixture.provider.initialized") == null,
"Discovery initialized provider");
check(System.getProperty("fixture.models") == null, "Discovery constructed Model");
check(System.getProperty("fixture.controllers") == null, "Discovery constructed Controller");
if (mode.equals("discovery")) {
System.out.println("PASS discovery " + checks);
return;
}
var started = new CountDownLatch(1);
Platform.startup(
() -> {
Platform.setImplicitExit(false);
started.countDown();
});
check(started.await(10, TimeUnit.SECONDS), "FX startup");
TargetPreview preview = null;
try {
String id = mode.startsWith("missing-") ? "standard" : mode;
var descriptor =
descriptors.stream().filter(d -> d.id().equals(id)).findFirst().orElseThrow();
preview =
TargetPreview.open(
descriptor, loader, Duration.ofMillis(mode.equals("timeout") ? 2000 : 10000));
if (mode.equals("async") || mode.equals("cancel")) {
for (int i = 0; i < 100 && Examples.pending == null; i++) Thread.sleep(20);
check(Examples.pending != null, "Provider readiness created");
check(!preview.ready().toCompletableFuture().isDone(), "Premature readiness");
if (mode.equals("async")) Examples.pending.complete(null);
else preview.cancel();
}
if (java.util.Set.of(
"timeout",
"cancel",
"readiness-failure",
"wrong-model",
"invalid-attribute",
"factory-failure",
"missing-components",
"missing-model")
.contains(mode)) {
try {
preview.ready().toCompletableFuture().get(15, TimeUnit.SECONDS);
throw new AssertionError("Failure accepted: " + mode);
} catch (ExecutionException error) {
var f = failure(error);
var expected =
switch (mode) {
case "timeout" -> PreviewFailure.Code.TIMEOUT;
case "cancel" -> PreviewFailure.Code.CANCELLED;
case "readiness-failure" -> PreviewFailure.Code.READINESS;
case "wrong-model" -> PreviewFailure.Code.MODEL_TYPE;
case "missing-components", "missing-model" -> PreviewFailure.Code.MODULE_ACCESS;
default -> PreviewFailure.Code.LOAD;
};
check(f.code() == expected, "Wrong diagnostic: " + f.code() + " expected " + expected);
}
preview.cleanup().toCompletableFuture().get(5, TimeUnit.SECONDS);
if (!mode.equals("missing-components")) {
check(Examples.service.closes == 1, "Failure did not clean services once");
if (!mode.equals("factory-failure"))
check(Examples.closes == 1, "Session cleanup missing");
}
} else {
TargetPreview ready = preview.ready().toCompletableFuture().get(15, TimeUnit.SECONDS);
fx(
() -> {
var view = ready.loadedView();
check(view.getViewModel() instanceof PersonViewModel, "Named ViewModel not selected");
var controller = Examples.context.getComponent(PersonController.class);
check(controller.received == view.getViewModel(), "Controller ViewModel handoff");
check(controller.greeting == Examples.service, "Configured service not injected");
var card = (Card) view.getRoot().lookup("#card");
var second = (Card) view.getRoot().lookup("#second");
check(card != second, "Component instances reused");
check(
card.getModel() == Examples.person.getHome()
&& second.getModel() == card.getModel(),
"Nested Model identity not shared");
check(card.received == second.received, "Nested ViewModel identity changed");
check(
card.greeting == Examples.service && second.greeting == Examples.service,
"Component injection");
check(card.getCaption().equals("FXML caption"), "Custom caption lost");
check(
card.getPrefWidth() == 310 && card.getAlignment() == Pos.CENTER_LEFT,
"Inherited/coerced attributes lost");
check(card.injected, "Component initialization");
TextField name = (TextField) view.getRoot().lookup("#name");
check(name.getText().equals("Ada"), "Initial parent binding");
name.setText("Grace");
check(Examples.person.getName().equals("Grace"), "Parent write-back");
card.street().setText("Changed Street");
check(
Examples.person.getHome().getStreet().equals("Changed Street")
&& second.street().getText().equals("Changed Street"),
"Component write-back and shared Model");
check(
((Label) view.getRoot().lookup("#greeting")).getText().startsWith("configured-"),
"Computed controller binding");
byte[] rgba = ready.render(420, 240);
check(rgba.length == 420 * 240 * 4, "Render size");
boolean nonzero = false;
for (byte value : rgba)
if (value != 0) {
nonzero = true;
break;
}
check(nonzero, "Blank rendering");
try {
ready.render(8192, 8192);
throw new AssertionError("Oversized image allowed");
} catch (IllegalArgumentException expected) {
checks++;
}
return null;
});
System.out.println(
"SESSION "
+ Examples.service.identity
+ " models="
+ System.getProperty("fixture.models"));
preview.close();
try {
preview.cleanup().toCompletableFuture().get(5, TimeUnit.SECONDS);
check(!mode.equals("cleanup-failure"), "Cleanup failure hidden");
} catch (ExecutionException error) {
check(
mode.equals("cleanup-failure")
&& failure(error).code() == PreviewFailure.Code.CLEANUP,
"Wrong cleanup diagnostic");
}
check(
Examples.closes == 1 && Examples.service.closes == 1,
"Owned resources not cleaned exactly once");
preview.close();
check(Examples.closes == 1, "Repeated close cleaned twice");
}
try {
TargetPreview.open(descriptor, loader, Duration.ofSeconds(1));
throw new AssertionError("Process session reused");
} catch (PreviewFailure e) {
check(e.code() == PreviewFailure.Code.SESSION_REUSE, "Wrong reuse diagnostic");
}
System.out.println("PASS " + mode + " " + checks);
} finally {
if (preview != null) preview.close();
Platform.exit();
}
}
}
@@ -0,0 +1,62 @@
package fixture.preview;
import de.neitzel.fxdesigner.preview.*;
import de.neitzel.injection.ApplicationContext;
import fixture.components.*;
import fixture.model.Person;
import java.util.concurrent.*;
public final class Examples implements PreviewProvider<Person, ApplicationContext> {
static {
System.setProperty("fixture.provider.initialized", "true");
}
public static Person person;
public static ApplicationContext context;
public static Greeting service;
public static volatile CompletableFuture<Void> pending;
public static int closes;
public Class<Person> modelType() {
return Person.class;
}
public PreviewSession<Person, ApplicationContext> open(String id) throws Exception {
if (!javafx.application.Platform.isFxApplicationThread())
throw new AssertionError("Provider not on FX");
person = new Person();
context = new ApplicationContext("fixture.components");
service = context.getComponent(Greeting.class);
service.prefix = "configured";
if (id.equals("factory-failure")) {
service.close();
throw new IllegalStateException("Factory failed after cleaning partial resources");
}
pending = new CompletableFuture<>();
if (!java.util.Set.of("async", "timeout", "cancel", "readiness-failure").contains(id))
pending.complete(null);
return new PreviewSession<>() {
public Person model() {
return id.equals("wrong-model") ? null : person;
}
public ApplicationContext context() {
return context;
}
public CompletionStage<Void> ready() {
if (id.equals("readiness-failure"))
return CompletableFuture.failedFuture(new IllegalStateException("Readiness failed"));
return pending;
}
public void close() {
if (!javafx.application.Platform.isFxApplicationThread())
throw new AssertionError("Cleanup not on FX");
closes++;
service.close();
if (id.equals("cleanup-failure")) throw new IllegalStateException("Cleanup failure");
}
};
}
}
@@ -0,0 +1,56 @@
format=1
count=9
example.0.id=standard
example.0.label=standard
example.0.modelType=fixture.model.Person
example.0.providerClass=fixture.preview.Examples
example.0.viewResource=fixture/components/Person.fxml
example.0.componentPackage=fixture.components
example.1.id=async
example.1.label=async
example.1.modelType=fixture.model.Person
example.1.providerClass=fixture.preview.Examples
example.1.viewResource=fixture/components/Person.fxml
example.1.componentPackage=fixture.components
example.2.id=timeout
example.2.label=timeout
example.2.modelType=fixture.model.Person
example.2.providerClass=fixture.preview.Examples
example.2.viewResource=fixture/components/Person.fxml
example.2.componentPackage=fixture.components
example.3.id=cancel
example.3.label=cancel
example.3.modelType=fixture.model.Person
example.3.providerClass=fixture.preview.Examples
example.3.viewResource=fixture/components/Person.fxml
example.3.componentPackage=fixture.components
example.4.id=readiness-failure
example.4.label=readiness-failure
example.4.modelType=fixture.model.Person
example.4.providerClass=fixture.preview.Examples
example.4.viewResource=fixture/components/Person.fxml
example.4.componentPackage=fixture.components
example.5.id=wrong-model
example.5.label=wrong-model
example.5.modelType=fixture.model.Person
example.5.providerClass=fixture.preview.Examples
example.5.viewResource=fixture/components/Person.fxml
example.5.componentPackage=fixture.components
example.6.id=invalid-attribute
example.6.label=invalid-attribute
example.6.modelType=fixture.model.Person
example.6.providerClass=fixture.preview.Examples
example.6.viewResource=fixture/components/Invalid.fxml
example.6.componentPackage=fixture.components
example.7.id=cleanup-failure
example.7.label=cleanup-failure
example.7.modelType=fixture.model.Person
example.7.providerClass=fixture.preview.Examples
example.7.viewResource=fixture/components/Person.fxml
example.7.componentPackage=fixture.components
example.8.id=factory-failure
example.8.label=factory-failure
example.8.modelType=fixture.model.Person
example.8.providerClass=fixture.preview.Examples
example.8.viewResource=fixture/components/Person.fxml
example.8.componentPackage=fixture.components
@@ -0,0 +1,15 @@
{
"schemaVersion": "1.0",
"contexts": {
"fixture": {
"applicationModule": "app",
"launchMode": "classpath",
"buildRuntime": "java21",
"targetRuntime": "java21",
"preview": {
"sourceSet": "test",
"provider": "fixture.preview.Examples"
}
}
}
}
@@ -0,0 +1,41 @@
<?xml version="1.0" ?>
<project xmlns="http://maven.apache.org/POM/4.0.0">
<modelVersion>4.0.0</modelVersion>
<groupId>de.neitzel.fxdesigner.fixtures</groupId>
<artifactId>preview-classpath</artifactId>
<version>1</version>
<packaging>pom</packaging>
<modules>
<module>app</module>
</modules>
<properties>
<maven.compiler.release>21</maven.compiler.release>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<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>
@@ -0,0 +1,18 @@
<?xml version="1.0" ?>
<project xmlns="http://maven.apache.org/POM/4.0.0">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>de.neitzel.fxdesigner.fixtures</groupId>
<artifactId>preview-fixture-modulepath</artifactId>
<version>1</version>
</parent>
<artifactId>app-modulepath</artifactId>
<dependencies>
<dependency>
<groupId>de.neitzel.lib</groupId>
<artifactId>fx</artifactId>
<version>1.0-SNAPSHOT</version>
<scope>compile</scope>
</dependency>
</dependencies>
</project>
@@ -0,0 +1,51 @@
package fixture.components;
@jakarta.inject.Singleton
public class Card extends javafx.scene.layout.VBox implements de.neitzel.fx.view.HasViewModel {
public final Greeting greeting;
private final javafx.beans.property.ObjectProperty<Object> model =
new javafx.beans.property.SimpleObjectProperty<>();
@javafx.fxml.FXML private javafx.scene.control.Label captionLabel;
@javafx.fxml.FXML private javafx.scene.control.TextField street;
public de.neitzel.fx.view.ViewModel received;
public boolean injected;
public Card(Greeting greeting) {
this.greeting = greeting;
}
@javafx.fxml.FXML
public void initialize() {
injected = true;
captionLabel.setText(greeting.prefix);
}
public String getCaption() {
return captionLabel.getText();
}
public void setCaption(String value) {
if (!injected) throw new IllegalStateException("Caption applied before injection");
captionLabel.setText(value);
}
public javafx.beans.property.ObjectProperty<Object> modelProperty() {
return model;
}
public Object getModel() {
return model.get();
}
public void setModel(Object value) {
model.set(value);
}
public void setViewModel(de.neitzel.fx.view.ViewModel vm) {
received = vm;
}
public javafx.scene.control.TextField street() {
return street;
}
}
@@ -0,0 +1,16 @@
package fixture.components;
@jakarta.inject.Singleton
public class Greeting implements AutoCloseable {
public final String identity = java.util.UUID.randomUUID().toString();
public String prefix = "sample";
public int closes;
public void close() {
closes++;
}
public String text() {
return prefix + "-" + identity;
}
}
@@ -0,0 +1,22 @@
package fixture.components;
@jakarta.inject.Singleton
@de.neitzel.fx.view.ViewModelClass(PersonViewModel.class)
public class PersonController implements de.neitzel.fx.view.HasViewModel {
public final Greeting greeting;
public de.neitzel.fx.view.ViewModel received;
public PersonController(Greeting greeting) {
this.greeting = greeting;
System.setProperty("fixture.controllers", "constructed");
}
public void setViewModel(de.neitzel.fx.view.ViewModel vm) {
received = vm;
}
public String greeting() {
if (received == null) throw new IllegalStateException("No ViewModel before bindings");
return greeting.text();
}
}
@@ -0,0 +1,7 @@
package fixture.components;
public class PersonViewModel extends de.neitzel.fx.view.ViewModel {
public PersonViewModel(Object model) {
super(model);
}
}
@@ -0,0 +1,13 @@
package fixture.model;
public class Address {
private String street = "First Street";
public String getStreet() {
return street;
}
public void setStreet(String street) {
this.street = street;
}
}
@@ -0,0 +1,28 @@
package fixture.model;
public class Person {
private String name = "Ada";
private Address home = new Address();
public Person() {
System.setProperty(
"fixture.models",
Integer.toString(Integer.parseInt(System.getProperty("fixture.models", "0")) + 1));
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public Address getHome() {
return home;
}
public void setHome(Address home) {
this.home = home;
}
}
@@ -0,0 +1,18 @@
module fixture.app {
requires fx;
requires injection;
requires jakarta.inject;
requires javafx.controls;
requires javafx.fxml;
exports fixture.model to
fixture.preview,
fixture.checks;
exports fixture.components to
fixture.preview,
fixture.checks;
opens fixture.components;
opens fixture.model to
fx;
}
@@ -0,0 +1,2 @@
<?import javafx.scene.layout.VBox?><?import javafx.scene.control.Label?><?import javafx.scene.control.TextField?><?import de.neitzel.fx.view.BindingList?><?import de.neitzel.fx.view.DirectBinding?>
<fx:root type="javafx.scene.layout.VBox" xmlns:fx="http://javafx.com/fxml/1"><fx:define><BindingList><DirectBinding control="street" property="text" path="street" direction="both"/></BindingList></fx:define><Label fx:id="captionLabel"/><TextField fx:id="street"/></fx:root>
@@ -0,0 +1,2 @@
<?import javafx.scene.layout.VBox?><?import javafx.scene.control.Label?><?import javafx.scene.control.TextField?><?import fixture.components.Card?><?import de.neitzel.fx.view.BindingList?><?import de.neitzel.fx.view.DirectBinding?><?import de.neitzel.fx.view.ComputedBinding?>
<VBox xmlns:fx="http://javafx.com/fxml/1" fx:controller="fixture.components.PersonController" prefWidth="420" prefHeight="240"><fx:define><BindingList><DirectBinding control="name" property="text" path="name" direction="both"/><DirectBinding control="card" property="model" path="home" direction="both"/><DirectBinding control="second" property="model" path="home" direction="both"/><ComputedBinding control="greeting" property="text" method="#greeting"/></BindingList></fx:define><TextField fx:id="name"/><Label fx:id="greeting"/><Card fx:id="card" caption="FXML caption" prefWidth="not-a-number" alignment="CENTER_LEFT"/><Card fx:id="second" caption="Second"/></VBox>
@@ -0,0 +1,2 @@
<?import javafx.scene.layout.VBox?><?import javafx.scene.control.Label?><?import javafx.scene.control.TextField?><?import fixture.components.Card?><?import de.neitzel.fx.view.BindingList?><?import de.neitzel.fx.view.DirectBinding?><?import de.neitzel.fx.view.ComputedBinding?>
<VBox xmlns:fx="http://javafx.com/fxml/1" fx:controller="fixture.components.PersonController" prefWidth="420" prefHeight="240"><fx:define><BindingList><DirectBinding control="name" property="text" path="name" direction="both"/><DirectBinding control="card" property="model" path="home" direction="both"/><DirectBinding control="second" property="model" path="home" direction="both"/><ComputedBinding control="greeting" property="text" method="#greeting"/></BindingList></fx:define><TextField fx:id="name"/><Label fx:id="greeting"/><Card fx:id="card" caption="FXML caption" prefWidth="310" alignment="CENTER_LEFT"/><Card fx:id="second" caption="Second"/></VBox>
@@ -0,0 +1,24 @@
<?xml version="1.0" ?>
<project xmlns="http://maven.apache.org/POM/4.0.0">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>de.neitzel.fxdesigner.fixtures</groupId>
<artifactId>preview-fixture-modulepath</artifactId>
<version>1</version>
</parent>
<artifactId>checks-modulepath</artifactId>
<dependencies>
<dependency>
<groupId>de.neitzel.fxdesigner.fixtures</groupId>
<artifactId>preview-modulepath</artifactId>
<version>1</version>
<scope>compile</scope>
</dependency>
<dependency>
<groupId>de.neitzel.fxdesigner</groupId>
<artifactId>designer-target-tools</artifactId>
<version>0.1.0-SNAPSHOT</version>
<scope>compile</scope>
</dependency>
</dependencies>
</project>
@@ -0,0 +1,197 @@
package fixture.checks;
import de.neitzel.fxdesigner.preview.*;
import de.neitzel.fxdesigner.target.*;
import fixture.components.*;
import fixture.model.*;
import fixture.preview.Examples;
import java.time.Duration;
import java.util.*;
import java.util.concurrent.*;
import javafx.application.Platform;
import javafx.geometry.Pos;
import javafx.scene.control.*;
public final class PreviewChecks {
static int checks;
static void check(boolean b, String message) {
if (!b) throw new AssertionError(message);
checks++;
}
static <T> T fx(Callable<T> action) throws Exception {
var result = new CompletableFuture<T>();
Platform.runLater(
() -> {
try {
result.complete(action.call());
} catch (Throwable e) {
result.completeExceptionally(e);
}
});
return result.get(10, TimeUnit.SECONDS);
}
static PreviewFailure failure(Throwable error) {
for (Throwable t = error; t != null; t = t.getCause())
if (t instanceof PreviewFailure f) return f;
throw new AssertionError("No structured preview diagnostic", error);
}
public static void main(String[] args) throws Exception {
String mode = args[0];
ClassLoader loader = PreviewChecks.class.getClassLoader();
var descriptors = ExampleCatalog.discover(loader);
check(descriptors.size() == 9, "Descriptor discovery");
check(
System.getProperty("fixture.provider.initialized") == null,
"Discovery initialized provider");
check(System.getProperty("fixture.models") == null, "Discovery constructed Model");
check(System.getProperty("fixture.controllers") == null, "Discovery constructed Controller");
if (mode.equals("discovery")) {
System.out.println("PASS discovery " + checks);
return;
}
var started = new CountDownLatch(1);
Platform.startup(
() -> {
Platform.setImplicitExit(false);
started.countDown();
});
check(started.await(10, TimeUnit.SECONDS), "FX startup");
TargetPreview preview = null;
try {
String id = mode.startsWith("missing-") ? "standard" : mode;
var descriptor =
descriptors.stream().filter(d -> d.id().equals(id)).findFirst().orElseThrow();
preview =
TargetPreview.open(
descriptor, loader, Duration.ofMillis(mode.equals("timeout") ? 2000 : 10000));
if (mode.equals("async") || mode.equals("cancel")) {
for (int i = 0; i < 100 && Examples.pending == null; i++) Thread.sleep(20);
check(Examples.pending != null, "Provider readiness created");
check(!preview.ready().toCompletableFuture().isDone(), "Premature readiness");
if (mode.equals("async")) Examples.pending.complete(null);
else preview.cancel();
}
if (java.util.Set.of(
"timeout",
"cancel",
"readiness-failure",
"wrong-model",
"invalid-attribute",
"factory-failure",
"missing-components",
"missing-model")
.contains(mode)) {
try {
preview.ready().toCompletableFuture().get(15, TimeUnit.SECONDS);
throw new AssertionError("Failure accepted: " + mode);
} catch (ExecutionException error) {
var f = failure(error);
var expected =
switch (mode) {
case "timeout" -> PreviewFailure.Code.TIMEOUT;
case "cancel" -> PreviewFailure.Code.CANCELLED;
case "readiness-failure" -> PreviewFailure.Code.READINESS;
case "wrong-model" -> PreviewFailure.Code.MODEL_TYPE;
case "missing-components", "missing-model" -> PreviewFailure.Code.MODULE_ACCESS;
default -> PreviewFailure.Code.LOAD;
};
check(f.code() == expected, "Wrong diagnostic: " + f.code() + " expected " + expected);
}
preview.cleanup().toCompletableFuture().get(5, TimeUnit.SECONDS);
if (!mode.equals("missing-components")) {
check(Examples.service.closes == 1, "Failure did not clean services once");
if (!mode.equals("factory-failure"))
check(Examples.closes == 1, "Session cleanup missing");
}
} else {
TargetPreview ready = preview.ready().toCompletableFuture().get(15, TimeUnit.SECONDS);
fx(
() -> {
var view = ready.loadedView();
check(view.getViewModel() instanceof PersonViewModel, "Named ViewModel not selected");
var controller = Examples.context.getComponent(PersonController.class);
check(controller.received == view.getViewModel(), "Controller ViewModel handoff");
check(controller.greeting == Examples.service, "Configured service not injected");
var card = (Card) view.getRoot().lookup("#card");
var second = (Card) view.getRoot().lookup("#second");
check(card != second, "Component instances reused");
check(
card.getModel() == Examples.person.getHome()
&& second.getModel() == card.getModel(),
"Nested Model identity not shared");
check(card.received == second.received, "Nested ViewModel identity changed");
check(
card.greeting == Examples.service && second.greeting == Examples.service,
"Component injection");
check(card.getCaption().equals("FXML caption"), "Custom caption lost");
check(
card.getPrefWidth() == 310 && card.getAlignment() == Pos.CENTER_LEFT,
"Inherited/coerced attributes lost");
check(card.injected, "Component initialization");
TextField name = (TextField) view.getRoot().lookup("#name");
check(name.getText().equals("Ada"), "Initial parent binding");
name.setText("Grace");
check(Examples.person.getName().equals("Grace"), "Parent write-back");
card.street().setText("Changed Street");
check(
Examples.person.getHome().getStreet().equals("Changed Street")
&& second.street().getText().equals("Changed Street"),
"Component write-back and shared Model");
check(
((Label) view.getRoot().lookup("#greeting")).getText().startsWith("configured-"),
"Computed controller binding");
byte[] rgba = ready.render(420, 240);
check(rgba.length == 420 * 240 * 4, "Render size");
boolean nonzero = false;
for (byte value : rgba)
if (value != 0) {
nonzero = true;
break;
}
check(nonzero, "Blank rendering");
try {
ready.render(8192, 8192);
throw new AssertionError("Oversized image allowed");
} catch (IllegalArgumentException expected) {
checks++;
}
return null;
});
System.out.println(
"SESSION "
+ Examples.service.identity
+ " models="
+ System.getProperty("fixture.models"));
preview.close();
try {
preview.cleanup().toCompletableFuture().get(5, TimeUnit.SECONDS);
check(!mode.equals("cleanup-failure"), "Cleanup failure hidden");
} catch (ExecutionException error) {
check(
mode.equals("cleanup-failure")
&& failure(error).code() == PreviewFailure.Code.CLEANUP,
"Wrong cleanup diagnostic");
}
check(
Examples.closes == 1 && Examples.service.closes == 1,
"Owned resources not cleaned exactly once");
preview.close();
check(Examples.closes == 1, "Repeated close cleaned twice");
}
try {
TargetPreview.open(descriptor, loader, Duration.ofSeconds(1));
throw new AssertionError("Process session reused");
} catch (PreviewFailure e) {
check(e.code() == PreviewFailure.Code.SESSION_REUSE, "Wrong reuse diagnostic");
}
System.out.println("PASS " + mode + " " + checks);
} finally {
if (preview != null) preview.close();
Platform.exit();
}
}
}
@@ -0,0 +1,10 @@
module fixture.checks {
requires fixture.app;
requires fixture.preview;
requires de.neitzel.fxdesigner.target;
requires de.neitzel.fxdesigner.preview;
requires fx;
requires injection;
requires javafx.controls;
requires javafx.fxml;
}
@@ -0,0 +1,19 @@
{
"schemaVersion": "1.0",
"contexts": {
"fixture": {
"applicationModule": "app",
"launchMode": "module-path",
"buildRuntime": "java21",
"targetRuntime": "java21",
"preview": {
"sourceSet": "module",
"module": "preview",
"provider": "fixture.preview.Examples"
},
"modulePath": {
"entryModule": "fixture.checks"
}
}
}
}
@@ -0,0 +1,43 @@
<?xml version="1.0" ?>
<project xmlns="http://maven.apache.org/POM/4.0.0">
<modelVersion>4.0.0</modelVersion>
<groupId>de.neitzel.fxdesigner.fixtures</groupId>
<artifactId>preview-fixture-modulepath</artifactId>
<version>1</version>
<packaging>pom</packaging>
<modules>
<module>app</module>
<module>preview</module>
<module>checks</module>
</modules>
<properties>
<maven.compiler.release>21</maven.compiler.release>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<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>
@@ -0,0 +1,24 @@
<?xml version="1.0" ?>
<project xmlns="http://maven.apache.org/POM/4.0.0">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>de.neitzel.fxdesigner.fixtures</groupId>
<artifactId>preview-fixture-modulepath</artifactId>
<version>1</version>
</parent>
<artifactId>preview-modulepath</artifactId>
<dependencies>
<dependency>
<groupId>de.neitzel.fxdesigner.fixtures</groupId>
<artifactId>app-modulepath</artifactId>
<version>1</version>
<scope>compile</scope>
</dependency>
<dependency>
<groupId>de.neitzel.fxdesigner</groupId>
<artifactId>designer-preview-api</artifactId>
<version>0.1.0-SNAPSHOT</version>
<scope>compile</scope>
</dependency>
</dependencies>
</project>
@@ -0,0 +1,62 @@
package fixture.preview;
import de.neitzel.fxdesigner.preview.*;
import de.neitzel.injection.ApplicationContext;
import fixture.components.*;
import fixture.model.Person;
import java.util.concurrent.*;
public final class Examples implements PreviewProvider<Person, ApplicationContext> {
static {
System.setProperty("fixture.provider.initialized", "true");
}
public static Person person;
public static ApplicationContext context;
public static Greeting service;
public static volatile CompletableFuture<Void> pending;
public static int closes;
public Class<Person> modelType() {
return Person.class;
}
public PreviewSession<Person, ApplicationContext> open(String id) throws Exception {
if (!javafx.application.Platform.isFxApplicationThread())
throw new AssertionError("Provider not on FX");
person = new Person();
context = new ApplicationContext("fixture.components");
service = context.getComponent(Greeting.class);
service.prefix = "configured";
if (id.equals("factory-failure")) {
service.close();
throw new IllegalStateException("Factory failed after cleaning partial resources");
}
pending = new CompletableFuture<>();
if (!java.util.Set.of("async", "timeout", "cancel", "readiness-failure").contains(id))
pending.complete(null);
return new PreviewSession<>() {
public Person model() {
return id.equals("wrong-model") ? null : person;
}
public ApplicationContext context() {
return context;
}
public CompletionStage<Void> ready() {
if (id.equals("readiness-failure"))
return CompletableFuture.failedFuture(new IllegalStateException("Readiness failed"));
return pending;
}
public void close() {
if (!javafx.application.Platform.isFxApplicationThread())
throw new AssertionError("Cleanup not on FX");
closes++;
service.close();
if (id.equals("cleanup-failure")) throw new IllegalStateException("Cleanup failure");
}
};
}
}
@@ -0,0 +1,10 @@
module fixture.preview {
requires fixture.app;
requires injection;
requires javafx.graphics;
requires de.neitzel.fxdesigner.preview;
exports fixture.preview to
fixture.checks,
de.neitzel.fxdesigner.target;
}
@@ -0,0 +1,56 @@
format=1
count=9
example.0.id=standard
example.0.label=standard
example.0.modelType=fixture.model.Person
example.0.providerClass=fixture.preview.Examples
example.0.viewResource=fixture/components/Person.fxml
example.0.componentPackage=fixture.components
example.1.id=async
example.1.label=async
example.1.modelType=fixture.model.Person
example.1.providerClass=fixture.preview.Examples
example.1.viewResource=fixture/components/Person.fxml
example.1.componentPackage=fixture.components
example.2.id=timeout
example.2.label=timeout
example.2.modelType=fixture.model.Person
example.2.providerClass=fixture.preview.Examples
example.2.viewResource=fixture/components/Person.fxml
example.2.componentPackage=fixture.components
example.3.id=cancel
example.3.label=cancel
example.3.modelType=fixture.model.Person
example.3.providerClass=fixture.preview.Examples
example.3.viewResource=fixture/components/Person.fxml
example.3.componentPackage=fixture.components
example.4.id=readiness-failure
example.4.label=readiness-failure
example.4.modelType=fixture.model.Person
example.4.providerClass=fixture.preview.Examples
example.4.viewResource=fixture/components/Person.fxml
example.4.componentPackage=fixture.components
example.5.id=wrong-model
example.5.label=wrong-model
example.5.modelType=fixture.model.Person
example.5.providerClass=fixture.preview.Examples
example.5.viewResource=fixture/components/Person.fxml
example.5.componentPackage=fixture.components
example.6.id=invalid-attribute
example.6.label=invalid-attribute
example.6.modelType=fixture.model.Person
example.6.providerClass=fixture.preview.Examples
example.6.viewResource=fixture/components/Invalid.fxml
example.6.componentPackage=fixture.components
example.7.id=cleanup-failure
example.7.label=cleanup-failure
example.7.modelType=fixture.model.Person
example.7.providerClass=fixture.preview.Examples
example.7.viewResource=fixture/components/Person.fxml
example.7.componentPackage=fixture.components
example.8.id=factory-failure
example.8.label=factory-failure
example.8.modelType=fixture.model.Person
example.8.providerClass=fixture.preview.Examples
example.8.viewResource=fixture/components/Person.fxml
example.8.componentPackage=fixture.components
+1
View File
@@ -37,6 +37,7 @@ version = subprocess.run([str(target_java), "-version"], capture_output=True, te
if not re.search(r'version "21[.\"]', version.stderr):
sys.exit("Target linkage gate requires Java 21")
run([target_java, "-cp", classpath, "de.neitzel.fxdesigner.target.TargetToolInfo"])
run([sys.executable, ROOT / "scripts/check-preview-fixtures.py", "--target-java-home", args.target_java_home])
run([wrapper, "-B", "-ntp", "-pl", "designer-standalone", "org.apache.maven.plugins:maven-dependency-plugin:3.6.1:build-classpath", "-DincludeScope=test", "-Dmdep.outputFile=target/smoke-classpath.txt"])
smoke_classpath = os.pathsep.join([str(ROOT / "designer-standalone/target/test-classes"), str(ROOT / "designer-standalone/target/classes"), (ROOT / "designer-standalone/target/smoke-classpath.txt").read_text().strip()])
run([Path(os.environ["JAVA_HOME"]) / "bin/java", "-cp", smoke_classpath, "de.neitzel.fxdesigner.build.BuildRefreshSmoke", ROOT, args.target_java_home])
+83
View File
@@ -0,0 +1,83 @@
#!/usr/bin/env python3
"""Qualify real NeitzelLib preview sessions on classpath and named module path."""
import argparse
import json
import os
from pathlib import Path
import re
import shutil
import subprocess
import tempfile
ROOT = Path(__file__).resolve().parent.parent
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--target-java-home", required=True)
args = parser.parse_args()
java_home = Path(args.target_java_home).resolve()
env = dict(os.environ, JAVA_HOME=str(java_home))
output = ROOT / "designer-target-tools/target/preview-fixtures"
output.mkdir(parents=True, exist_ok=True)
results = []
def run(label, command, timeout=180):
result = subprocess.run([str(x) for x in command], env=env, cwd=ROOT,
capture_output=True, text=True, timeout=timeout)
log = result.stdout + result.stderr
(output / (label + ".log")).write_text(log)
if result.returncode:
raise RuntimeError(label + "\n" + log[-12000:])
return log
def build(path, label):
run(label, [ROOT / "mvnw", "-B", "-ntp", "-f", path / "pom.xml", "package",
"org.apache.maven.plugins:maven-dependency-plugin:3.6.1:build-classpath",
"-DincludeScope=test", "-Dmdep.outputFile=target/runtime.txt"])
def launch(path, arrangement, mode, label):
if arrangement == "classpath":
target = path / "app/target"
cp = os.pathsep.join([str(target / "test-classes"), str(target / "classes"),
(target / "runtime.txt").read_text().strip()])
command = ["-cp", cp, "fixture.checks.PreviewChecks"]
else:
target = path / "checks/target"
cp = str(target / "checks-modulepath-1.jar") + os.pathsep + (target / "runtime.txt").read_text().strip()
command = ["--module-path", cp, "--add-modules", "ALL-MODULE-PATH",
"-m", "fixture.checks/fixture.checks.PreviewChecks"]
log = run(label, [java_home / "bin/java", *command, mode], timeout=30)
match = re.search(r"PASS " + re.escape(mode) + r" (\d+)", log)
assert match, log
results.append({"scenario": label, "checks": int(match[1])})
print(label + ": " + match[0], flush=True)
return log
with tempfile.TemporaryDirectory(prefix="designer-preview-") as temp:
for arrangement in ("classpath", "modulepath"):
path = Path(temp) / arrangement
shutil.copytree(ROOT / "integration-fixtures/preview" / arrangement, path,
ignore=shutil.ignore_patterns("target"))
build(path, arrangement + "-build")
first = None
for mode in ("discovery", "standard", "async", "timeout", "cancel",
"readiness-failure", "wrong-model", "invalid-attribute",
"cleanup-failure", "factory-failure"):
log = launch(path, arrangement, mode, arrangement + "-" + mode)
if mode == "standard":
first = re.search(r"SESSION (\S+) models=1", log)[1]
repeat = launch(path, arrangement, "standard", arrangement + "-fresh-process")
assert first != re.search(r"SESSION (\S+) models=1", repeat)[1], "Service identity reused"
if arrangement == "modulepath":
descriptor = path / "app/src/main/java/module-info.java"
original = descriptor.read_text()
for mode, declaration in (("missing-components", "opens fixture.components;"),
("missing-model", "opens fixture.model to fx;")):
descriptor.write_text(re.sub(r"\s+".join(map(re.escape, declaration.split())), "", original))
build(path, mode + "-build")
launch(path, arrangement, mode, mode)
descriptor.write_text(original)
(output / "results.json").write_text(json.dumps(results, indent=2) + "\n")
print(f"PASS {len(results)} target processes, {sum(r['checks'] for r in results)} fixture checks")