diff --git a/.github/workflows/build-linux.yaml b/.github/workflows/build-linux.yaml
index b4cbb024..11b21acc 100644
--- a/.github/workflows/build-linux.yaml
+++ b/.github/workflows/build-linux.yaml
@@ -1,9 +1,9 @@
name: Build Linux
on:
push:
- branches: [ "main" ]
+ branches: [ "feature/automated-ui-test" ]
pull_request:
- branches: [ "main" ]
+ branches: [ "feature/automated-ui-test" ]
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
@@ -19,8 +19,37 @@ jobs:
java-version: '21'
distribution: 'adopt'
cache: maven
+ - name: Install and configure Tinyproxy
+ run: |
+ sudo apt-get update
+ sudo apt-get install -y tinyproxy
+
+ sudo sed -i 's/^Allow 127.0.0.1/Allow 0.0.0.0\/0/' /etc/tinyproxy/tinyproxy.conf
+
+ sudo systemctl start tinyproxy
+
+ curl -x http://localhost:8888 https://www.github.com
+ - name: Install required packages
+ run: sudo apt-get update && sudo apt-get install -y xvfb libgtk-3-0
+ - name: Start Xvfb
+ run: |
+ Xvfb :99 -screen 0 1024x768x24 &
+ echo "Xvfb started"
+ - name: Set DISPLAY environment variable
+ run: echo "DISPLAY=:99" >> $GITHUB_ENV
- name: Build with Maven
- run: mvn -B clean install -Plinux -Pci-build -Dno-native-profile
+ run: mvn -B clean install -Plinux -Pci-build -Dno-native-profile -Dmaven.test.skip=true
+ - name: Run headless test
+ run: mvn test -Plinux -Pci-build -Dno-native-profile
+ - name: Run headless test with proxy
+ run: mvn test -Plinux -Pci-build -Dno-native-profile -Dhttps.proxyHost=localhost -Dhttps.proxyPort=8888
+ - name: Upload reference files and test results
+ if: failure()
+ uses: actions/upload-artifact@v4
+ with:
+ name: test resources
+ path: |
+ pdf-over-gui/src/test/resources/
- name: Describe current commit
run: echo "commit_sha=${GITHUB_SHA::7}" >> $GITHUB_ENV
- name: Remove Previous Build Artifacts
@@ -38,9 +67,9 @@ jobs:
- name: Upload Build Artifacts
uses: actions/upload-artifact@v4
with:
- name: pdf-over-${{ github.event.pull_request.number || github.ref_name }}-${{ env.commit_sha }}-linux-x86_64
+ name: pdf-over-feature-automated-ui-test-${{ env.commit_sha }}-linux-x86_64
path: pdf-over.tar
permissions:
- contents: read
- actions: write
+ contents: read
+ actions: write
\ No newline at end of file
diff --git a/.github/workflows/build-mac-aarch64.yaml b/.github/workflows/build-mac-aarch64.yaml
index 9fe85b71..1278965b 100644
--- a/.github/workflows/build-mac-aarch64.yaml
+++ b/.github/workflows/build-mac-aarch64.yaml
@@ -1,9 +1,10 @@
name: Build MacOS (aarch64)
on:
push:
- branches: [ "main" ]
+ branches: [ "feature/automated-ui-test#" ]
pull_request:
- branches: [ "main" ]
+ branches: [ "feature/automated-ui-test#" ]
+
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
@@ -20,7 +21,9 @@ jobs:
distribution: 'adopt'
cache: maven
- name: Build with Maven
- run: mvn -B clean install -Pmac-aarch64 -Pci-build -Dno-native-profile
+ run: mvn -B clean install -Pmac-aarch64 -Pci-build -Dno-native-profile -Dmaven.test.skip=true
+ - name: Run tests
+ run: mvn test -DargLine="-XstartOnFirstThread" -Dmaven.test.skip=true
- name: Describe current commit
run: echo "commit_sha=${GITHUB_SHA::7}" >> $GITHUB_ENV
- name: Remove Previous Build Artifacts
@@ -38,9 +41,9 @@ jobs:
- name: Upload Build Artifacts
uses: actions/upload-artifact@v4
with:
- name: pdf-over-${{ github.event.pull_request.number || github.ref_name }}-${{ env.commit_sha }}-macos-aarch64
+ name: pdf-over-feature-automated-ui-test-${{ env.commit_sha }}-macos-aarch64
path: pdf-over.tar
permissions:
- contents: read
- actions: write
+ contents: read
+ actions: write
\ No newline at end of file
diff --git a/.github/workflows/build-mac-x86_64.yaml b/.github/workflows/build-mac-x86_64.yaml
index 50ea3167..eae47958 100644
--- a/.github/workflows/build-mac-x86_64.yaml
+++ b/.github/workflows/build-mac-x86_64.yaml
@@ -1,9 +1,9 @@
name: Build MacOS (x86_64)
on:
push:
- branches: [ "main" ]
+ branches: [ "feature/automated-ui-test#" ]
pull_request:
- branches: [ "main" ]
+ branches: [ "feature/automated-ui-test#" ]
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
@@ -20,7 +20,9 @@ jobs:
distribution: 'adopt'
cache: maven
- name: Build with Maven
- run: mvn -B clean install -Pmac -Pci-build -Dno-native-profile
+ run: mvn -B clean install -Pmac -Pci-build -Dno-native-profile -Dmaven.test.skip=true
+ - name: Run Tests
+ run: mvn test -DargLine="-XstartOnFirstThread" -Dmaven.test.skip=true
- name: Describe current commit
run: echo "commit_sha=${GITHUB_SHA::7}" >> $GITHUB_ENV
- name: Remove Previous Build Artifacts
@@ -38,9 +40,9 @@ jobs:
- name: Upload Build Artifacts
uses: actions/upload-artifact@v4
with:
- name: pdf-over-${{ github.event.pull_request.number || github.ref_name }}-${{ env.commit_sha }}-macos-x86_64
+ name: pdf-over-feature-automated-ui-test-${{ env.commit_sha }}-macos-x86_64
path: pdf-over.tar
permissions:
- contents: read
- actions: write
+ contents: read
+ actions: write
\ No newline at end of file
diff --git a/.github/workflows/build-windows.yaml b/.github/workflows/build-windows.yaml
index cc9305e6..d8983e8f 100644
--- a/.github/workflows/build-windows.yaml
+++ b/.github/workflows/build-windows.yaml
@@ -1,9 +1,10 @@
+
name: Build Windows
on:
push:
- branches: [ "main" ]
+ branches: [ "feature/automated-ui-test" ]
pull_request:
- branches: [ "main" ]
+ branches: [ "feature/automated-ui-test" ]
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
@@ -20,7 +21,16 @@ jobs:
distribution: 'adopt'
cache: maven
- name: Build with Maven
- run: mvn -B clean install -Pwindows -Pci-build -Dno-native-profile
+ run: mvn -B clean install -Pwindows -Pci-build -Dno-native-profile -DskipTests
+ - name: Run tests
+ run: mvn -B test -Pwindows -Pci-build -Dno-native-profile
+ - name: Upload reference files and test results
+ if: failure()
+ uses: actions/upload-artifact@v4
+ with:
+ name: test resources
+ path: |
+ pdf-over-gui/src/test/resources/
- name: Describe current commit
run: echo "commit_sha=$("${{ github.sha }}".SubString(0,7))" >> $env:GITHUB_ENV
- name: Remove Previous Build Artifacts
@@ -34,9 +44,9 @@ jobs:
- name: Upload Build Artifacts
uses: actions/upload-artifact@v4
with:
- name: pdf-over-${{ github.event.pull_request.number || github.ref_name }}-${{ env.commit_sha }}-windows-x86_64
+ name: pdf-over-feature-automated-ui-test-${{ env.commit_sha }}-windows-x86_64
path: pdf-over-build
permissions:
- contents: read
- actions: write
+ contents: read
+ actions: write
\ No newline at end of file
diff --git a/pdf-over-gui/pom.xml b/pdf-over-gui/pom.xml
index 63689f5b..06642c37 100644
--- a/pdf-over-gui/pom.xml
+++ b/pdf-over-gui/pom.xml
@@ -26,6 +26,11 @@
logback-classic
1.5.18
+
+ commons-cli
+ commons-cli
+ 1.10.0
+
at.a-sit
pdf-over-signer
@@ -74,17 +79,39 @@
webauthn-client-java
0.0.1
+
+ org.junit.jupiter
+ junit-jupiter-params
+ test
+
org.junit.jupiter
junit-jupiter-engine
test
+
+ org.junit.vintage
+ junit-vintage-engine
+ test
+
org.projectlombok
lombok
1.18.36
provided
+
+ org.eclipse.swtbot.swt
+ finder
+ 4.3.0.202509040840
+ test
+
+
+ org.hamcrest
+ hamcrest
+ 3.0
+ test
+
@@ -104,7 +131,6 @@
-
org.eclipse.m2e
lifecycle-mapping
@@ -133,6 +159,11 @@
+
+ org.apache.maven.plugins
+ maven-surefire-plugin
+ 3.5.4
+
maven-clean-plugin
3.2.0
@@ -846,4 +877,4 @@
file://${pdfover-build.root-dir}/repo
-
+
\ No newline at end of file
diff --git a/pdf-over-gui/src/main/resources/cfg/PDFASConfig.zip b/pdf-over-gui/src/main/resources/cfg/PDFASConfig.zip
index 3297c51f..248faba8 100644
Binary files a/pdf-over-gui/src/main/resources/cfg/PDFASConfig.zip and b/pdf-over-gui/src/main/resources/cfg/PDFASConfig.zip differ
diff --git a/pdf-over-gui/src/test/java/at/asit/pdfover/gui/tests/AbstractSignatureUITest.java b/pdf-over-gui/src/test/java/at/asit/pdfover/gui/tests/AbstractSignatureUITest.java
new file mode 100644
index 00000000..ddc4cd28
--- /dev/null
+++ b/pdf-over-gui/src/test/java/at/asit/pdfover/gui/tests/AbstractSignatureUITest.java
@@ -0,0 +1,254 @@
+package at.asit.pdfover.gui.tests;
+
+import java.io.ByteArrayOutputStream;
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.*;
+import java.util.concurrent.BrokenBarrierException;
+import java.util.concurrent.CyclicBarrier;
+
+import org.apache.pdfbox.pdmodel.PDDocument;
+import org.apache.pdfbox.pdmodel.PDPage;
+import org.eclipse.swt.graphics.Point;
+import org.eclipse.swt.widgets.Display;
+import org.eclipse.swt.widgets.Shell;
+import org.eclipse.swtbot.swt.finder.SWTBot;
+import org.eclipse.swtbot.swt.finder.exceptions.WidgetNotFoundException;
+import org.eclipse.swtbot.swt.finder.waits.ICondition;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.BeforeEach;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import at.asit.pdfover.commons.Constants;
+import at.asit.pdfover.commons.Messages;
+import at.asit.pdfover.commons.Profile;
+import at.asit.pdfover.gui.Main;
+import at.asit.pdfover.gui.workflow.StateMachine;
+import at.asit.pdfover.gui.workflow.config.ConfigurationManager;
+import lombok.NonNull;
+
+import org.apache.commons.io.FileUtils;
+import org.apache.commons.io.FilenameUtils;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+public abstract class AbstractSignatureUITest {
+
+ private static Thread uiThread;
+ private static Shell shell;
+ private static StateMachine sm;
+ private SWTBot bot;
+
+ private static final File inputFile = new File("src/test/resources/TestFile.pdf");
+ private static String outputDir = inputFile.getAbsoluteFile().getParent();
+ private Profile currentProfile;
+ private final String postFix = "_superSigned";
+ private static final List profiles = new ArrayList<>();
+
+ private static final Logger logger = LoggerFactory
+ .getLogger(AbstractSignatureUITest.class);
+
+ protected String str(String k) { return Messages.getString(k); }
+
+ @BeforeAll
+ public static void prepareTestEnvironment() throws IOException {
+ deleteTempDir();
+ createTempDir();
+ setSignatureProfiles();
+ setupPdfBox();
+ }
+
+ private static void setupPdfBox() throws IOException{
+ byte[] pdfBytes;
+ try (PDDocument doc = new PDDocument()) {
+ doc.addPage(new PDPage());
+ try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
+ doc.save(baos);
+ pdfBytes = baos.toByteArray();
+ }
+ }
+
+ // Load PDF to trigger initialization
+ try (PDDocument ignored = PDDocument.load(pdfBytes)) {
+
+ }
+ }
+
+ private static void deleteTempDir() throws IOException {
+ String root = inputFile.getAbsoluteFile().getParent();
+ File dir = new File(root);
+ for (File f : Objects.requireNonNull(dir.listFiles())) {
+ if (f.getName().startsWith("output_")) {
+ FileUtils.deleteDirectory(f);
+ }
+ }
+ }
+
+ private static void createTempDir() throws IOException {
+ Path tmpDir = Files.createTempDirectory(Paths.get(inputFile.getAbsoluteFile().getParent()), "output_");
+ tmpDir.toFile().deleteOnExit();
+ outputDir = FilenameUtils.separatorsToSystem(tmpDir.toString());
+ }
+
+
+ @BeforeEach
+ public final void setupUITest() throws InterruptedException, BrokenBarrierException {
+ final CyclicBarrier swtBarrier = new CyclicBarrier(2);
+
+ if (uiThread == null) {
+ uiThread = new Thread(new Runnable() {
+ @Override
+ public void run() {
+
+ Display.getDefault().syncExec(() -> {
+ currentProfile = getCurrentProfile();
+ setConfig(currentProfile);
+ sm = Main.setup(new String[]{inputFile.getAbsolutePath()});
+ shell = sm.getMainShell();
+
+ try {
+ swtBarrier.await();
+ } catch (InterruptedException | BrokenBarrierException e) {
+ throw new RuntimeException(e);
+ }
+ sm.start();
+ });
+ }
+ });
+ uiThread.setDaemon(true);
+ uiThread.start();
+ }
+ swtBarrier.await();
+ bot = new SWTBot(shell);
+ }
+
+ @AfterEach
+ public void reset() throws InterruptedException {
+ deleteOutputFile();
+ closeShell();
+ }
+
+ public void closeShell() throws InterruptedException {
+ Display.getDefault().syncExec(new Runnable() {
+ public void run() {
+ shell.close();
+ }
+ });
+ uiThread.join();
+ uiThread = null;
+ }
+
+
+ protected void setCredentials() {
+ try {
+ ICondition widgetExists = new WidgetExistsCondition(str("mobileBKU.number"));
+ bot.waitUntil(widgetExists, 80000);
+ bot.textWithLabel(str("mobileBKU.number")).setText("TestUser-1902503362");
+ bot.textWithLabel(str("mobileBKU.password")).setText("123456789");
+ bot.button(str("common.Ok")).click();
+ }
+ catch (WidgetNotFoundException wnf) {
+ bot.button(str("common.Cancel")).click();
+ fail(wnf.getMessage());
+ }
+
+ File output = new File(getPathOutputFile());
+ ICondition outputExists = new FileExistsCondition(output);
+ bot.waitUntil(outputExists, 20000);
+
+ if(!output.exists()) {
+ bot.button(str("common.Cancel")).click();
+ }
+ assertTrue(output.exists(), "Received signed PDF");
+ }
+
+ private void deleteOutputFile() {
+ if (getPathOutputFile() != null) {
+ File outputFile = new File(getPathOutputFile());
+ outputFile.delete();
+ assertFalse(outputFile.exists());
+ logger.info("Deleted output file");
+ }
+ }
+
+ protected void testSignature(boolean negative, boolean captureRefImage) throws IOException {
+ String outputFile = getPathOutputFile();
+ assertNotNull(currentProfile);
+ assertNotNull(outputFile);
+
+ try (SignaturePositionValidator provider = new SignaturePositionValidator(negative, captureRefImage, currentProfile, outputFile)) {
+ provider.verifySignaturePosition();
+ } catch (Exception e) {
+ fail("Error verifiying signature position", e);
+ }
+ }
+
+ private static void setProperty(@NonNull Properties props, @NonNull String key, @NonNull String value) { props.setProperty(key, value); }
+
+ private void setConfig(Profile currentProfile) {
+ ConfigurationManager cm = new ConfigurationManager();
+ Point size = cm.getMainWindowSize();
+
+ Map testParams = Map.ofEntries(
+ Map.entry(Constants.CFG_BKU, cm.getDefaultBKUPersistent().name()),
+ Map.entry(Constants.CFG_KEYSTORE_PASSSTORETYPE, "memory"),
+ Map.entry(Constants.CFG_LOCALE, cm.getInterfaceLocale().toString()),
+ Map.entry(Constants.CFG_LOGO_ONLY_SIZE, Double.toString(cm.getLogoOnlyTargetSize())),
+ Map.entry(Constants.CFG_MAINWINDOW_SIZE, size.x + "," + size.y),
+ Map.entry(Constants.CFG_OUTPUT_FOLDER, outputDir),
+ Map.entry(Constants.CFG_POSTFIX, postFix),
+ Map.entry(Constants.CFG_SIGNATURE_NOTE, currentProfile.getDefaultSignatureBlockNote(Locale.GERMANY)),
+ Map.entry(Constants.CFG_SIGNATURE_POSITION, "auto"),
+ Map.entry(Constants.SIGNATURE_PROFILE, currentProfile.toString()),
+ Map.entry(Constants.CFG_SIGNATURE_LOCALE, cm.getSignatureLocale().toString())
+ );
+
+ File pdfOverConfig = new File(Constants.CONFIG_DIRECTORY + File.separator + Constants.DEFAULT_CONFIG_FILENAME);
+ Properties props = new Properties();
+ testParams.forEach((k, v) -> setProperty(props, k, v));
+
+ try {
+ FileOutputStream outputStream = new FileOutputStream(pdfOverConfig, false);
+ props.store(outputStream, "TEST Configuration file was generated!");
+ } catch (IOException e) {
+ logger.warn("Failed to create configuration file.");
+ }
+ }
+
+ public static void setSignatureProfiles() {
+ Collections.addAll(profiles, Profile.values());
+ assert(profiles.containsAll(EnumSet.allOf(Profile.class)));
+ }
+
+ public Profile getCurrentProfile() {
+ currentProfile = profiles.get(0);
+ profiles.remove(0);
+ if (profiles.isEmpty()) {
+ setSignatureProfiles();
+ }
+ return currentProfile;
+ }
+
+ /**
+ * Returns path of the signed document.
+ */
+ private String getPathOutputFile() {
+ String fileNameSigned = inputFile
+ .getName()
+ .substring(0, inputFile.getName().lastIndexOf('.'))
+ .concat(postFix)
+ .concat(".pdf");
+ String pathOutputFile = FilenameUtils.separatorsToSystem(outputDir
+ .concat("/")
+ .concat(fileNameSigned));
+ assertNotNull(pathOutputFile);
+ return pathOutputFile;
+ }
+
+}
\ No newline at end of file
diff --git a/pdf-over-gui/src/test/java/at/asit/pdfover/gui/tests/FileExistsCondition.java b/pdf-over-gui/src/test/java/at/asit/pdfover/gui/tests/FileExistsCondition.java
new file mode 100644
index 00000000..5bf92908
--- /dev/null
+++ b/pdf-over-gui/src/test/java/at/asit/pdfover/gui/tests/FileExistsCondition.java
@@ -0,0 +1,25 @@
+package at.asit.pdfover.gui.tests;
+
+import org.eclipse.swtbot.swt.finder.waits.DefaultCondition;
+
+import java.io.File;
+
+public class FileExistsCondition extends DefaultCondition {
+
+ private final File file;
+
+ public FileExistsCondition(File file) {
+ this.file = file;
+ }
+
+ @Override
+ public boolean test() {
+ return file.exists();
+ }
+
+ @Override
+ public String getFailureMessage() {
+ return String.format("Could not create output file %s", file.getName());
+ }
+
+}
\ No newline at end of file
diff --git a/pdf-over-gui/src/test/java/at/asit/pdfover/gui/tests/ImageComparisonResult.java b/pdf-over-gui/src/test/java/at/asit/pdfover/gui/tests/ImageComparisonResult.java
new file mode 100644
index 00000000..3da2eff2
--- /dev/null
+++ b/pdf-over-gui/src/test/java/at/asit/pdfover/gui/tests/ImageComparisonResult.java
@@ -0,0 +1,126 @@
+package at.asit.pdfover.gui.tests;
+
+import lombok.Getter;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.awt.*;
+import java.awt.image.BufferedImage;
+import java.util.Objects;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+/**
+ * Holds the results of comparing two images, including the modified images
+ * and difference image.
+ */
+@Getter
+public class ImageComparisonResult implements AutoCloseable {
+ private static final Logger logger = LoggerFactory.getLogger(ImageComparisonResult.class);
+ private static final Color BACKGROUND_COLOR = Color.WHITE;
+ private static final Color DIFFERENCE_COLOR = Color.RED;
+
+ private final BufferedImage modifiedSigned;
+ private final BufferedImage modifiedReference;
+ private final BufferedImage differenceImage;
+ private final boolean equal;
+
+ /**
+ * Creates a new comparison result by comparing two images.
+ *
+ * @param signedImage the modified signed image
+ * @param referenceImage the modified reference image
+ */
+ public ImageComparisonResult(BufferedImage signedImage, BufferedImage referenceImage, BufferedImage differenceImage, boolean areEqual) {
+ this.modifiedSigned = Objects.requireNonNull(signedImage, "Signed image cannot be null");
+ this.modifiedReference = Objects.requireNonNull(referenceImage, "Reference image cannot be null");
+ this.differenceImage = Objects.requireNonNull(differenceImage, "Difference image cannot be null");
+ this.equal = areEqual;
+ }
+
+ /**
+ * Creates a new blank image for visualizing differences between compared images.
+ * The image is initialized with a white background ({@link #BACKGROUND_COLOR})
+ * and will be used to mark pixel differences in red ({@link #DIFFERENCE_COLOR}).
+ *
+ * @param width The width of the image in pixels
+ * @param height The height of the image in pixels
+ * @return A new BufferedImage initialized with a white background
+ */
+ private static BufferedImage createDifferenceImage(int width, int height) {
+ BufferedImage diff = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);
+ Graphics2D g2d = diff.createGraphics();
+ try {
+ g2d.setColor(BACKGROUND_COLOR);
+ g2d.fillRect(0, 0, width, height);
+ return diff;
+ } finally {
+ g2d.dispose();
+ }
+ }
+
+ /**
+ * Performs pixel-by-pixel comparison of two images and marks differences.
+ *
+ * @param signed The signed image to compare
+ * @param reference The reference image to compare against
+ * @param difference The image where differences will be marked in {@link #DIFFERENCE_COLOR}
+ * @return true if all pixels match exactly, false if any differences are found
+ */
+ private static boolean comparePixels(BufferedImage signed, BufferedImage reference, BufferedImage difference) {
+ boolean match = true;
+ for (int x = 0; x < signed.getWidth(); x++) {
+ for (int y = 0; y < signed.getHeight(); y++) {
+ if (signed.getRGB(x, y) != reference.getRGB(x, y)) {
+ match = false;
+ difference.setRGB(x, y, Color.RED.getRGB());
+ }
+ }
+ }
+ return match;
+ }
+
+ /**
+ * Validates that the dimensions of the signed image match the reference image exactly.
+ * Throws AssertionError if either width or height differs between the images.
+ *
+ * @param signed The signed image to validate
+ * @param reference The reference image to compare against
+ */
+ private static void validateImageDimensions(BufferedImage signed, BufferedImage reference) {
+ Objects.requireNonNull(signed, "Signed image cannot be null");
+ Objects.requireNonNull(reference, "Reference image cannot be null");
+
+ assertEquals(reference.getWidth(), signed.getWidth(),
+ "Width of image differs from reference");
+ assertEquals(reference.getHeight(), signed.getHeight(),
+ "Height of image differs from reference");
+ }
+
+ /**
+ * Creates a new comparison result by comparing two images.
+ */
+ public static ImageComparisonResult compare(BufferedImage signedImage, BufferedImage referenceImage) {
+ validateImageDimensions(signedImage, referenceImage);
+
+ BufferedImage differenceImage = createDifferenceImage(signedImage.getWidth(), signedImage.getHeight());
+ boolean areEqual = comparePixels(signedImage, referenceImage, differenceImage);
+
+ return new ImageComparisonResult(signedImage, referenceImage, differenceImage, areEqual);
+ }
+
+ @Override
+ public void close() {
+ try {
+ dispose();
+ } catch (Exception e) {
+ logger.warn("Error disposing image resources", e);
+ }
+ }
+
+ public void dispose() {
+ modifiedSigned.flush();
+ modifiedReference.flush();
+ differenceImage.flush();
+ }
+}
\ No newline at end of file
diff --git a/pdf-over-gui/src/test/java/at/asit/pdfover/gui/tests/ReferenceFile.java b/pdf-over-gui/src/test/java/at/asit/pdfover/gui/tests/ReferenceFile.java
new file mode 100644
index 00000000..241f9232
--- /dev/null
+++ b/pdf-over-gui/src/test/java/at/asit/pdfover/gui/tests/ReferenceFile.java
@@ -0,0 +1,60 @@
+package at.asit.pdfover.gui.tests;
+
+
+import at.asit.pdfover.commons.Profile;
+import lombok.Getter;
+
+import java.util.Arrays;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+/**
+ * Enum representing reference files for signature profiles.
+ */
+@Getter
+public enum ReferenceFile {
+ AMTSSIGNATUR("refFileAmtssignatur.png", Profile.AMTSSIGNATURBLOCK),
+ SIGNATURBLOCK_SMALL("refFileSignaturblockSmallNote.png", Profile.SIGNATURBLOCK_SMALL),
+ BASE_LOGO("refFileBaseLogo.png", Profile.BASE_LOGO),
+ INVISIBLE("refFileInvisible.png", Profile.INVISIBLE),
+ TEST_NEGATIVE("refFileTestNegative.png", null);
+
+ private final String fileName;
+ private final Profile profile;
+ private static final Map REFERENCE_FILES;
+
+ static {
+ REFERENCE_FILES = Arrays.stream(values())
+ .filter(ref -> ref.profile != null)
+ .collect(Collectors.toMap(
+ ref -> ref.profile,
+ ref -> ref.fileName
+ ));
+ }
+
+ ReferenceFile(String fileName, Profile profile) {
+ this.fileName = fileName;
+ this.profile = profile;
+ }
+
+ /**
+ * Gets the reference file for a specific profile.
+ * @param profile the signature profile
+ * @return the corresponding reference file name
+ */
+ public static String getFileNameForProfile(Profile profile) {
+ String fileName = REFERENCE_FILES.get(profile);
+ if (fileName == null) {
+ throw new IllegalArgumentException("No reference file defined for profile: " + profile);
+ }
+ return fileName;
+ }
+
+ /**
+ * Gets the reference file name for negative test cases.
+ * @return the negative test reference file name
+ */
+ public static String getNegativeTestFileName() {
+ return TEST_NEGATIVE.fileName;
+ }
+}
\ No newline at end of file
diff --git a/pdf-over-gui/src/test/java/at/asit/pdfover/gui/tests/SignaturePositionValidator.java b/pdf-over-gui/src/test/java/at/asit/pdfover/gui/tests/SignaturePositionValidator.java
new file mode 100644
index 00000000..29cd85fa
--- /dev/null
+++ b/pdf-over-gui/src/test/java/at/asit/pdfover/gui/tests/SignaturePositionValidator.java
@@ -0,0 +1,379 @@
+package at.asit.pdfover.gui.tests;
+
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.awt.*;
+import java.awt.image.BufferedImage;
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.LinkOption;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.*;
+import java.util.List;
+
+import javax.imageio.ImageIO;
+
+import org.apache.pdfbox.pdmodel.PDDocument;
+import org.apache.pdfbox.rendering.PDFRenderer;
+
+import at.asit.pdfover.commons.Profile;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Validates the position of signatures in PDF documents by comparing them with reference images.
+ * This class provides functionality to:
+ * * - Capture and save reference images for comparison
+ * * - Compare signature positions between signed documents and reference images
+ * * - Handle ignored areas in signature blocks (e.g., date fields)
+ * *
+ * * The class supports different signature profiles (Amtssignatur, Base Logo, etc.)
+ * * and can perform both positive and negative comparison tests.
+ * * @author resekk, mtappler
+ */
+public class SignaturePositionValidator implements AutoCloseable {
+ private static final Logger logger = LoggerFactory.getLogger(SignaturePositionValidator.class);
+
+ private static int DEFAULT_PAGE = 1;
+ private static final float ZOOM = 4;
+ private static final String UNIX_SEPARATOR = "/";
+ private static final String PNG_FORMAT = "png";
+
+ private static final String REF_IMAGE_IGNORED = "refImage_ignored." + PNG_FORMAT;
+ private static final String SIG_PAGE_IMAGE_IGNORED = "sigPageImage_ignored." + PNG_FORMAT;
+ private static final String DIFFERENCE_IMAGE = "difference." + PNG_FORMAT;
+
+ private final boolean captureRefImage;
+ private final String refFileDir;
+ private final Profile currentProfile;
+ private final boolean isNegativeTest;
+ private final String pathOutputFile;
+
+ /**
+ * Map of profiles and belonging coordinates for the black triangle
+ * which overwrites ignored areas such as the date.
+ */
+ private static final Map IGNORED_AREAS = Map.of(
+ Profile.AMTSSIGNATURBLOCK, "215,679,142,9",
+ Profile.SIGNATURBLOCK_SMALL, "287,690,90,6"
+ );
+
+ public SignaturePositionValidator(boolean negative, boolean captureRefImage,
+ Profile currentProfile, String outputFile ) {
+ this.isNegativeTest = negative;
+ this.captureRefImage = captureRefImage;
+ this.currentProfile = currentProfile;
+ this.pathOutputFile = outputFile;
+ this.refFileDir = "src/test/resources";
+ }
+
+ /**
+ * Verifies the signature position by comparing the signed PDF against a reference image.
+ * Positive tests expect the images to match.
+ * Negative tests expect the images to differ.
+ */
+ protected void verifySignaturePosition() throws IOException {
+ String refImagePath = getReferencePath();
+ logger.debug("Starting signature position verification with reference: {}", refImagePath);
+
+ if (shouldSkipComparison()) return;
+
+ BufferedImage signedImage = captureImage(pathOutputFile);
+ BufferedImage referenceImage = loadReferenceImage(refImagePath);
+
+ try {
+ applyIgnoredAreas(signedImage, referenceImage);
+
+ try (ImageComparisonResult result = ImageComparisonResult.compare(signedImage, referenceImage)) {
+ saveComparisonResults(result, refImagePath);
+ validateComparisonResult(result);
+ }
+ } finally {
+ if (signedImage != null) signedImage.flush();
+ if (referenceImage != null) referenceImage.flush();
+ }
+ }
+
+ /**
+ * Validates the result of an image comparison based on the test type.
+ * For positive tests, the images should match exactly.
+ * For negative tests, the images should have differences.
+ *
+ * @param result The ImageComparisonResult containing the comparison outcome
+ */
+ private void validateComparisonResult(ImageComparisonResult result) {
+ String testType = isNegativeTest ? "negative" : "positive";
+ assertTrue(isNegativeTest != result.isEqual(),
+ String.format("Unexpected comparison result for %s test " + currentProfile + ". Images %s match.",
+ testType, result.isEqual() ? "do" : "do not"));
+ }
+
+ /**
+ * Applies ignored areas to signed image and reference image by clearing specified rectangular regions.
+ * For each image a Graphics context is created.
+ * Defined areas are cleared using the current profile's ignored area definitions.
+ *
+ * @param images One or more BufferedImages to apply ignored areas to
+ */
+ private void applyIgnoredAreas(BufferedImage... images) {
+ List areas = parseIgnoredAreas();
+ int height = images[0].getHeight();
+
+ for (BufferedImage image : images) {
+ Graphics g = image.getGraphics();
+ try {
+ for (Rectangle area : areas) {
+ clearIgnoredArea(g, area, height);
+ }
+ } finally {
+ if (g != null) {
+ g.dispose();
+ }
+ }
+ }
+ }
+
+ /**
+ * Parses the ignored areas defined for the current profile into a list of Rectangle objects.
+ * Areas are defined in the {@link #IGNORED_AREAS} map as semicolon-separated strings,
+ * each representing a rectangle in the format "x,y,width,height".
+ *
+ * @return List of Rectangle objects representing areas to ignore during comparison,
+ * or an empty list if no areas are defined for the current profile
+ */
+ private List parseIgnoredAreas() {
+ String areaDefinitions = IGNORED_AREAS.get(currentProfile);
+ if (areaDefinitions == null || areaDefinitions.trim().isEmpty()) {
+ return Collections.emptyList();
+ }
+
+ return Arrays.stream(areaDefinitions.split(";"))
+ .map(this::parseIgnoredArea)
+ .filter(Objects::nonNull)
+ .toList();
+ }
+
+ /**
+ * This parses one ignored area definition and returns a Rectangle-object
+ * representing it. The definitions have the exact format
+ * ",,,", with x and y specifying the coordinates of
+ * the upper left corner of the area and width and height specifying the
+ * size in pixels (width and height extends to the right and the bottom of
+ * the image).
+ *
+ * @param coordinates
+ * an ignored area definition
+ * @return a rectangle representing the area
+ */
+ private Rectangle parseIgnoredArea(String coordinates) {
+ try {
+ String[] parts = coordinates.split(",");
+ if (parts.length != 4) {
+ logger.warn("Invalid coordinate format: {}", coordinates);
+ return null;
+ }
+ return new Rectangle(
+ Integer.parseInt(parts[0].trim()),
+ Integer.parseInt(parts[1]),
+ Integer.parseInt(parts[2]),
+ Integer.parseInt(parts[3])
+ );
+ } catch (NumberFormatException e) {
+ logger.warn("Failed to parse coordinates: {}", coordinates, e);
+ return null;
+ }
+ }
+
+ /**
+ * Clears a rectangular area in the image using the provided Graphics context.
+ * Converts PDF coordinates to AWT coordinates and applies the zoom factor.
+ * In PDF coordinates, (0,0) is at the bottom-left, while in AWT it's at the top-left.
+ *
+ * @param g The Graphics context to draw on
+ * @param area The Rectangle defining the area to clear in PDF coordinates
+ * @param imageHeight The total height of the image for coordinate conversion
+ * @see #ZOOM The zoom factor applied to the coordinates
+ */
+ private void clearIgnoredArea(Graphics g, Rectangle area, int imageHeight) {
+ int x = (int) (area.x * ZOOM);
+ int y = imageHeight - (int) (area.y * ZOOM); // Convert PDF to AWT coordinates
+ int width = (int) (area.width * ZOOM);
+ int height = (int) (area.height * ZOOM);
+ g.clearRect(x, y, width, height);
+ }
+
+ /**
+ * Retrieves the full path to the reference image file based on the test type.
+ *
+ * @return The complete file path to the reference image
+ */
+ private String getReferencePath() {
+ String refImageFileName = isNegativeTest ?
+ ReferenceFile.getNegativeTestFileName() :
+ ReferenceFile.getFileNameForProfile(currentProfile);
+ Objects.requireNonNull(refImageFileName, "Reference image filename cannot be null");
+ return buildPath(refFileDir, refImageFileName);
+ }
+
+ /**
+ * Loads a reference image from the filesystem.
+ *
+ * @param refImageFilePath Path to the reference image
+ * @return The loaded BufferedImage
+ * @throws IOException if image loading fails
+ * @throws AssertionError if the image couldn't be loaded
+ */
+ private BufferedImage loadReferenceImage(String refImageFilePath) throws IOException {
+ BufferedImage refImage = ImageIO.read(new File(refImageFilePath));
+ assertNotNull(refImage, "Could not load reference image");
+ return refImage;
+ }
+
+ private String buildPath(String... parts) {
+ return String.join(UNIX_SEPARATOR, parts);
+ }
+
+ /**
+ * Determines whether the image comparison should be skipped based on test configuration.
+ *
+ * @return true if comparison should be skipped, false otherwise
+ * @throws IOException if there's an error while capturing the reference image
+ * @see #captureReferenceImage(String, String)
+ */
+ private boolean shouldSkipComparison() throws IOException {
+ if (captureRefImage && isNegativeTest) {
+ logger.debug("Skipping comparison: Negative test in capture mode");
+ return true;
+ }
+
+ if (captureRefImage) {
+ String refPath = buildPath(refFileDir, ReferenceFile.getFileNameForProfile(currentProfile));
+ captureReferenceImage(refPath, pathOutputFile);
+ logger.debug("Captured reference image: {}", refFileDir);
+ return true;
+ }
+
+ return false;
+ }
+
+ /**
+ * Saves a BufferedImage to the specified file path in PNG format.
+ * Creates parent directories if they don't exist.
+ *
+ * @param image The BufferedImage to save
+ * @param filePath The path where the image should be saved
+ */
+ private void saveImage(BufferedImage image, String filePath) throws IOException {
+ Objects.requireNonNull(image, "Image cannot be null");
+ Objects.requireNonNull(filePath, "File path cannot be null");
+
+ File outputFile = new File(filePath);
+ if (!outputFile.getParentFile().mkdirs() && !outputFile.getParentFile().exists()) {
+ throw new IOException("Failed to create directory " + outputFile.getParentFile());
+ }
+
+ if (!ImageIO.write(image, PNG_FORMAT, outputFile)) {
+ throw new IOException("Failed to write image to file: " + filePath);
+ }
+ image.flush();
+ }
+
+ /**
+ * Saves the comparison results between the reference and signed images to the filesystem.
+ * Creates a directory based on the reference image name and saves three images:
+ * - Modified reference image with ignored areas
+ * - Modified signed image with ignored areas
+ * - Difference image highlighting pixel differences in red
+ *
+ * @param result The ImageComparisonResult containing the modified and difference images
+ * @param refImagePath The path to the reference image, used to determine the output directory name
+ */
+ private void saveComparisonResults(ImageComparisonResult result, String refImagePath) throws IOException {
+ String resultDir = createResultDirectory(refImagePath);
+
+ Map imagesToSave = Map.of(
+ REF_IMAGE_IGNORED, result.getModifiedReference(),
+ SIG_PAGE_IMAGE_IGNORED, result.getModifiedSigned(),
+ DIFFERENCE_IMAGE, result.getDifferenceImage()
+ );
+
+ for (Map.Entry entry : imagesToSave.entrySet()) {
+ String outputPath = buildPath(resultDir, entry.getKey());
+ try {
+ saveImage(entry.getValue(), outputPath);
+ logger.debug("Saved comparison result image: {}", outputPath);
+ } catch (IOException e) {
+ logger.error("Failed to save comparison result image: {}", outputPath, e);
+ throw new IOException("Failed to save comparison result: " + outputPath, e);
+ }
+ }
+ }
+
+ /**
+ * Creates a directory for storing comparison result files.
+ * If the directory doesn't exist, it will be created along with any necessary parent directories.
+ *
+ * @param refImagePath The path to the reference image file used for comparison
+ * @return The absolute path to the created directory as a String
+ */
+ private String createResultDirectory(String refImagePath) throws IOException {
+ String dirName = extractResultDirectoryName(refImagePath);
+ Path path = Paths.get(dirName);
+ if (!Files.exists(path, LinkOption.NOFOLLOW_LINKS)){
+ Files.createDirectories(path);
+ }
+ return dirName;
+ }
+
+ /**
+ * Extracts the result directory name from the reference image path.
+ *
+ * @param refImagePath The full path to the reference image file
+ * @return The constructed directory name as a String
+ */
+ private String extractResultDirectoryName(String refImagePath) {
+ Objects.requireNonNull(refImagePath, "Reference image path cannot be null");
+
+ String fileName = Paths.get(refImagePath).getFileName().toString();
+ String baseName = fileName.substring(7, fileName.lastIndexOf('.'));
+
+ return buildPath(refFileDir,
+ isNegativeTest ? baseName + currentProfile : baseName
+ );
+ }
+
+ /**
+ * Captures and saves a reference image from a signed PDF document for signature comparison.
+ *
+ * @param refPath the file path where the reference image will be saved
+ * @param signedPath the path to the signed PDF file from which to capture the image
+ */
+ private void captureReferenceImage(String refPath, String signedPath) throws IOException {
+ BufferedImage image = captureImage(signedPath);
+ saveImage(image, refPath);
+ }
+
+ /**
+ * Captures and renders a specific page from a PDF document as a BufferedImage.
+ * The page is rendered using the configured zoom factor ({@link #ZOOM}).
+ * Note: Page numbers are 1-based, but PDFRenderer uses 0-based indexing internally.
+ *
+ * @param pdfPath path to the PDF file to be rendered
+ * @return the captured page as a BufferedImage
+ */
+ private BufferedImage captureImage(String pdfPath) throws IOException {
+ Objects.requireNonNull(pdfPath, "PDF path cannot be null");
+
+ try (PDDocument pdf = PDDocument.load(new File(pdfPath))) {
+ return new PDFRenderer(pdf).renderImage(DEFAULT_PAGE - 1, ZOOM);
+ }
+ }
+
+ @Override
+ public void close() throws Exception {
+
+ }
+}
\ No newline at end of file
diff --git a/pdf-over-gui/src/test/java/at/asit/pdfover/gui/tests/SignatureUITest.java b/pdf-over-gui/src/test/java/at/asit/pdfover/gui/tests/SignatureUITest.java
new file mode 100644
index 00000000..ddca838f
--- /dev/null
+++ b/pdf-over-gui/src/test/java/at/asit/pdfover/gui/tests/SignatureUITest.java
@@ -0,0 +1,24 @@
+package at.asit.pdfover.gui.tests;
+
+import java.io.IOException;
+
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.EnumSource;
+import at.asit.pdfover.commons.Profile;
+
+public class SignatureUITest extends AbstractSignatureUITest {
+
+ @ParameterizedTest
+ @EnumSource(Profile.class)
+ public void testSignatureAutoPosition() throws IOException {
+ setCredentials();
+ testSignature(false, false);
+ }
+
+ @ParameterizedTest
+ @EnumSource(Profile.class)
+ public void testSignatureAutoPositionNegative() throws IOException {
+ setCredentials();
+ testSignature(true, false);
+ }
+}
\ No newline at end of file
diff --git a/pdf-over-gui/src/test/java/at/asit/pdfover/gui/tests/WidgetExistsCondition.java b/pdf-over-gui/src/test/java/at/asit/pdfover/gui/tests/WidgetExistsCondition.java
new file mode 100644
index 00000000..9433d79d
--- /dev/null
+++ b/pdf-over-gui/src/test/java/at/asit/pdfover/gui/tests/WidgetExistsCondition.java
@@ -0,0 +1,23 @@
+package at.asit.pdfover.gui.tests;
+
+import org.eclipse.swtbot.swt.finder.waits.DefaultCondition;
+
+public class WidgetExistsCondition extends DefaultCondition {
+
+ private final String widgetName;
+
+ public WidgetExistsCondition(String widget) {
+ this.widgetName = widget;
+ }
+
+ @Override
+ public boolean test() {
+ return bot.textWithLabel(widgetName).isVisible();
+ }
+
+ @Override
+ public String getFailureMessage() {
+ return String.format("Could not find widget %s", widgetName);
+ }
+
+}
\ No newline at end of file
diff --git a/pdf-over-gui/src/test/resources/TestFile.pdf b/pdf-over-gui/src/test/resources/TestFile.pdf
new file mode 100644
index 00000000..867f68db
Binary files /dev/null and b/pdf-over-gui/src/test/resources/TestFile.pdf differ
diff --git a/pdf-over-gui/src/test/resources/refFileAmtssignatur.png b/pdf-over-gui/src/test/resources/refFileAmtssignatur.png
new file mode 100644
index 00000000..6550abaf
Binary files /dev/null and b/pdf-over-gui/src/test/resources/refFileAmtssignatur.png differ
diff --git a/pdf-over-gui/src/test/resources/refFileBaseLogo.png b/pdf-over-gui/src/test/resources/refFileBaseLogo.png
new file mode 100644
index 00000000..dc113df6
Binary files /dev/null and b/pdf-over-gui/src/test/resources/refFileBaseLogo.png differ
diff --git a/pdf-over-gui/src/test/resources/refFileInvisible.png b/pdf-over-gui/src/test/resources/refFileInvisible.png
new file mode 100644
index 00000000..28be15d2
Binary files /dev/null and b/pdf-over-gui/src/test/resources/refFileInvisible.png differ
diff --git a/pdf-over-gui/src/test/resources/refFileSignaturblockSmallNote.png b/pdf-over-gui/src/test/resources/refFileSignaturblockSmallNote.png
new file mode 100644
index 00000000..533c7b77
Binary files /dev/null and b/pdf-over-gui/src/test/resources/refFileSignaturblockSmallNote.png differ
diff --git a/pdf-over-gui/src/test/resources/refFileTestNegative.png b/pdf-over-gui/src/test/resources/refFileTestNegative.png
new file mode 100644
index 00000000..d9c4f3cd
Binary files /dev/null and b/pdf-over-gui/src/test/resources/refFileTestNegative.png differ
diff --git a/pdf-over-signer/pom.xml b/pdf-over-signer/pom.xml
index c05c27d9..6cff1eb1 100644
--- a/pdf-over-signer/pom.xml
+++ b/pdf-over-signer/pom.xml
@@ -57,7 +57,6 @@
org.apache.pdfbox
pdfbox
- 2.0.24
org.projectlombok
diff --git a/pom.xml b/pom.xml
index 0aab062d..35c0ccf6 100644
--- a/pom.xml
+++ b/pom.xml
@@ -113,6 +113,7 @@
21
${project.basedir}
2.0.34
+ 5.13.4
@@ -259,7 +260,19 @@
org.junit.jupiter
junit-jupiter-engine
- 5.9.0
+ ${jupiter.version}
+ test
+
+
+ org.junit.jupiter
+ junit-jupiter-params
+ ${jupiter.version}
+ test
+
+
+ org.junit.vintage
+ junit-vintage-engine
+ ${jupiter.version}
test
diff --git a/repo/org/eclipse/swtbot/swt/finder/4.1.0.202306071420/finder-4.1.0.202306071420.jar b/repo/org/eclipse/swtbot/swt/finder/4.1.0.202306071420/finder-4.1.0.202306071420.jar
new file mode 100644
index 00000000..f1c5188c
Binary files /dev/null and b/repo/org/eclipse/swtbot/swt/finder/4.1.0.202306071420/finder-4.1.0.202306071420.jar differ
diff --git a/repo/org/eclipse/swtbot/swt/finder/4.3.0.202509040840/finder-4.3.0.202509040840.jar b/repo/org/eclipse/swtbot/swt/finder/4.3.0.202509040840/finder-4.3.0.202509040840.jar
new file mode 100644
index 00000000..b531d2f8
Binary files /dev/null and b/repo/org/eclipse/swtbot/swt/finder/4.3.0.202509040840/finder-4.3.0.202509040840.jar differ