diff --git a/Modules/Sources/WordPressShared/Utility/RichContentFormatter.swift b/Modules/Sources/WordPressShared/Utility/RichContentFormatter.swift index 38530a2d2043..c9eca4825392 100644 --- a/Modules/Sources/WordPressShared/Utility/RichContentFormatter.swift +++ b/Modules/Sources/WordPressShared/Utility/RichContentFormatter.swift @@ -50,17 +50,17 @@ import Foundation content = RegEx.styleTags.stringByReplacingMatches(in: content, options: .reportCompletion, - range: NSRange(location: 0, length: content.count), + range: content.fullNSRange, withTemplate: "") content = RegEx.scriptTags.stringByReplacingMatches(in: content, options: .reportCompletion, - range: NSRange(location: 0, length: content.count), + range: content.fullNSRange, withTemplate: "") content = RegEx.gutenbergComments.stringByReplacingMatches(in: content, options: .reportCompletion, - range: NSRange(location: 0, length: content.count), + range: content.fullNSRange, withTemplate: "") return content @@ -84,23 +84,23 @@ import Foundation // Convert div tags to p tags content = RegEx.divTagsStart.stringByReplacingMatches(in: content, options: .reportCompletion, - range: NSRange(location: 0, length: content.count), + range: content.fullNSRange, withTemplate: openPTag) content = RegEx.divTagsEnd.stringByReplacingMatches(in: content, options: .reportCompletion, - range: NSRange(location: 0, length: content.count), + range: content.fullNSRange, withTemplate: closePTag) // Remove duplicate/redundant p tags. content = RegEx.pTagsStart.stringByReplacingMatches(in: content, options: .reportCompletion, - range: NSRange(location: 0, length: content.count), + range: content.fullNSRange, withTemplate: openPTag) content = RegEx.pTagsEnd.stringByReplacingMatches(in: content, options: .reportCompletion, - range: NSRange(location: 0, length: content.count), + range: content.fullNSRange, withTemplate: closePTag) content = filterNewLines(content) @@ -114,11 +114,11 @@ import Foundation var ranges = [NSRange]() // We don't want to remove new lines from preformatted tag blocks, // so get the ranges of such blocks. - let matches = RegEx.preTags.matches(in: content, options: .reportCompletion, range: NSRange(location: 0, length: content.count)) + let matches = RegEx.preTags.matches(in: content, options: .reportCompletion, range: content.fullNSRange) if matches.isEmpty { // No blocks found, so we'll parse the whole string. - ranges.append(NSRange(location: 0, length: content.count)) + ranges.append(content.fullNSRange) } else { // One or more preformatted blocks found, we don't want to remove new lines @@ -133,7 +133,7 @@ import Foundation location = match.range.location + match.range.length } - length = content.count - location + length = content.utf16.count - location ranges.append(NSRange(location: location, length: length)) } @@ -163,7 +163,7 @@ import Foundation content = RegEx.styleAttr.stringByReplacingMatches(in: content, options: .reportCompletion, - range: NSRange(location: 0, length: content.count), + range: content.fullNSRange, withTemplate: "") return content @@ -187,7 +187,9 @@ import Foundation let location = attrRange.location + attrRange.length let length = elementStr.length - location let ending = elementStr.range(of: "\"", options: .caseInsensitive, range: NSRange(location: location, length: length)) - value = elementStr.substring(with: NSRange(location: location, length: ending.location - location)) + if ending.location != NSNotFound { + value = elementStr.substring(with: NSRange(location: location, length: ending.location - location)) + } } return value @@ -206,10 +208,9 @@ import Foundation } var content = string.trim() - let matches = RegEx.trailingBRTags.matches(in: content, options: .reportCompletion, range: NSRange(location: 0, length: content.count)) - if let match = matches.first { - let index = content.index(content.startIndex, offsetBy: match.range.location) - content = String(content.prefix(upTo: index)) + let matches = RegEx.trailingBRTags.matches(in: content, options: .reportCompletion, range: content.fullNSRange) + if let match = matches.first, let matchRange = Range(match.range, in: content) { + content = String(content[..test

test

