From 3348e1cd7c8f98cf83e8c20c9abb2bed3c2b8e16 Mon Sep 17 00:00:00 2001 From: ErykKul Date: Wed, 9 Sep 2026 18:30:53 +0200 Subject: [PATCH 1/2] Compare featured item HTML with a normalizer instead of an exact server-formatted string --- .../UpdateCollectionFeaturedItems.test.ts | 5 +- test/testHelpers/html/htmlNormalizer.ts | 145 ++++++++++++++++++ test/unit/testHelpers/htmlNormalizer.test.ts | 77 ++++++++++ 3 files changed, 226 insertions(+), 1 deletion(-) create mode 100644 test/testHelpers/html/htmlNormalizer.ts create mode 100644 test/unit/testHelpers/htmlNormalizer.test.ts diff --git a/test/functional/collections/UpdateCollectionFeaturedItems.test.ts b/test/functional/collections/UpdateCollectionFeaturedItems.test.ts index 87d3ebec..6f7e162c 100644 --- a/test/functional/collections/UpdateCollectionFeaturedItems.test.ts +++ b/test/functional/collections/UpdateCollectionFeaturedItems.test.ts @@ -27,6 +27,7 @@ import { FeaturedItemType } from '../../../src/collections/domain/models/FeaturedItem' import { uploadFileViaApi } from '../../testHelpers/files/filesHelper' +import { normalizeHtml } from '../../testHelpers/html/htmlNormalizer' import { deletePublishedDatasetViaApi, publishDatasetViaApi, @@ -165,7 +166,9 @@ describe('execute', () => { expect(secondItemResponse.imageFileUrl).toBeUndefined() expect(secondItemResponse.imageFileName).toBeUndefined() - expect(thirdItemResponse.content).toEqual(EXPECTED_CONTENT_FIELD_WITH_ALL_TAGS) + expect(normalizeHtml(thirdItemResponse.content)).toEqual( + normalizeHtml(EXPECTED_CONTENT_FIELD_WITH_ALL_TAGS) + ) expect(thirdItemResponse.displayOrder).toBe(newFeaturedItems[2].displayOrder) expect(thirdItemResponse.imageFileName).toEqual('featured-item-test-image-3.png') expect(thirdItemResponse.imageFileUrl).toContain( diff --git a/test/testHelpers/html/htmlNormalizer.ts b/test/testHelpers/html/htmlNormalizer.ts new file mode 100644 index 00000000..3e307caf --- /dev/null +++ b/test/testHelpers/html/htmlNormalizer.ts @@ -0,0 +1,145 @@ +const WHITESPACE_SENSITIVE_TAGS = new Set(['pre', 'textarea']) + +const BLOCK_TAGS = new Set([ + 'address', + 'article', + 'aside', + 'blockquote', + 'body', + 'br', + 'div', + 'dd', + 'dl', + 'dt', + 'fieldset', + 'figcaption', + 'figure', + 'footer', + 'form', + 'h1', + 'h2', + 'h3', + 'h4', + 'h5', + 'h6', + 'head', + 'header', + 'hr', + 'html', + 'li', + 'main', + 'nav', + 'ol', + 'p', + 'pre', + 'section', + 'table', + 'tbody', + 'td', + 'tfoot', + 'th', + 'thead', + 'tr', + 'ul' +]) + +const TAG_PATTERN = /^<\s*(\/?)\s*([a-zA-Z][\w:-]*)([\s\S]*?)(\/?)\s*>$/ +const ATTRIBUTE_PATTERN = /([\w:-]+)(?:\s*=\s*("[^"]*"|'[^']*'|[^\s"'>]+))?/g + +interface ParsedTag { + closing: boolean + name: string + selfClosing: boolean +} + +const parseTag = (token: string): ParsedTag | undefined => { + const match = TAG_PATTERN.exec(token) + if (match === null) { + return undefined + } + return { + closing: match[1] === '/', + name: match[2].toLowerCase(), + selfClosing: match[4] === '/' + } +} + +const normalizeTag = (token: string): string => { + const match = TAG_PATTERN.exec(token) + if (match === null) { + return token + } + const [, closing, name, attributeSource, selfClosing] = match + const attributes = Array.from(attributeSource.matchAll(ATTRIBUTE_PATTERN)) + .map(([, attributeName, attributeValue]) => + attributeValue === undefined + ? attributeName.toLowerCase() + : `${attributeName.toLowerCase()}=${normalizeAttributeValue(attributeValue)}` + ) + .sort() + const renderedAttributes = attributes.length === 0 ? '' : ` ${attributes.join(' ')}` + return `<${closing}${name.toLowerCase()}${renderedAttributes}${selfClosing}>` +} + +const normalizeAttributeValue = (value: string): string => { + const unquoted = + (value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'")) + ? value.slice(1, -1) + : value + return `"${unquoted}"` +} + +const isBlockBoundary = (token: string | undefined): boolean => { + if (token === undefined) { + return true + } + const tag = parseTag(token) + return tag !== undefined && BLOCK_TAGS.has(tag.name) +} + +export const normalizeHtml = (html: string): string => { + const tokens = html.split(/(<[^>]*>)/).filter((token) => token !== '') + const normalized: string[] = [] + let whitespaceSensitiveDepth = 0 + + tokens.forEach((token, index) => { + const tag = token.startsWith('<') ? parseTag(token) : undefined + + if (tag !== undefined) { + if (tag.closing && WHITESPACE_SENSITIVE_TAGS.has(tag.name)) { + whitespaceSensitiveDepth = Math.max(0, whitespaceSensitiveDepth - 1) + } + normalized.push(normalizeTag(token)) + if (!tag.closing && !tag.selfClosing && WHITESPACE_SENSITIVE_TAGS.has(tag.name)) { + whitespaceSensitiveDepth += 1 + } + return + } + + if (token.startsWith('<') || whitespaceSensitiveDepth > 0) { + normalized.push(token) + return + } + + const previousToken = tokens[index - 1] + const nextToken = tokens[index + 1] + + if (token.trim() === '') { + if (!isBlockBoundary(previousToken) && !isBlockBoundary(nextToken)) { + normalized.push(' ') + } + return + } + + let text = token.replace(/\s+/g, ' ') + if (isBlockBoundary(previousToken)) { + text = text.replace(/^ /, '') + } + if (isBlockBoundary(nextToken)) { + text = text.replace(/ $/, '') + } + normalized.push(text) + }) + + return normalized.join('') +} diff --git a/test/unit/testHelpers/htmlNormalizer.test.ts b/test/unit/testHelpers/htmlNormalizer.test.ts new file mode 100644 index 00000000..5b877662 --- /dev/null +++ b/test/unit/testHelpers/htmlNormalizer.test.ts @@ -0,0 +1,77 @@ +import { normalizeHtml } from '../../testHelpers/html/htmlNormalizer' +import { + CONTENT_FIELD_WITH_ALL_TAGS, + EXPECTED_CONTENT_FIELD_WITH_ALL_TAGS +} from '../../testHelpers/collections/collectionHelper' + +describe('normalizeHtml', () => { + describe('differences the server may introduce', () => { + test('should ignore the order of attributes', () => { + expect( + normalizeHtml('t') + ).toEqual( + normalizeHtml('t') + ) + }) + + test('should ignore indentation introduced between block elements', () => { + expect(normalizeHtml('')).toEqual( + normalizeHtml('') + ) + }) + + test('should ignore indentation around the content of a block element', () => { + expect(normalizeHtml('

Item

')).toEqual(normalizeHtml('

\n Item\n

')) + }) + + test('should ignore the case of tag and attribute names', () => { + expect(normalizeHtml('

t

')).toEqual(normalizeHtml('

t

')) + }) + + test('should treat the sent and pretty-printed forms of the featured item fixture as equal', () => { + expect(normalizeHtml(CONTENT_FIELD_WITH_ALL_TAGS)).toEqual( + normalizeHtml(EXPECTED_CONTENT_FIELD_WITH_ALL_TAGS) + ) + }) + }) + + describe('differences that must still be detected', () => { + test('should not ignore differing text content', () => { + expect(normalizeHtml('

Item

')).not.toEqual(normalizeHtml('

Other

')) + }) + + test('should not ignore differing attribute values', () => { + expect(normalizeHtml('t')).not.toEqual( + normalizeHtml('t') + ) + }) + + test('should not ignore a dropped attribute', () => { + expect(normalizeHtml('t')).not.toEqual( + normalizeHtml('t') + ) + }) + + test('should not ignore differing structure', () => { + expect(normalizeHtml('')).not.toEqual( + normalizeHtml('') + ) + }) + + test('should not ignore a changed tag', () => { + expect(normalizeHtml('t')).not.toEqual(normalizeHtml('t')) + }) + + test('should preserve whitespace inside a preformatted block', () => { + expect(normalizeHtml('
  indented\n  lines
')).not.toEqual( + normalizeHtml('
indented lines
') + ) + }) + + test('should preserve whitespace that separates inline elements', () => { + expect(normalizeHtml('

a b

')).not.toEqual( + normalizeHtml('

ab

') + ) + }) + }) +}) From 3fdadcc57aebd5cdd799502b5af40938ab1816cf Mon Sep 17 00:00:00 2001 From: ErykKul Date: Fri, 11 Sep 2026 10:14:28 +0200 Subject: [PATCH 2/2] fix: parse featured item HTML with jsdom instead of regex --- package-lock.json | 4 + package.json | 2 + .../UpdateCollectionFeaturedItems.test.ts | 3 +- .../collections/collectionHelper.ts | 5 +- test/testHelpers/html/htmlNormalizer.ts | 138 +++++++----------- test/unit/testHelpers/htmlNormalizer.test.ts | 38 ++++- 6 files changed, 100 insertions(+), 90 deletions(-) diff --git a/package-lock.json b/package-lock.json index 375f91bc..1a25d2f6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,7 @@ }, "devDependencies": { "@types/jest": "^29.5.12", + "@types/jsdom": "^20.0.1", "@typescript-eslint/eslint-plugin": "5.51.0", "@typescript-eslint/parser": "5.51.0", "@web-std/file": "3.0.3", @@ -30,6 +31,7 @@ "husky": "9.1.7", "jest": "^29.4.3", "jest-environment-jsdom": "29.7.0", + "jsdom": "^20.0.3", "prettier": "2.8.4", "testcontainers": "^10.11.0", "ts-jest": "^29.0.5", @@ -1534,6 +1536,7 @@ "resolved": "https://registry.npmjs.org/@types/jsdom/-/jsdom-20.0.1.tgz", "integrity": "sha512-d0r18sZPmMQr1eG35u12FZfhIXNrnsPU/g5wvRKCUf/tOGilKKwYMYGqh33BNR6ba+2gkHw1EUiHoN3mn7E5IQ==", "dev": true, + "license": "MIT", "dependencies": { "@types/node": "*", "@types/tough-cookie": "*", @@ -6110,6 +6113,7 @@ "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-20.0.3.tgz", "integrity": "sha512-SYhBvTh89tTfCD/CRdSOm13mOBa42iTaTyfyEWBdKcGdPxPtLFBXuHR8XHb33YNYaP+lLbmSvBTsnoesCNJEsQ==", "dev": true, + "license": "MIT", "dependencies": { "abab": "^2.0.6", "acorn": "^8.8.1", diff --git a/package.json b/package.json index 282eced4..aa400f89 100644 --- a/package.json +++ b/package.json @@ -43,6 +43,7 @@ "homepage": "https://github.com/IQSS/dataverse-client-javascript#readme", "devDependencies": { "@types/jest": "^29.5.12", + "@types/jsdom": "^20.0.1", "@typescript-eslint/eslint-plugin": "5.51.0", "@typescript-eslint/parser": "5.51.0", "@web-std/file": "3.0.3", @@ -56,6 +57,7 @@ "husky": "9.1.7", "jest": "^29.4.3", "jest-environment-jsdom": "29.7.0", + "jsdom": "^20.0.3", "prettier": "2.8.4", "testcontainers": "^10.11.0", "ts-jest": "^29.0.5", diff --git a/test/functional/collections/UpdateCollectionFeaturedItems.test.ts b/test/functional/collections/UpdateCollectionFeaturedItems.test.ts index 6f7e162c..b993e27e 100644 --- a/test/functional/collections/UpdateCollectionFeaturedItems.test.ts +++ b/test/functional/collections/UpdateCollectionFeaturedItems.test.ts @@ -18,7 +18,6 @@ import { CONTENT_FIELD_WITH_ALL_TAGS, createCollectionViaApi, deleteCollectionViaApi, - EXPECTED_CONTENT_FIELD_WITH_ALL_TAGS, publishCollectionViaApi } from '../../testHelpers/collections/collectionHelper' import { @@ -167,7 +166,7 @@ describe('execute', () => { expect(secondItemResponse.imageFileName).toBeUndefined() expect(normalizeHtml(thirdItemResponse.content)).toEqual( - normalizeHtml(EXPECTED_CONTENT_FIELD_WITH_ALL_TAGS) + normalizeHtml(CONTENT_FIELD_WITH_ALL_TAGS) ) expect(thirdItemResponse.displayOrder).toBe(newFeaturedItems[2].displayOrder) expect(thirdItemResponse.imageFileName).toEqual('featured-item-test-image-3.png') diff --git a/test/testHelpers/collections/collectionHelper.ts b/test/testHelpers/collections/collectionHelper.ts index 404f5113..9a2eafe2 100644 --- a/test/testHelpers/collections/collectionHelper.ts +++ b/test/testHelpers/collections/collectionHelper.ts @@ -268,11 +268,12 @@ export const createCollectionFacetRequestPayload = (): CollectionFacetPayload => } export const CONTENT_FIELD_WITH_ALL_TAGS = - '

A title

Esto es una oracion que contiene texto en negrita, italica, subrayada, tachado, de tipo code, este es un link que apunta a youtube.

Una lista desordenada:

Una lista ordenada:

  1. Item 1

  2. Item 2

Este es un blockquote.

Esto que viene es un bloque de codigo.

      <Controller name={`featuredItems.${itemIndex}.content`} control={control} rules={rules} render={({ field: { onChange, ref, value }, fieldState: { invalid, error } }) => { console.log({ value }) return ( <Col> <RichTextEditor initialValue={value as string} editorContentAriaLabelledBy={`featuredItems.${itemIndex}.content`} onChange={onChange} invalid={invalid} ariaRequired ref={ref} /> {invalid && <div className={styles["error-msg"]}>{error?.message}</div>} </Col> ) }} />
' + '

A title

Esto es una oracion que contiene texto en negrita, italica, subrayada, tachado, de tipo code, este es un link que apunta a youtube.

Negrita seguida de italica

Una lista desordenada:

Una lista ordenada:

  1. Item 1

  2. Item 2

Este es un blockquote.

Esto que viene es un bloque de codigo.

      <Controller name={`featuredItems.${itemIndex}.content`} control={control} rules={rules} render={({ field: { onChange, ref, value }, fieldState: { invalid, error } }) => { console.log({ value }) return ( <Col> <RichTextEditor initialValue={value as string} editorContentAriaLabelledBy={`featuredItems.${itemIndex}.content`} onChange={onChange} invalid={invalid} ariaRequired ref={ref} /> {invalid && <div className={styles["error-msg"]}>{error?.message}</div>} </Col> ) }} />
' -export const EXPECTED_CONTENT_FIELD_WITH_ALL_TAGS = +export const SERVER_FORMATTED_CONTENT_FIELD_WITH_ALL_TAGS = '

A title

\n' + '

Esto es una oracion que contiene texto en negrita, italica, subrayada, tachado, de tipo code, este es un link que apunta a youtube.

\n' + + '

Negrita seguida de italica

\n' + '

Una lista desordenada:

\n' + '