浏览代码

Merge branch 'release/1.6.2'

Armin Schrenk 3 年之前
父节点
当前提交
8151cc9714
共有 26 个文件被更改,包括 191 次插入93 次删除
  1. 1 0
      .github/workflows/release.yml
  2. 1 1
      .idea/runConfigurations/Cryptomator_macOS.xml
  3. 1 1
      .idea/runConfigurations/Cryptomator_macOS_Dev.xml
  4. 1 0
      dist/linux/appimage/build.sh
  5. 7 0
      dist/win/resources/main.wxs
  6. 2 2
      pom.xml
  7. 27 6
      src/main/java/org/cryptomator/common/ErrorCode.java
  8. 3 1
      src/main/java/org/cryptomator/ipc/IpcCommunicator.java
  9. 5 6
      src/main/java/org/cryptomator/ui/addvaultwizard/ChooseExistingVaultController.java
  10. 18 0
      src/main/java/org/cryptomator/ui/addvaultwizard/CreateNewVaultPasswordController.java
  11. 1 21
      src/main/java/org/cryptomator/ui/traymenu/TrayIconController.java
  12. 1 5
      src/main/java/org/cryptomator/ui/traymenu/TrayImageFactory.java
  13. 2 2
      src/main/resources/i18n/strings.properties
  14. 二进制
      src/main/resources/img/select-masterkey-mac-dark.png
  15. 二进制
      src/main/resources/img/select-masterkey-mac-dark@2x.png
  16. 二进制
      src/main/resources/img/select-masterkey-mac.png
  17. 二进制
      src/main/resources/img/select-masterkey-mac@2x.png
  18. 二进制
      src/main/resources/img/select-masterkey-win.png
  19. 二进制
      src/main/resources/img/select-masterkey-win@2x.png
  20. 二进制
      src/main/resources/img/tray_icon_mac.png
  21. 二进制
      src/main/resources/img/tray_icon_mac@2x.png
  22. 二进制
      src/main/resources/img/tray_icon_mac_black.png
  23. 二进制
      src/main/resources/img/tray_icon_mac_black@2x.png
  24. 二进制
      src/main/resources/img/tray_icon_mac_white.png
  25. 二进制
      src/main/resources/img/tray_icon_mac_white@2x.png
  26. 121 48
      src/test/java/org/cryptomator/common/ErrorCodeTest.java

+ 1 - 0
.github/workflows/release.yml

@@ -145,6 +145,7 @@ jobs:
           jpackageoptions: >
             --app-version "${{ needs.metadata.outputs.semVerNum }}"
             --java-options "-Dfile.encoding=\"utf-8\""
+            --java-options "-Dapple.awt.enableTemplateImages=true"
             --java-options "-Dcryptomator.logDir=\"~/Library/Logs/Cryptomator\""
             --java-options "-Dcryptomator.pluginDir=\"~/Library/Application Support/Cryptomator/Plugins\""
             --java-options "-Dcryptomator.settingsPath=\"~/Library/Application Support/Cryptomator/settings.json\""

+ 1 - 1
.idea/runConfigurations/Cryptomator_macOS.xml

@@ -5,7 +5,7 @@
     </envs>
     <option name="MAIN_CLASS_NAME" value="org.cryptomator.launcher.Cryptomator" />
     <module name="cryptomator" />
-    <option name="VM_PARAMETERS" value="-Duser.language=en -Dcryptomator.settingsPath=&quot;~/Library/Application Support/Cryptomator/settings.json&quot; -Dcryptomator.ipcSocketPath=&quot;~/Library/Application Support/Cryptomator/ipc.socket&quot; -Dcryptomator.logDir=&quot;~/Library/Logs/Cryptomator&quot; -Dcryptomator.pluginDir=&quot;~/Library/Application Support/Cryptomator/Plugins&quot; -Dcryptomator.showTrayIcon=true -Xss2m -Xmx512m -ea" />
+    <option name="VM_PARAMETERS" value="-Duser.language=en -Dapple.awt.enableTemplateImages=true -Dcryptomator.settingsPath=&quot;~/Library/Application Support/Cryptomator/settings.json&quot; -Dcryptomator.ipcSocketPath=&quot;~/Library/Application Support/Cryptomator/ipc.socket&quot; -Dcryptomator.logDir=&quot;~/Library/Logs/Cryptomator&quot; -Dcryptomator.pluginDir=&quot;~/Library/Application Support/Cryptomator/Plugins&quot; -Dcryptomator.showTrayIcon=true -Xss2m -Xmx512m -ea" />
     <method v="2">
       <option name="Make" enabled="true" />
     </method>

