Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@ import {
CONTENT_FIELD_WITH_ALL_TAGS,
createCollectionViaApi,
deleteCollectionViaApi,
EXPECTED_CONTENT_FIELD_WITH_ALL_TAGS,
publishCollectionViaApi
} from '../../testHelpers/collections/collectionHelper'
import {
Expand All @@ -27,6 +26,7 @@ import {
FeaturedItemType
} from '../../../src/collections/domain/models/FeaturedItem'
import { uploadFileViaApi } from '../../testHelpers/files/filesHelper'
import { normalizeHtml } from '../../testHelpers/html/htmlNormalizer'
import {
deletePublishedDatasetViaApi,
publishDatasetViaApi,
Expand Down Expand Up @@ -165,7 +165,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(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(
Expand Down
5 changes: 3 additions & 2 deletions test/testHelpers/collections/collectionHelper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -268,11 +268,12 @@ export const createCollectionFacetRequestPayload = (): CollectionFacetPayload =>
}

export const CONTENT_FIELD_WITH_ALL_TAGS =
'<h1 class="rte-heading">A title</h1><p class="rte-paragraph">Esto es una oracion que contiene texto en <strong class="rte-bold">negrita</strong>, <em class="rte-italic">italica</em>, <u class="rte-underline">subrayada</u>, <s class="rte-strike">tachado</s>, <code class="rte-code">de tipo code</code>, este es <a target="_blank" rel="noopener noreferrer nofollow" class="rte-link" href="https://youtube.com">un link que apunta a youtube</a>.</p><p class="rte-paragraph">Una lista desordenada:</p><ul class="rte-bullet-list"><li><p class="rte-paragraph">Item</p></li><li><p class="rte-paragraph">Item</p></li></ul><p class="rte-paragraph">Una lista ordenada:</p><ol class="rte-ordered-list"><li><p class="rte-paragraph">Item 1</p></li><li><p class="rte-paragraph">Item 2</p></li></ol><blockquote class="rte-blockquote"><p class="rte-paragraph">Este es un blockquote.</p></blockquote><p class="rte-paragraph">Esto que viene es un bloque de codigo.</p><pre class="rte-code-block"><code> &lt;Controller name={`featuredItems.${itemIndex}.content`} control={control} rules={rules} render={({ field: { onChange, ref, value }, fieldState: { invalid, error } }) =&gt; { console.log({ value }) return ( &lt;Col&gt; &lt;RichTextEditor initialValue={value as string} editorContentAriaLabelledBy={`featuredItems.${itemIndex}.content`} onChange={onChange} invalid={invalid} ariaRequired ref={ref} /&gt; {invalid &amp;&amp; &lt;div className={styles["error-msg"]}&gt;{error?.message}&lt;/div&gt;} &lt;/Col&gt; ) }} /&gt;</code></pre>'
'<h1 class="rte-heading">A title</h1><p class="rte-paragraph">Esto es una oracion que contiene texto en <strong class="rte-bold">negrita</strong>, <em class="rte-italic">italica</em>, <u class="rte-underline">subrayada</u>, <s class="rte-strike">tachado</s>, <code class="rte-code">de tipo code</code>, este es <a target="_blank" rel="noopener noreferrer nofollow" class="rte-link" href="https://youtube.com">un link que apunta a youtube</a>.</p><p class="rte-paragraph"><strong class="rte-bold">Negrita</strong> <em class="rte-italic">seguida de italica</em></p><p class="rte-paragraph">Una lista desordenada:</p><ul class="rte-bullet-list"><li><p class="rte-paragraph">Item</p></li><li><p class="rte-paragraph">Item</p></li></ul><p class="rte-paragraph">Una lista ordenada:</p><ol class="rte-ordered-list"><li><p class="rte-paragraph">Item 1</p></li><li><p class="rte-paragraph">Item 2</p></li></ol><blockquote class="rte-blockquote"><p class="rte-paragraph">Este es un blockquote.</p></blockquote><p class="rte-paragraph">Esto que viene es un bloque de codigo.</p><pre class="rte-code-block"><code> &lt;Controller name={`featuredItems.${itemIndex}.content`} control={control} rules={rules} render={({ field: { onChange, ref, value }, fieldState: { invalid, error } }) =&gt; { console.log({ value }) return ( &lt;Col&gt; &lt;RichTextEditor initialValue={value as string} editorContentAriaLabelledBy={`featuredItems.${itemIndex}.content`} onChange={onChange} invalid={invalid} ariaRequired ref={ref} /&gt; {invalid &amp;&amp; &lt;div className={styles["error-msg"]}&gt;{error?.message}&lt;/div&gt;} &lt;/Col&gt; ) }} /&gt;</code></pre>'

export const EXPECTED_CONTENT_FIELD_WITH_ALL_TAGS =
export const SERVER_FORMATTED_CONTENT_FIELD_WITH_ALL_TAGS =
'<h1 class="rte-heading">A title</h1>\n' +
'<p class="rte-paragraph">Esto es una oracion que contiene texto en <strong class="rte-bold">negrita</strong>, <em class="rte-italic">italica</em>, <u class="rte-underline">subrayada</u>, <s class="rte-strike">tachado</s>, <code class="rte-code">de tipo code</code>, este es <a target="_blank" rel="noopener noreferrer nofollow" class="rte-link" href="https://youtube.com">un link que apunta a youtube</a>.</p>\n' +
'<p class="rte-paragraph"><strong class="rte-bold">Negrita</strong> <em class="rte-italic">seguida de italica</em></p>\n' +
'<p class="rte-paragraph">Una lista desordenada:</p>\n' +
'<ul class="rte-bullet-list">\n' +
' <li><p class="rte-paragraph">Item</p></li>\n' +
Expand Down
117 changes: 117 additions & 0 deletions test/testHelpers/html/htmlNormalizer.ts

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

htmlNormalizer tokenizes tags with /<[^>]*>/, which breaks valid quoted attributes containing >, such as title="A > B". Those fragments are no longer parsed or attribute-sorted, so equivalent server-formatted HTML can still fail this functional test. Use an HTML parser/DOM serialization approach, like const document = new DOMParser().parseFromString(html, 'text/html')
, or make tokenization quote-aware and add a regression test.

  expect(
    normalizeHtml('<a title="A > B" href="/example">link</a>')
  ).toEqual(
    normalizeHtml('<a href="/example" title="A > B">link</a>')
  )
})

@ErykKul ErykKul Sep 11, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks! I went with the parser, as you suggested. The DOMParser isn't a global in the node jest environment the functional tests run in, so the helper imports jsdom directly (already in the tree via jest-environment-jsdom, now declared). Your regression test is in, plus a negative case. Fixed in 3fdadcc.

Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import { JSDOM } from 'jsdom'

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 document = new JSDOM('').window.document

const isElement = (node: Node | null | undefined): node is Element =>
node !== null && node !== undefined && node.nodeType === node.ELEMENT_NODE

const isText = (node: Node): node is Text => node.nodeType === node.TEXT_NODE

const tagNameOf = (element: Element): string => element.tagName.toLowerCase()

const isBlockBoundary = (sibling: Node | undefined, parent: Node): boolean =>
sibling === undefined
? !isElement(parent) || BLOCK_TAGS.has(tagNameOf(parent))
: isElement(sibling) && BLOCK_TAGS.has(tagNameOf(sibling))

const sortAttributes = (element: Element): void => {
const attributes = Array.from(element.attributes).sort((one, other) =>
one.name.localeCompare(other.name)
)
attributes.forEach((attribute) => element.removeAttribute(attribute.name))
attributes.forEach((attribute) => element.setAttribute(attribute.name, attribute.value))
}

const normalizeText = (text: Text, afterBoundary: boolean, beforeBoundary: boolean): void => {
if (text.data.trim() === '') {
if (afterBoundary || beforeBoundary) {
text.remove()
} else {
text.data = ' '
}
return
}

let collapsed = text.data.replace(/\s+/g, ' ')
if (afterBoundary) {
collapsed = collapsed.replace(/^ /, '')
}
if (beforeBoundary) {
collapsed = collapsed.replace(/ $/, '')
}
text.data = collapsed
}

const normalizeChildren = (parent: Node, whitespaceSensitive: boolean): void => {
const childrenAsParsed = Array.from(parent.childNodes)

childrenAsParsed.forEach((child, index) => {
if (isElement(child)) {
sortAttributes(child)
normalizeChildren(
child,
whitespaceSensitive || WHITESPACE_SENSITIVE_TAGS.has(tagNameOf(child))
)
return
}
if (isText(child) && !whitespaceSensitive) {
normalizeText(
child,
isBlockBoundary(childrenAsParsed[index - 1], parent),
isBlockBoundary(childrenAsParsed[index + 1], parent)
)
}
})
}

export const normalizeHtml = (html: string): string => {
const template = document.createElement('template')
template.innerHTML = html
normalizeChildren(template.content, false)
return template.innerHTML
}
109 changes: 109 additions & 0 deletions test/unit/testHelpers/htmlNormalizer.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import { normalizeHtml } from '../../testHelpers/html/htmlNormalizer'
import {
CONTENT_FIELD_WITH_ALL_TAGS,
SERVER_FORMATTED_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('<a target="_blank" rel="nofollow" class="x" href="https://a.b">t</a>')
).toEqual(
normalizeHtml('<a class="x" href="https://a.b" rel="nofollow" target="_blank">t</a>')
)
})

