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('
Item
Item
\nItem
')).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(' indented\n lines')).not.toEqual(
+ normalizeHtml('indented lines')
+ )
+ })
+
+ test('should preserve whitespace that separates inline elements', () => {
+ expect(normalizeHtml('a b
')).not.toEqual( + normalizeHtml('ab
') + ) + }) + }) +})