文件差异内容过多而无法显示
+ 1 - 1
.idea/runConfigurations/Cryptomator_macOS_Dev.xml


+ 1 - 0
dist/linux/appimage/build.sh

@@ -41,6 +41,7 @@ ${JAVA_HOME}/bin/jpackage \
     --app-version "${VERSION}.${REVISION_NO}" \
     --java-options "-Dfile.encoding=\"utf-8\"" \
     --java-options "-Dcryptomator.logDir=\"~/.local/share/Cryptomator/logs\"" \
+    --java-options "-Dcryptomator.pluginDir=\"~/.local/share/Cryptomator/plugins\"" \
     --java-options "-Dcryptomator.settingsPath=\"~/.config/Cryptomator/settings.json:~/.Cryptomator/settings.json\"" \
     --java-options "-Dcryptomator.ipcSocketPath=\"~/.config/Cryptomator/ipc.socket\"" \
     --java-options "-Dcryptomator.mountPointsDir=\"~/.local/share/Cryptomator/mnt\"" \

+ 7 - 0
dist/win/resources/main.wxs

@@ -75,6 +75,13 @@
     <Condition Message="A lower version of [ProductName] is already installed. Uninstall it first and then start the setup again. Setup will now exit.">
         <![CDATA[Installed OR NOT OLDEXEINSTALLER]]>
     </Condition>
+    <!-- Cryptomator uses UNIX Sockets, which are supported starting with Windows 10 v1803-->
+    <Property Id="WINDOWSBUILDNUMBER" Secure="yes">
+      <RegistrySearch Id="BuildNumberSearch" Root="HKLM" Key="SOFTWARE\Microsoft\Windows NT\CurrentVersion" Name="CurrentBuildNumber" Type="raw" />
+    </Property>
+    <Condition Message="This application requires Windows 10 version 1803 (build 17134) or newer.">
+      <![CDATA[Installed OR (WINDOWSBUILDNUMBER >= 17134)]]>
+    </Condition>
 
     <!-- Non-Opening ProgID -->
     <DirectoryRef Id="INSTALLDIR">

+ 2 - 2
pom.xml

@@ -3,7 +3,7 @@
 	<modelVersion>4.0.0</modelVersion>
 	<groupId>org.cryptomator</groupId>
 	<artifactId>cryptomator</artifactId>
-	<version>1.6.1</version>
+	<version>1.6.2</version>
 	<name>Cryptomator Desktop App</name>
 
 	<organization>
@@ -31,7 +31,7 @@
 		<cryptomator.integrations.version>1.0.0</cryptomator.integrations.version>
 		<cryptomator.integrations.win.version>1.0.0</cryptomator.integrations.win.version>
 		<cryptomator.integrations.mac.version>1.0.0</cryptomator.integrations.mac.version>
-		<cryptomator.integrations.linux.version>1.0.0</cryptomator.integrations.linux.version>
+		<cryptomator.integrations.linux.version>1.0.1</cryptomator.integrations.linux.version>
 		<cryptomator.fuse.version>1.3.3</cryptomator.fuse.version>
 		<cryptomator.dokany.version>1.3.3</cryptomator.dokany.version>
 		<cryptomator.webdav.version>1.2.6</cryptomator.webdav.version>

+ 27 - 6
src/main/java/org/cryptomator/common/ErrorCode.java

@@ -1,5 +1,6 @@
 package org.cryptomator.common;
 
+import com.google.common.base.Preconditions;
 import com.google.common.base.Strings;
 import com.google.common.base.Throwables;
 
