diff --git a/README.md b/README.md
index a31129741..87c740b67 100644
--- a/README.md
+++ b/README.md
@@ -1,7 +1,7 @@
 # OpenRefine

 [![DOI](https://zenodo.org/badge/6220644.svg)](https://zenodo.org/badge/latestdoi/6220644)
-[![Join the chat at https://gitter.im/OpenRefine/OpenRefine](https://badges.gitter.im/OpenRefine/OpenRefine.svg)](https://gitter.im/OpenRefine/OpenRefine)
+[![Join the chat at https://gitter.im/OpenRefine/OpenRefine](https://badges.gitter.im/OpenRefine/OpenRefine.svg)](https://gitter.im/OpenRefine/OpenRefine)
 [![Snapshot release](https://github.com/OpenRefine/OpenRefine/actions/workflows/snapshot_release.yml/badge.svg)](https://github.com/OpenRefine/OpenRefine/actions/workflows/snapshot_release.yml) [![Coverage Status](https://coveralls.io/repos/github/OpenRefine/OpenRefine/badge.svg?branch=master)](https://coveralls.io/github/OpenRefine/OpenRefine?branch=master) [![Translation progress](https://hosted.weblate.org/widgets/openrefine/-/svg-badge.svg)](https://hosted.weblate.org/engage/openrefine/?utm_source=widget)

 OpenRefine is a Java-based power tool that allows you to load data, understand it,
@@ -30,7 +30,7 @@ If you have cloned this repository to your computer, you can run OpenRefine with
 * `./refine` on Mac OS and Linux
 * `refine.bat` on Windows

-This requires [JDK 11](https://adoptium.net/) or newer, [Apache Maven](https://maven.apache.org/) and [NPM](https://www.npmjs.com/) 16 or newer.
+This requires [JDK 11](https://adoptium.net/) or newer, [Apache Maven](https://maven.apache.org/) and [Node.js 18](https://nodejs.org/) or newer.

 ## Documentation

diff --git a/extensions/gdata/pom.xml b/extensions/gdata/pom.xml
index 3842e9f18..95a9ab82b 100644
--- a/extensions/gdata/pom.xml
+++ b/extensions/gdata/pom.xml
@@ -83,12 +83,12 @@
     <dependency>
       <groupId>com.google.apis</groupId>
       <artifactId>google-api-services-drive</artifactId>
-      <version>v3-rev20240722-2.0.0</version>
+      <version>v3-rev20240730-2.0.0</version>
     </dependency>
     <dependency>
       <groupId>com.google.apis</groupId>
       <artifactId>google-api-services-sheets</artifactId>
-      <version>v4-rev20240716-2.0.0</version>
+      <version>v4-rev20240730-2.0.0</version>
     </dependency>
     <dependency>
       <groupId>com.fasterxml.jackson.core</groupId>
diff --git a/extensions/wikibase/src/org/openrefine/wikibase/schema/WbDateConstant.java b/extensions/wikibase/src/org/openrefine/wikibase/schema/WbDateConstant.java
index cbc86100e..eedb5bbd3 100644
--- a/extensions/wikibase/src/org/openrefine/wikibase/schema/WbDateConstant.java
+++ b/extensions/wikibase/src/org/openrefine/wikibase/schema/WbDateConstant.java
@@ -25,10 +25,12 @@
 package org.openrefine.wikibase.schema;

 import java.text.ParseException;
-import java.text.ParsePosition;
-import java.text.SimpleDateFormat;
+import java.time.format.DateTimeFormatter;
+import java.time.format.DateTimeParseException;
+import java.time.temporal.ChronoField;
+import java.time.temporal.TemporalAccessor;
+import java.time.temporal.TemporalQuery;
 import java.util.Calendar;
-import java.util.Date;
 import java.util.Map;
 import java.util.Map.Entry;
 import java.util.regex.Matcher;
@@ -55,16 +57,16 @@ public class WbDateConstant implements WbExpression<TimeValue> {
      * Map of formats accepted by the parser. Each format is associated to the time precision it induces (an integer
      * according to Wikibase's data model).
      */
-    public static Map<SimpleDateFormat, Integer> acceptedFormats = ImmutableMap.<SimpleDateFormat, Integer> builder()
-            .put(new SimpleDateFormat("yyyy'M'"), 6)
-            .put(new SimpleDateFormat("yyyy'C'"), 7)
-            .put(new SimpleDateFormat("yyyy'D'"), 8)
-            .put(new SimpleDateFormat("yyyy"), 9)
-            .put(new SimpleDateFormat("yyyy-MM"), 10)
-            .put(new SimpleDateFormat("yyyy-MM-dd"), 11)
+    public static Map<DateTimeFormatter, Integer> acceptedFormats = ImmutableMap.<DateTimeFormatter, Integer> builder()
+            .put(DateTimeFormatter.ofPattern("yyyy'M'"), 6)
+            .put(DateTimeFormatter.ofPattern("yyyy'C'"), 7)
+            .put(DateTimeFormatter.ofPattern("yyyy'D'"), 8)
+            .put(DateTimeFormatter.ofPattern("yyyy"), 9)
+            .put(DateTimeFormatter.ofPattern("yyyy-MM"), 10)
+            .put(DateTimeFormatter.ofPattern("yyyy-MM-dd['T'HH:mm[:ss]['Z']]"), 11)
             .build();

-    public static Pattern calendarSuffixPattern = Pattern.compile("_(Q[1-9][0-9]*)$");
+    public static Pattern calendarSuffixPattern = Pattern.compile("(.*)_(Q[1-9][0-9]*)$");

     private TimeValue parsed;
     private final String origDatestamp;
@@ -112,9 +114,8 @@ public class WbDateConstant implements WbExpression<TimeValue> {
      */
     public static TimeValue parse(String datestamp)
             throws ParseException {
-        Date bestDate = null;
+        TimeValue bestDate = null;
         int precision = 0; // default precision (will be overridden if successfully parsed)
-        int maxLength = 0; // the maximum length parsed
         boolean bceFlag = false; // judge whether this is a BCE year
         String calendarIri = TimeValue.CM_GREGORIAN_PRO; // Gregorian calendar is assumed by default

@@ -135,54 +136,52 @@ public class WbDateConstant implements WbExpression<TimeValue> {
             bceFlag = true;
         }

-        for (Entry<SimpleDateFormat, Integer> entry : acceptedFormats.entrySet()) {
-            ParsePosition position = new ParsePosition(0);
-            Date date = entry.getKey().parse(trimmedDatestamp, position);
+        Matcher matcher = calendarSuffixPattern.matcher(trimmedDatestamp);
+        if (matcher.find()) {
+            String calendarQid = matcher.group(2);
+            calendarIri = Datamodel.SITE_WIKIDATA + calendarQid;
+            trimmedDatestamp = matcher.group(1);
+        }

-            if (date == null) {
+        for (Entry<DateTimeFormatter, Integer> entry : acceptedFormats.entrySet()) {
+            TemporalQuery<TimeValue> temporalQuery = getTemporalQuery(entry.getValue(), bceFlag, calendarIri);
+            TimeValue date;
+            try {
+                date = entry.getKey().parse(trimmedDatestamp, temporalQuery);
+            } catch (DateTimeParseException e) {
                 continue;
             }

-            // Potentially parse the calendar Qid after the date
-            int consumedUntil = position.getIndex();
-            if (consumedUntil < trimmedDatestamp.length()) {
-                Matcher matcher = calendarSuffixPattern.matcher(
-                        trimmedDatestamp.subSequence(position.getIndex(), trimmedDatestamp.length()));
-                if (matcher.find()) {
-                    String calendarQid = matcher.group(1);
-                    calendarIri = Datamodel.SITE_WIKIDATA + calendarQid;
-                    consumedUntil = trimmedDatestamp.length();
-                }
-            }
-
             // Ignore parses which failed or do not consume all the input
-            if (date != null && position.getIndex() > maxLength
-            // only allow to partially consume the input if the precision is day and followed by a T (as in ISO)
-                    && (consumedUntil == trimmedDatestamp.length()
-                            || (entry.getValue() == 11 && trimmedDatestamp.charAt(consumedUntil) == 'T'))) {
-                precision = entry.getValue();
-                bestDate = date;
-                maxLength = position.getIndex();
-            }
+            precision = entry.getValue();
+            bestDate = date;
         }
         if (bestDate == null || precision == 0) {
             throw new ParseException("Invalid date.", 0);
         } else {
-            Calendar calendar = Calendar.getInstance();
-            calendar = Calendar.getInstance();
-            calendar.setTime(bestDate);
-            long year = calendar.get(Calendar.YEAR);
-            int month = precision < 10 ? 0 : calendar.get(Calendar.MONTH) + 1;
-            int day_of_month = precision < 11 ? 0 : calendar.get(Calendar.DAY_OF_MONTH);
-            if (bceFlag)
-                year = -1 * year;
-            return Datamodel.makeTimeValue(year, (byte) month,
-                    (byte) day_of_month, (byte) calendar.get(Calendar.HOUR_OF_DAY),
-                    (byte) calendar.get(Calendar.MINUTE), (byte) calendar.get(Calendar.SECOND), (byte) precision, 0, 0,
-                    0, calendarIri);
+            return bestDate;
         }
     }

+    protected static TemporalQuery<TimeValue> getTemporalQuery(int precision, boolean bceFlag, String calendarIri) {
+        return new TemporalQuery<>() {
+
+            @Override
+            public TimeValue queryFrom(TemporalAccessor temporal) {
+                long year = temporal.get(ChronoField.YEAR);
+                int month = precision < 10 ? 0 : temporal.get(ChronoField.MONTH_OF_YEAR);
+                int day_of_month = precision < 11 ? 0 : temporal.get(ChronoField.DAY_OF_MONTH);
+                if (bceFlag)
+                    year = -1 * year;
+                return Datamodel.makeTimeValue(year, (byte) month,
+                        (byte) day_of_month, (byte) 0,
+                        (byte) 0, (byte) 0, (byte) precision, 0, 0,
+                        0, calendarIri);
+            }
+
+        };
+    }
+
     /**
      * @return the original datestamp
      */
diff --git a/main/src/com/google/refine/expr/functions/strings/LevenshteinDistance.java b/main/src/com/google/refine/expr/functions/strings/LevenshteinDistance.java
new file mode 100644
index 000000000..98eafb56b
--- /dev/null
+++ b/main/src/com/google/refine/expr/functions/strings/LevenshteinDistance.java
@@ -0,0 +1,82 @@
+/*
+
+Copyright 2024, OpenRefine contributors
+All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are
+met:
+
+    * Redistributions of source code must retain the above copyright
+notice, this list of conditions and the following disclaimer.
+    * Redistributions in binary form must reproduce the above
+copyright notice, this list of conditions and the following disclaimer
+in the documentation and/or other materials provided with the
+distribution.
+    * Neither the name of Google Inc. nor the names of its
+contributors may be used to endorse or promote products derived from
+this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+*/
+
+package com.google.refine.expr.functions.strings;
+
+import java.util.Properties;
+
+import com.google.refine.clustering.knn.SimilarityDistance;
+import com.google.refine.clustering.knn.VicinoDistance;
+import com.google.refine.expr.EvalError;
+import com.google.refine.grel.ControlFunctionRegistry;
+import com.google.refine.grel.EvalErrorMessage;
+import com.google.refine.grel.Function;
+
+public class LevenshteinDistance implements Function {
+
+    /**
+     * Calculate the number of edits required to make one value match another.
+     *
+     * @param bindings
+     *            bindings
+     * @param args
+     *            arguments
+     * @return result
+     */
+    @Override
+    public Object call(Properties bindings, Object[] args) {
+        if (args != null && args.length == 2 && args[0] instanceof String && args[1] instanceof String) {
+            String s1 = (String) args[0];
+            String s2 = (String) args[1];
+
+            SimilarityDistance levenshteinDistance = new VicinoDistance(new edu.mit.simile.vicino.distances.LevenshteinDistance());
+            return levenshteinDistance.compute(s1, s2);
+        }
+        return new EvalError(EvalErrorMessage.expects_two_strings(ControlFunctionRegistry.getFunctionName(this)));
+    }
+
+    @Override
+    public String getDescription() {
+        return "Calculate the number of edits required to make one value match another.";
+    }
+
+    @Override
+    public String getParams() {
+        return "string s1, string s2";
+    }
+
+    @Override
+    public String getReturns() {
+        return "number";
+    }
+}
diff --git a/main/src/com/google/refine/grel/ControlFunctionRegistry.java b/main/src/com/google/refine/grel/ControlFunctionRegistry.java
index 799b1da61..81ca595f5 100644
--- a/main/src/com/google/refine/grel/ControlFunctionRegistry.java
+++ b/main/src/com/google/refine/grel/ControlFunctionRegistry.java
@@ -112,6 +112,7 @@ import com.google.refine.expr.functions.strings.Find;
 import com.google.refine.expr.functions.strings.Fingerprint;
 import com.google.refine.expr.functions.strings.IndexOf;
 import com.google.refine.expr.functions.strings.LastIndexOf;
+import com.google.refine.expr.functions.strings.LevenshteinDistance;
 import com.google.refine.expr.functions.strings.MD5;
 import com.google.refine.expr.functions.strings.Match;
 import com.google.refine.expr.functions.strings.NGram;
@@ -265,6 +266,7 @@ public class ControlFunctionRegistry {
         registerFunction("ngram", new NGram());
         registerFunction("match", new Match());
         registerFunction("find", new Find());
+        registerFunction("levenshteinDistance", new LevenshteinDistance());

         registerFunction("parseUri", new ParseUri());

diff --git a/packaging/THIRD-PARTY.txt b/packaging/THIRD-PARTY.txt
index 62f4002ef..62768f437 100644
--- a/packaging/THIRD-PARTY.txt
+++ b/packaging/THIRD-PARTY.txt
@@ -41,7 +41,6 @@ Lists of 109 third-party dependencies.
      (CDDL/GPLv2+CE) JavaBeans Activation Framework API jar (javax.activation:javax.activation-api:1.2.0 - http://java.net/all/javax.activation-api/)
      (CDDL + GPLv2 with classpath exception) Java Servlet API (javax.servlet:javax.servlet-api:4.0.1 - https://javaee.github.io/servlet-spec/)
      (CDDL 1.1) (GPL2 w/ CPE) jaxb-api (javax.xml.bind:jaxb-api:2.3.1 - https://github.com/javaee/jaxb-spec/jaxb-api)
-     (Eclipse Public License 1.0) JUnit (junit:junit:4.13 - http://junit.org)
      (The Apache Software License, Version 2.0) jsonic (net.arnx:jsonic:1.2.11 - http://jsonic.sourceforge.jp/)
      (Apache License, Version 2.0) Byte Buddy (without dependencies) (net.bytebuddy:byte-buddy:1.12.22 - https://bytebuddy.net/byte-buddy)
      (Apache License, Version 2.0) Byte Buddy agent (net.bytebuddy:byte-buddy-agent:1.12.22 - https://bytebuddy.net/byte-buddy-agent)
diff --git a/pom.xml b/pom.xml
index d7d4d5ad3..ffe30bc82 100644
--- a/pom.xml
+++ b/pom.xml
@@ -78,13 +78,13 @@
     <jackson.version>2.17.2</jackson.version>
     <servlet-api.version>4.0.1</servlet-api.version>
     <butterfly.version>1.2.5</butterfly.version>
-    <slf4j.version>2.0.13</slf4j.version>
+    <slf4j.version>2.0.16</slf4j.version>
     <log4j.version>2.23.1</log4j.version>
     <jetty.version>10.0.16</jetty.version>
     <okhttp.version>4.12.0</okhttp.version>
     <jena.version>4.10.0</jena.version>
     <poi.version>5.3.0</poi.version>
-    <commons-lang3.version>3.15.0</commons-lang3.version>
+    <commons-lang3.version>3.16.0</commons-lang3.version>
     <commons-fileupload.version>1.5</commons-fileupload.version>
     <commons-collections.version>3.2.2</commons-collections.version>
     <commons-text.version>1.12.0</commons-text.version>
@@ -92,7 +92,7 @@
     <org-jvnet-localizer.version>1.31</org-jvnet-localizer.version>
     <!-- v1.15 of commons-codec is needed for Jena 3.14+ - copied from org.apache.jena:jena:pom -->
     <commons-codec.version>1.17.1</commons-codec.version>
-    <commons-compress.version>1.26.2</commons-compress.version>
+    <commons-compress.version>1.27.0</commons-compress.version>
     <velocity.version>2.3</velocity.version>
     <marc4j.version>2.9.5</marc4j.version>
     <jsoup.version>1.18.1</jsoup.version>
@@ -117,7 +117,7 @@
     <build-helper-maven-plugin.version>3.6.0</build-helper-maven-plugin.version>
     <maven-compiler-plugin.version>3.13.0</maven-compiler-plugin.version>
     <maven-resources-plugin.version>3.3.1</maven-resources-plugin.version>
-    <exec-maven-plugin.version>3.3.0</exec-maven-plugin.version>
+    <exec-maven-plugin.version>3.4.0</exec-maven-plugin.version>
     <maven-dependency-plugin.version>3.7.1</maven-dependency-plugin.version>
     <jacoco-maven-plugin.version>0.8.12</jacoco-maven-plugin.version>
     <surefire.version>3.3.1</surefire.version>
@@ -312,7 +312,7 @@
         <!-- Signature -->
         <groupId>org.apache.maven.plugins</groupId>
         <artifactId>maven-gpg-plugin</artifactId>
-        <version>3.2.4</version>
+        <version>3.2.5</version>
         <executions>
             <execution>
                 <id>sign-artifacts</id>

diff --git a/extensions/wikibase/tests/src/org/openrefine/wikibase/manifests/ManifestTest.java b/extensions/wikibase/tests/src/org/openrefine/wikibase/manifests/ManifestTest.java
index 6a633f14c..6451adfc3 100644
--- a/extensions/wikibase/tests/src/org/openrefine/wikibase/manifests/ManifestTest.java
+++ b/extensions/wikibase/tests/src/org/openrefine/wikibase/manifests/ManifestTest.java
@@ -3,6 +3,7 @@ package org.openrefine.wikibase.manifests;

 import static org.testng.Assert.assertFalse;
 import static org.testng.Assert.assertNull;
+// The version below has parameter order backwards from the standard TestNG assertEquals we use everywhere else
 import static org.testng.AssertJUnit.assertEquals;

 import java.io.IOException;
diff --git a/main/tests/cypress/package.json b/main/tests/cypress/package.json
index 5f0cb8503..fbf63cb8c 100644
--- a/main/tests/cypress/package.json
+++ b/main/tests/cypress/package.json
@@ -20,10 +20,10 @@
         "cypress-file-upload": "^5.0.8"
     },
     "devDependencies": {
-        "eslint": "^9.8.0",
+        "eslint": "^9.9.0",
         "eslint-config-google": "^0.14.0",
         "eslint-config-prettier": "^9.1.0",
-        "eslint-plugin-cypress": "^3.4.0",
+        "eslint-plugin-cypress": "^3.5.0",
         "prettier": "3.3.3"
     }
 }
diff --git a/main/tests/cypress/yarn.lock b/main/tests/cypress/yarn.lock
index f7ed19f18..ee8520b31 100644
--- a/main/tests/cypress/yarn.lock
+++ b/main/tests/cypress/yarn.lock
@@ -75,10 +75,10 @@
     minimatch "^3.1.2"
     strip-json-comments "^3.1.1"

-"@eslint/js@9.8.0":
-  version "9.8.0"
-  resolved "https://registry.yarnpkg.com/@eslint/js/-/js-9.8.0.tgz#ae9bc14bb839713c5056f5018bcefa955556d3a4"
-  integrity sha512-MfluB7EUfxXtv3i/++oh89uzAr4PDI4nn201hsp+qaXqsjAWzinlZEHEfPgAX4doIlKvPG/i0A9dpKxOLII8yA==
+"@eslint/js@9.9.0":
+  version "9.9.0"
+  resolved "https://registry.yarnpkg.com/@eslint/js/-/js-9.9.0.tgz#d8437adda50b3ed4401964517b64b4f59b0e2638"
+  integrity sha512-hhetes6ZHP3BlXLxmd8K2SNgkhNSi+UcecbnwWKwpP7kyi/uC75DJ1lOOBO3xrC4jyojtGE3YxKZPHfk4yrgug==

 "@eslint/object-schema@^2.1.4":
   version "2.1.4"
@@ -567,10 +567,10 @@ eslint-config-prettier@^9.1.0:
   resolved "https://registry.yarnpkg.com/eslint-config-prettier/-/eslint-config-prettier-9.1.0.tgz#31af3d94578645966c082fcb71a5846d3c94867f"
   integrity sha512-NSWl5BFQWEPi1j4TjVNItzYV7dZXZ+wP6I6ZhrBGpChQhZRUaElihE9uRRkcbRnNb76UMKDF3r+WTmNcGPKsqw==

-eslint-plugin-cypress@^3.4.0:
-  version "3.4.0"
-  resolved "https://registry.yarnpkg.com/eslint-plugin-cypress/-/eslint-plugin-cypress-3.4.0.tgz#6a7f508cda7579184265b50129b853e0c06c9944"
-  integrity sha512-Rrrr3Ri6wHqzrRr+TyUV7bDS4UnMMrFY1R1PP2F7XdGfe9txDC6lQEshyoNOWqGoPkbbeDm1x1XPc/adxemsnA==
+eslint-plugin-cypress@^3.5.0:
+  version "3.5.0"
+  resolved "https://registry.yarnpkg.com/eslint-plugin-cypress/-/eslint-plugin-cypress-3.5.0.tgz#380ef5049ad80ebeca923db69e4aa96e72fcd893"
+  integrity sha512-JZQ6XnBTNI8h1B9M7wJSFzc48SYbh7VMMKaNTQOFa3BQlnmXPrVc4PKen8R+fpv6VleiPeej6VxloGb42zdRvw==
   dependencies:
     globals "^13.20.0"

@@ -592,16 +592,16 @@ eslint-visitor-keys@^4.0.0:
   resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-4.0.0.tgz#e3adc021aa038a2a8e0b2f8b0ce8f66b9483b1fb"
   integrity sha512-OtIRv/2GyiF6o/d8K7MYKKbXrOUBIK6SfkIRM4Z0dY3w+LiQ0vy3F57m0Z71bjbyeiWFiHJ8brqnmE6H6/jEuw==

-eslint@^9.8.0:
-  version "9.8.0"
-  resolved "https://registry.yarnpkg.com/eslint/-/eslint-9.8.0.tgz#a4f4a090c8ea2d10864d89a6603e02ce9f649f0f"
-  integrity sha512-K8qnZ/QJzT2dLKdZJVX6W4XOwBzutMYmt0lqUS+JdXgd+HTYFlonFgkJ8s44d/zMPPCnOOk0kMWCApCPhiOy9A==
+eslint@^9.9.0:
+  version "9.9.0"
+  resolved "https://registry.yarnpkg.com/eslint/-/eslint-9.9.0.tgz#8d214e69ae4debeca7ae97daebbefe462072d975"
+  integrity sha512-JfiKJrbx0506OEerjK2Y1QlldtBxkAlLxT5OEcRF8uaQ86noDe2k31Vw9rnSWv+MXZHj7OOUV/dA0AhdLFcyvA==
   dependencies:
     "@eslint-community/eslint-utils" "^4.2.0"
     "@eslint-community/regexpp" "^4.11.0"
     "@eslint/config-array" "^0.17.1"
     "@eslint/eslintrc" "^3.1.0"
-    "@eslint/js" "9.8.0"
+    "@eslint/js" "9.9.0"
     "@humanwhocodes/module-importer" "^1.0.1"
     "@humanwhocodes/retry" "^0.3.0"
     "@nodelib/fs.walk" "^1.2.8"
diff --git a/main/tests/server/src/com/google/refine/ProjectManagerTests.java b/main/tests/server/src/com/google/refine/ProjectManagerTests.java
index 7128fc421..d48ee2258 100644
--- a/main/tests/server/src/com/google/refine/ProjectManagerTests.java
+++ b/main/tests/server/src/com/google/refine/ProjectManagerTests.java
@@ -41,7 +41,7 @@ import static org.mockito.Mockito.times;
 import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.verifyNoMoreInteractions;
 import static org.mockito.Mockito.when;
-import static org.testng.AssertJUnit.assertEquals;
+import static org.testng.Assert.assertEquals;

 import java.lang.reflect.Field;
 import java.time.Instant;
@@ -238,8 +238,8 @@ public class ProjectManagerTests extends RefineTest {
     }

     protected void AssertProjectRegistered() {
-        Assert.assertEquals(SUT.getProject(project.id), project);
-        Assert.assertEquals(SUT.getProjectMetadata(project.id), metadata);
+        assertEquals(SUT.getProject(project.id), project);
+        assertEquals(SUT.getProjectMetadata(project.id), metadata);
     }

     protected void whenGetSaveTimes(Project proj, ProjectMetadata meta) {
diff --git a/main/tests/server/src/com/google/refine/clustering/binning/KeyerTests.java b/main/tests/server/src/com/google/refine/clustering/binning/FingerprintKeyerTests.java
similarity index 85%
rename from main/tests/server/src/com/google/refine/clustering/binning/KeyerTests.java
rename to main/tests/server/src/com/google/refine/clustering/binning/FingerprintKeyerTests.java
index ba24449f0..81dac8c21 100644
--- a/main/tests/server/src/com/google/refine/clustering/binning/KeyerTests.java
+++ b/main/tests/server/src/com/google/refine/clustering/binning/FingerprintKeyerTests.java
@@ -39,11 +39,8 @@ import org.testng.annotations.BeforeTest;
 import org.testng.annotations.Test;

 import com.google.refine.RefineTest;
-import com.google.refine.expr.MetaParser;
-import com.google.refine.expr.ParsingException;
-import com.google.refine.grel.Parser;

-public class KeyerTests extends RefineTest {
+public class FingerprintKeyerTests extends RefineTest {

     private static Keyer keyer;

@@ -131,16 +128,6 @@ public class KeyerTests extends RefineTest {
         logger = LoggerFactory.getLogger(this.getClass());
     }

-    @BeforeMethod
-    public void registerGRELParser() {
-        MetaParser.registerLanguageParser("grel", "GREL", Parser.grelParser, "value");
-    }
-
-    @AfterMethod
-    public void unregisterGRELParser() {
-        MetaParser.unregisterLanguageParser("grel");
-    }
-
     @BeforeMethod
     public void SetUp() {
         keyer = new FingerprintKeyer();
@@ -164,36 +151,4 @@ public class KeyerTests extends RefineTest {
                     "Fingerprint for string: " + ss[0] + " failed");
         }
     }
-
-    @Test
-    public void testUserDefinedKeyer1() {
-        try {
-            String expression = "value.fingerprint()";
-            keyer = new UserDefinedKeyer(expression);
-        } catch (ParsingException e) {
-            throw new RuntimeException(e);
-        }
-        for (String[] ss : testStrings) {
-            Assert.assertEquals(ss.length, 2, "Invalid test"); // Not a valid test
-            Assert.assertEquals(keyer.key(ss[0]), ss[1],
-                    "User defined keying for string: " + ss[0] + " failed");
-        }
-    }
-
-    @Test
-    public void testUserDefinedKeyer2() {
-        try {
-            String expression = "value + ' world'";
-            keyer = new UserDefinedKeyer(expression);
-        } catch (ParsingException e) {
-            throw new RuntimeException(e);
-        }
-
-        String[] strs = { "hello", "new", "fantastic" };
-        for (String s : strs) {
-            String output = keyer.key(s);
-            Assert.assertEquals(output, s + " world",
-                    "User defined keying for string: " + s + " failed");
-        }
-    }
 }
diff --git a/main/tests/server/src/com/google/refine/clustering/binning/UserDefinedKeyerTests.java b/main/tests/server/src/com/google/refine/clustering/binning/UserDefinedKeyerTests.java
new file mode 100644
index 000000000..e1637ad93
--- /dev/null
+++ b/main/tests/server/src/com/google/refine/clustering/binning/UserDefinedKeyerTests.java
@@ -0,0 +1,99 @@
+/*
+Copyright 2024 OpenRefine contributors
+All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are
+met:
+
+    * Redistributions of source code must retain the above copyright
+notice, this list of conditions and the following disclaimer.
+    * Redistributions in binary form must reproduce the above
+copyright notice, this list of conditions and the following disclaimer
+in the documentation and/or other materials provided with the
+distribution.
+    * Neither the names of the project or its
+contributors may be used to endorse or promote products derived from
+this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+*/
+
+package com.google.refine.clustering.binning;
+
+import org.testng.Assert;
+import org.testng.annotations.AfterMethod;
+import org.testng.annotations.BeforeMethod;
+import org.testng.annotations.Test;
+
+import com.google.refine.expr.MetaParser;
+import com.google.refine.expr.ParsingException;
+import com.google.refine.grel.Parser;
+
+public class UserDefinedKeyerTests {
+
+    private Keyer keyer;
+    // some sample test cases taken from KeyerTests
+    private static final String[][] testStrings = {
+            { " école ÉCole ecoLe ", "ecole" },
+            { " d c b a ", "a b c d" },
+            { "\tABC \t DEF ", "abc def" }, // test leading and trailing whitespace
+            { "bbb\taaa", "aaa bbb" },
+//        {"å","aa"}, // Requested by issue #650, but conflicts with diacritic folding
+            { "æø", "aeoe" }, // Norwegian replacements from #650
+            { "©ß", "css" }, // issue #409 esszet
+    };
+
+    @BeforeMethod
+    public void registerGRELParser() {
+        MetaParser.registerLanguageParser("grel", "GREL", Parser.grelParser, "value");
+    }
+
+    @AfterMethod
+    public void unregisterGRELParser() {
+        MetaParser.unregisterLanguageParser("grel");
+    }
+
+    @Test
+    public void testUserDefinedKeyer1() {
+        try {
+            String expression = "value.fingerprint()";
+            keyer = new UserDefinedKeyer(expression);
+        } catch (ParsingException e) {
+            throw new RuntimeException(e);
+        }
+        for (String[] ss : testStrings) {
+            Assert.assertEquals(ss.length, 2, "Invalid test"); // Not a valid test
+            Assert.assertEquals(keyer.key(ss[0]), ss[1],
+                    "User defined keying for string: " + ss[0] + " failed");
+        }
+    }
+
+    @Test
+    public void testUserDefinedKeyer2() {
+        try {
+            String expression = "value + ' world'";
+            keyer = new UserDefinedKeyer(expression);
+        } catch (ParsingException e) {
+            throw new RuntimeException(e);
+        }
+
+        String[] strs = { "hello", "new", "fantastic" };
+        for (String s : strs) {
+            String output = keyer.key(s);
+            Assert.assertEquals(output, s + " world",
+                    "User defined keying for string: " + s + " failed");
+        }
+    }
+
+}
diff --git a/main/tests/server/src/com/google/refine/expr/functions/strings/LevenshteinDistanceTest.java b/main/tests/server/src/com/google/refine/expr/functions/strings/LevenshteinDistanceTest.java
new file mode 100644
index 000000000..7ef7261c4
--- /dev/null
+++ b/main/tests/server/src/com/google/refine/expr/functions/strings/LevenshteinDistanceTest.java
@@ -0,0 +1,73 @@
+/*
+
+Copyright 2024, OpenRefine contributors
+All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are
+met:
+
+    * Redistributions of source code must retain the above copyright
+notice, this list of conditions and the following disclaimer.
+    * Redistributions in binary form must reproduce the above
+copyright notice, this list of conditions and the following disclaimer
+in the documentation and/or other materials provided with the
+distribution.
+    * Neither the name of Google Inc. nor the names of its
+contributors may be used to endorse or promote products derived from
+this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+*/
+
+package com.google.refine.expr.functions.strings;
+
+import static org.testng.Assert.assertEquals;
+import static org.testng.Assert.assertTrue;
+
+import org.testng.annotations.Test;
+
+import com.google.refine.expr.EvalError;
+import com.google.refine.grel.GrelTestBase;
+
+public class LevenshteinDistanceTest extends GrelTestBase {
+
+    @Test
+    public void testValidArguments() {
+        assertEquals(invoke("levenshteinDistance", new Object[] { "New York", "NewYork" }), 1.0);
+        assertEquals(invoke("levenshteinDistance", new Object[] { "M. Makeba", "Miriam Makeba" }), 5.0);
+    }
+
+    @Test
+    public void testInvalidArguments() {
+        assertTrue(invoke("levenshteinDistance") instanceof EvalError);
+        assertTrue(invoke("levenshteinDistance", new Object[] { "test" }) instanceof EvalError);
+        assertTrue(invoke("levenshteinDistance", new Object[] { "test", new Object() }) instanceof EvalError);
+    }
+
+    @Test
+    public void testEmptyStrings() {
+        assertEquals(invoke("levenshteinDistance", new Object[] { "", "" }), 0.0);
+    }
+
+    @Test
+    public void testSingleCharacterStrings() {
+        assertEquals(invoke("levenshteinDistance", new Object[] { "a", "b" }), 1.0);
+    }
+
+    @Test
+    public void testDifferentLengthStrings() {
+        assertEquals(invoke("levenshteinDistance", new Object[] { "", "abc" }), 3.0);
+    }
+}
diff --git a/main/tests/server/src/com/google/refine/expr/util/CalenderParserTest.java b/main/tests/server/src/com/google/refine/expr/util/CalenderParserTest.java
index fcb16026b..e2cd4588f 100644
--- a/main/tests/server/src/com/google/refine/expr/util/CalenderParserTest.java
+++ b/main/tests/server/src/com/google/refine/expr/util/CalenderParserTest.java
@@ -1,10 +1,10 @@

 package com.google.refine.expr.util;

-import static org.junit.Assert.assertEquals;
-import static org.junit.Assert.assertNotNull;
-import static org.junit.Assert.assertNull;
-import static org.junit.Assert.assertThrows;
+import static org.testng.Assert.assertNotNull;
+import static org.testng.Assert.assertNull;
+// TODO: The static import below should really use the native parameter ordering, but we're too lazy to switch all the calls
+import static org.testng.AssertJUnit.assertEquals; // JUnit compatible parameter order

 import java.util.ArrayList;
 import java.util.Calendar;
@@ -182,132 +182,100 @@ public class CalenderParserTest {
         assertEquals(expected, CalendarParser.getOrderString(input));
     }

-    @Test
-    public void shouldThrowExceptionWhenNegativeNumberInDateStr_parseTest() {
+    @Test(expectedExceptions = CalendarParserException.class, expectedExceptionsMessageRegExp = "Found negative number in date \"-22/02/2024\"")
+    public void shouldThrowExceptionWhenNegativeNumberInDateStr_parseTest() throws CalendarParserException {
         String dateStr = "-22/02/2024";
-        CalendarParserException exception = assertThrows(CalendarParserException.class,
-                () -> CalendarParser.parse(dateStr, CalendarParser.DD_MM_YY));
-        assertEquals(String.format("Found negative number in date \"%s\"", dateStr), exception.getMessage());
+        CalendarParser.parse(dateStr, CalendarParser.DD_MM_YY);
     }

-    @Test
-    public void shouldThrowExceptionWhenYearMissingDD_MM_YY_parseTest() {
+    @Test(expectedExceptions = CalendarParserException.class, expectedExceptionsMessageRegExp = "Year missing from \"20/01/\"")
+    public void shouldThrowExceptionWhenYearMissingDD_MM_YY_parseTest() throws CalendarParserException {
         String dateStr = "20/01/";
-        CalendarParserException exception = assertThrows(CalendarParserException.class,
-                () -> CalendarParser.parse(dateStr, CalendarParser.DD_MM_YY));
-        assertEquals(String.format("Year missing from \"%s\"", dateStr), exception.getMessage());
+        CalendarParser.parse(dateStr, CalendarParser.DD_MM_YY);
     }

-    @Test
-    public void shouldThrowExceptionWhenMonthMissingDD_MM_YY_parseTest() {
+    @Test(expectedExceptions = CalendarParserException.class, expectedExceptionsMessageRegExp = "Month missing from \"20//2024\"")
+    public void shouldThrowExceptionWhenMonthMissingDD_MM_YY_parseTest() throws CalendarParserException {
         String dateStr = "20//2024";
-        CalendarParserException exception = assertThrows(CalendarParserException.class,
-                () -> CalendarParser.parse(dateStr, CalendarParser.DD_MM_YY));
-        assertEquals(String.format("Month missing from \"%s\"", dateStr), exception.getMessage());
+        CalendarParser.parse(dateStr, CalendarParser.DD_MM_YY);
     }

-    @Test
-    public void shouldThrowExceptionWhenDateMissingMM_DD_YY_parseTest() {
+    @Test(expectedExceptions = CalendarParserException.class, expectedExceptionsMessageRegExp = "^Day missing from .*")
+    public void shouldThrowExceptionWhenDateMissingMM_DD_YY_parseTest() throws CalendarParserException {
         String dateStr = "01//2024";
-        CalendarParserException exception = assertThrows(CalendarParserException.class,
-                () -> CalendarParser.parse(dateStr, CalendarParser.MM_DD_YY));
-        assertEquals(String.format("Day missing from \"%s\"", dateStr), exception.getMessage());
+        CalendarParser.parse(dateStr, CalendarParser.MM_DD_YY);
     }

-    @Test
-    public void shouldThrowExceptionWhenMonthAndYearAreMissingDD_MM_YY_parseTest() {
+    @Test(expectedExceptions = CalendarParserException.class, expectedExceptionsMessageRegExp = "^Year and month missing from .*")
+    public void shouldThrowExceptionWhenMonthAndYearAreMissingDD_MM_YY_parseTest() throws CalendarParserException {
         String dateStr = "20/";
-        CalendarParserException exception = assertThrows(CalendarParserException.class,
-                () -> CalendarParser.parse(dateStr, CalendarParser.DD_MM_YY));
-        assertEquals(String.format("Year and month missing from \"%s\"", dateStr), exception.getMessage());
+        CalendarParser.parse(dateStr, CalendarParser.DD_MM_YY);
     }

-    @Test
-    public void shouldThrowExceptionWhenMonthAndDayAreMissingYY_DD_MM_parseTest() {
+    @Test(expectedExceptions = CalendarParserException.class, expectedExceptionsMessageRegExp = "^Day and month missing from .*")
+    public void shouldThrowExceptionWhenMonthAndDayAreMissingYY_DD_MM_parseTest() throws CalendarParserException {
         String dateStr = "2024-";
-        CalendarParserException exception = assertThrows(CalendarParserException.class,
-                () -> CalendarParser.parse(dateStr, CalendarParser.YY_DD_MM));
-        assertEquals(String.format("Day and month missing from \"%s\"", dateStr), exception.getMessage());
+        CalendarParser.parse(dateStr, CalendarParser.YY_DD_MM);
     }

-    @Test
-    public void shouldThrowExceptionWhenHourValueIsBad_parseTest() {
+    @Test(expectedExceptions = CalendarParserException.class, expectedExceptionsMessageRegExp = "Bad hour 28 in .*")
+    public void shouldThrowExceptionWhenHourValueIsBad_parseTest() throws CalendarParserException {
         String dateStr = "20/01/2024 28:30:54:003 pm +05:30";
-        CalendarParserException exception = assertThrows(CalendarParserException.class,
-                () -> CalendarParser.parse(dateStr, CalendarParser.YY_DD_MM));
-        assertEquals(String.format("Bad hour 28 in \"%s\"", dateStr), exception.getMessage());
+        CalendarParser.parse(dateStr, CalendarParser.YY_DD_MM);
     }

-    @Test
-    public void shouldThrowExceptionWhenHourIsBad_parseTest() {
+    @Test(expectedExceptions = CalendarParserException.class, expectedExceptionsMessageRegExp = "Bad hour string \"1hour\" in .*")
+    public void shouldThrowExceptionWhenHourIsBad_parseTest() throws CalendarParserException {
         String dateStr = "20/01/2024 1hour:30:54:003 pm +05:30";
-        CalendarParserException exception = assertThrows(CalendarParserException.class,
-                () -> CalendarParser.parse(dateStr, CalendarParser.YY_DD_MM));
-        assertEquals(String.format("Bad hour string \"1hour\" in \"%s\"", dateStr), exception.getMessage());
+        CalendarParser.parse(dateStr, CalendarParser.YY_DD_MM);
     }

-    @Test
-    public void shouldThrowExceptionWhenMinuteValueIsBad_parseTest() {
+    @Test(expectedExceptions = CalendarParserException.class, expectedExceptionsMessageRegExp = "Bad minute 90 in .*")
+    public void shouldThrowExceptionWhenMinuteValueIsBad_parseTest() throws CalendarParserException {
         String dateStr = "20/01/2024 8:90:54:003 pm +05:30";
-        CalendarParserException exception = assertThrows(CalendarParserException.class,
-                () -> CalendarParser.parse(dateStr, CalendarParser.YY_DD_MM));
-        assertEquals(String.format("Bad minute 90 in \"%s\"", dateStr), exception.getMessage());
+        CalendarParser.parse(dateStr, CalendarParser.YY_DD_MM);
     }

-    @Test
-    public void shouldThrowExceptionWhenMinuteIsBad_parseTest() {
+    @Test(expectedExceptions = CalendarParserException.class, expectedExceptionsMessageRegExp = "Bad minute string \"30Minute\" in .*")
+    public void shouldThrowExceptionWhenMinuteIsBad_parseTest() throws CalendarParserException {
         String dateStr = "20/01/2024 2:30Minute:54:003 pm +05:30";
-        CalendarParserException exception = assertThrows(CalendarParserException.class,
-                () -> CalendarParser.parse(dateStr, CalendarParser.YY_DD_MM));
-        assertEquals(String.format("Bad minute string \"30Minute\" in \"%s\"", dateStr), exception.getMessage());
+        CalendarParser.parse(dateStr, CalendarParser.YY_DD_MM);
     }

-    @Test
-    public void shouldThrowExceptionWhenSecondValueIsBad_parseTest() {
+    @Test(expectedExceptions = CalendarParserException.class, expectedExceptionsMessageRegExp = "Bad second 94 in .*")
+    public void shouldThrowExceptionWhenSecondValueIsBad_parseTest() throws CalendarParserException {
         String dateStr = "20/01/2024 8:30:94:003 pm +05:30";
-        CalendarParserException exception = assertThrows(CalendarParserException.class,
-                () -> CalendarParser.parse(dateStr, CalendarParser.YY_DD_MM));
-        assertEquals(String.format("Bad second 94 in \"%s\"", dateStr), exception.getMessage());
+        CalendarParser.parse(dateStr, CalendarParser.YY_DD_MM);
     }

-    @Test
-    public void shouldThrowExceptionWhenSecondIsBad_parseTest() {
+    @Test(expectedExceptions = CalendarParserException.class, expectedExceptionsMessageRegExp = "Bad second string \"54Sec\" in .*")
+    public void shouldThrowExceptionWhenSecondIsBad_parseTest() throws CalendarParserException {
         String dateStr = "20/01/2024 2:30:54Sec:003 pm +05:30";
-        CalendarParserException exception = assertThrows(CalendarParserException.class,
-                () -> CalendarParser.parse(dateStr, CalendarParser.YY_DD_MM));
-        assertEquals(String.format("Bad second string \"54Sec\" in \"%s\"", dateStr), exception.getMessage());
+        CalendarParser.parse(dateStr, CalendarParser.YY_DD_MM);
     }

-    @Test
-    public void shouldThrowExceptionWhenMilliSecondValueIsBad_parseTest() {
+    @Test(expectedExceptions = CalendarParserException.class, expectedExceptionsMessageRegExp = "Bad millisecond 1111 in .*")
+    public void shouldThrowExceptionWhenMilliSecondValueIsBad_parseTest() throws CalendarParserException {
         String dateStr = "20/01/2024 8:30:44:1111 pm +05:30";
-        CalendarParserException exception = assertThrows(CalendarParserException.class,
-                () -> CalendarParser.parse(dateStr, CalendarParser.YY_DD_MM));
-        assertEquals(String.format("Bad millisecond 1111 in \"%s\"", dateStr), exception.getMessage());
+        CalendarParser.parse(dateStr, CalendarParser.YY_DD_MM);
     }

-    @Test
-    public void shouldThrowExceptionWhenMilliSecondIsBad_parseTest() {
+    @Test(expectedExceptions = CalendarParserException.class, expectedExceptionsMessageRegExp = "Bad millisecond string \"003milli\" in .*")
+    public void shouldThrowExceptionWhenMilliSecondIsBad_parseTest() throws CalendarParserException {
         String dateStr = "20/01/2024 2:30:54:003milli pm +05:30";
-        CalendarParserException exception = assertThrows(CalendarParserException.class,
-                () -> CalendarParser.parse(dateStr, CalendarParser.YY_DD_MM));
-        assertEquals(String.format("Bad millisecond string \"003milli\" in \"%s\"", dateStr), exception.getMessage());
+        CalendarParser.parse(dateStr, CalendarParser.YY_DD_MM);
     }

-    @Test
-    public void shouldThrowExceptionWhenBadTime_parseTest() {
+    @Test(expectedExceptions = CalendarParserException.class, expectedExceptionsMessageRegExp = "Unrecognized time \"hour:minute:second\" in date .*")
+    public void shouldThrowExceptionWhenBadTime_parseTest() throws CalendarParserException {
         String dateStr = "20/01/2024 hour:minute:second";
-        CalendarParserException exception = assertThrows(CalendarParserException.class,
-                () -> CalendarParser.parse(dateStr, CalendarParser.YY_DD_MM));
-        assertEquals(String.format("Unrecognized time \"hour:minute:second\" in date \"%s\"", dateStr), exception.getMessage());
+        CalendarParser.parse(dateStr, CalendarParser.YY_DD_MM);
     }

-    @Test
-    public void shouldThrowExceptionWhenBadTimeZone_parseTest() {
+    @Test(expectedExceptions = CalendarParserException.class, expectedExceptionsMessageRegExp = "^Bad time zone minute offset \"30min\" in .*")
+    public void shouldThrowExceptionWhenBadTimeZone_parseTest() throws CalendarParserException {
         String dateStr = "20/01/2024 8:30:54:003 pm +05:30min";
-        CalendarParserException exception = assertThrows(CalendarParserException.class,
-                () -> CalendarParser.parse(dateStr, CalendarParser.YY_DD_MM));
-        assertEquals(String.format("Bad time zone minute offset \"30min\" in \"%s\"", dateStr), exception.getMessage());
+        CalendarParser.parse(dateStr, CalendarParser.YY_DD_MM);
     }

     private static Calendar getCalendar(int year, int month, int date, int hour, int minutes, int seconds, int milliSeconds,
diff --git a/main/tests/server/src/com/google/refine/importers/SeparatorBasedImporterTests.java b/main/tests/server/src/com/google/refine/importers/SeparatorBasedImporterTests.java
index 4f59715c0..89460d18c 100644
--- a/main/tests/server/src/com/google/refine/importers/SeparatorBasedImporterTests.java
+++ b/main/tests/server/src/com/google/refine/importers/SeparatorBasedImporterTests.java
@@ -33,8 +33,8 @@ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

 package com.google.refine.importers;

-import static org.testng.AssertJUnit.assertEquals;
-import static org.testng.AssertJUnit.assertFalse;
+import static org.testng.Assert.assertEquals;
+import static org.testng.Assert.assertFalse;

 import java.io.File;
 import java.io.IOException;
@@ -584,7 +584,7 @@ public class SeparatorBasedImporterTests extends ImporterTest {
     public void testThatDefaultGuessIsATabSeparatorAndDefaultProcessQuotesToFalse() {
         ObjectNode options = SUT.createParserUIInitializationData(
                 job, new LinkedList<>(), "text/json");
-        assertEquals("\\t", options.get("separator").textValue());
+        assertEquals(options.get("separator").textValue(), "\\t");
         assertFalse(options.get("processQuotes").asBoolean());
     }

@@ -593,7 +593,7 @@ public class SeparatorBasedImporterTests extends ImporterTest {
         List<ObjectNode> fileRecords = prepareFileRecords("food.small.csv");
         ObjectNode options = SUT.createParserUIInitializationData(
                 job, fileRecords, "text/csv");
-        assertEquals(",", options.get("separator").textValue());
+        assertEquals(options.get("separator").textValue(), ",");
     }

     @Test
@@ -601,7 +601,7 @@ public class SeparatorBasedImporterTests extends ImporterTest {
         List<ObjectNode> fileRecords = prepareFileRecords("movies-condensed.tsv");
         ObjectNode options = SUT.createParserUIInitializationData(
                 job, fileRecords, "text/tsv");
-        assertEquals("\\t", options.get("separator").textValue());
+        assertEquals(options.get("separator").textValue(), "\\t");
         assertFalse(options.get("processQuotes").asBoolean());
     }

diff --git a/main/tests/server/src/com/google/refine/importers/XmlImporterTests.java b/main/tests/server/src/com/google/refine/importers/XmlImporterTests.java
index 2827588a4..07e431695 100644
--- a/main/tests/server/src/com/google/refine/importers/XmlImporterTests.java
+++ b/main/tests/server/src/com/google/refine/importers/XmlImporterTests.java
@@ -33,8 +33,8 @@ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

 package com.google.refine.importers;

-import static org.junit.Assert.assertNotEquals;
-import static org.junit.Assert.assertNotNull;
+import static org.testng.Assert.assertNotEquals;
+import static org.testng.Assert.assertNotNull;

 import java.io.ByteArrayInputStream;
 import java.io.File;

Fixes #6774.

Migrates away from `SimpleDateFormat` which is known not to be thread-safe. The existing tests are hopefully sufficient to guarantee that behavior is preserved.

fix: Ensure thread safety of Wikibase date parsing