Skip to content

Commit 59bcf3c

Browse files
authored
Merge pull request #485 from IQSS/fix/normalize-featured-item-html-comparison
Normalize HTML when comparing featured item content (unblocks test-functional on all PRs)
2 parents 9e49b8b + 3fdadcc commit 59bcf3c

6 files changed

Lines changed: 239 additions & 4 deletions

File tree

‎package-lock.json‎

Lines changed: 4 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎package.json‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@
4343
"homepage": "https://github.com/IQSS/dataverse-client-javascript#readme",
4444
"devDependencies": {
4545
"@types/jest": "^29.5.12",
46+
"@types/jsdom": "^20.0.1",
4647
"@typescript-eslint/eslint-plugin": "5.51.0",
4748
"@typescript-eslint/parser": "5.51.0",
4849
"@web-std/file": "3.0.3",
@@ -56,6 +57,7 @@
5657
"husky": "9.1.7",
5758
"jest": "^29.4.3",
5859
"jest-environment-jsdom": "29.7.0",
60+
"jsdom": "^20.0.3",
5961
"prettier": "2.8.4",
6062
"testcontainers": "^10.11.0",
6163
"ts-jest": "^29.0.5",

‎test/functional/collections/UpdateCollectionFeaturedItems.test.ts‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,6 @@ import {
1818
CONTENT_FIELD_WITH_ALL_TAGS,
1919
createCollectionViaApi,
2020
deleteCollectionViaApi,
21-
EXPECTED_CONTENT_FIELD_WITH_ALL_TAGS,
2221
publishCollectionViaApi
2322
} from '../../testHelpers/collections/collectionHelper'
2423
import {
@@ -27,6 +26,7 @@ import {
2726
FeaturedItemType
2827
} from '../../../src/collections/domain/models/FeaturedItem'
2928
import { uploadFileViaApi } from '../../testHelpers/files/filesHelper'
29+
import { normalizeHtml } from '../../testHelpers/html/htmlNormalizer'
3030
import {
3131
deletePublishedDatasetViaApi,
3232
publishDatasetViaApi,
@@ -165,7 +165,9 @@ describe('execute', () => {
165165
expect(secondItemResponse.imageFileUrl).toBeUndefined()
166166
expect(secondItemResponse.imageFileName).toBeUndefined()
167167

168-
expect(thirdItemResponse.content).toEqual(EXPECTED_CONTENT_FIELD_WITH_ALL_TAGS)
168+
expect(normalizeHtml(thirdItemResponse.content)).toEqual(
169+
normalizeHtml(CONTENT_FIELD_WITH_ALL_TAGS)
170+
)
169171
expect(thirdItemResponse.displayOrder).toBe(newFeaturedItems[2].displayOrder)
170172
expect(thirdItemResponse.imageFileName).toEqual('featured-item-test-image-3.png')
171173
expect(thirdItemResponse.imageFileUrl).toContain(

‎test/testHelpers/collections/collectionHelper.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -268,11 +268,12 @@ export const createCollectionFacetRequestPayload = (): CollectionFacetPayload =>
268268
}
269269

270270
export const CONTENT_FIELD_WITH_ALL_TAGS =
271-
'<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>'
271+
'<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>'
272272

273-
export const EXPECTED_CONTENT_FIELD_WITH_ALL_TAGS =
273+
export const SERVER_FORMATTED_CONTENT_FIELD_WITH_ALL_TAGS =
274274
'<h1 class="rte-heading">A title</h1>\n' +
275275
'<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' +
276+
'<p class="rte-paragraph"><strong class="rte-bold">Negrita</strong> <em class="rte-italic">seguida de italica</em></p>\n' +
276277
'<p class="rte-paragraph">Una lista desordenada:</p>\n' +
277278
'<ul class="rte-bullet-list">\n' +
278279
' <li><p class="rte-paragraph">Item</p></li>\n' +
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
import { JSDOM } from 'jsdom'
2+
3+
const WHITESPACE_SENSITIVE_TAGS = new Set(['pre', 'textarea'])
4+
5+
const BLOCK_TAGS = new Set([
6+
'address',
7+
'article',
8+
'aside',
9+
'blockquote',
10+
'body',
11+
'br',
12+
'div',
13+
'dd',
14+
'dl',
15+
'dt',
16+
'fieldset',
17+
'figcaption',
18+
'figure',
19+
'footer',
20+
'form',
21+
'h1',
22+
'h2',
23+
'h3',
24+
'h4',
25+
'h5',
26+
'h6',
27+
'head',
28+
'header',
29+
'hr',
30+
'html',
31+
'li',
32+
'main',
33+
'nav',
34+
'ol',
35+
'p',
36+
'pre',
37+
'section',
38+
'table',
39+
'tbody',
40+
'td',
41+
'tfoot',
42+
'th',
43+
'thead',
44+
'tr',
45+
'ul'
46+
])
47+
48+
const document = new JSDOM('').window.document
49+
50+
const isElement = (node: Node | null | undefined): node is Element =>
51+
node !== null && node !== undefined && node.nodeType === node.ELEMENT_NODE
52+
53+
const isText = (node: Node): node is Text => node.nodeType === node.TEXT_NODE
54+
55+
const tagNameOf = (element: Element): string => element.tagName.toLowerCase()
56+
57+
const isBlockBoundary = (sibling: Node | undefined, parent: Node): boolean =>
58+
sibling === undefined
59+
? !isElement(parent) || BLOCK_TAGS.has(tagNameOf(parent))
60+
: isElement(sibling) && BLOCK_TAGS.has(tagNameOf(sibling))
61+
62+
const sortAttributes = (element: Element): void => {
63+
const attributes = Array.from(element.attributes).sort((one, other) =>
64+
one.name.localeCompare(other.name)
65+
)
66+
attributes.forEach((attribute) => element.removeAttribute(attribute.name))
67+
attributes.forEach((attribute) => element.setAttribute(attribute.name, attribute.value))
68+
}
69+
70+
const normalizeText = (text: Text, afterBoundary: boolean, beforeBoundary: boolean): void => {
71+
if (text.data.trim() === '') {
72+
if (afterBoundary || beforeBoundary) {
73+
text.remove()
74+
} else {
75+
text.data = ' '
76+
}
77+
return
78+
}
79+
80+
let collapsed = text.data.replace(/\s+/g, ' ')
81+
if (afterBoundary) {
82+
collapsed = collapsed.replace(/^ /, '')
83+
}
84+
if (beforeBoundary) {
85+
collapsed = collapsed.replace(/ $/, '')
86+
}
87+
text.data = collapsed
88+
}
89+
90+
const normalizeChildren = (parent: Node, whitespaceSensitive: boolean): void => {
91+
const childrenAsParsed = Array.from(parent.childNodes)
92+
93+
childrenAsParsed.forEach((child, index) => {
94+
if (isElement(child)) {
95+
sortAttributes(child)
96+
normalizeChildren(
97+
child,
98+
whitespaceSensitive || WHITESPACE_SENSITIVE_TAGS.has(tagNameOf(child))
99+
)
100+
return
101+
}
102+
if (isText(child) && !whitespaceSensitive) {
103+
normalizeText(
104+
child,
105+
isBlockBoundary(childrenAsParsed[index - 1], parent),
106+
isBlockBoundary(childrenAsParsed[index + 1], parent)
107+
)
108+
}
109+
})
110+
}
111+
112+
export const normalizeHtml = (html: string): string => {
113+
const template = document.createElement('template')
114+
template.innerHTML = html
115+
normalizeChildren(template.content, false)
116+
return template.innerHTML
117+
}
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import { normalizeHtml } from '../../testHelpers/html/htmlNormalizer'
2+
import {
3+
CONTENT_FIELD_WITH_ALL_TAGS,
4+
SERVER_FORMATTED_CONTENT_FIELD_WITH_ALL_TAGS
5+
} from '../../testHelpers/collections/collectionHelper'
6+
7+
describe('normalizeHtml', () => {
8+
describe('differences the server may introduce', () => {
9+
test('should ignore the order of attributes', () => {
10+
expect(
11+
normalizeHtml('<a target="_blank" rel="nofollow" class="x" href="https://a.b">t</a>')
12+
).toEqual(
13+
normalizeHtml('<a class="x" href="https://a.b" rel="nofollow" target="_blank">t</a>')
14+
)
15+
})
16+
17+
test('should ignore indentation introduced between block elements', () => {
18+
expect(normalizeHtml('<ul><li><p>Item</p></li></ul>')).toEqual(
19+
normalizeHtml('<ul>\n <li>\n <p>Item</p>\n </li>\n</ul>')
20+
)
21+
})
22+
23+
test('should ignore indentation around the content of a block element', () => {
24+
expect(normalizeHtml('<p>Item</p>')).toEqual(normalizeHtml('<p>\n Item\n</p>'))
25+
})
26+
27+
test('should ignore the case of tag and attribute names', () => {
28+
expect(normalizeHtml('<P CLASS="x">t</P>')).toEqual(normalizeHtml('<p class="x">t</p>'))
29+
})
30+
31+
test('should ignore the order of attributes whose values contain angle brackets', () => {
32+
expect(normalizeHtml('<a title="A > B" href="/example">link</a>')).toEqual(
33+
normalizeHtml('<a href="/example" title="A > B">link</a>')
34+
)
35+
})
36+
37+
test('should ignore the order of attributes quoted with single quotes', () => {
38+
expect(normalizeHtml("<a title='A > B' href='/example'>link</a>")).toEqual(
39+
normalizeHtml('<a href="/example" title="A > B">link</a>')
40+
)
41+
})
42+
43+
test('should ignore equivalent spellings of a void element', () => {
44+
expect(normalizeHtml('<p>a<br>b</p>')).toEqual(normalizeHtml('<p>a<br/>b</p>'))
45+
})
46+
47+
test('should ignore equivalent spellings of an escaped character', () => {
48+
expect(normalizeHtml('<p>a &amp; b</p>')).toEqual(normalizeHtml('<p>a & b</p>'))
49+
})
50+
51+
test('should treat the sent and server-returned forms of the featured item fixture as equal', () => {
52+
expect(normalizeHtml(CONTENT_FIELD_WITH_ALL_TAGS)).toEqual(
53+
normalizeHtml(SERVER_FORMATTED_CONTENT_FIELD_WITH_ALL_TAGS)
54+
)
55+
})
56+
})
57+
58+
describe('differences that must still be detected', () => {
59+
test('should not ignore differing text content', () => {
60+
expect(normalizeHtml('<p>Item</p>')).not.toEqual(normalizeHtml('<p>Other</p>'))
61+
})
62+
63+
test('should not ignore differing attribute values', () => {
64+
expect(normalizeHtml('<a href="https://a.b">t</a>')).not.toEqual(
65+
normalizeHtml('<a href="https://evil.example">t</a>')
66+
)
67+
})
68+
69+
test('should not ignore a dropped attribute', () => {
70+
expect(normalizeHtml('<a rel="nofollow" href="https://a.b">t</a>')).not.toEqual(
71+
normalizeHtml('<a href="https://a.b">t</a>')
72+
)
73+
})
74+
75+
test('should not ignore a differing attribute value that contains angle brackets', () => {
76+
expect(normalizeHtml('<a title="A > B" href="/example">link</a>')).not.toEqual(
77+
normalizeHtml('<a title="A > C" href="/example">link</a>')
78+
)
79+
})
80+
81+
test('should not ignore differing structure', () => {
82+
expect(normalizeHtml('<ul><li>a</li><li>b</li></ul>')).not.toEqual(
83+
normalizeHtml('<ul><li>a</li></ul>')
84+
)
85+
})
86+
87+
test('should not ignore a changed tag', () => {
88+
expect(normalizeHtml('<strong>t</strong>')).not.toEqual(normalizeHtml('<em>t</em>'))
89+
})
90+
91+
test('should preserve whitespace inside a preformatted block', () => {
92+
expect(normalizeHtml('<pre><code> indented\n lines</code></pre>')).not.toEqual(
93+
normalizeHtml('<pre><code>indented lines</code></pre>')
94+
)
95+
})
96+
97+
test('should preserve the whitespace that separates inline elements in the featured item fixture', () => {
98+
expect(normalizeHtml(CONTENT_FIELD_WITH_ALL_TAGS)).not.toEqual(
99+
normalizeHtml(CONTENT_FIELD_WITH_ALL_TAGS.replace('</strong> <em', '</strong><em'))
100+
)
101+
})
102+
103+
test('should preserve whitespace that separates inline elements', () => {
104+
expect(normalizeHtml('<p><em>a</em> <em>b</em></p>')).not.toEqual(
105+
normalizeHtml('<p><em>a</em><em>b</em></p>')
106+
)
107+
})
108+
})
109+
})

0 commit comments

Comments
 (0)