Skip to content

Commit 3348e1c

Browse files
committed
Compare featured item HTML with a normalizer instead of an exact server-formatted string
1 parent 9e49b8b commit 3348e1c

3 files changed

Lines changed: 226 additions & 1 deletion

File tree

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

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import {
2727
FeaturedItemType
2828
} from '../../../src/collections/domain/models/FeaturedItem'
2929
import { uploadFileViaApi } from '../../testHelpers/files/filesHelper'
30+
import { normalizeHtml } from '../../testHelpers/html/htmlNormalizer'
3031
import {
3132
deletePublishedDatasetViaApi,
3233
publishDatasetViaApi,
@@ -165,7 +166,9 @@ describe('execute', () => {
165166
expect(secondItemResponse.imageFileUrl).toBeUndefined()
166167
expect(secondItemResponse.imageFileName).toBeUndefined()
167168

168-
expect(thirdItemResponse.content).toEqual(EXPECTED_CONTENT_FIELD_WITH_ALL_TAGS)
169+
expect(normalizeHtml(thirdItemResponse.content)).toEqual(
170+
normalizeHtml(EXPECTED_CONTENT_FIELD_WITH_ALL_TAGS)
171+
)
169172
expect(thirdItemResponse.displayOrder).toBe(newFeaturedItems[2].displayOrder)
170173
expect(thirdItemResponse.imageFileName).toEqual('featured-item-test-image-3.png')
171174
expect(thirdItemResponse.imageFileUrl).toContain(
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
const WHITESPACE_SENSITIVE_TAGS = new Set(['pre', 'textarea'])
2+
3+
const BLOCK_TAGS = new Set([
4+
'address',
5+
'article',
6+
'aside',
7+
'blockquote',
8+
'body',
9+
'br',
10+
'div',
11+
'dd',
12+
'dl',
13+
'dt',
14+
'fieldset',
15+
'figcaption',
16+
'figure',
17+
'footer',
18+
'form',
19+
'h1',
20+
'h2',
21+
'h3',
22+
'h4',
23+
'h5',
24+
'h6',
25+
'head',
26+
'header',
27+
'hr',
28+
'html',
29+
'li',
30+
'main',
31+
'nav',
32+
'ol',
33+
'p',
34+
'pre',
35+
'section',
36+
'table',
37+
'tbody',
38+
'td',
39+
'tfoot',
40+
'th',
41+
'thead',
42+
'tr',
43+
'ul'
44+
])
45+
46+
const TAG_PATTERN = /^<\s*(\/?)\s*([a-zA-Z][\w:-]*)([\s\S]*?)(\/?)\s*>$/
47+
const ATTRIBUTE_PATTERN = /([\w:-]+)(?:\s*=\s*("[^"]*"|'[^']*'|[^\s"'>]+))?/g
48+
49+
interface ParsedTag {
50+
closing: boolean
51+
name: string
52+
selfClosing: boolean
53+
}
54+
55+
const parseTag = (token: string): ParsedTag | undefined => {
56+
const match = TAG_PATTERN.exec(token)
57+
if (match === null) {
58+
return undefined
59+
}
60+
return {
61+
closing: match[1] === '/',
62+
name: match[2].toLowerCase(),
63+
selfClosing: match[4] === '/'
64+
}
65+
}
66+
67+
const normalizeTag = (token: string): string => {
68+
const match = TAG_PATTERN.exec(token)
69+
if (match === null) {
70+
return token
71+
}
72+
const [, closing, name, attributeSource, selfClosing] = match
73+
const attributes = Array.from(attributeSource.matchAll(ATTRIBUTE_PATTERN))
74+
.map(([, attributeName, attributeValue]) =>
75+
attributeValue === undefined
76+
? attributeName.toLowerCase()
77+
: `${attributeName.toLowerCase()}=${normalizeAttributeValue(attributeValue)}`
78+
)
79+
.sort()
80+
const renderedAttributes = attributes.length === 0 ? '' : ` ${attributes.join(' ')}`
81+
return `<${closing}${name.toLowerCase()}${renderedAttributes}${selfClosing}>`
82+
}
83+
84+
const normalizeAttributeValue = (value: string): string => {
85+
const unquoted =
86+
(value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))
87+
? value.slice(1, -1)
88+
: value
89+
return `"${unquoted}"`
90+
}
91+
92+
const isBlockBoundary = (token: string | undefined): boolean => {
93+
if (token === undefined) {
94+
return true
95+
}
96+
const tag = parseTag(token)
97+
return tag !== undefined && BLOCK_TAGS.has(tag.name)
98+
}
99+
100+
export const normalizeHtml = (html: string): string => {
101+
const tokens = html.split(/(<[^>]*>)/).filter((token) => token !== '')
102+
const normalized: string[] = []
103+
let whitespaceSensitiveDepth = 0
104+
105+
tokens.forEach((token, index) => {
106+
const tag = token.startsWith('<') ? parseTag(token) : undefined
107+
108+
if (tag !== undefined) {
109+
if (tag.closing && WHITESPACE_SENSITIVE_TAGS.has(tag.name)) {
110+
whitespaceSensitiveDepth = Math.max(0, whitespaceSensitiveDepth - 1)
111+
}
112+
normalized.push(normalizeTag(token))
113+
if (!tag.closing && !tag.selfClosing && WHITESPACE_SENSITIVE_TAGS.has(tag.name)) {
114+
whitespaceSensitiveDepth += 1
115+
}
116+
return
117+
}
118+
119+
if (token.startsWith('<') || whitespaceSensitiveDepth > 0) {
120+
normalized.push(token)
121+
return
122+
}
123+
124+
const previousToken = tokens[index - 1]
125+
const nextToken = tokens[index + 1]
126+
127+
if (token.trim() === '') {
128+
if (!isBlockBoundary(previousToken) && !isBlockBoundary(nextToken)) {
129+
normalized.push(' ')
130+
}
131+
return
132+
}
133+
134+
let text = token.replace(/\s+/g, ' ')
135+
if (isBlockBoundary(previousToken)) {
136+
text = text.replace(/^ /, '')
137+
}
138+
if (isBlockBoundary(nextToken)) {
139+
text = text.replace(/ $/, '')
140+
}
141+
normalized.push(text)
142+
})
143+
144+
return normalized.join('')
145+
}
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import { normalizeHtml } from '../../testHelpers/html/htmlNormalizer'
2+
import {
3+
CONTENT_FIELD_WITH_ALL_TAGS,
4+
EXPECTED_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 treat the sent and pretty-printed forms of the featured item fixture as equal', () => {
32+
expect(normalizeHtml(CONTENT_FIELD_WITH_ALL_TAGS)).toEqual(
33+
normalizeHtml(EXPECTED_CONTENT_FIELD_WITH_ALL_TAGS)
34+
)
35+
})
36+
})
37+
38+
describe('differences that must still be detected', () => {
39+
test('should not ignore differing text content', () => {
40+
expect(normalizeHtml('<p>Item</p>')).not.toEqual(normalizeHtml('<p>Other</p>'))
41+
})
42+
43+
test('should not ignore differing attribute values', () => {
44+
expect(normalizeHtml('<a href="https://a.b">t</a>')).not.toEqual(
45+
normalizeHtml('<a href="https://evil.example">t</a>')
46+
)
47+
})
48+
49+
test('should not ignore a dropped attribute', () => {
50+
expect(normalizeHtml('<a rel="nofollow" href="https://a.b">t</a>')).not.toEqual(
51+
normalizeHtml('<a href="https://a.b">t</a>')
52+
)
53+
})
54+
55+
test('should not ignore differing structure', () => {
56+
expect(normalizeHtml('<ul><li>a</li><li>b</li></ul>')).not.toEqual(
57+
normalizeHtml('<ul><li>a</li></ul>')
58+
)
59+
})
60+
61+
test('should not ignore a changed tag', () => {
62+
expect(normalizeHtml('<strong>t</strong>')).not.toEqual(normalizeHtml('<em>t</em>'))
63+
})
64+
65+
test('should preserve whitespace inside a preformatted block', () => {
66+
expect(normalizeHtml('<pre><code> indented\n lines</code></pre>')).not.toEqual(
67+
normalizeHtml('<pre><code>indented lines</code></pre>')
68+
)
69+
})
70+
71+
test('should preserve whitespace that separates inline elements', () => {
72+
expect(normalizeHtml('<p><em>a</em> <em>b</em></p>')).not.toEqual(
73+
normalizeHtml('<p><em>a</em><em>b</em></p>')
74+
)
75+
})
76+
})
77+
})

0 commit comments

Comments
 (0)