" let sanitizedStr = RichContentFormatter.removeInlineStyles(styleStr) - XCTAssertTrue(str == sanitizedStr, "The inline styles were not removed.") + #expect(str == sanitizedStr, "The inline styles were not removed.") } - func testRemoveForbiddenTags() { + @Test func testRemoveForbiddenTags() { let str = "

test

test

" - let styleStr = "

test

test

\n

" + let styleStr = + "

test

test

\n

" let sanitizedStr = RichContentFormatter.removeForbiddenTags(styleStr) - XCTAssertTrue(str == sanitizedStr, "The forbidden tags were not removed.") + #expect(str == sanitizedStr, "The forbidden tags were not removed.") } - func testNormalizeParagraphs() { + @Test func testNormalizeParagraphs() { let str = "

test

\n\ntest\n\n

test

" let styleStr = "

test

\n\ntest\n\n
\n

test

\n" let sanitizedStr = RichContentFormatter.normalizeParagraphs(styleStr) - XCTAssertTrue(str == sanitizedStr, "Not all paragraphs were normalized.") + #expect(str == sanitizedStr, "Not all paragraphs were normalized.") } - func testFilterNewLines() { + @Test func testFilterNewLines() { let str = "

test

\n\ntest\n\n

test

" let styleStr = "

test

\n\ntest\n\n
\n

test

\n" let sanitizedStr = RichContentFormatter.filterNewLines(styleStr) - XCTAssertTrue(str == sanitizedStr, "Not all paragraphs were normalized.") + #expect(str == sanitizedStr, "Not all paragraphs were normalized.") } - func testRemoveTrailingBRTags() { + @Test func testRemoveTrailingBRTags() { let str = "

test


test

" let styleStr = "

test


test



" let sanitizedStr = RichContentFormatter.removeTrailingBreakTags(styleStr) - XCTAssertTrue(str == sanitizedStr, "The inline styles were not removed.") + #expect(str == sanitizedStr, "The inline styles were not removed.") } - func testRemoveGutenbergGalleryListMarkup() { - let str = "Some text. Some text." + @Test func testRemoveGutenbergGalleryListMarkup() { + let str = + "Some text. Some text." let sanitizedString = RichContentFormatter.formatGutenbergGallery(str) as NSString // Checks if the UL was removed. var range = sanitizedString.range(of: "block-gallery") - XCTAssertTrue(range.location == NSNotFound) + #expect(range.location == NSNotFound) // Checks if the LI was removed range = sanitizedString.range(of: "blocks-gallery") - XCTAssertTrue(range.location == NSNotFound) + #expect(range.location == NSNotFound) // Checks if the FIGCAPTION was kept. range = sanitizedString.range(of: "figcaption") - XCTAssertTrue(range.location != NSNotFound) + #expect(range.location != NSNotFound) } - func testFormatVideoTags() { + @Test func testFormatVideoTags() { let str1 = "

Some text.

Some text.

" let sanitizedStr1 = RichContentFormatter.formatVideoTags(str1) as NSString - XCTAssert(sanitizedStr1.contains("controls")) + #expect(sanitizedStr1.contains("controls")) let str2 = "

Some text.

Some text.

" let sanitizedStr2 = RichContentFormatter.formatVideoTags(str2) as NSString - XCTAssert(sanitizedStr2.contains(" controls ")) + #expect(sanitizedStr2.contains(" controls ")) let str3 = "

Some text.

Some text.

" let sanitizedStr3 = RichContentFormatter.formatVideoTags(str3) as NSString - XCTAssert(!sanitizedStr3.contains("controls controls")) + #expect(!sanitizedStr3.contains("controls controls")) + } + + // MARK: - Multi-code-unit input + // + // The bug sized each search range from the grapheme count (`content.count`) rather than the + // UTF-16 length. A cluster that is one grapheme but several UTF-16 units β€” emoji, a ZWJ + // sequence, a flag, a keycap, a skin-tone modifier, or a decomposed accent β€” therefore leaves + // a token near the end of the string just past the range, so the search never reaches it. + // Each test drives one such spot; the exact-output check also confirms the cluster is intact. + + @Test func testRegionalFlagStyleBlockSurvivesInTail() { + // A ") + #expect(out == "πŸ‡ΊπŸ‡Έhi") + } + + @Test func testZWJFamilyScriptTagSurvivesInTail() { + // A ") + #expect(out == "πŸ‘¨β€πŸ‘©β€πŸ‘§β€πŸ‘¦") + } + + @Test func testKeycapGutenbergCommentSurvivesInTail() { + // A Gutenberg block comment after a keycap emoji is stripped. + let out = RichContentFormatter.removeForbiddenTags("1️⃣