test('should ignore indentation introduced between block elements', () => {
expect(normalizeHtml('<ul><li><p>Item</p></li></ul>')).toEqual(
normalizeHtml('<ul>\n <li>\n <p>Item</p>\n </li>\n</ul>')
)
})

test('should ignore indentation around the content of a block element', () => {
expect(normalizeHtml('<p>Item</p>')).toEqual(normalizeHtml('<p>\n Item\n</p>'))
})

test('should ignore the case of tag and attribute names', () => {
expect(normalizeHtml('<P CLASS="x">t</P>')).toEqual(normalizeHtml('<p class="x">t</p>'))
})

test('should ignore the order of attributes whose values contain angle brackets', () => {
expect(normalizeHtml('<a title="A > B" href="/example">link</a>')).toEqual(
normalizeHtml('<a href="/example" title="A > B">link</a>')
)
})

test('should ignore the order of attributes quoted with single quotes', () => {
expect(normalizeHtml("<a title='A > B' href='/example'>link</a>")).toEqual(
normalizeHtml('<a href="/example" title="A > B">link</a>')
)
})

test('should ignore equivalent spellings of a void element', () => {
expect(normalizeHtml('<p>a<br>b</p>')).toEqual(normalizeHtml('<p>a<br/>b</p>'))
})