@@ -80,7 +81,7 @@ public class ErrorCode {
 		if (causalChain.size() > 1) {
 			var rootCause = causalChain.get(causalChain.size() - 1);
 			var parentOfRootCause = causalChain.get(causalChain.size() - 2);
-			var rootSpecificFrames = nonOverlappingFrames(parentOfRootCause.getStackTrace(), rootCause.getStackTrace());
+			var rootSpecificFrames = countTopmostFrames(rootCause.getStackTrace(), parentOfRootCause.getStackTrace());
 			return new ErrorCode(throwable, rootCause, rootSpecificFrames);
 		} else {
 			return new ErrorCode(throwable, throwable, ALL_FRAMES);
@@ -107,11 +108,31 @@ public class ErrorCode {
 		return result;
 	}
 
-	private static int nonOverlappingFrames(StackTraceElement[] frames, StackTraceElement[] enclosingFrames) {
-		// Compute the number of elements in `frames` not contained in `enclosingFrames` by iterating backwards
-		// Result should usually be equal to the difference in size of both traces
-		var i = reverseStream(enclosingFrames).iterator();
-		return (int) reverseStream(frames).dropWhile(f -> i.hasNext() && i.next().equals(f)).count();
+	/**
+	 * Counts the number of <em>additional</em> frames contained in <code>allFrames</code> but not in <code>bottomFrames</code>.
+	 * <p>
+	 * If <code>allFrames</code> does not end with <code>bottomFrames</code>, it is considered distinct and all its frames are counted.
+	 *
+	 * @param allFrames Some stack frames
+	 * @param bottomFrames Other stack frames, potentially forming the bottom of the stack of <code>allFrames</code>
+	 * @return The number of additional frames in <code>allFrames</code>. In most cases this should be equal to the difference in size.
+	 */
+	// visible for testing
+	static int countTopmostFrames(StackTraceElement[] allFrames, StackTraceElement[] bottomFrames) {
+		if (allFrames.length < bottomFrames.length) {
+			// if frames had been stacked on top of bottomFrames, allFrames would be larger
+			return allFrames.length;
+		} else {
+			return allFrames.length - commonSuffixLength(allFrames, bottomFrames);
+		}
+	}
+
+	// visible for testing
+	static <T> int commonSuffixLength(T[] set, T[] subset) {
+		Preconditions.checkArgument(set.length >= subset.length);
+		// iterate items backwards as long as they are identical
+		var iterator = reverseStream(subset).iterator();
+		return (int) reverseStream(set).takeWhile(item -> iterator.hasNext() && iterator.next().equals(item)).count();
 	}
 
 	private static <T> Stream<T> reverseStream(T[] array) {

+ 3 - 1
src/main/java/org/cryptomator/ipc/IpcCommunicator.java

@@ -44,7 +44,9 @@ public interface IpcCommunicator extends Closeable {
 		}
 		// Didn't get any connection yet? I.e. we're the first app instance, so let's launch a server:
 		try {
-			return Server.create(socketPaths.iterator().next());
+			final var socketPath = socketPaths.iterator().next();
+			Files.deleteIfExists(socketPath); // ensure path does not exist before creating it
+			return Server.create(socketPath);
 		} catch (IOException e) {
 			LOG.warn("Failed to create IPC server", e);
 			return new LoopbackCommunicator();

+ 5 - 6
src/main/java/org/cryptomator/ui/addvaultwizard/ChooseExistingVaultController.java

@@ -56,11 +56,10 @@ public class ChooseExistingVaultController implements FxController {
 
 	@FXML
 	public void initialize() {
-		final String resource = SystemUtils.IS_OS_MAC ? "/img/select-masterkey-mac.png" : "/img/select-masterkey-win.png";
-		try (InputStream in = getClass().getResourceAsStream(resource)) {
-			this.screenshot = new Image(in);
-		} catch (IOException e) {
-			throw new UncheckedIOException(e);
+		if (SystemUtils.IS_OS_MAC) {
+			this.screenshot = new Image(getClass().getResource("/img/select-masterkey-mac.png").toString());
+		} else {
+			this.screenshot = new Image(getClass().getResource("/img/select-masterkey-win.png").toString());
 		}
 	}
 
@@ -73,7 +72,7 @@ public class ChooseExistingVaultController implements FxController {
 	public void chooseFileAndNext() {
 		FileChooser fileChooser = new FileChooser();
 		fileChooser.setTitle(resourceBundle.getString("addvaultwizard.existing.filePickerTitle"));
-		fileChooser.getExtensionFilters().add(new FileChooser.ExtensionFilter("Cryptomator Masterkey", "*.cryptomator"));
+		fileChooser.getExtensionFilters().add(new FileChooser.ExtensionFilter("Cryptomator Vault", "*.cryptomator"));
 		File masterkeyFile = fileChooser.showOpenDialog(window);
 		if (masterkeyFile != null) {
 			vaultPath.setValue(masterkeyFile.toPath().toAbsolutePath().getParent());

+ 18 - 0
src/main/java/org/cryptomator/ui/addvaultwizard/CreateNewVaultPasswordController.java

@@ -44,8 +44,10 @@ import java.nio.file.Files;
 import java.nio.file.Path;
 import java.nio.file.StandardOpenOption;
 import java.security.SecureRandom;
+import java.util.Comparator;
 import java.util.ResourceBundle;
 import java.util.concurrent.ExecutorService;
+import java.util.concurrent.atomic.AtomicBoolean;
 
 import static java.nio.charset.StandardCharsets.US_ASCII;
 import static org.cryptomator.common.Constants.MASTERKEY_FILENAME;
@@ -195,12 +197,28 @@ public class CreateNewVaultPasswordController implements FxController {
 			} catch (CryptoException e) {
 				throw new IOException("Failed initialize vault.", e);
 			}
+		} finally {
+			AtomicBoolean cleanupFailed = new AtomicBoolean(false);
+			Files.walk(path)
+				.sorted(Comparator.reverseOrder())
+				.forEach(p -> {
+					try {
+						Files.deleteIfExists(p);
+					} catch (IOException e) {
+						cleanupFailed.set(false);
+					}
+				});
+			if(cleanupFailed.get()) {
+				LOG.warn("Failed to cleanup after failed vault creation at {}. Leftovers need to be deleted manually.", path);
+			}
 		}
 
 		// 4. write vault-external readme file:
 		String storagePathReadmeFileName = resourceBundle.getString("addvault.new.readme.storageLocation.fileName");
 		try (WritableByteChannel ch = Files.newByteChannel(path.resolve(storagePathReadmeFileName), StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE)) {
 			ch.write(US_ASCII.encode(readmeGenerator.createVaultStorageLocationReadmeRtf()));
+		} catch (IOException e) {
+			LOG.warn("Unable to create vault storage location readme.", e);
 		}
 
 		LOG.info("Created vault at {}", path);

+ 1 - 21
src/main/java/org/cryptomator/ui/traymenu/TrayIconController.java

@@ -2,9 +2,6 @@ package org.cryptomator.ui.traymenu;
 
 import com.google.common.base.Preconditions;
 import org.apache.commons.lang3.SystemUtils;
-import org.cryptomator.integrations.uiappearance.Theme;
-import org.cryptomator.integrations.uiappearance.UiAppearanceException;
-import org.cryptomator.integrations.uiappearance.UiAppearanceProvider;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
@@ -12,38 +9,25 @@ import javax.inject.Inject;
 import java.awt.AWTException;
 import java.awt.SystemTray;
 import java.awt.TrayIcon;
-import java.util.Optional;
 
 @TrayMenuScoped
 public class TrayIconController {
 
 	private static final Logger LOG = LoggerFactory.getLogger(TrayIconController.class);
 
-	private final TrayImageFactory imageFactory;
-	private final Optional<UiAppearanceProvider> appearanceProvider;
 	private final TrayMenuController trayMenuController;
 	private final TrayIcon trayIcon;
 	private volatile boolean initialized;
 
 	@Inject
-	TrayIconController(TrayImageFactory imageFactory, TrayMenuController trayMenuController, Optional<UiAppearanceProvider> appearanceProvider) {
+	TrayIconController(TrayImageFactory imageFactory, TrayMenuController trayMenuController) {
 		this.trayMenuController = trayMenuController;
-		this.imageFactory = imageFactory;
-		this.appearanceProvider = appearanceProvider;
 		this.trayIcon = new TrayIcon(imageFactory.loadImage(), "Cryptomator", trayMenuController.getMenu());
 	}
 
 	public synchronized void initializeTrayIcon() throws IllegalStateException {
 		Preconditions.checkState(!initialized);
 
-		appearanceProvider.ifPresent(appearanceProvider -> {
-			try {
-				appearanceProvider.addListener(this::systemInterfaceThemeChanged);
-			} catch (UiAppearanceException e) {
-				LOG.error("Failed to enable automatic tray icon theme switching.");
-			}
-		});
-
 		trayIcon.setImageAutoSize(true);
 		if (SystemUtils.IS_OS_WINDOWS) {
 			trayIcon.addActionListener(trayMenuController::showMainWindow);
@@ -61,10 +45,6 @@ public class TrayIconController {
 		this.initialized = true;
 	}
 
-	private void systemInterfaceThemeChanged(Theme theme) {
-		trayIcon.setImage(imageFactory.loadImage()); // TODO refactor "theme" is re-queried in loadImage()
-	}
-
 	public boolean isInitialized() {
 		return initialized;
 	}

+ 1 - 5
src/main/java/org/cryptomator/ui/traymenu/TrayImageFactory.java

@@ -25,11 +25,7 @@ class TrayImageFactory {
 	}
 
 	private String getMacResourceName() {
-		var theme = appearanceProvider.map(UiAppearanceProvider::getSystemTheme).orElse(Theme.LIGHT);
-		return switch (theme) {
-			case DARK -> "/img/tray_icon_mac_white.png";
-			case LIGHT -> "/img/tray_icon_mac_black.png";
-		};
+		return "/img/tray_icon_mac.png";
 	}
 
 	private String getWinOrLinuxResourceName() {

+ 2 - 2
src/main/resources/i18n/strings.properties

@@ -76,9 +76,9 @@ addvault.new.readme.accessLocation.2=This is your vault's access location.
 addvault.new.readme.accessLocation.3=Any files added to this volume will be encrypted by Cryptomator. You can work on it like on any other drive/folder. This is only a decrypted view of its content, your files stay encrypted on your hard drive all the time.
 addvault.new.readme.accessLocation.4=Feel free to remove this file.
 ## Existing
-addvaultwizard.existing.instruction=Choose the "masterkey.cryptomator" file of your existing vault.
+addvaultwizard.existing.instruction=Choose the "vault.cryptomator" file of your existing vault. If only a file named "masterkey.cryptomator" exists, select that instead.
 addvaultwizard.existing.chooseBtn=Choose…
-addvaultwizard.existing.filePickerTitle=Select Masterkey File
+addvaultwizard.existing.filePickerTitle=Select Vault File
 ## Success
 addvaultwizard.success.nextStepsInstructions=Added vault "%s".\nYou need to unlock this vault to access or add contents. Alternatively you can unlock it at any later point in time.
 addvaultwizard.success.unlockNow=Unlock Now

二进制
src/main/resources/img/select-masterkey-mac-dark.png


二进制
src/main/resources/img/select-masterkey-mac-dark@2x.png


二进制
src/main/resources/img/select-masterkey-mac.png


二进制
src/main/resources/img/select-masterkey-mac@2x.png


二进制
src/main/resources/img/select-masterkey-win.png


二进制
src/main/resources/img/select-masterkey-win@2x.png


二进制
src/main/resources/img/tray_icon_mac.png


二进制
src/main/resources/img/tray_icon_mac@2x.png


二进制
src/main/resources/img/tray_icon_mac_black.png


二进制
src/main/resources/img/tray_icon_mac_black@2x.png


二进制
src/main/resources/img/tray_icon_mac_white.png


二进制
src/main/resources/img/tray_icon_mac_white@2x.png


+ 121 - 48
src/test/java/org/cryptomator/common/ErrorCodeTest.java

@@ -1,59 +1,119 @@
 package org.cryptomator.common;
 
+import com.google.common.base.CharMatcher;
+import com.google.common.base.Splitter;
 import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.DisplayName;
 import org.junit.jupiter.api.Nested;
 import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.converter.ConvertWith;
+import org.junit.jupiter.params.converter.SimpleArgumentConverter;
+import org.junit.jupiter.params.provider.CsvSource;
+import org.mockito.Mockito;
 
 public class ErrorCodeTest {
 
-	private static ErrorCode codeCaughtFrom(RunnableThrowingException<RuntimeException> runnable) {
-		try {
-			runnable.run();
-			throw new IllegalStateException("should not reach this point");
-		} catch (RuntimeException e) {
-			return ErrorCode.of(e);
-		}
-	}
+	private final StackTraceElement foo = new StackTraceElement("ErrorCodeTest", "foo", null, 0);
+	private final StackTraceElement bar = new StackTraceElement("ErrorCodeTest", "bar", null, 0);
+	private final StackTraceElement baz = new StackTraceElement("ErrorCodeTest", "baz", null, 0);
+	private final Exception fooException = Mockito.mock(NullPointerException.class, "fooException");
 
 	@Test
 	@DisplayName("same exception leads to same error code")
-	public void testDifferentErrorCodes() {
-		var code1 = codeCaughtFrom(this::throwNpe);
-		var code2 = codeCaughtFrom(this::throwNpe);
+	public void testDeterministicErrorCode() {
+		Mockito.doReturn(new StackTraceElement[]{foo, bar, baz}).when(fooException).getStackTrace();
+		var code1 = ErrorCode.of(fooException);
+		var code2 = ErrorCode.of(fooException);
 
 		Assertions.assertEquals(code1.toString(), code2.toString());
 	}
 
-	private void throwNpe() {
-		throwException(new NullPointerException());
+	@Test
+	@DisplayName("three error code segments change independently")
+	public void testErrorCodeSegments() {
+		Exception fooBarException = Mockito.mock(IndexOutOfBoundsException.class, "fooBarException");
+		Mockito.doReturn(new StackTraceElement[]{foo, foo, foo}).when(fooBarException).getStackTrace();
+		Mockito.doReturn(fooException).when(fooBarException).getCause();
+		Mockito.doReturn(new StackTraceElement[]{bar, bar, bar, foo, foo, foo}).when(fooException).getStackTrace();
+
+		var code = ErrorCode.of(fooBarException);
+
+		Assertions.assertNotEquals(code.throwableCode(), code.rootCauseCode());
+		Assertions.assertNotEquals(code.rootCauseCode(), code.methodCode());
 	}
 
-	private void throwException(RuntimeException e) throws RuntimeException {
-		throw e;
+	@DisplayName("commonSuffixLength()")
+	@ParameterizedTest
+	@CsvSource({"1 2 3, 1 2 3, 3", "1 2 3, 0 2 3, 2", "1 2 3 4, 3 4, 2", "1 2 3 4, 5 6, 0", "1 2 3 4 5 6,, 0",})
+	public void commonSuffixLength1(@ConvertWith(IntegerArrayConverter.class) Integer[] set, @ConvertWith(IntegerArrayConverter.class) Integer[] subset, int expected) {
+		var result = ErrorCode.commonSuffixLength(set, subset);
+
+		Assertions.assertEquals(expected, result);
 	}
 
-	@DisplayName("when different cause but same root cause")
-	@Nested
-	public class SameRootCauseDifferentCause {
+	@DisplayName("commonSuffixLength() with too short array")
+	@ParameterizedTest
+	@CsvSource({"1 2, 3 4 5 6", ",1 2 3 4 5 6",})
+	public void commonSuffixLength2(@ConvertWith(IntegerArrayConverter.class) Integer[] set, @ConvertWith(IntegerArrayConverter.class) Integer[] subset) {
+		Assertions.assertThrows(IllegalArgumentException.class, () -> {
+			ErrorCode.commonSuffixLength(set, subset);
+		});
+	}
+
+	@Test
+	@DisplayName("countTopmostFrames() with partially overlapping suffix")
+	public void testCountTopmostFrames1() {
+		var allFrames = new StackTraceElement[]{foo, bar, baz, bar, foo};
+		var bottomFrames = new StackTraceElement[]{baz, bar, foo};
 
-		private final ErrorCode code1 = codeCaughtFrom(this::foo);
-		private final ErrorCode code2 = codeCaughtFrom(this::bar);
+		int result = ErrorCode.countTopmostFrames(allFrames, bottomFrames);
 
-		private void foo() throws IllegalArgumentException {
-			try {
-				throwNpe();
-			} catch (NullPointerException e) {
-				throw new IllegalArgumentException(e);
-			}
-		}
+		Assertions.assertEquals(2, result);
+	}
 
-		private void bar() throws IllegalStateException {
-			try {
-				throwNpe();
-			} catch (NullPointerException e) {
-				throw new IllegalStateException(e);
-			}
+	@Test
+	@DisplayName("countTopmostFrames() without overlapping suffix")
+	public void testCountTopmostFrames2() {
+		var allFrames = new StackTraceElement[]{foo, foo, foo};
+		var bottomFrames = new StackTraceElement[]{bar, bar, bar};
+
+		int result = ErrorCode.countTopmostFrames(allFrames, bottomFrames);
+
+		Assertions.assertEquals(3, result);
+	}
+
+	@Test
+	@DisplayName("countUniqueFrames() fully overlapping")
+	public void testCountUniqueFrames3() {
+		var allFrames = new StackTraceElement[]{foo, bar, baz};
+		var bottomFrames = new StackTraceElement[]{foo, bar, baz};
+
+		int result = ErrorCode.countTopmostFrames(allFrames, bottomFrames);
+
+		Assertions.assertEquals(0, result);
+	}
+
+	@DisplayName("when different exception with same root cause")
+	@Nested
+	public class DifferentExceptionWithSameRootCause {
+
+		private final Exception fooBarException = Mockito.mock(IllegalArgumentException.class, "fooBarException");
+		private final Exception fooBazException = Mockito.mock(IndexOutOfBoundsException.class, "fooBazException");
+
+		private ErrorCode code1;
+		private ErrorCode code2;
+
+		@BeforeEach
+		public void setup() {
+			Mockito.doReturn(new StackTraceElement[]{baz, bar, foo}).when(fooException).getStackTrace();
+			Mockito.doReturn(new StackTraceElement[]{foo}).when(fooBarException).getStackTrace();
+			Mockito.doReturn(new StackTraceElement[]{foo}).when(fooBazException).getStackTrace();
+			Mockito.doReturn(fooException).when(fooBarException).getCause();
+			Mockito.doReturn(fooException).when(fooBazException).getCause();
+			this.code1 = ErrorCode.of(fooBarException);
+			this.code2 = ErrorCode.of(fooBazException);
 		}
 
 		@Test
@@ -82,23 +142,21 @@ public class ErrorCodeTest {
 
 	}
 
-	@DisplayName("when same cause but different call stack")
+	@DisplayName("when same exception with different call stacks")
 	@Nested
-	public class SameCauseDifferentCallStack {
+	public class SameExceptionDifferentCallStack {
 
-		private final ErrorCode code1 = codeCaughtFrom(this::foo);
-		private final ErrorCode code2 = codeCaughtFrom(this::bar);
+		private final Exception barException = Mockito.mock(NullPointerException.class, "barException");
 
-		private void foo() throws NullPointerException {
-			try {
-				throwNpe();
-			} catch (NullPointerException e) {
-				throw new IllegalArgumentException(e);
-			}
-		}
+		private ErrorCode code1;
+		private ErrorCode code2;
 
-		private void bar() throws NullPointerException {
-			foo();
+		@BeforeEach
+		public void setup() {
+			Mockito.doReturn(new StackTraceElement[]{foo, bar, baz}).when(fooException).getStackTrace();
+			Mockito.doReturn(new StackTraceElement[]{foo, baz, bar}).when(barException).getStackTrace();
+			this.code1 = ErrorCode.of(fooException);
+			this.code2 = ErrorCode.of(barException);
 		}
 
 		@Test
@@ -114,9 +172,9 @@ public class ErrorCodeTest {
 		}
 
 		@Test
-		@DisplayName("rootCauseCodes are equal")
+		@DisplayName("rootCauseCodes are different")
 		public void testSameRootCauseCodes() {
-			Assertions.assertEquals(code1.rootCauseCode(), code2.rootCauseCode());
+			Assertions.assertNotEquals(code1.rootCauseCode(), code2.rootCauseCode());
 		}
 
 		@Test
@@ -127,4 +185,19 @@ public class ErrorCodeTest {
 
 	}
 
+	public static class IntegerArrayConverter extends SimpleArgumentConverter {
+
+		@Override
+		protected Integer[] convert(Object source, Class<?> targetType) {
+			if (source == null) {
+				return new Integer[0];
+			} else if (source instanceof String s && Integer[].class.isAssignableFrom(targetType)) {
+				return Splitter.on(CharMatcher.inRange('0', '9').negate()).splitToStream(s).map(Integer::valueOf).toArray(Integer[]::new);
+			} else {
+				throw new IllegalArgumentException("Conversion from " + source.getClass() + " to " + targetType + " not supported.");
+			}
+		}
+
+	}
+
 }