M13 vollständig abgeschlossen: V2.0-Freigabe (AP-001 bis AP-009)
- AP-001: Betriebs- und Startdokumentation für GUI und headless konsolidiert (betrieb.md, README.md) - AP-002: Endbenutzer-Bedienanleitung gui-bedienanleitung.md angelegt (deskriptiv, 13 Kapitel, deutsch, Windows-Hinweise) - AP-003: Konfigurationsbeispiele docs/examples/application.properties und docs/examples/prompt.txt konsolidiert, konsistent mit Standardvorlage - AP-004: Regressionstests für headless Abwärtskompatibilität (JAR-Smoke-IT mit --config-Varianten und JavaFX-Freiheit) - AP-005: GUI-Smoke-Tests für V2.0-Kernumfang vervollständigt (Startup-Notice-Sichtbarkeit im Header) - AP-006: Build- und Packaging-Dokumentation im Abschnitt "Build und Packaging" in betrieb.md, README-Artefaktnamen korrigiert - AP-007: Integrierte Gesamtprüfung durchgeführt, V2.0-Abschnitt in befundliste.md — keine Release-Blocker, zwei nicht blockierende Restpunkte (R1 ByteBuddy-Warning, R2 fehlender visueller GUI-Render-Test) - AP-008: entfiel (keine Release-Blocker zu beheben) - AP-009: Finale Gesamtprüfung, Freigabedokument docs/freigabe-v2_0.md mit Git-HEAD, Build-/Test-Ergebnissen, Freigabeaussage. Ein während der Stichprobe entdeckter Doku-Defekt (R3: API-Key-Legacy-Variable) wurde unmittelbar in gui-bedienanleitung.md korrigiert. V2.0 ist freigabefähig. 1.403 Tests grün, 0 Failures, 0 Errors. Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
+287
@@ -1,6 +1,7 @@
|
||||
package de.gecheckt.pdf.umbenenner.bootstrap;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
@@ -19,6 +20,14 @@ import org.junit.jupiter.api.io.TempDir;
|
||||
* <p>
|
||||
* These tests verify that the shaded executable JAR can be run via {@code java -jar}
|
||||
* and behaves correctly for both success and invalid configuration scenarios.
|
||||
* Regression scenarios included:
|
||||
* <ul>
|
||||
* <li>Headless start without {@code --config}: uses default path, exits 0</li>
|
||||
* <li>Headless start with valid {@code --config}: uses supplied path, exits 0</li>
|
||||
* <li>Headless start with non-existent {@code --config}: hard startup failure, exits 1</li>
|
||||
* <li>Invalid configuration: controlled failure, exits 1</li>
|
||||
* <li>Headless output free of JavaFX initialisation markers</li>
|
||||
* </ul>
|
||||
* <p>
|
||||
* Tests are executed by the maven-failsafe-plugin after the package phase.
|
||||
* The *IT suffix ensures failsafe picks them up as integration tests.
|
||||
@@ -277,4 +286,282 @@ class ExecutableJarSmokeTestIT {
|
||||
"Output should indicate configuration/validation error. Got: " + outputText
|
||||
);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Regression: headless start with explicit valid --config path
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Regression test: {@code --headless --config <valid-path>} must locate the supplied
|
||||
* configuration file and complete successfully with exit code 0.
|
||||
* <p>
|
||||
* This guards against a regression where the explicit config path would be silently ignored
|
||||
* and the default path would be used instead (or vice-versa causing a startup failure).
|
||||
*/
|
||||
@Test
|
||||
void jar_headlessWithExplicitValidConfigPath_exitCode0(@TempDir Path workDir) throws Exception {
|
||||
Path configDir = Files.createDirectory(workDir.resolve("mycfg"));
|
||||
Path sourceDir = Files.createDirectory(workDir.resolve("source"));
|
||||
Path targetDir = Files.createDirectory(workDir.resolve("target"));
|
||||
Path logsDir = Files.createDirectory(workDir.resolve("logs"));
|
||||
Path dbParent = Files.createDirectory(workDir.resolve("data"));
|
||||
Path promptDir = Files.createDirectory(workDir.resolve("mycfg/prompts"));
|
||||
|
||||
Path sqliteFile = Files.createFile(dbParent.resolve("pdf-umbenenner.db"));
|
||||
Path promptTemplateFile = Files.createFile(promptDir.resolve("template.txt"));
|
||||
Files.writeString(promptTemplateFile, "Test prompt template for smoke test.");
|
||||
|
||||
// Store the config in a non-default location (not config/application.properties)
|
||||
Path configFile = configDir.resolve("custom.properties");
|
||||
String validConfig = """
|
||||
source.folder=%s
|
||||
target.folder=%s
|
||||
sqlite.file=%s
|
||||
ai.provider.active=openai-compatible
|
||||
ai.provider.openai-compatible.baseUrl=http://localhost:8080/api
|
||||
ai.provider.openai-compatible.model=gpt-4o-mini
|
||||
ai.provider.openai-compatible.timeoutSeconds=30
|
||||
ai.provider.openai-compatible.apiKey=test-api-key-for-smoke-test
|
||||
max.retries.transient=3
|
||||
max.pages=10
|
||||
max.text.characters=5000
|
||||
prompt.template.file=%s
|
||||
runtime.lock.file=%s/lock.pid
|
||||
log.directory=%s
|
||||
log.level=INFO
|
||||
""".formatted(
|
||||
sourceDir.toAbsolutePath(),
|
||||
targetDir.toAbsolutePath(),
|
||||
sqliteFile.toAbsolutePath(),
|
||||
promptTemplateFile.toAbsolutePath(),
|
||||
workDir.toAbsolutePath(),
|
||||
logsDir.toAbsolutePath()
|
||||
);
|
||||
Files.writeString(configFile, validConfig);
|
||||
|
||||
Path shadedJar = findShadedJar();
|
||||
|
||||
List<String> command = new ArrayList<>();
|
||||
command.add(JAVA_EXECUTABLE);
|
||||
command.add("-jar");
|
||||
command.add(shadedJar.toString());
|
||||
command.add("--headless");
|
||||
command.add("--config");
|
||||
command.add(configFile.toAbsolutePath().toString());
|
||||
|
||||
// Run in a fresh empty directory — no default config/application.properties present,
|
||||
// so the process must load via the explicit --config path.
|
||||
Path emptyWorkDir = Files.createDirectory(workDir.resolve("empty-cwd"));
|
||||
ProcessBuilder pb = new ProcessBuilder(command);
|
||||
pb.directory(emptyWorkDir.toFile());
|
||||
pb.redirectErrorStream(true);
|
||||
|
||||
System.out.println("[SMOKE-TEST-EXPLICIT-CONFIG] Command: " + String.join(" ", command));
|
||||
|
||||
Process process = pb.start();
|
||||
boolean completed = process.waitFor(PROCESS_TIMEOUT_MS, java.util.concurrent.TimeUnit.MILLISECONDS);
|
||||
byte[] outputBytes = process.getInputStream().readAllBytes();
|
||||
String outputText = new String(outputBytes);
|
||||
|
||||
System.out.println("[SMOKE-TEST-EXPLICIT-CONFIG] Exit code: " + process.exitValue());
|
||||
System.out.println("[SMOKE-TEST-EXPLICIT-CONFIG] Output:\n" + outputText);
|
||||
|
||||
assertTrue(completed, "Process should complete within timeout");
|
||||
assertEquals(0, process.exitValue(),
|
||||
"Headless start with explicit valid --config path must exit 0. Output: " + outputText);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Regression: headless start with non-existent --config path → hard failure
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Regression test: {@code --headless --config <non-existent-path>} must be treated as
|
||||
* a hard startup error and produce exit code 1.
|
||||
* <p>
|
||||
* According to the startup semantics, a missing {@code --config} target in headless mode
|
||||
* is never a silent fallback but always a configuration error that blocks the run.
|
||||
* The output must contain a keyword that helps the operator diagnose the root cause.
|
||||
*/
|
||||
@Test
|
||||
void jar_headlessWithNonExistentConfigPath_exitCode1(@TempDir Path workDir) throws Exception {
|
||||
Path shadedJar = findShadedJar();
|
||||
|
||||
String missingPath = workDir.resolve("does-not-exist.properties").toAbsolutePath().toString();
|
||||
|
||||
List<String> command = new ArrayList<>();
|
||||
command.add(JAVA_EXECUTABLE);
|
||||
command.add("-jar");
|
||||
command.add(shadedJar.toString());
|
||||
command.add("--headless");
|
||||
command.add("--config");
|
||||
command.add(missingPath);
|
||||
|
||||
ProcessBuilder pb = new ProcessBuilder(command);
|
||||
pb.directory(workDir.toFile());
|
||||
pb.redirectErrorStream(true);
|
||||
|
||||
System.out.println("[SMOKE-TEST-MISSING-CONFIG] Command: " + String.join(" ", command));
|
||||
|
||||
Process process = pb.start();
|
||||
boolean completed = process.waitFor(PROCESS_TIMEOUT_MS, java.util.concurrent.TimeUnit.MILLISECONDS);
|
||||
byte[] outputBytes = process.getInputStream().readAllBytes();
|
||||
String outputText = new String(outputBytes);
|
||||
|
||||
System.out.println("[SMOKE-TEST-MISSING-CONFIG] Exit code: " + process.exitValue());
|
||||
System.out.println("[SMOKE-TEST-MISSING-CONFIG] Output:\n" + outputText);
|
||||
|
||||
assertTrue(completed, "Process should complete within timeout");
|
||||
assertEquals(1, process.exitValue(),
|
||||
"Headless start with non-existent --config path must exit 1. Output: " + outputText);
|
||||
|
||||
// Verify that the output contains a diagnostic keyword so operators can trace the cause.
|
||||
// Only stable keywords are checked; exact message text may evolve.
|
||||
assertTrue(
|
||||
outputText.toLowerCase().contains("not found")
|
||||
|| outputText.toLowerCase().contains("does not exist")
|
||||
|| outputText.toLowerCase().contains("missing")
|
||||
|| outputText.toLowerCase().contains("error")
|
||||
|| outputText.toLowerCase().contains("config"),
|
||||
"Output must contain a diagnostic keyword for the missing config file. Got: " + outputText
|
||||
);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Regression: headless output must be free of JavaFX initialisation markers
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Regression test verifying that a headless start does not trigger JavaFX initialisation.
|
||||
* <p>
|
||||
* The headless path must not require a JavaFX runtime. This test runs the JAR in headless
|
||||
* mode and checks that no JavaFX-specific strings appear in the combined stdout/stderr.
|
||||
* Strings checked: "JavaFX", "Platform.startup", "Monocle", "javafx".
|
||||
* <p>
|
||||
* This is a process-level complement to the unit-level proof in
|
||||
* {@link BootstrapRunnerStartupDispatchTest#headlessStart_doesNotInvokeGuiAdapterFactory()}.
|
||||
* That unit test proves the GuiAdapterFactory is never called; this test proves the
|
||||
* observable JAR output is likewise free of JavaFX initialisation noise.
|
||||
*/
|
||||
@Test
|
||||
void jar_headlessStart_outputFreeOfJavaFxInitialisationMarkers(@TempDir Path workDir) throws Exception {
|
||||
Path configDir = Files.createDirectory(workDir.resolve("config"));
|
||||
Path sourceDir = Files.createDirectory(workDir.resolve("source"));
|
||||
Path targetDir = Files.createDirectory(workDir.resolve("target"));
|
||||
Path logsDir = Files.createDirectory(workDir.resolve("logs"));
|
||||
Path dbParent = Files.createDirectory(workDir.resolve("data"));
|
||||
Path promptDir = Files.createDirectory(workDir.resolve("config/prompts"));
|
||||
|
||||
Path sqliteFile = Files.createFile(dbParent.resolve("pdf-umbenenner.db"));
|
||||
Path promptTemplateFile = Files.createFile(promptDir.resolve("template.txt"));
|
||||
Files.writeString(promptTemplateFile, "Test prompt template for headless JavaFX-freedom check.");
|
||||
|
||||
Path configFile = configDir.resolve("application.properties");
|
||||
String validConfig = """
|
||||
source.folder=%s
|
||||
target.folder=%s
|
||||
sqlite.file=%s
|
||||
ai.provider.active=openai-compatible
|
||||
ai.provider.openai-compatible.baseUrl=http://localhost:8080/api
|
||||
ai.provider.openai-compatible.model=gpt-4o-mini
|
||||
ai.provider.openai-compatible.timeoutSeconds=30
|
||||
ai.provider.openai-compatible.apiKey=test-api-key-javafx-check
|
||||
max.retries.transient=3
|
||||
max.pages=10
|
||||
max.text.characters=5000
|
||||
prompt.template.file=%s
|
||||
runtime.lock.file=%s/lock.pid
|
||||
log.directory=%s
|
||||
log.level=INFO
|
||||
""".formatted(
|
||||
sourceDir.toAbsolutePath(),
|
||||
targetDir.toAbsolutePath(),
|
||||
sqliteFile.toAbsolutePath(),
|
||||
promptTemplateFile.toAbsolutePath(),
|
||||
workDir.toAbsolutePath(),
|
||||
logsDir.toAbsolutePath()
|
||||
);
|
||||
Files.writeString(configFile, validConfig);
|
||||
|
||||
Path shadedJar = findShadedJar();
|
||||
|
||||
List<String> command = new ArrayList<>();
|
||||
command.add(JAVA_EXECUTABLE);
|
||||
command.add("-jar");
|
||||
command.add(shadedJar.toString());
|
||||
command.add("--headless");
|
||||
|
||||
ProcessBuilder pb = new ProcessBuilder(command);
|
||||
pb.directory(workDir.toFile());
|
||||
pb.redirectErrorStream(true);
|
||||
|
||||
System.out.println("[SMOKE-TEST-JAVAFX-FREEDOM] Command: " + String.join(" ", command));
|
||||
|
||||
Process process = pb.start();
|
||||
boolean completed = process.waitFor(PROCESS_TIMEOUT_MS, java.util.concurrent.TimeUnit.MILLISECONDS);
|
||||
byte[] outputBytes = process.getInputStream().readAllBytes();
|
||||
String outputText = new String(outputBytes);
|
||||
|
||||
System.out.println("[SMOKE-TEST-JAVAFX-FREEDOM] Exit code: " + process.exitValue());
|
||||
System.out.println("[SMOKE-TEST-JAVAFX-FREEDOM] Output:\n" + outputText);
|
||||
|
||||
assertTrue(completed, "Process should complete within timeout");
|
||||
assertEquals(0, process.exitValue(),
|
||||
"Headless start must exit 0 for the JavaFX-freedom check to be meaningful. "
|
||||
+ "Output: " + outputText);
|
||||
|
||||
// JavaFX initialisation would produce one of these markers in stdout/stderr.
|
||||
// Their absence is the evidence that the headless path is JavaFX-free at runtime.
|
||||
assertFalse(
|
||||
outputText.contains("Platform.startup")
|
||||
|| outputText.contains("Monocle")
|
||||
|| outputText.contains("com.sun.javafx")
|
||||
|| outputText.contains("javafx.application"),
|
||||
"Headless output must not contain JavaFX initialisation markers. Got:\n" + outputText
|
||||
);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Shared helper: locate the shaded JAR
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Locates the shaded executable JAR produced by the {@code maven-shade-plugin}.
|
||||
* <p>
|
||||
* Searches in {@code pdf-umbenenner-bootstrap/target} relative to the Maven reactor root
|
||||
* ({@code user.dir}) first, then falls back to the local {@code target/} directory.
|
||||
*
|
||||
* @return absolute path to the shaded JAR
|
||||
* @throws AssertionError if no matching JAR can be found
|
||||
*/
|
||||
private Path findShadedJar() {
|
||||
Path projectRoot = Paths.get(System.getProperty("user.dir"));
|
||||
Path bootstrapTarget = projectRoot.resolve("pdf-umbenenner-bootstrap/target");
|
||||
if (!Files.exists(bootstrapTarget)) {
|
||||
bootstrapTarget = Paths.get("target");
|
||||
}
|
||||
|
||||
assertTrue(Files.exists(bootstrapTarget),
|
||||
"Bootstrap target directory must exist: " + bootstrapTarget);
|
||||
|
||||
File[] jars = bootstrapTarget.toFile().listFiles(
|
||||
(dir, name) -> name.endsWith(".jar")
|
||||
&& !name.contains("original")
|
||||
&& !name.contains("tests"));
|
||||
|
||||
assertNotNull(jars, "JAR files should exist in target directory");
|
||||
assertTrue(jars.length > 0, "At least one JAR should exist in target directory");
|
||||
|
||||
Path shadedJar = Paths.get(jars[0].getAbsolutePath());
|
||||
for (File jar : jars) {
|
||||
if (jar.getName().contains("shaded")
|
||||
|| jar.getName().equals("pdf-umbenenner-bootstrap-0.0.1-SNAPSHOT.jar")) {
|
||||
shadedJar = jar.toPath().toAbsolutePath();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
assertTrue(Files.exists(shadedJar), "Shaded JAR file must exist: " + shadedJar);
|
||||
return shadedJar;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user