test('should ignore equivalent spellings of an escaped character', () => {
expect(normalizeHtml('<p>a &amp; b</p>')).toEqual(normalizeHtml('<p>a & b</p>'))
})

test('should treat the sent and server-returned forms of the featured item fixture as equal', () => {
expect(normalizeHtml(CONTENT_FIELD_WITH_ALL_TAGS)).toEqual(
normalizeHtml(SERVER_FORMATTED_CONTENT_FIELD_WITH_ALL_TAGS)
)
})
})

describe('differences that must still be detected', () => {
test('should not ignore differing text content', () => {
expect(normalizeHtml('<p>Item</p>')).not.toEqual(normalizeHtml('<p>Other</p>'))
})

test('should not ignore differing attribute values', () => {
expect(normalizeHtml('<a href="https://a.b">t</a>')).not.toEqual(
normalizeHtml('<a href="https://evil.example">t</a>')
)
})

test('should not ignore a dropped attribute', () => {
expect(normalizeHtml('<a rel="nofollow" href="https://a.b">t</a>')).not.toEqual(
normalizeHtml('<a href="https://a.b">t</a>')
)
})

test('should not ignore a differing attribute value that contains angle brackets', () => {
expect(normalizeHtml('<a title="A > B" href="/example">link</a>')).not.toEqual(
normalizeHtml('<a title="A > C" href="/example">link</a>')
)
})

test('should not ignore differing structure', () => {
expect(normalizeHtml('<ul><li>a</li><li>b</li></ul>')).not.toEqual(
normalizeHtml('<ul><li>a</li></ul>')
)
})

test('should not ignore a changed tag', () => {
expect(normalizeHtml('<strong>t</strong>')).not.toEqual(normalizeHtml('<em>t</em>'))
})

test('should preserve whitespace inside a preformatted block', () => {
expect(normalizeHtml('<pre><code> indented\n lines</code></pre>')).not.toEqual(
normalizeHtml('<pre><code>indented lines</code></pre>')
)
})

test('should preserve the whitespace that separates inline elements in the featured item fixture', () => {
expect(normalizeHtml(CONTENT_FIELD_WITH_ALL_TAGS)).not.toEqual(
normalizeHtml(CONTENT_FIELD_WITH_ALL_TAGS.replace('</strong> <em', '</strong><em'))
)
})

test('should preserve whitespace that separates inline elements', () => {
expect(normalizeHtml('<p><em>a</em> <em>b</em></p>')).not.toEqual(
normalizeHtml('<p><em>a</em><em>b</em></p>')
)
})
})
})
Loading