From 37d941e0b9cd3b09f99ed108e80b641c43c4c189 Mon Sep 17 00:00:00 2001
From: Sriharsha Chintalapani
Date: Sun, 26 Apr 2026 09:34:47 -0700
Subject: [PATCH 1/4] test: cover glossary CSV typed relations across unit,
integration, and Playwright
Add round-trip and validation coverage for typed glossary term relations
(synonym, broader, narrower, custom types from settings) in CSV
import/export. Unit tests in CsvUtilTest exercise addTermRelations
serialization edge cases. Integration tests in GlossaryCsvRelationTypesIT
add a true export-reimport round-trip, mixed-format per-term API
verification, asymmetric relation visibility from both sides, and a
custom-relation-type round-trip via GlossaryTermRelationSettings.
Playwright specs in GlossaryImportExport.spec.ts drive the UI bulk
import/export flow with typed relations and assert the validation
surface rejects unknown relation types.
Co-Authored-By: Claude Opus 4.7 (1M context)
---
.../it/tests/GlossaryCsvRelationTypesIT.java | 294 ++++++++++++++++++
.../org/openmetadata/csv/CsvUtilTest.java | 65 ++++
.../e2e/Pages/GlossaryImportExport.spec.ts | 195 ++++++++++++
3 files changed, 554 insertions(+)
diff --git a/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/GlossaryCsvRelationTypesIT.java b/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/GlossaryCsvRelationTypesIT.java
index 35c24f19264f..d786367738da 100644
--- a/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/GlossaryCsvRelationTypesIT.java
+++ b/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/GlossaryCsvRelationTypesIT.java
@@ -18,13 +18,18 @@
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
+import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.node.ArrayNode;
+import com.fasterxml.jackson.databind.node.ObjectNode;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.api.parallel.Execution;
@@ -36,6 +41,7 @@
import org.openmetadata.it.util.TestNamespaceExtension;
import org.openmetadata.schema.entity.data.Glossary;
import org.openmetadata.schema.entity.data.GlossaryTerm;
+import org.openmetadata.schema.type.TermRelation;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -415,6 +421,294 @@ void testFqnWithColonIsNotMisinterpreted(TestNamespace ns) throws Exception {
LOG.debug("FQN with colon handling verified for glossary: {}", glossary.getName());
}
+ private static final Object SETTINGS_LOCK = new Object();
+
+ @Test
+ void testImportPreservesMixedRelationsViaApi(TestNamespace ns) throws Exception {
+ Glossary glossary = GlossaryTestFactory.createSimple(ns);
+ GlossaryTerm t1 = GlossaryTermTestFactory.createWithName(ns, glossary, "t1");
+ GlossaryTerm t2 = GlossaryTermTestFactory.createWithName(ns, glossary, "t2");
+ GlossaryTerm t3 = GlossaryTermTestFactory.createWithName(ns, glossary, "t3");
+
+ String newTermName = ns.prefix("") + "_mixed";
+ String csvContent =
+ String.format(
+ "parent,name*,displayName,description,synonyms,relatedTerms,references,tags,reviewers,owner,glossaryStatus,color,iconURL,extension%n"
+ + ",%s,Mixed,Mixed term,,synonym:%s;%s;narrower:%s,,,,,Draft,,,",
+ newTermName,
+ t1.getFullyQualifiedName(),
+ t2.getFullyQualifiedName(),
+ t3.getFullyQualifiedName());
+
+ String result = importGlossaryCsv(glossary.getName(), csvContent, false);
+ assertNotNull(result);
+ assertTrue(
+ result.contains("\"numberOfRowsPassed\":1"), "Expected one row to pass. Result: " + result);
+
+ GlossaryTerm imported =
+ getGlossaryTerm(glossary.getFullyQualifiedName() + "." + newTermName, "relatedTerms");
+ assertNotNull(imported, "Imported term should be retrievable via API");
+ assertNotNull(imported.getRelatedTerms(), "Imported term should have related terms");
+ assertEquals(
+ 3,
+ imported.getRelatedTerms().size(),
+ "Expected exactly 3 relations. Got: " + imported.getRelatedTerms());
+
+ Map typeByTermId =
+ imported.getRelatedTerms().stream()
+ .collect(
+ Collectors.toMap(
+ r -> r.getTerm().getId().toString(), TermRelation::getRelationType));
+ assertEquals("synonym", typeByTermId.get(t1.getId().toString()), "t1 should be synonym");
+ assertEquals("relatedTo", typeByTermId.get(t2.getId().toString()), "t2 should be relatedTo");
+ assertEquals("narrower", typeByTermId.get(t3.getId().toString()), "t3 should be narrower");
+ }
+
+ @Test
+ void testAsymmetricRelationExportShowsBothSides(TestNamespace ns) throws Exception {
+ Glossary glossary = GlossaryTestFactory.createSimple(ns);
+ GlossaryTerm parentTerm = GlossaryTermTestFactory.createWithName(ns, glossary, "parentConcept");
+ GlossaryTerm childTerm = GlossaryTermTestFactory.createWithName(ns, glossary, "childConcept");
+
+ addTermRelation(childTerm.getId().toString(), parentTerm.getId().toString(), "broader");
+
+ String csv = exportGlossaryCsv(glossary.getName());
+ LOG.debug("Exported CSV for asymmetric test:\n{}", csv);
+
+ String[] lines = csv.split("\\R");
+ String childRow = findRowByTerm(lines, childTerm.getName());
+ String parentRow = findRowByTerm(lines, parentTerm.getName());
+ assertNotNull(childRow, "Child term row should be in CSV");
+ assertNotNull(parentRow, "Parent term row should be in CSV");
+
+ assertTrue(
+ childRow.contains("broader:" + parentTerm.getFullyQualifiedName()),
+ "Child term row should reference parent with 'broader' prefix. Row: " + childRow);
+ assertTrue(
+ parentRow.contains("narrower:" + childTerm.getFullyQualifiedName()),
+ "Parent term row should reference child with 'narrower' prefix (inverse). Row: "
+ + parentRow);
+ }
+
+ @Test
+ void testFullExportReimportPreservesRelationTypes(TestNamespace ns) throws Exception {
+ Glossary glossary = GlossaryTestFactory.createSimple(ns);
+ GlossaryTerm t1 = GlossaryTermTestFactory.createWithName(ns, glossary, "alpha");
+ GlossaryTerm t2 = GlossaryTermTestFactory.createWithName(ns, glossary, "beta");
+ GlossaryTerm t3 = GlossaryTermTestFactory.createWithName(ns, glossary, "gamma");
+ GlossaryTerm origin = GlossaryTermTestFactory.createWithName(ns, glossary, "origin");
+
+ addTermRelation(origin.getId().toString(), t1.getId().toString(), "synonym");
+ addTermRelation(origin.getId().toString(), t2.getId().toString(), "broader");
+ addTermRelation(origin.getId().toString(), t3.getId().toString(), "relatedTo");
+
+ String exportedCsv = exportGlossaryCsv(glossary.getName());
+ String[] lines = exportedCsv.split("\\R");
+ String header = lines[0];
+ String originRow = findRowByTerm(lines, origin.getName());
+ assertNotNull(originRow, "Origin row should be present in exported CSV");
+
+ String cloneName = ns.prefix("") + "_clone";
+ String clonedRow = originRow.replace("," + origin.getName() + ",", "," + cloneName + ",");
+ assertFalse(
+ clonedRow.equals(originRow),
+ "Replacement should produce a different name; row was: " + originRow);
+
+ String reimportCsv = header + "\r\n" + clonedRow;
+ String result = importGlossaryCsv(glossary.getName(), reimportCsv, false);
+ assertNotNull(result);
+ assertTrue(
+ result.contains("\"numberOfRowsPassed\":1"),
+ "Reimport should pass exactly one row. Result: " + result);
+
+ GlossaryTerm clone =
+ getGlossaryTerm(glossary.getFullyQualifiedName() + "." + cloneName, "relatedTerms");
+ assertNotNull(clone, "Cloned term should be retrievable via API");
+ assertNotNull(clone.getRelatedTerms(), "Cloned term should have related terms");
+ assertEquals(
+ 3,
+ clone.getRelatedTerms().size(),
+ "Cloned term should have 3 relations. Got: " + clone.getRelatedTerms());
+
+ Map typeByTermId =
+ clone.getRelatedTerms().stream()
+ .collect(
+ Collectors.toMap(
+ r -> r.getTerm().getId().toString(), TermRelation::getRelationType));
+ assertEquals(
+ "synonym", typeByTermId.get(t1.getId().toString()), "synonym relation should round-trip");
+ assertEquals(
+ "broader", typeByTermId.get(t2.getId().toString()), "broader relation should round-trip");
+ assertEquals(
+ "relatedTo",
+ typeByTermId.get(t3.getId().toString()),
+ "relatedTo relation should round-trip");
+ }
+
+ @Test
+ void testRoundTripWithCustomRelationType(TestNamespace ns) throws Exception {
+ synchronized (SETTINGS_LOCK) {
+ String customType = "causes" + System.currentTimeMillis();
+ String inverseType = "causedBy" + System.currentTimeMillis();
+ addCustomRelationTypePair(customType, inverseType);
+ try {
+ Glossary glossary = GlossaryTestFactory.createSimple(ns);
+ GlossaryTerm cause = GlossaryTermTestFactory.createWithName(ns, glossary, "cause");
+ GlossaryTerm effect = GlossaryTermTestFactory.createWithName(ns, glossary, "effect");
+
+ addTermRelation(cause.getId().toString(), effect.getId().toString(), customType);
+
+ String csv = exportGlossaryCsv(glossary.getName());
+ String[] lines = csv.split("\\R");
+ String causeRow = findRowByTerm(lines, cause.getName());
+ assertNotNull(causeRow, "Cause row should be present in exported CSV");
+ assertTrue(
+ causeRow.contains(customType + ":" + effect.getFullyQualifiedName()),
+ "Cause row should contain '" + customType + ":'. Row: " + causeRow);
+
+ String newName = ns.prefix("") + "_imported";
+ String csvImport =
+ String.format(
+ "parent,name*,displayName,description,synonyms,relatedTerms,references,tags,reviewers,owner,glossaryStatus,color,iconURL,extension%n"
+ + ",%s,Imported,via custom type,,%s:%s,,,,,Draft,,,",
+ newName, customType, effect.getFullyQualifiedName());
+ String result = importGlossaryCsv(glossary.getName(), csvImport, false);
+ assertNotNull(result);
+ assertTrue(
+ result.contains("\"numberOfRowsPassed\":1"),
+ "Import with custom relation type should pass. Result: " + result);
+
+ GlossaryTerm imported =
+ getGlossaryTerm(glossary.getFullyQualifiedName() + "." + newName, "relatedTerms");
+ assertNotNull(imported, "Imported term should be retrievable");
+ assertNotNull(imported.getRelatedTerms(), "Imported term should have related terms");
+ assertEquals(1, imported.getRelatedTerms().size(), "Expected one custom relation");
+ assertEquals(
+ customType,
+ imported.getRelatedTerms().get(0).getRelationType(),
+ "Custom relation type should be preserved through CSV import");
+ } finally {
+ cleanupCustomTypes(customType, inverseType);
+ }
+ }
+ }
+
+ private String findRowByTerm(String[] lines, String termName) {
+ for (int i = 1; i < lines.length; i++) {
+ String[] fields = lines[i].split(",", -1);
+ if (fields.length > 1 && termName.equals(fields[1])) {
+ return lines[i];
+ }
+ }
+ return null;
+ }
+
+ private void addCustomRelationTypePair(String customType, String inverseType) throws Exception {
+ JsonNode current = getRelationSettings();
+ ArrayNode types = (ArrayNode) current.get("config_value").get("relationTypes");
+
+ ObjectNode forward = OBJECT_MAPPER.createObjectNode();
+ forward.put("name", customType);
+ forward.put("displayName", "Causes");
+ forward.put("description", "Test custom relation");
+ forward.put("inverseRelation", inverseType);
+ forward.put("isSymmetric", false);
+ forward.put("isTransitive", false);
+ forward.put("isCrossGlossaryAllowed", true);
+ forward.put("category", "associative");
+ forward.put("isSystemDefined", false);
+ forward.put("color", "#aa00ff");
+ types.add(forward);
+
+ ObjectNode inverse = OBJECT_MAPPER.createObjectNode();
+ inverse.put("name", inverseType);
+ inverse.put("displayName", "Caused By");
+ inverse.put("description", "Inverse of the test custom relation");
+ inverse.put("inverseRelation", customType);
+ inverse.put("isSymmetric", false);
+ inverse.put("isTransitive", false);
+ inverse.put("isCrossGlossaryAllowed", true);
+ inverse.put("category", "associative");
+ inverse.put("isSystemDefined", false);
+ inverse.put("color", "#ff00aa");
+ types.add(inverse);
+
+ ObjectNode payload = OBJECT_MAPPER.createObjectNode();
+ payload.set("relationTypes", types);
+ putRelationSettings(payload);
+ }
+
+ private void cleanupCustomTypes(String... customTypes) {
+ try {
+ JsonNode current = getRelationSettings();
+ ArrayNode types = (ArrayNode) current.get("config_value").get("relationTypes");
+ ArrayNode filtered = OBJECT_MAPPER.createArrayNode();
+ for (JsonNode type : types) {
+ String name = type.get("name").asText();
+ boolean drop = false;
+ for (String custom : customTypes) {
+ if (custom.equals(name)) {
+ drop = true;
+ break;
+ }
+ }
+ if (!drop) {
+ filtered.add(type);
+ }
+ }
+ ObjectNode payload = OBJECT_MAPPER.createObjectNode();
+ payload.set("relationTypes", filtered);
+ putRelationSettings(payload);
+ } catch (Exception e) {
+ LOG.warn(
+ "Failed to cleanup custom relation types {}: {}", List.of(customTypes), e.getMessage());
+ }
+ }
+
+ private JsonNode getRelationSettings() throws Exception {
+ String baseUrl = SdkClients.getServerUrl();
+ String token = SdkClients.getAdminToken();
+ HttpRequest request =
+ HttpRequest.newBuilder()
+ .uri(URI.create(baseUrl + "/v1/system/settings/glossaryTermRelationSettings"))
+ .header("Authorization", "Bearer " + token)
+ .header("Accept", "application/json")
+ .timeout(Duration.ofSeconds(30))
+ .GET()
+ .build();
+ HttpResponse response = HTTP_CLIENT.send(request, HttpResponse.BodyHandlers.ofString());
+ if (response.statusCode() != 200) {
+ throw new RuntimeException("Failed to read settings: " + response.body());
+ }
+ return OBJECT_MAPPER.readTree(response.body());
+ }
+
+ private void putRelationSettings(ObjectNode configValue) throws Exception {
+ String baseUrl = SdkClients.getServerUrl();
+ String token = SdkClients.getAdminToken();
+ ObjectNode payload = OBJECT_MAPPER.createObjectNode();
+ payload.put("config_type", "glossaryTermRelationSettings");
+ payload.set("config_value", configValue);
+
+ HttpRequest request =
+ HttpRequest.newBuilder()
+ .uri(URI.create(baseUrl + "/v1/system/settings"))
+ .header("Authorization", "Bearer " + token)
+ .header("Content-Type", "application/json")
+ .timeout(Duration.ofSeconds(30))
+ .PUT(HttpRequest.BodyPublishers.ofString(OBJECT_MAPPER.writeValueAsString(payload)))
+ .build();
+
+ HttpResponse response = HTTP_CLIENT.send(request, HttpResponse.BodyHandlers.ofString());
+ if (response.statusCode() != 200) {
+ throw new RuntimeException(
+ "Failed to update settings: status="
+ + response.statusCode()
+ + ", body="
+ + response.body());
+ }
+ }
+
private GlossaryTerm addTermRelation(String fromTermId, String toTermId, String relationType)
throws Exception {
String baseUrl = SdkClients.getServerUrl();
diff --git a/openmetadata-service/src/test/java/org/openmetadata/csv/CsvUtilTest.java b/openmetadata-service/src/test/java/org/openmetadata/csv/CsvUtilTest.java
index 74b2b8d8a66e..677882802a46 100644
--- a/openmetadata-service/src/test/java/org/openmetadata/csv/CsvUtilTest.java
+++ b/openmetadata-service/src/test/java/org/openmetadata/csv/CsvUtilTest.java
@@ -31,6 +31,7 @@
import org.openmetadata.schema.entity.type.CustomProperty;
import org.openmetadata.schema.type.EntityReference;
import org.openmetadata.schema.type.TagLabel;
+import org.openmetadata.schema.type.TermRelation;
public class CsvUtilTest {
@Test
@@ -243,6 +244,70 @@ void testAddExtensionFormatsStructuredValues() {
assertTrue(extensionField.contains("delta,with,comma"));
}
+ @Test
+ void testAddTermRelationsHandlesNullAndEmptyInputs() {
+ List csvRecord = new ArrayList<>();
+ CsvUtil.addTermRelations(csvRecord, null);
+ assertEquals(Collections.singletonList(null), csvRecord);
+
+ csvRecord = new ArrayList<>();
+ CsvUtil.addTermRelations(csvRecord, Collections.emptyList());
+ assertEquals(Collections.singletonList(null), csvRecord);
+ }
+
+ @Test
+ void testAddTermRelationsOmitsRelatedToPrefix() {
+ List csvRecord = new ArrayList<>();
+ CsvUtil.addTermRelations(
+ csvRecord,
+ List.of(
+ new TermRelation()
+ .withRelationType("relatedTo")
+ .withTerm(new EntityReference().withFullyQualifiedName("Glossary.Alpha"))));
+ assertEquals(List.of("Glossary.Alpha"), csvRecord);
+ }
+
+ @Test
+ void testAddTermRelationsTreatsNullRelationTypeAsRelatedTo() {
+ List csvRecord = new ArrayList<>();
+ CsvUtil.addTermRelations(
+ csvRecord,
+ List.of(
+ new TermRelation()
+ .withTerm(new EntityReference().withFullyQualifiedName("Glossary.Alpha"))));
+ assertEquals(List.of("Glossary.Alpha"), csvRecord);
+ }
+
+ @Test
+ void testAddTermRelationsEmitsPrefixForNonDefaultType() {
+ List csvRecord = new ArrayList<>();
+ CsvUtil.addTermRelations(
+ csvRecord,
+ List.of(
+ new TermRelation()
+ .withRelationType("synonym")
+ .withTerm(new EntityReference().withFullyQualifiedName("Glossary.Alpha"))));
+ assertEquals(List.of("synonym:Glossary.Alpha"), csvRecord);
+ }
+
+ @Test
+ void testAddTermRelationsSortsAndMixesTypes() {
+ List csvRecord = new ArrayList<>();
+ CsvUtil.addTermRelations(
+ csvRecord,
+ List.of(
+ new TermRelation()
+ .withRelationType("synonym")
+ .withTerm(new EntityReference().withFullyQualifiedName("Glossary.Zeta")),
+ new TermRelation()
+ .withRelationType("relatedTo")
+ .withTerm(new EntityReference().withFullyQualifiedName("Glossary.Alpha")),
+ new TermRelation()
+ .withRelationType("broader")
+ .withTerm(new EntityReference().withFullyQualifiedName("Glossary.Beta"))));
+ assertEquals(List.of("Glossary.Alpha;broader:Glossary.Beta;synonym:Glossary.Zeta"), csvRecord);
+ }
+
public static void assertCsv(String expectedCsv, String actualCsv) {
// Break a csv text into records, sort it and compare
List expectedCsvRecords = listOf(expectedCsv.split(CsvUtil.LINE_SEPARATOR));
diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/GlossaryImportExport.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/GlossaryImportExport.spec.ts
index 92ba4fd5b490..44d3dd527236 100644
--- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/GlossaryImportExport.spec.ts
+++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/GlossaryImportExport.spec.ts
@@ -723,4 +723,199 @@ ${partialGlossary.data.name}.selfRef,selfRef,selfRef,Self-referential term
{
+ const { apiContext, afterAction } = await getApiContext(page);
+ const relGlossary = new Glossary('TypedRelations');
+ const target1 = new GlossaryTerm(relGlossary, undefined, 'TR_target1');
+ const target2 = new GlossaryTerm(relGlossary, undefined, 'TR_target2');
+ const target3 = new GlossaryTerm(relGlossary, undefined, 'TR_target3');
+
+ try {
+ await test.step('Create glossary and three target terms', async () => {
+ await relGlossary.create(apiContext);
+ await target1.create(apiContext);
+ await target2.create(apiContext);
+ await target3.create(apiContext);
+ });
+
+ const importedTermName = `TR_imported_${uuid()}`;
+ const importedTermFqn = `${relGlossary.data.name}.${importedTermName}`;
+
+ await test.step('Import CSV with synonym/relatedTo/narrower mix', async () => {
+ await sidebarClick(page, SidebarItem.GLOSSARY);
+ await selectActiveGlossary(page, relGlossary.data.displayName);
+
+ await page.click('[data-testid="manage-button"]');
+ await page.click('[data-testid="import-button-description"]');
+
+ const csvContent =
+ `parent,name*,displayName,description,synonyms,relatedTerms,references,tags,reviewers,owner,glossaryStatus,color,iconURL,extension\n` +
+ `,${importedTermName},${importedTermName},Imported,,` +
+ `synonym:${target1.responseData.fullyQualifiedName};` +
+ `${target2.responseData.fullyQualifiedName};` +
+ `narrower:${target3.responseData.fullyQualifiedName},,,,user:admin,Approved,,,`;
+
+ await page.locator('[type="file"]').waitFor({ state: 'attached' });
+ await page.setInputFiles('[type="file"]', {
+ name: 'typed-relations.csv',
+ mimeType: 'text/csv',
+ buffer: Buffer.from(csvContent),
+ });
+
+ await page
+ .getByTestId('upload-file-widget')
+ .waitFor({ state: 'hidden' });
+
+ await expect(page.locator('.rdg-header-row')).toBeVisible();
+ await page.getByRole('button', { name: 'Next' }).click();
+
+ const loader = page.locator(
+ '.inovua-react-toolkit-load-mask__background-layer'
+ );
+
+ await loader.waitFor({ state: 'hidden' });
+
+ await validateImportStatus(page, {
+ passed: '1',
+ processed: '1',
+ failed: '0',
+ });
+
+ await page.getByRole('button', { name: 'Update' }).click();
+ await loader.waitFor({ state: 'detached' });
+ });
+
+ await test.step('Verify each relation type via API', async () => {
+ const response = await apiContext.get(
+ `/api/v1/glossaryTerms/name/${encodeURIComponent(
+ importedTermFqn
+ )}?fields=relatedTerms`
+ );
+
+ expect(response.status()).toBe(200);
+ const term = await response.json();
+ expect(term.relatedTerms).toHaveLength(3);
+
+ const typeByFqn: Record = {};
+ for (const rel of term.relatedTerms) {
+ typeByFqn[rel.term.fullyQualifiedName] = rel.relationType;
+ }
+
+ expect(typeByFqn[target1.responseData.fullyQualifiedName]).toBe(
+ 'synonym'
+ );
+ expect(typeByFqn[target2.responseData.fullyQualifiedName]).toBe(
+ 'relatedTo'
+ );
+ expect(typeByFqn[target3.responseData.fullyQualifiedName]).toBe(
+ 'narrower'
+ );
+ });
+
+ await test.step('Export and verify CSV emits relation type prefixes', async () => {
+ await sidebarClick(page, SidebarItem.GLOSSARY);
+ await selectActiveGlossary(page, relGlossary.data.displayName);
+
+ await page.click('[data-testid="manage-button"]');
+ await page.click('[data-testid="export-button-description"]');
+ await page.locator('[role="dialog"]').waitFor();
+
+ const downloadPromise = page.waitForEvent('download');
+ await page.getByRole('button', { name: 'Export' }).click();
+ const download = await downloadPromise;
+
+ const stream = await download.createReadStream();
+ const chunks: Buffer[] = [];
+
+ for await (const chunk of stream) {
+ chunks.push(Buffer.from(chunk));
+ }
+
+ const csvContent = Buffer.concat(chunks).toString('utf-8');
+ const lines = csvContent.split(/\r?\n/);
+ const importedRow = lines.find((line) =>
+ line.includes(`,${importedTermName},`)
+ );
+
+ expect(importedRow).toBeDefined();
+ expect(importedRow).toContain(
+ `synonym:${target1.responseData.fullyQualifiedName}`
+ );
+ expect(importedRow).toContain(
+ `narrower:${target3.responseData.fullyQualifiedName}`
+ );
+ // relatedTo entries are emitted without a prefix.
+ expect(importedRow).toContain(target2.responseData.fullyQualifiedName);
+ });
+ } finally {
+ await relGlossary.delete(apiContext);
+ await afterAction();
+ }
+ });
+
+ test('Glossary CSV import rejects unknown relation type', async ({
+ page,
+ }) => {
+ const { apiContext, afterAction } = await getApiContext(page);
+ const relGlossary = new Glossary('TypedRelationsInvalid');
+ const target = new GlossaryTerm(relGlossary, undefined, 'TR_targetX');
+
+ try {
+ await test.step('Create glossary and target term', async () => {
+ await relGlossary.create(apiContext);
+ await target.create(apiContext);
+ });
+
+ await test.step('Import CSV with invalid relation type and assert failure', async () => {
+ await sidebarClick(page, SidebarItem.GLOSSARY);
+ await selectActiveGlossary(page, relGlossary.data.displayName);
+
+ await page.click('[data-testid="manage-button"]');
+ await page.click('[data-testid="import-button-description"]');
+
+ const newTermName = `TR_invalid_${uuid()}`;
+ const csvContent =
+ `parent,name*,displayName,description,synonyms,relatedTerms,references,tags,reviewers,owner,glossaryStatus,color,iconURL,extension\n` +
+ `,${newTermName},${newTermName},Invalid,,` +
+ `notarealtype:${target.responseData.fullyQualifiedName},,,,user:admin,Approved,,,`;
+
+ await page.locator('[type="file"]').waitFor({ state: 'attached' });
+ await page.setInputFiles('[type="file"]', {
+ name: 'invalid-relation.csv',
+ mimeType: 'text/csv',
+ buffer: Buffer.from(csvContent),
+ });
+
+ await page
+ .getByTestId('upload-file-widget')
+ .waitFor({ state: 'hidden' });
+
+ await expect(page.locator('.rdg-header-row')).toBeVisible();
+ await page.getByRole('button', { name: 'Next' }).click();
+
+ const loader = page.locator(
+ '.inovua-react-toolkit-load-mask__background-layer'
+ );
+
+ await loader.waitFor({ state: 'hidden' });
+
+ await validateImportStatus(page, {
+ passed: '0',
+ processed: '1',
+ failed: '1',
+ });
+
+ const firstRow = page.locator('.rdg-row').first();
+ const errorText = await firstRow
+ .locator('.rdg-cell-details')
+ .textContent();
+
+ expect(errorText).toContain('Invalid relation type');
+ });
+ } finally {
+ await relGlossary.delete(apiContext);
+ await afterAction();
+ }
+ });
});
From af8bc85f9f82e27c8e6c2ad2d896befa76db418e Mon Sep 17 00:00:00 2001
From: Sriharsha Chintalapani
Date: Sun, 26 Apr 2026 10:37:31 -0700
Subject: [PATCH 2/4] test(glossary-csv): address review feedback on
typed-relation tests
- Replace naive comma split in findRowByTerm with Apache Commons CSV
parser so quoted/escaped fields don't shift column indices.
- Replace class-local SETTINGS_LOCK with JUnit @ResourceLock keyed on
"glossaryTermRelationSettings" so settings mutations serialise across
test classes, and document the contract for future authors.
- Add uuid() suffixes to Playwright glossary names to avoid collisions
under fullyParallel test execution.
- Guard download.createReadStream() against null and use a waiting
toContainText assertion on the error cell so a missing selector
surfaces a clearer failure.
Co-Authored-By: Claude Opus 4.7 (1M context)
---
.../it/tests/GlossaryCsvRelationTypesIT.java | 123 ++++++++++--------
.../e2e/Pages/GlossaryImportExport.spec.ts | 49 +++++--
2 files changed, 105 insertions(+), 67 deletions(-)
diff --git a/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/GlossaryCsvRelationTypesIT.java b/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/GlossaryCsvRelationTypesIT.java
index d786367738da..9c1f07f34e8f 100644
--- a/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/GlossaryCsvRelationTypesIT.java
+++ b/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/GlossaryCsvRelationTypesIT.java
@@ -22,6 +22,7 @@
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
+import java.io.StringReader;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
@@ -30,10 +31,15 @@
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
+import org.apache.commons.csv.CSVFormat;
+import org.apache.commons.csv.CSVParser;
+import org.apache.commons.csv.CSVRecord;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.api.parallel.Execution;
import org.junit.jupiter.api.parallel.ExecutionMode;
+import org.junit.jupiter.api.parallel.ResourceAccessMode;
+import org.junit.jupiter.api.parallel.ResourceLock;
import org.openmetadata.it.factories.GlossaryTermTestFactory;
import org.openmetadata.it.factories.GlossaryTestFactory;
import org.openmetadata.it.util.SdkClients;
@@ -421,7 +427,12 @@ void testFqnWithColonIsNotMisinterpreted(TestNamespace ns) throws Exception {
LOG.debug("FQN with colon handling verified for glossary: {}", glossary.getName());
}
- private static final Object SETTINGS_LOCK = new Object();
+ /**
+ * Shared resource key for the global {@code glossaryTermRelationSettings} endpoint. Any IT class
+ * that mutates these settings must use the same key on a {@link ResourceLock} so JUnit serialises
+ * across classes; a class-local synchronized block would only guard intra-class concurrency.
+ */
+ private static final String SETTINGS_RESOURCE_KEY = "glossaryTermRelationSettings";
@Test
void testImportPreservesMixedRelationsViaApi(TestNamespace ns) throws Exception {
@@ -475,9 +486,8 @@ void testAsymmetricRelationExportShowsBothSides(TestNamespace ns) throws Excepti
String csv = exportGlossaryCsv(glossary.getName());
LOG.debug("Exported CSV for asymmetric test:\n{}", csv);
- String[] lines = csv.split("\\R");
- String childRow = findRowByTerm(lines, childTerm.getName());
- String parentRow = findRowByTerm(lines, parentTerm.getName());
+ String childRow = findRowByTerm(csv, childTerm.getName());
+ String parentRow = findRowByTerm(csv, parentTerm.getName());
assertNotNull(childRow, "Child term row should be in CSV");
assertNotNull(parentRow, "Parent term row should be in CSV");
@@ -505,7 +515,7 @@ void testFullExportReimportPreservesRelationTypes(TestNamespace ns) throws Excep
String exportedCsv = exportGlossaryCsv(glossary.getName());
String[] lines = exportedCsv.split("\\R");
String header = lines[0];
- String originRow = findRowByTerm(lines, origin.getName());
+ String originRow = findRowByTerm(exportedCsv, origin.getName());
assertNotNull(originRow, "Origin row should be present in exported CSV");
String cloneName = ns.prefix("") + "_clone";
@@ -546,58 +556,65 @@ void testFullExportReimportPreservesRelationTypes(TestNamespace ns) throws Excep
}
@Test
+ @ResourceLock(value = SETTINGS_RESOURCE_KEY, mode = ResourceAccessMode.READ_WRITE)
void testRoundTripWithCustomRelationType(TestNamespace ns) throws Exception {
- synchronized (SETTINGS_LOCK) {
- String customType = "causes" + System.currentTimeMillis();
- String inverseType = "causedBy" + System.currentTimeMillis();
- addCustomRelationTypePair(customType, inverseType);
- try {
- Glossary glossary = GlossaryTestFactory.createSimple(ns);
- GlossaryTerm cause = GlossaryTermTestFactory.createWithName(ns, glossary, "cause");
- GlossaryTerm effect = GlossaryTermTestFactory.createWithName(ns, glossary, "effect");
-
- addTermRelation(cause.getId().toString(), effect.getId().toString(), customType);
-
- String csv = exportGlossaryCsv(glossary.getName());
- String[] lines = csv.split("\\R");
- String causeRow = findRowByTerm(lines, cause.getName());
- assertNotNull(causeRow, "Cause row should be present in exported CSV");
- assertTrue(
- causeRow.contains(customType + ":" + effect.getFullyQualifiedName()),
- "Cause row should contain '" + customType + ":'. Row: " + causeRow);
-
- String newName = ns.prefix("") + "_imported";
- String csvImport =
- String.format(
- "parent,name*,displayName,description,synonyms,relatedTerms,references,tags,reviewers,owner,glossaryStatus,color,iconURL,extension%n"
- + ",%s,Imported,via custom type,,%s:%s,,,,,Draft,,,",
- newName, customType, effect.getFullyQualifiedName());
- String result = importGlossaryCsv(glossary.getName(), csvImport, false);
- assertNotNull(result);
- assertTrue(
- result.contains("\"numberOfRowsPassed\":1"),
- "Import with custom relation type should pass. Result: " + result);
-
- GlossaryTerm imported =
- getGlossaryTerm(glossary.getFullyQualifiedName() + "." + newName, "relatedTerms");
- assertNotNull(imported, "Imported term should be retrievable");
- assertNotNull(imported.getRelatedTerms(), "Imported term should have related terms");
- assertEquals(1, imported.getRelatedTerms().size(), "Expected one custom relation");
- assertEquals(
- customType,
- imported.getRelatedTerms().get(0).getRelationType(),
- "Custom relation type should be preserved through CSV import");
- } finally {
- cleanupCustomTypes(customType, inverseType);
- }
+ String customType = "causes" + System.currentTimeMillis();
+ String inverseType = "causedBy" + System.currentTimeMillis();
+ addCustomRelationTypePair(customType, inverseType);
+ try {
+ Glossary glossary = GlossaryTestFactory.createSimple(ns);
+ GlossaryTerm cause = GlossaryTermTestFactory.createWithName(ns, glossary, "cause");
+ GlossaryTerm effect = GlossaryTermTestFactory.createWithName(ns, glossary, "effect");
+
+ addTermRelation(cause.getId().toString(), effect.getId().toString(), customType);
+
+ String csv = exportGlossaryCsv(glossary.getName());
+ String causeRow = findRowByTerm(csv, cause.getName());
+ assertNotNull(causeRow, "Cause row should be present in exported CSV");
+ assertTrue(
+ causeRow.contains(customType + ":" + effect.getFullyQualifiedName()),
+ "Cause row should contain '" + customType + ":'. Row: " + causeRow);
+
+ String newName = ns.prefix("") + "_imported";
+ String csvImport =
+ String.format(
+ "parent,name*,displayName,description,synonyms,relatedTerms,references,tags,reviewers,owner,glossaryStatus,color,iconURL,extension%n"
+ + ",%s,Imported,via custom type,,%s:%s,,,,,Draft,,,",
+ newName, customType, effect.getFullyQualifiedName());
+ String result = importGlossaryCsv(glossary.getName(), csvImport, false);
+ assertNotNull(result);
+ assertTrue(
+ result.contains("\"numberOfRowsPassed\":1"),
+ "Import with custom relation type should pass. Result: " + result);
+
+ GlossaryTerm imported =
+ getGlossaryTerm(glossary.getFullyQualifiedName() + "." + newName, "relatedTerms");
+ assertNotNull(imported, "Imported term should be retrievable");
+ assertNotNull(imported.getRelatedTerms(), "Imported term should have related terms");
+ assertEquals(1, imported.getRelatedTerms().size(), "Expected one custom relation");
+ assertEquals(
+ customType,
+ imported.getRelatedTerms().get(0).getRelationType(),
+ "Custom relation type should be preserved through CSV import");
+ } finally {
+ cleanupCustomTypes(customType, inverseType);
}
}
- private String findRowByTerm(String[] lines, String termName) {
- for (int i = 1; i < lines.length; i++) {
- String[] fields = lines[i].split(",", -1);
- if (fields.length > 1 && termName.equals(fields[1])) {
- return lines[i];
+ /**
+ * Locate a CSV row by its glossary-term name. Uses Apache Commons CSV so quoted/escaped fields
+ * (e.g. a deeply nested parent FQN containing a comma) don't shift column indices and break the
+ * lookup. Returns the original line text so callers can run substring assertions against it.
+ */
+ private String findRowByTerm(String csvContent, String termName) throws Exception {
+ String[] lines = csvContent.split("\\R");
+ try (CSVParser parser =
+ CSVFormat.DEFAULT.withFirstRecordAsHeader().parse(new StringReader(csvContent))) {
+ for (CSVRecord record : parser) {
+ if (termName.equals(record.get("name*"))) {
+ int lineIndex = Math.toIntExact(record.getRecordNumber());
+ return lineIndex < lines.length ? lines[lineIndex] : null;
+ }
}
}
return null;
diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/GlossaryImportExport.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/GlossaryImportExport.spec.ts
index 44d3dd527236..5393a77b2491 100644
--- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/GlossaryImportExport.spec.ts
+++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/GlossaryImportExport.spec.ts
@@ -726,10 +726,23 @@ ${partialGlossary.data.name}.selfRef,selfRef,selfRef,Self-referential term
{
const { apiContext, afterAction } = await getApiContext(page);
- const relGlossary = new Glossary('TypedRelations');
- const target1 = new GlossaryTerm(relGlossary, undefined, 'TR_target1');
- const target2 = new GlossaryTerm(relGlossary, undefined, 'TR_target2');
- const target3 = new GlossaryTerm(relGlossary, undefined, 'TR_target3');
+ const suffix = uuid();
+ const relGlossary = new Glossary(`TypedRelations_${suffix}`);
+ const target1 = new GlossaryTerm(
+ relGlossary,
+ undefined,
+ `TR_target1_${suffix}`
+ );
+ const target2 = new GlossaryTerm(
+ relGlossary,
+ undefined,
+ `TR_target2_${suffix}`
+ );
+ const target3 = new GlossaryTerm(
+ relGlossary,
+ undefined,
+ `TR_target3_${suffix}`
+ );
try {
await test.step('Create glossary and three target terms', async () => {
@@ -826,10 +839,13 @@ ${partialGlossary.data.name}.selfRef,selfRef,selfRef,Self-referential term
Self-referential term
{
const { apiContext, afterAction } = await getApiContext(page);
- const relGlossary = new Glossary('TypedRelationsInvalid');
- const target = new GlossaryTerm(relGlossary, undefined, 'TR_targetX');
+ const suffix = uuid();
+ const relGlossary = new Glossary(`TypedRelationsInvalid_${suffix}`);
+ const target = new GlossaryTerm(
+ relGlossary,
+ undefined,
+ `TR_targetX_${suffix}`
+ );
try {
await test.step('Create glossary and target term', async () => {
@@ -906,12 +927,12 @@ ${partialGlossary.data.name}.selfRef,selfRef,selfRef,Self-referential term
Date: Sun, 26 Apr 2026 20:30:00 -0700
Subject: [PATCH 3/4] test(rdf-it): mark RDF-mutating glossary ITs @Isolated to
stop cross-class flakes
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
GlossaryOntologyExportIT and GlossaryTermRelationIT both flip global
RdfConfiguration in @BeforeAll and write synchronously to a Fuseki
backend. They were marked @Execution(ExecutionMode.SAME_THREAD), which
only serialises within the class — other test classes still run
concurrently, inherit the enabled RDF state, and contend for the same
Fuseki backend, causing the export request to time out at 60s.
Add @Isolated so JUnit 5 ensures no other class runs while these are
running. This matches the pattern RdfResourceIT already uses for the
same reason.
Co-Authored-By: Claude Opus 4.7 (1M context)
---
.../openmetadata/it/tests/GlossaryOntologyExportIT.java | 7 ++++++-
.../org/openmetadata/it/tests/GlossaryTermRelationIT.java | 5 +++++
2 files changed, 11 insertions(+), 1 deletion(-)
diff --git a/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/GlossaryOntologyExportIT.java b/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/GlossaryOntologyExportIT.java
index 8a2f50f062af..7d12aaa59223 100644
--- a/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/GlossaryOntologyExportIT.java
+++ b/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/GlossaryOntologyExportIT.java
@@ -17,6 +17,7 @@
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.api.parallel.Execution;
import org.junit.jupiter.api.parallel.ExecutionMode;
+import org.junit.jupiter.api.parallel.Isolated;
import org.openmetadata.it.bootstrap.TestSuiteBootstrap;
import org.openmetadata.it.factories.GlossaryTermTestFactory;
import org.openmetadata.it.factories.GlossaryTestFactory;
@@ -43,8 +44,12 @@
*
* Parallelization: Runs with @Execution(ExecutionMode.SAME_THREAD) because each test
* blocks a server thread on synchronous Fuseki writes; concurrent execution can exhaust the
- * server thread pool and cause request timeouts.
+ * server thread pool and cause request timeouts. The class is also @Isolated because the
+ * @BeforeAll hook flips global RDF configuration to a test Fuseki container — any other
+ * test class running concurrently would inherit that state and contend for the same backend
+ * (matches the pattern used by RdfResourceIT for the same reason).
*/
+@Isolated
@Execution(ExecutionMode.SAME_THREAD)
@ExtendWith(TestNamespaceExtension.class)
public class GlossaryOntologyExportIT {
diff --git a/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/GlossaryTermRelationIT.java b/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/GlossaryTermRelationIT.java
index 436b0dbb6163..cf2430125b15 100644
--- a/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/GlossaryTermRelationIT.java
+++ b/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/GlossaryTermRelationIT.java
@@ -26,6 +26,7 @@
import org.junit.jupiter.api.TestMethodOrder;
import org.junit.jupiter.api.parallel.Execution;
import org.junit.jupiter.api.parallel.ExecutionMode;
+import org.junit.jupiter.api.parallel.Isolated;
import org.openmetadata.it.bootstrap.TestSuiteBootstrap;
import org.openmetadata.it.util.SdkClients;
import org.openmetadata.schema.api.configuration.rdf.RdfConfiguration;
@@ -45,7 +46,11 @@
*
*
These tests verify that typed semantic relationships between glossary terms (e.g.,
* calculatedFrom, synonym, broader) are correctly stored and returned by the API.
+ *
+ *
@Isolated because @BeforeAll flips global RDF configuration; any concurrent class
+ * would inherit that state and contend for the shared Fuseki backend, causing flaky timeouts.
*/
+@Isolated
@Execution(ExecutionMode.SAME_THREAD)
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
public class GlossaryTermRelationIT {
From 5d12c9ffd0de02cb0314e77f607254a0261cfe0d Mon Sep 17 00:00:00 2001
From: Sriharsha Chintalapani
Date: Wed, 29 Apr 2026 09:51:12 -0700
Subject: [PATCH 4/4] test(glossary-csv): address Copilot review feedback on
typed-relation tests
- Replace Collectors.toMap with imperative HashMap.put when grouping
TermRelations by id so a regression that drops relationType surfaces
as a clear assertEquals(null vs expected) instead of a NullPointerException
buried inside the collector.
- Reconstruct findRowByTerm output via CSVFormat.format(record.values())
rather than indexing csvContent.split("\\R") with record.getRecordNumber();
recordNumber is a record counter, not a physical line index, so blank
lines or embedded newlines would have offset the lookup.
- Add closeFirstPopupAlert(page) after selectActiveGlossary in both
typed-relation Playwright specs to match the existing Bulk Import Export
guard against the intermittent "glossary not found" popup under parallel
execution.
Co-Authored-By: Claude Opus 4.7 (1M context)
---
.../it/tests/GlossaryCsvRelationTypesIT.java | 29 +++++++++----------
.../e2e/Pages/GlossaryImportExport.spec.ts | 4 +++
2 files changed, 17 insertions(+), 16 deletions(-)
diff --git a/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/GlossaryCsvRelationTypesIT.java b/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/GlossaryCsvRelationTypesIT.java
index 9c1f07f34e8f..2fac00477693 100644
--- a/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/GlossaryCsvRelationTypesIT.java
+++ b/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/GlossaryCsvRelationTypesIT.java
@@ -28,9 +28,9 @@
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
+import java.util.HashMap;
import java.util.List;
import java.util.Map;
-import java.util.stream.Collectors;
import org.apache.commons.csv.CSVFormat;
import org.apache.commons.csv.CSVParser;
import org.apache.commons.csv.CSVRecord;
@@ -465,11 +465,10 @@ void testImportPreservesMixedRelationsViaApi(TestNamespace ns) throws Exception
imported.getRelatedTerms().size(),
"Expected exactly 3 relations. Got: " + imported.getRelatedTerms());
- Map typeByTermId =
- imported.getRelatedTerms().stream()
- .collect(
- Collectors.toMap(
- r -> r.getTerm().getId().toString(), TermRelation::getRelationType));
+ Map typeByTermId = new HashMap<>();
+ for (TermRelation r : imported.getRelatedTerms()) {
+ typeByTermId.put(r.getTerm().getId().toString(), r.getRelationType());
+ }
assertEquals("synonym", typeByTermId.get(t1.getId().toString()), "t1 should be synonym");
assertEquals("relatedTo", typeByTermId.get(t2.getId().toString()), "t2 should be relatedTo");
assertEquals("narrower", typeByTermId.get(t3.getId().toString()), "t3 should be narrower");
@@ -540,11 +539,10 @@ void testFullExportReimportPreservesRelationTypes(TestNamespace ns) throws Excep
clone.getRelatedTerms().size(),
"Cloned term should have 3 relations. Got: " + clone.getRelatedTerms());
- Map typeByTermId =
- clone.getRelatedTerms().stream()
- .collect(
- Collectors.toMap(
- r -> r.getTerm().getId().toString(), TermRelation::getRelationType));
+ Map typeByTermId = new HashMap<>();
+ for (TermRelation r : clone.getRelatedTerms()) {
+ typeByTermId.put(r.getTerm().getId().toString(), r.getRelationType());
+ }
assertEquals(
"synonym", typeByTermId.get(t1.getId().toString()), "synonym relation should round-trip");
assertEquals(
@@ -603,17 +601,16 @@ void testRoundTripWithCustomRelationType(TestNamespace ns) throws Exception {
/**
* Locate a CSV row by its glossary-term name. Uses Apache Commons CSV so quoted/escaped fields
- * (e.g. a deeply nested parent FQN containing a comma) don't shift column indices and break the
- * lookup. Returns the original line text so callers can run substring assertions against it.
+ * (commas, embedded newlines, etc.) don't shift column indices and break the lookup. Returns a
+ * normalized CSV row reconstructed from the parsed record so callers can run substring
+ * assertions without relying on physical line numbers.
*/
private String findRowByTerm(String csvContent, String termName) throws Exception {
- String[] lines = csvContent.split("\\R");
try (CSVParser parser =
CSVFormat.DEFAULT.withFirstRecordAsHeader().parse(new StringReader(csvContent))) {
for (CSVRecord record : parser) {
if (termName.equals(record.get("name*"))) {
- int lineIndex = Math.toIntExact(record.getRecordNumber());
- return lineIndex < lines.length ? lines[lineIndex] : null;
+ return CSVFormat.DEFAULT.format((Object[]) record.values());
}
}
}
diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/GlossaryImportExport.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/GlossaryImportExport.spec.ts
index 5393a77b2491..f3675d18d938 100644
--- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/GlossaryImportExport.spec.ts
+++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/GlossaryImportExport.spec.ts
@@ -758,6 +758,8 @@ ${partialGlossary.data.name}.selfRef,selfRef,selfRef,Self-referential term
{
await sidebarClick(page, SidebarItem.GLOSSARY);
await selectActiveGlossary(page, relGlossary.data.displayName);
+ // Safety check: parallel test runs can surface a "glossary not found" popup.
+ await closeFirstPopupAlert(page);
await page.click('[data-testid="manage-button"]');
await page.click('[data-testid="import-button-description"]');
@@ -829,6 +831,7 @@ ${partialGlossary.data.name}.selfRef,selfRef,selfRef,Self-referential term
{
await sidebarClick(page, SidebarItem.GLOSSARY);
await selectActiveGlossary(page, relGlossary.data.displayName);
+ await closeFirstPopupAlert(page);
await page.click('[data-testid="manage-button"]');
await page.click('[data-testid="export-button-description"]');
@@ -891,6 +894,7 @@ ${partialGlossary.data.name}.selfRef,selfRef,selfRef,Self-referential term
{
await sidebarClick(page, SidebarItem.GLOSSARY);
await selectActiveGlossary(page, relGlossary.data.displayName);
+ await closeFirstPopupAlert(page);
await page.click('[data-testid="manage-button"]');
await page.click('[data-testid="import-button-description"]');