Make configuration and compatibility contracts executable

This commit is contained in:
2026-11-12 08:27:53 +01:00
parent 306266ae40
commit 85a75383a0
28 changed files with 2284 additions and 8 deletions
+21
View File
@@ -11,6 +11,11 @@
<module.java.release>21</module.java.release>
</properties>
<dependencies>
<dependency>
<groupId>com.networknt</groupId>
<artifactId>json-schema-validator</artifactId>
<version>1.5.9</version>
</dependency>
<dependency>
<groupId>de.neitzel.fxdesigner</groupId>
<artifactId>designer-contracts</artifactId>
@@ -24,6 +29,22 @@
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-jar-plugin</artifactId>
<executions>
<execution>
<goals>
<goal>test-jar</goal>
</goals>
<configuration>
<includes>
<include>**/ConfigurationHostContract.class</include>
</includes>
</configuration>
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-enforcer-plugin</artifactId>
@@ -0,0 +1,15 @@
package de.neitzel.fxdesigner.config;
/** A tooling setup failure; never a reason to close or disable a source document. */
public final class ConfigurationException extends RuntimeException {
private final String code;
public ConfigurationException(String code, String message) {
super(message);
this.code = code;
}
public String code() {
return code;
}
}
@@ -0,0 +1,109 @@
package de.neitzel.fxdesigner.config;
import com.fasterxml.jackson.core.*;
import com.fasterxml.jackson.databind.*;
import com.networknt.schema.*;
import de.neitzel.fxdesigner.config.DesignerConfiguration.*;
import java.io.*;
import java.nio.file.*;
import java.util.*;
/**
* Strict, bounded JSON input, validated against the packaged schemas; never resolves user $refs.
*/
public final class ConfigurationFiles {
public static final int MAX_BYTES = 1024 * 1024;
private static final ObjectMapper JSON =
new ObjectMapper(
JsonFactory.builder()
.enable(StreamReadFeature.STRICT_DUPLICATE_DETECTION)
.streamReadConstraints(
StreamReadConstraints.builder()
.maxNestingDepth(32)
.maxStringLength(65536)
.build())
.build())
.enable(DeserializationFeature.FAIL_ON_TRAILING_TOKENS);
private ConfigurationFiles() {}
public static JsonNode read(Path file, String schema) {
try (var in = Files.newInputStream(file)) {
byte[] bytes = in.readNBytes(MAX_BYTES + 1);
if (bytes.length > MAX_BYTES)
throw new ConfigurationException("CONFIG_SIZE", "Configuration exceeds 1 MiB");
return validate(bytes, schema);
} catch (IOException e) {
throw new ConfigurationException("CONFIG_IO", "Cannot read configuration: " + file);
}
}
public static JsonNode validate(byte[] bytes, String schema) {
if (bytes.length > MAX_BYTES)
throw new ConfigurationException("CONFIG_SIZE", "Configuration exceeds 1 MiB");
if (!Set.of("configuration", "local-settings").contains(schema))
throw new IllegalArgumentException("Unknown built-in schema");
try (var resource =
ConfigurationFiles.class.getResourceAsStream("/schema/" + schema + "-1.0.json")) {
JsonNode node = JSON.readTree(bytes);
var validator =
JsonSchemaFactory.getInstance(SpecVersion.VersionFlag.V202012).getSchema(resource);
var errors = validator.validate(node);
if (!errors.isEmpty())
throw new ConfigurationException(
"CONFIG_SCHEMA",
errors.stream()
.map(ValidationMessage::getMessage)
.sorted()
.limit(8)
.reduce((a, b) -> a + "; " + b)
.orElse("Invalid configuration"));
return node;
} catch (IOException e) {
throw new ConfigurationException("CONFIG_JSON", "Malformed JSON configuration");
}
}
static List<String> strings(JsonNode node) {
var result = new ArrayList<String>();
node.forEach(n -> result.add(n.asText()));
return List.copyOf(result);
}
public static DesignerConfiguration load(Path file) {
JsonNode root = read(file, "configuration");
var contexts = new TreeMap<String, Context>();
root.get("contexts")
.fields()
.forEachRemaining(
e -> {
var n = e.getValue();
var p = n.get("preview");
var m = n.get("modulePath");
contexts.put(
e.getKey(),
new Context(
n.get("applicationModule").asText(),
strings(n.path("mavenProfiles")),
n.get("launchMode").asText(),
n.get("buildRuntime").asText(),
n.get("targetRuntime").asText(),
n.path("buildJava").asInt(21),
n.path("targetJava").asInt(21),
new HashSet<>(strings(n.path("requiredCapabilities"))),
new Preview(
p.get("sourceSet").asText(),
p.path("module").asText(null),
p.get("provider").asText(),
p.path("defaultExample").asText(null),
strings(p.path("examples"))),
m == null
? null
: new ModulePath(
m.get("entryModule").asText(),
strings(m.path("addReads")),
strings(m.path("addOpens")))));
});
return new DesignerConfiguration("1.0", contexts);
}
}
@@ -0,0 +1,58 @@
package de.neitzel.fxdesigner.config;
import de.neitzel.fxdesigner.host.*;
import java.util.*;
import java.util.concurrent.*;
/** Shared tooling gate. Failing setup reports diagnostics but never mutates host document state. */
public final class ConfigurationGate {
public record Ready(
String context,
HostServices.RuntimeInstallation build,
HostServices.RuntimeInstallation target) {}
private final HostServices host;
public ConfigurationGate(HostServices host) {
this.host = Objects.requireNonNull(host);
}
public CompletionStage<Ready> check(
ContextCatalog catalog, String name, Set<String> capabilities) {
try {
var context = catalog.context(name);
var missing = new TreeSet<>(context.requiredCapabilities());
missing.removeAll(capabilities);
if (!missing.isEmpty())
throw new ConfigurationException(
"CAPABILITY_MISSING", "Unsupported required capabilities: " + missing);
return host.runtimes()
.resolve(context.buildRuntime(), HostServices.RuntimeRole.BUILD)
.thenCombine(
host.runtimes().resolve(context.targetRuntime(), HostServices.RuntimeRole.TARGET),
(build, target) -> {
if (build.javaFeature() < context.buildJava()
|| target.javaFeature() < context.targetJava())
throw new ConfigurationException(
"RUNTIME_VERSION",
"Selected build/target JDK does not satisfy context requirements");
return new Ready(name, build, target);
})
.whenComplete(
(r, e) -> {
if (e != null) report(e);
});
} catch (RuntimeException e) {
report(e);
return CompletableFuture.failedFuture(e);
}
}
private void report(Throwable e) {
while (e instanceof CompletionException && e.getCause() != null) e = e.getCause();
host.diagnostics()
.report(
new HostServices.Diagnostic(
"configuration", HostServices.Severity.ERROR, e.getMessage(), Optional.empty()));
}
}
@@ -0,0 +1,174 @@
package de.neitzel.fxdesigner.config;
import de.neitzel.fxdesigner.config.DesignerConfiguration.*;
import java.io.*;
import java.nio.file.*;
import java.util.*;
/**
* Context selection over an explicit effective Maven reactor model supplied by build integration.
*/
public final class ContextCatalog {
public record MavenModule(Path directory, List<Path> sourceRoots, Set<Path> dependencies) {
public MavenModule {
if (!directory.isAbsolute()
|| sourceRoots.stream().anyMatch(p -> !p.isAbsolute())
|| dependencies.stream().anyMatch(p -> !p.isAbsolute()))
throw failure("REACTOR_PATH", "Effective Maven paths must be absolute");
sourceRoots = List.copyOf(sourceRoots);
dependencies = Set.copyOf(dependencies);
}
}
public record ReactorView(List<String> profiles, Collection<MavenModule> modules) {
public ReactorView {
profiles = List.copyOf(profiles);
modules = List.copyOf(modules);
}
}
public record Selection(Optional<String> selected, List<String> candidates) {
public Selection {
candidates = List.copyOf(candidates);
}
}
private final Path configuration;
private final DesignerConfiguration settings;
private final Map<String, Set<Path>> roots = new TreeMap<>();
/**
* Convenience for a single unprofiled effective reactor. Profiled contexts require explicit
* views.
*/
public ContextCatalog(
Path configuration, DesignerConfiguration settings, Collection<MavenModule> reactor) {
this(configuration, settings, unprofiled(settings, reactor));
}
private static Map<String, ReactorView> unprofiled(
DesignerConfiguration settings, Collection<MavenModule> reactor) {
var views = new HashMap<String, ReactorView>();
settings
.contexts()
.forEach(
(name, c) -> {
if (!c.mavenProfiles().isEmpty())
throw failure(
"REACTOR_PROFILES", "Supply a profile-qualified effective reactor view");
views.put(name, new ReactorView(List.of(), reactor));
});
return views;
}
public ContextCatalog(
Path configuration, DesignerConfiguration settings, Map<String, ReactorView> views) {
this.configuration = real(configuration);
this.settings = settings;
Path root = this.configuration.getParent();
for (var entry : settings.contexts().entrySet()) {
var c = entry.getValue();
var view = views.get(entry.getKey());
if (view == null || !view.profiles().equals(c.mavenProfiles()))
throw failure(
"REACTOR_PROFILES",
"Missing or mismatched effective reactor profiles for " + entry.getKey());
var modules = new HashMap<Path, MavenModule>();
for (var m : view.modules()) {
Path dir = real(m.directory());
if (!dir.startsWith(root) || !Files.isRegularFile(dir.resolve("pom.xml")))
throw failure("REACTOR_MODULE", "Invalid reactor module: " + dir);
if (modules.put(dir, m) != null)
throw failure("REACTOR_MODULE", "Duplicate reactor module");
}
if (!modules.containsKey(root))
throw failure("REACTOR_ROOT", "Configuration must live at the effective reactor root");
Path app = module(root, c.applicationModule(), modules);
var collected = new HashSet<Path>();
collect(app, modules, new HashSet<>(), collected);
if (c.preview().sourceSet().equals("module"))
collect(module(root, c.preview().module(), modules), modules, new HashSet<>(), collected);
validateAccess(c.modulePath());
roots.put(entry.getKey(), Set.copyOf(collected));
}
}
private static void validateAccess(ModulePath m) {
if (m == null) return;
String qualified = "[A-Za-z_$][A-Za-z0-9_$]*(?:[.][A-Za-z_$][A-Za-z0-9_$]*)*";
String targets = "(?:" + qualified + "|ALL-UNNAMED)(?:,(?:" + qualified + "|ALL-UNNAMED))*";
if (!m.entryModule().matches(qualified)) throw failure("MODULE_ACCESS", "Invalid entry module");
for (String s : m.addReads())
if (!s.matches(qualified + "=" + targets))
throw failure("MODULE_ACCESS", "Invalid addReads declaration");
for (String s : m.addOpens())
if (!s.matches(qualified + "/" + qualified + "=" + targets))
throw failure("MODULE_ACCESS", "Invalid addOpens declaration");
}
private static Path module(Path root, String name, Map<Path, MavenModule> modules) {
Path relative = Path.of(name);
if (relative.isAbsolute()) throw failure("MODULE_PATH", "Module path must be relative");
Path p = real(root.resolve(relative));
if (!p.startsWith(root) || !modules.containsKey(p))
throw failure("MODULE_PATH", "Module is not in the effective reactor: " + name);
return p;
}
private static void collect(
Path dir, Map<Path, MavenModule> modules, Set<Path> visited, Set<Path> result) {
if (!visited.add(dir)) return;
var m = modules.get(dir);
if (m == null) throw failure("REACTOR_DEPENDENCY", "Dependency missing from reactor model");
for (Path s : m.sourceRoots()) {
Path p = real(s);
if (!p.startsWith(dir)) throw failure("SOURCE_PATH", "Source root outside module: " + p);
result.add(p);
}
for (Path d : m.dependencies()) collect(real(d), modules, visited, result);
}
private static Path real(Path path) {
try {
return path.toRealPath();
} catch (IOException e) {
throw failure("PATH_MISSING", "Path does not exist: " + path);
}
}
private static ConfigurationException failure(String c, String m) {
return new ConfigurationException(c, m);
}
public Path configuration() {
return configuration;
}
public Context context(String name) {
var c = settings.contexts().get(name);
if (c == null) throw failure("CONTEXT_UNKNOWN", "Unknown context: " + name);
return c;
}
/**
* Inherited context is explicit parent ownership, even for a Component outside the parent's
* roots.
*/
public Selection select(Path document, Optional<String> inherited, Optional<String> remembered) {
real(document);
if (inherited.isPresent()) {
context(inherited.get());
return new Selection(inherited, List.of(inherited.get()));
}
Path file = real(document);
List<String> matches =
roots.entrySet().stream()
.filter(e -> e.getValue().stream().anyMatch(file::startsWith))
.map(Map.Entry::getKey)
.toList();
if (matches.size() == 1) return new Selection(Optional.of(matches.getFirst()), matches);
if (remembered.isPresent() && matches.contains(remembered.get()))
return new Selection(remembered, matches);
return new Selection(Optional.empty(), matches);
}
}
@@ -0,0 +1,103 @@
package de.neitzel.fxdesigner.config;
import de.neitzel.fxdesigner.host.*;
import java.io.*;
import java.nio.file.*;
import java.util.*;
import java.util.concurrent.*;
/** Machine-local mappings. Probes JDK release metadata without executing application code. */
public final class LocalSettings implements HostServices.Runtimes {
private final Map<ConfiguredRuntimes.Key, Path> homes;
private final Map<String, String> remembered;
private LocalSettings(Map<ConfiguredRuntimes.Key, Path> homes, Map<String, String> remembered) {
this.homes = Map.copyOf(homes);
this.remembered = Map.copyOf(remembered);
}
public static LocalSettings load(Path file) {
var root = ConfigurationFiles.read(file, "local-settings");
var homes = new HashMap<ConfiguredRuntimes.Key, Path>();
root.get("runtimes")
.fields()
.forEachRemaining(
e -> {
for (var role : HostServices.RuntimeRole.values()) {
var value = e.getValue().get(role.name().toLowerCase(Locale.ROOT));
if (value != null) {
Path p = Path.of(value.asText());
if (!p.isAbsolute())
throw new ConfigurationException(
"RUNTIME_PATH", "Local JDK paths must be absolute");
homes.put(new ConfiguredRuntimes.Key(e.getKey(), role), p);
}
}
});
var remembered = new HashMap<String, String>();
root.path("rememberedContexts")
.fields()
.forEachRemaining(e -> remembered.put(e.getKey(), e.getValue().asText()));
return new LocalSettings(homes, remembered);
}
public Optional<String> remembered(Path configuration) {
try {
return Optional.ofNullable(remembered.get(configuration.toRealPath().toUri().toString()));
} catch (IOException e) {
return Optional.empty();
}
}
/** Atomically persist a choice locally; callers reload settings after this explicit action. */
public static void remember(Path settingsFile, Path configuration, String context)
throws IOException {
if (!context.matches("[A-Za-z_][A-Za-z0-9_.-]{0,127}"))
throw new IllegalArgumentException("Invalid context name");
var root =
(com.fasterxml.jackson.databind.node.ObjectNode)
ConfigurationFiles.read(settingsFile, "local-settings");
var choices = root.withObject("/rememberedContexts");
choices.put(configuration.toRealPath().toUri().toString(), context);
byte[] bytes = root.toPrettyString().getBytes(java.nio.charset.StandardCharsets.UTF_8);
ConfigurationFiles.validate(bytes, "local-settings");
Path file = settingsFile.toRealPath();
Path temporary = Files.createTempFile(file.getParent(), "designer-settings-", ".json");
try {
Files.write(temporary, bytes);
if (Files.getFileAttributeView(file, java.nio.file.attribute.PosixFileAttributeView.class)
!= null) Files.setPosixFilePermissions(temporary, Files.getPosixFilePermissions(file));
Files.move(
temporary, file, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
} finally {
Files.deleteIfExists(temporary);
}
}
public CompletionStage<HostServices.RuntimeInstallation> resolve(
String name, HostServices.RuntimeRole role) {
return CompletableFuture.supplyAsync(
() -> {
Path home = homes.get(new ConfiguredRuntimes.Key(name, role));
if (home == null)
throw new ConfigurationException(
"RUNTIME_MISSING", "Configure " + role + " runtime: " + name);
try {
home = home.toRealPath();
String suffix = System.getProperty("os.name").startsWith("Windows") ? ".exe" : "";
if (!Files.isExecutable(home.resolve("bin/java" + suffix))
|| !Files.isExecutable(home.resolve("bin/javac" + suffix))) throw new IOException();
Properties release = new Properties();
try (var reader = Files.newBufferedReader(home.resolve("release"))) {
release.load(reader);
}
String version = release.getProperty("JAVA_VERSION", "").replace("\"", "");
int feature = Runtime.Version.parse(version).feature();
return new HostServices.RuntimeInstallation(home, feature);
} catch (IOException | IllegalArgumentException e) {
throw new ConfigurationException(
"RUNTIME_INVALID", "Invalid JDK installation for " + role + ": " + name);
}
});
}
}
@@ -0,0 +1,58 @@
package de.neitzel.fxdesigner.config;
import de.neitzel.fxdesigner.contracts.*;
import de.neitzel.fxdesigner.host.*;
import java.nio.file.*;
import java.util.*;
import java.util.concurrent.*;
/** Same behavioral contract executed with real standalone and IntelliJ document stores. */
public final class ConfigurationHostContract {
public static final String VALID =
"""
{"schemaVersion":"1.0","contexts":{"desktop":{"applicationModule":".","launchMode":"classpath","buildRuntime":"build","targetRuntime":"target","preview":{"sourceSet":"test","provider":"fixtures.Examples"}}}}
""";
public static ContextCatalog fixture(Path root) throws Exception {
Files.createDirectories(root.resolve("src"));
Files.writeString(root.resolve("pom.xml"), "<project/>");
Files.writeString(root.resolve("src/View.fxml"), "original");
Path file = root.resolve("neitzel-fx-designer.json");
Files.writeString(file, VALID);
return new ContextCatalog(
file,
ConfigurationFiles.load(file),
List.of(new ContextCatalog.MavenModule(root, List.of(root.resolve("src")), Set.of())));
}
public static void exercise(HostServices host, ContextCatalog catalog) throws Exception {
Path file = catalog.configuration().getParent().resolve("src/View.fxml");
var document =
host.documents().open(file.toUri()).toCompletableFuture().get(10, TimeUnit.SECONDS);
try {
var choice = catalog.select(file, Optional.empty(), Optional.empty());
if (!choice.selected().equals(Optional.of("desktop")))
throw new AssertionError("Context not selected");
var gate = new ConfigurationGate(host);
try {
gate.check(catalog, "desktop", Set.of()).toCompletableFuture().get(10, TimeUnit.SECONDS);
throw new AssertionError("Missing runtime accepted");
} catch (ExecutionException expected) {
}
var draft = document.snapshot().toCompletableFuture().get(10, TimeUnit.SECONDS);
var changed =
document
.apply(
new EditBatch(
draft.revision(),
"Edit despite setup failure",
List.of(new EditBatch.Replacement(0, 8, "editable"))))
.toCompletableFuture()
.get(10, TimeUnit.SECONDS);
if (!changed.writable() || !changed.text().equals("editable"))
throw new AssertionError("Configuration failure disabled source editing");
} finally {
document.close();
}
}
}
@@ -0,0 +1,323 @@
package de.neitzel.fxdesigner.config;
import static org.junit.jupiter.api.Assertions.*;
import com.fasterxml.jackson.databind.*;
import com.fasterxml.jackson.databind.node.*;
import de.neitzel.fxdesigner.host.*;
import java.nio.file.*;
import java.util.*;
import java.util.concurrent.*;
import org.junit.jupiter.api.*;
import org.junit.jupiter.api.io.TempDir;
class ConfigurationTest {
@TempDir Path root;
private final ObjectMapper json = new ObjectMapper();
private ObjectNode valid() throws Exception {
return (ObjectNode) json.readTree(ConfigurationHostContract.VALID);
}
private ObjectNode context(ObjectNode n) {
return (ObjectNode) n.path("contexts").path("desktop");
}
private void validate(ObjectNode n) {
ConfigurationFiles.validate(
n.toString().getBytes(java.nio.charset.StandardCharsets.UTF_8), "configuration");
}
@Test
void packagedFixturesValidate() throws Exception {
for (String name : List.of("classpath-module", "module-path-test")) {
try (var in = getClass().getResourceAsStream("/configuration/" + name + ".json")) {
assertNotNull(in);
ConfigurationFiles.validate(in.readAllBytes(), "configuration");
}
}
}
@Test
void strictSchemaRejectsVersionsUnknownFieldsMissingConditionalFieldsAndDuplicateKeys()
throws Exception {
validate(valid());
var n = valid();
n.put("schemaVersion", "2.0");
assertThrows(ConfigurationException.class, () -> validate(n));
var unknown = valid();
context(unknown).put("typo", true);
assertThrows(ConfigurationException.class, () -> validate(unknown));
var module = valid();
context(module).put("launchMode", "module-path");
assertThrows(ConfigurationException.class, () -> validate(module));
context(module).putObject("modulePath").put("entryModule", "app");
validate(module);
var preview = valid();
((ObjectNode) context(preview).get("preview")).put("sourceSet", "module");
assertThrows(ConfigurationException.class, () -> validate(preview));
((ObjectNode) context(preview).get("preview")).put("module", "preview");
validate(preview);
assertThrows(
ConfigurationException.class,
() ->
ConfigurationFiles.validate(
"{\"schemaVersion\":\"1.0\",\"schemaVersion\":\"1.0\"}".getBytes(),
"configuration"));
assertThrows(
ConfigurationException.class,
() ->
ConfigurationFiles.validate(
(ConfigurationHostContract.VALID + " {}").getBytes(), "configuration"));
assertThrows(
ConfigurationException.class,
() ->
ConfigurationFiles.validate(
new byte[ConfigurationFiles.MAX_BYTES + 1], "configuration"));
}
@Test
void emptyNullAndOverNestedJsonFailAsConfigurationErrors() {
for (String value : List.of("", "null", "[".repeat(40) + "0" + "]".repeat(40)))
assertThrows(
ConfigurationException.class,
() -> ConfigurationFiles.validate(value.getBytes(), "configuration"));
assertThrows(
ConfigurationException.class,
() -> new ContextCatalog.MavenModule(Path.of("relative"), List.of(), Set.of()));
}
@Test
void selectAmbiguousRememberedInheritedAndNoMatch() throws Exception {
var base = ConfigurationHostContract.fixture(root);
var n = valid();
((ObjectNode) n.get("contexts")).set("other", context(n).deepCopy());
Files.writeString(base.configuration(), n.toString());
var catalog =
new ContextCatalog(
base.configuration(),
ConfigurationFiles.load(base.configuration()),
List.of(new ContextCatalog.MavenModule(root, List.of(root.resolve("src")), Set.of())));
Path view = root.resolve("src/View.fxml");
assertEquals(2, catalog.select(view, Optional.empty(), Optional.empty()).candidates().size());
assertTrue(catalog.select(view, Optional.empty(), Optional.empty()).selected().isEmpty());
assertEquals(
"other",
catalog.select(view, Optional.empty(), Optional.of("other")).selected().orElseThrow());
assertTrue(catalog.select(view, Optional.empty(), Optional.of("removed")).selected().isEmpty());
Path external = Files.writeString(root.resolve("Component.fxml"), "component");
assertTrue(catalog.select(external, Optional.empty(), Optional.empty()).candidates().isEmpty());
assertEquals(
"desktop",
catalog
.select(external, Optional.of("desktop"), Optional.of("other"))
.selected()
.orElseThrow());
assertThrows(
ConfigurationException.class,
() -> catalog.select(view, Optional.of("removed"), Optional.empty()));
}
@Test
void modulePathsMustBelongToEffectiveReactorIncludingSymlinkEscapes() throws Exception {
var base = ConfigurationHostContract.fixture(root);
Path fake = Files.createDirectories(root.resolve("unlisted"));
Files.writeString(fake.resolve("pom.xml"), "<project/>");
var n = valid();
context(n).put("applicationModule", "unlisted");
Files.writeString(base.configuration(), n.toString());
var model =
List.of(new ContextCatalog.MavenModule(root, List.of(root.resolve("src")), Set.of()));
assertThrows(
ConfigurationException.class,
() ->
new ContextCatalog(
base.configuration(), ConfigurationFiles.load(base.configuration()), model));
context(n).put("applicationModule", "missing");
Files.writeString(base.configuration(), n.toString());
assertThrows(
ConfigurationException.class,
() ->
new ContextCatalog(
base.configuration(), ConfigurationFiles.load(base.configuration()), model));
context(n).put("applicationModule", root.toString());
Files.writeString(base.configuration(), n.toString());
assertThrows(
ConfigurationException.class,
() ->
new ContextCatalog(
base.configuration(), ConfigurationFiles.load(base.configuration()), model));
Path outside = Files.createTempDirectory("designer-outside-");
try {
Files.createSymbolicLink(root.resolve("escape"), outside);
context(n).put("applicationModule", "escape");
Files.writeString(base.configuration(), n.toString());
assertThrows(
ConfigurationException.class,
() ->
new ContextCatalog(
base.configuration(), ConfigurationFiles.load(base.configuration()), model));
} finally {
Files.deleteIfExists(outside);
}
}
@Test
void dependencySourceRootsAndPreviewModuleParticipateInMatching() throws Exception {
var base = ConfigurationHostContract.fixture(root);
Path app = Files.createDirectories(root.resolve("app")),
shared = Files.createDirectories(root.resolve("shared")),
preview = Files.createDirectories(root.resolve("preview"));
for (Path p : List.of(app, shared, preview)) {
Files.writeString(p.resolve("pom.xml"), "<project/>");
Files.createDirectories(p.resolve("src"));
}
var n = valid();
context(n).put("applicationModule", "app");
var p = (ObjectNode) context(n).get("preview");
p.put("sourceSet", "module");
p.put("module", "preview");
Files.writeString(base.configuration(), n.toString());
var model =
List.of(
new ContextCatalog.MavenModule(root, List.of(), Set.of()),
new ContextCatalog.MavenModule(app, List.of(app.resolve("src")), Set.of(shared)),
new ContextCatalog.MavenModule(shared, List.of(shared.resolve("src")), Set.of()),
new ContextCatalog.MavenModule(preview, List.of(preview.resolve("src")), Set.of()));
var catalog =
new ContextCatalog(
base.configuration(), ConfigurationFiles.load(base.configuration()), model);
Path component = Files.writeString(shared.resolve("src/Card.fxml"), "component");
assertEquals(
"desktop",
catalog.select(component, Optional.empty(), Optional.empty()).selected().orElseThrow());
}
@Test
void profileSpecificReactorViewsAndExplicitModuleAccessAreRequired() throws Exception {
var base = ConfigurationHostContract.fixture(root);
var n = valid();
context(n).putArray("mavenProfiles").add("preview");
context(n).put("launchMode", "module-path");
var access = context(n).putObject("modulePath").put("entryModule", "example.app");
access.putArray("addOpens").add("example.app/example.model=ALL-UNNAMED");
Files.writeString(base.configuration(), n.toString());
var config = ConfigurationFiles.load(base.configuration());
var modules =
List.of(new ContextCatalog.MavenModule(root, List.of(root.resolve("src")), Set.of()));
assertThrows(
ConfigurationException.class,
() -> new ContextCatalog(base.configuration(), config, modules));
assertThrows(
ConfigurationException.class,
() ->
new ContextCatalog(
base.configuration(),
config,
Map.of("desktop", new ContextCatalog.ReactorView(List.of("wrong"), modules))));
assertNotNull(
new ContextCatalog(
base.configuration(),
config,
Map.of("desktop", new ContextCatalog.ReactorView(List.of("preview"), modules))));
access.putArray("addOpens").add("--add-opens=anything");
Files.writeString(base.configuration(), n.toString());
assertThrows(
ConfigurationException.class,
() ->
new ContextCatalog(
base.configuration(),
ConfigurationFiles.load(base.configuration()),
Map.of("desktop", new ContextCatalog.ReactorView(List.of("preview"), modules))));
}
@Test
void localMappingsVerifyInstallationsRolesAndPreserveRememberedChoice() throws Exception {
Files.writeString(root.resolve("neitzel-fx-designer.json"), ConfigurationHostContract.VALID);
Path home = Path.of(System.getProperty("java.home"));
Path local = root.resolve("local.json");
var n = json.createObjectNode().put("schemaVersion", "1.0");
n.putObject("runtimes").putObject("java").put("build", home.toString());
n.putObject("rememberedContexts")
.put(root.resolve("neitzel-fx-designer.json").toRealPath().toUri().toString(), "desktop");
Files.writeString(local, n.toString());
var settings = LocalSettings.load(local);
assertEquals(
Runtime.version().feature(),
settings
.resolve("java", HostServices.RuntimeRole.BUILD)
.toCompletableFuture()
.get()
.javaFeature());
assertThrows(
ExecutionException.class,
() ->
settings.resolve("java", HostServices.RuntimeRole.TARGET).toCompletableFuture().get());
assertEquals(
"desktop", settings.remembered(root.resolve("neitzel-fx-designer.json")).orElseThrow());
LocalSettings.remember(local, root.resolve("neitzel-fx-designer.json"), "other");
assertEquals(
"other",
LocalSettings.load(local)
.remembered(root.resolve("neitzel-fx-designer.json").toRealPath())
.orElseThrow());
((ObjectNode) n.path("runtimes").path("java")).put("target", root.toString());
Files.writeString(local, n.toString());
assertThrows(
ExecutionException.class,
() ->
LocalSettings.load(local)
.resolve("java", HostServices.RuntimeRole.TARGET)
.toCompletableFuture()
.get());
((ObjectNode) n.path("runtimes").path("java")).put("target", "relative");
Files.writeString(local, n.toString());
assertThrows(ConfigurationException.class, () -> LocalSettings.load(local));
}
@Test
void gateReportsCapabilitiesAndRuntimeRequirementsSeparatelyWithoutFallback() throws Exception {
var catalog = ConfigurationHostContract.fixture(root);
var n = valid();
context(n).put("targetJava", 99);
Files.writeString(catalog.configuration(), n.toString());
catalog =
new ContextCatalog(
catalog.configuration(),
ConfigurationFiles.load(catalog.configuration()),
List.of(new ContextCatalog.MavenModule(root, List.of(root.resolve("src")), Set.of())));
var diagnostics = new ArrayList<HostServices.Diagnostic>();
var install =
new HostServices.RuntimeInstallation(Path.of(System.getProperty("java.home")), 25);
var host =
new HostServices(
uri ->
CompletableFuture.failedFuture(
new AssertionError("Documents must not be accessed")),
(r, o) -> {
throw new AssertionError("Build must not run");
},
(name, role) -> CompletableFuture.completedFuture(install),
location -> CompletableFuture.completedFuture(null),
diagnostics::add,
new SessionScope());
var gate = new ConfigurationGate(host);
var checked = catalog;
assertThrows(
ExecutionException.class,
() -> gate.check(checked, "desktop", Set.of()).toCompletableFuture().get());
assertEquals(1, diagnostics.size());
context(n).put("targetJava", 21);
context(n).putArray("requiredCapabilities").add("unknown");
Files.writeString(catalog.configuration(), n.toString());
var capCatalog =
new ContextCatalog(
catalog.configuration(),
ConfigurationFiles.load(catalog.configuration()),
List.of(new ContextCatalog.MavenModule(root, List.of(root.resolve("src")), Set.of())));
assertThrows(
ExecutionException.class,
() -> gate.check(capCatalog, "desktop", Set.of()).toCompletableFuture().get());
assertNotNull(gate.check(capCatalog, "desktop", Set.of("unknown")).toCompletableFuture().get());
}
}
@@ -0,0 +1,26 @@
{
"schemaVersion": "1.0",
"contexts": {
"desktop": {
"applicationModule": "app",
"mavenProfiles": [
"designer-preview"
],
"launchMode": "classpath",
"buildRuntime": "build-java",
"targetRuntime": "application-java",
"buildJava": 25,
"targetJava": 21,
"requiredCapabilities": [
"discover-v1",
"render-rgba-v1"
],
"preview": {
"sourceSet": "module",
"module": "preview",
"provider": "example.preview.PersonExamples",
"defaultExample": "person.standard"
}
}
}
}
@@ -0,0 +1,33 @@
{
"schemaVersion": "1.0",
"contexts": {
"desktop": {
"applicationModule": "app",
"mavenProfiles": [
"designer-preview"
],
"launchMode": "module-path",
"buildRuntime": "build-java",
"targetRuntime": "application-java",
"buildJava": 25,
"targetJava": 21,
"requiredCapabilities": [
"discover-v1",
"render-rgba-v1"
],
"preview": {
"sourceSet": "test",
"provider": "example.preview.PersonExamples"
},
"modulePath": {
"entryModule": "example.app",
"addReads": [
"example.app=example.preview"
],
"addOpens": [
"example.app/example.model=ALL-UNNAMED"
]
}
}
}
}