") + #expect(out == "1️⃣") + } + + @Test func testSkinToneDivStartNotConvertedInTail() { + //
is converted to

even after a skin-tone emoji. + let out = RichContentFormatter.normalizeParagraphs("πŸ‘πŸ½

") + #expect(out == "πŸ‘πŸ½

") + } + + @Test func testNFDCombiningDivEndNotConvertedInTail() { + //

is converted to

after a decomposed "Γ©" (e + a combining accent). A composed + // "Γ©" is a single UTF-16 unit and would not reach past the range, so the decomposition matters. + let out = RichContentFormatter.normalizeParagraphs("cafe\u{301}
") + #expect(out == "cafe\u{301}

") + } + + @Test func testNormalizeParagraphsMergesTrailingDoubleOpenParagraph() { + // A redundant

is collapsed to a single

. + let out = RichContentFormatter.normalizeParagraphs("πŸ˜€

") + #expect(out == "πŸ˜€

") + } + + @Test func testNormalizeParagraphsMergesTrailingDoubleCloseParagraph() { + // A redundant

is collapsed to a single

. + let out = RichContentFormatter.normalizeParagraphs("πŸ˜€

") + #expect(out == "πŸ˜€

") + } + + @Test func testFilterNewLinesNoPreFallbackRemovesNewlinePastWideCluster() { + // A newline outside any
 block is removed.
+        let out = RichContentFormatter.filterNewLines("πŸ‘¨β€πŸ‘©β€πŸ‘§β€πŸ‘¦\nA")
+        #expect(out == "πŸ‘¨β€πŸ‘©β€πŸ‘§β€πŸ‘¦A")
+    }
+
+    @Test func testFilterNewLinesElseBranchPreservesTrailingNewlineAfterWideCluster() {
+        // With a 
 block present, a newline that follows it (outside the block) is still removed.
+        let out = RichContentFormatter.filterNewLines("
\n
πŸ‘¨β€πŸ‘©β€πŸ‘§β€πŸ‘¦\nZ") + #expect(out == "
\n
πŸ‘¨β€πŸ‘©β€πŸ‘§β€πŸ‘¦Z") + } + + @Test func testFilterNewLinesMultiPreInverseRanges() { + // Across several
 blocks: newlines inside them are kept, newlines outside are removed.
+        let out = RichContentFormatter.filterNewLines("πŸ‘¨β€πŸ‘©β€πŸ‘§β€πŸ‘¦\n
a\nb
\nπŸ˜€\n
c\nd
\nπŸ‡ΊπŸ‡Έ\n") + #expect(out == "πŸ‘¨β€πŸ‘©β€πŸ‘§β€πŸ‘¦
a\nb
πŸ˜€
c\nd
πŸ‡ΊπŸ‡Έ") + } + + @Test func testZWJFamilyStyleAttrSurvivesInTruncatedTail() { + // An inline style attribute after a family emoji is stripped. + let out = RichContentFormatter.removeInlineStyles("πŸ‘¨β€πŸ‘©β€πŸ‘§β€πŸ‘¦
") + #expect(out == "πŸ‘¨β€πŸ‘©β€πŸ‘§β€πŸ‘¦
") + } + + @Test func testZWJFamilyTrailingBreakSurvivesAndCutsCleanly() { + // A trailing
after a family emoji is removed, and the emoji before it stays intact. + let out = RichContentFormatter.removeTrailingBreakTags("πŸ‘¨β€πŸ‘©β€πŸ‘§β€πŸ‘¦text
") + #expect(out == "πŸ‘¨β€πŸ‘©β€πŸ‘§β€πŸ‘¦text") + } + + @Test func testTrailingBreakOnlyFinalRemovedEmojiIntact() { + // Only the trailing
is removed; an earlier
in the middle of the text stays. + let out = RichContentFormatter.removeTrailingBreakTags("πŸ˜€
text
") + #expect(out == "πŸ˜€
text") + } + + @Test func testForbiddenCleanMultibyteUnchanged() { + // Content with no tags to strip passes through unchanged. + let out = RichContentFormatter.removeForbiddenTags("Hello πŸ‘¨β€πŸ‘©β€πŸ‘§β€πŸ‘¦ world πŸ˜€!") + #expect(out == "Hello πŸ‘¨β€πŸ‘©β€πŸ‘§β€πŸ‘¦ world πŸ˜€!") + } + + // MARK: - Boundary + selectivity (not new fix sites) + + @Test func testBoundaryStraddleOffByOne() { + // One emoji makes the range exactly one UTF-16 unit short, and the token's closing ">" + // is exactly that dropped unit β€” pins the off-by-one where the wide-gap cases have slack. + let out = RichContentFormatter.removeForbiddenTags("text") + #expect(out == "text") + } + + @Test func testStripsTagInRangeAndInTailNotJustEverything() { + // The first style attribute is always in range; the ZWJ family pushes the second into the + // truncated tail. The fix strips both; the bug strips only the first β€” so the range, not a + // blanket "strip everything", decides which tags go. + let out = RichContentFormatter.removeInlineStyles("πŸ‘¨β€πŸ‘©β€πŸ‘§β€πŸ‘¦") + #expect(out == "πŸ‘¨β€πŸ‘©β€πŸ‘§β€πŸ‘¦") + } + + // MARK: - parseValueForAttribute robustness + + @Test func testParseValueForAttributeReturnsValue() { + let value = RichContentFormatter.parseValueForAttribute("src", inElement: "") + #expect(value == "http://x/a.jpg") + } + + @Test func testParseValueForAttributeMissingClosingQuoteReturnsEmpty() { + // Opening quote but no closing quote: the closing-quote search returns NSNotFound, so the + // range length would underflow to a huge value and crash substring(with:). Return "" instead. + let value = RichContentFormatter.parseValueForAttribute("src", inElement: "") + #expect(value.isEmpty) + } + + @Test func testParseValueForAttributeAbsentReturnsEmpty() { + let value = RichContentFormatter.parseValueForAttribute("src", inElement: "\"x\"") + #expect(value.isEmpty) } } diff --git a/Modules/Tests/WordPressSharedTests/RichContentFormatterUITests.swift b/Modules/Tests/WordPressSharedTests/RichContentFormatterUITests.swift index 8efaddb06f04..b123ae42bb50 100644 --- a/Modules/Tests/WordPressSharedTests/RichContentFormatterUITests.swift +++ b/Modules/Tests/WordPressSharedTests/RichContentFormatterUITests.swift @@ -1,10 +1,30 @@ -import XCTest +import Foundation +import Testing + @testable import WordPressShared @testable import WordPressSharedUI -class RichContentFormatterUITests: XCTestCase { +struct RichContentFormatterUITests { + + @Test func testResizeGalleryImageURLsForContentEmptyString() { + #expect(RichContentFormatter.resizeGalleryImageURL("", isPrivateSite: false).isEmpty) + } + + // The gallery-image src rewrite sized its search range from the grapheme count + // (`imgElementStr.count`) rather than the UTF-16 length, so a `src` sitting past a + // multi-code-unit cluster fell outside the range and was never swapped for the resized + // URL. Here five emoji in `alt` (10 UTF-16 units, 5 graphemes) push the trailing `src` + // past a grapheme-count range; the resized URL must still replace it, cluster intact. + @Test func testResizeGalleryImageURLReplacesSrcPastMultibyteCluster() { + let input = + "\"πŸ˜€πŸ˜€πŸ˜€πŸ˜€πŸ˜€\"" + + let output = RichContentFormatter.resizeGalleryImageURL(input, isPrivateSite: false) - func testResizeGalleryImageURLsForContentEmptyString() { - XCTAssertTrue("" == RichContentFormatter.resizeGalleryImageURL("", isPrivateSite: false)) + // The original src was found and rewritten to a resized (Photon) URL... + #expect(!output.contains("https://example.com/small.jpg")) + #expect(output.contains(".wp.com")) + // ...and the emoji cluster survived byte-for-byte. + #expect(output.contains("πŸ˜€πŸ˜€πŸ˜€πŸ˜€πŸ˜€")) } }