Make configuration and compatibility contracts executable
This commit is contained in:
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
+58
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user