diff --git a/Modules/Sources/WordPressShared/Utility/RichContentFormatter.swift b/Modules/Sources/WordPressShared/Utility/RichContentFormatter.swift index 38530a2d2043..44b59a81ccac 100644 --- a/Modules/Sources/WordPressShared/Utility/RichContentFormatter.swift +++ b/Modules/Sources/WordPressShared/Utility/RichContentFormatter.swift @@ -8,8 +8,8 @@ import Foundation /// public struct RegEx { // Forbidden tags - static let styleTags = try! NSRegularExpression(pattern: "]*?>[\\s\\S]*?", options: .caseInsensitive) - static let scriptTags = try! NSRegularExpression(pattern: "]*?>[\\s\\S]*?", options: .caseInsensitive) + static let styleTags = try! NSRegularExpression(pattern: "]*?>[\\s\\S]*?(?:|$)", options: .caseInsensitive) + static let scriptTags = try! NSRegularExpression(pattern: "]*?>[\\s\\S]*?(?:|$)", options: .caseInsensitive) static let gutenbergComments = try! NSRegularExpression(pattern: "

[\\n]?", options: .caseInsensitive) // Normalizaing Paragraphs @@ -19,10 +19,10 @@ import Foundation static let pTagsEnd = try! NSRegularExpression(pattern: "

\\s*

", options: .caseInsensitive) static let newLines = try! NSRegularExpression(pattern: "\\n", options: .caseInsensitive) static let preTags = try! NSRegularExpression(pattern: "]*>[\\s\\S]*?", options: .caseInsensitive) - static let videoTags = try! NSRegularExpression(pattern: "]*>", options: .caseInsensitive) + static let videoTags = try! NSRegularExpression(pattern: "]*)?>", options: .caseInsensitive) // Inline Styles - static let styleAttr = try! NSRegularExpression(pattern: "\\s*style=\"[^\"]*\"", options: .caseInsensitive) + static let styleAttr = try! NSRegularExpression(pattern: "\\s+style=(?:\"[^\"]*\"|'[^']*')", options: .caseInsensitive) // Gallery Images public static let galleryImgTags = try! NSRegularExpression(pattern: "]*data-orig-file[^>]*/>", options: .caseInsensitive) @@ -50,17 +50,17 @@ import Foundation content = RegEx.styleTags.stringByReplacingMatches(in: content, options: .reportCompletion, - range: NSRange(location: 0, length: content.count), + range: NSRange(location: 0, length: content.utf16.count), withTemplate: "") content = RegEx.scriptTags.stringByReplacingMatches(in: content, options: .reportCompletion, - range: NSRange(location: 0, length: content.count), + range: NSRange(location: 0, length: content.utf16.count), withTemplate: "") content = RegEx.gutenbergComments.stringByReplacingMatches(in: content, options: .reportCompletion, - range: NSRange(location: 0, length: content.count), + range: NSRange(location: 0, length: content.utf16.count), 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: NSRange(location: 0, length: content.utf16.count), withTemplate: openPTag) content = RegEx.divTagsEnd.stringByReplacingMatches(in: content, options: .reportCompletion, - range: NSRange(location: 0, length: content.count), + range: NSRange(location: 0, length: content.utf16.count), withTemplate: closePTag) // Remove duplicate/redundant p tags. content = RegEx.pTagsStart.stringByReplacingMatches(in: content, options: .reportCompletion, - range: NSRange(location: 0, length: content.count), + range: NSRange(location: 0, length: content.utf16.count), withTemplate: openPTag) content = RegEx.pTagsEnd.stringByReplacingMatches(in: content, options: .reportCompletion, - range: NSRange(location: 0, length: content.count), + range: NSRange(location: 0, length: content.utf16.count), 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: NSRange(location: 0, length: content.utf16.count)) if matches.isEmpty { // No blocks found, so we'll parse the whole string. - ranges.append(NSRange(location: 0, length: content.count)) + ranges.append(NSRange(location: 0, length: content.utf16.count)) } 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: NSRange(location: 0, length: content.utf16.count), withTemplate: "") return content @@ -178,19 +178,20 @@ import Foundation /// - Returns: The value for the attribute or an empty string.. /// @objc public class func parseValueForAttribute(_ attribute: String, inElement element: String) -> String { - let elementStr = element as NSString - var value = "" - let attrStr = "\(attribute)=\"" - let attrRange = elementStr.range(of: attrStr) - - if attrRange.location != NSNotFound { - 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)) + // Match the attribute name on a word boundary (so "rc" does not match inside "src") + // and capture its double-quoted value. A missing closing quote fails to match, so there + // is no out-of-bounds range to crash on. + let escaped = NSRegularExpression.escapedPattern(for: attribute) + guard let regex = try? NSRegularExpression(pattern: "(?alert(1)Hello", "Hello"), + ("Hello", "Hello"), + // Case-insensitive. + ("Hello", "Hello"), + ("Hello", "Hello"), + // Attributes on the opening tag. + ("Hi", "Hi"), + // Newlines inside the element body. + ("Hi", "Hi"), + // Multiple occurrences. + ("abc", "abc"), + ("abc", "abc"), + // Gutenberg block comments wrapped in

, with and without trailing newline. + ("

\nHi", "Hi"), + ("

Hi", "Hi"), + ("

Hi", "Hi"), + // An unclosed forbidden tag is stripped through the end of the input. + ("", "πŸ˜€πŸ˜€πŸ˜€πŸ˜€πŸ˜€"), + // No-ops. + ("

plain paragraph

", "

plain paragraph

"), + ("", "") + ] + ) + func strips(input: String, expected: String) { + #expect(RichContentFormatter.removeForbiddenTags(input) == expected) + } + } + + // MARK: - removeInlineStyles + + @Suite("removeInlineStyles") + struct RemoveInlineStyles { + @Test( + "strips double-quoted style attributes and the whitespace before them", + arguments: [ + ("

x

", "

x

"), + ("

x

", "

x

"), + ("

x

", "

x

"), + // Leading whitespace is consumed with the attribute. + ("y", "y"), + // Multiple styled elements. + ("

1

2

", "

1

2

"), + // Single-quoted styles are stripped too, leaving other attributes intact. + ("

x

", "

x

"), + ("y", "y"), + // Attribute names ending in `style` are NOT corrupted (left boundary). + ("", ""), + ("
t
", "
t
"), + // Non-BMP prefix must not shrink the UTF-16 search range. + ("πŸ˜€

t

", "πŸ˜€

t

"), + // No-ops. + ("

no style here

", "

no style here

"), + ("", "") + ] + ) + func strips(input: String, expected: String) { + #expect(RichContentFormatter.removeInlineStyles(input) == expected) + } + } + + // MARK: - normalizeParagraphs + + @Suite("normalizeParagraphs") + struct NormalizeParagraphs { + @Test( + "converts DIVs to Ps, collapses redundant Ps, and drops non-PRE newlines", + arguments: [ + // Anchor case from the original suite. + ( + "

test

\n\ntest\n\n
\n

test

\n", + "

test

\n\ntest\n\n

test

" + ), + // Simple div -> p. + ("
x
", "

x

"), + // Div with attributes. + ("
x
", "

x

"), + // Already-normal paragraphs are left alone. + ("

a

b

", "

a

b

"), + // Non-BMP prefix must not desync the div->p conversion (balanced tags). + ("πŸ˜€πŸ˜€πŸ˜€πŸ˜€πŸ˜€
x
", "πŸ˜€πŸ˜€πŸ˜€πŸ˜€πŸ˜€

x

"), + ("", "") + ] + ) + func normalizes(input: String, expected: String) { + #expect(RichContentFormatter.normalizeParagraphs(input) == expected) + } + } + + // MARK: - filterNewLines + + @Suite("filterNewLines") + struct FilterNewLines { + @Test( + "removes newlines except inside
 blocks",
+            arguments: [
+                // No PRE: every newline goes.
+                ("a\nb\nc", "abc"),
+                ("\n\n\n", ""),
+                // Newlines inside PRE are preserved; those outside are removed.
+                ("
a\nb
", "
a\nb
"), + ("x\n
a\nb
\ny", "x
a\nb
y"), + // Multiple PRE blocks. + ("
1\n2
\n
3\n4
\n", "
1\n2
3\n4
"), + ("", "") + ] + ) + func filters(input: String, expected: String) { + #expect(RichContentFormatter.filterNewLines(input) == expected) + } + + @Test("drops a newline after a non-BMP character (UTF-16-correct NSRange)") + func dropsNewlineAfterNonBMP() { + // "πŸ˜€" is one Character but two UTF-16 code units; the range must use the UTF-16 + // length or the trailing newline falls outside it and is left in place. + #expect(RichContentFormatter.filterNewLines("abπŸ˜€\n") == "abπŸ˜€") + } + } + + // MARK: - removeTrailingBreakTags + + @Suite("removeTrailingBreakTags") + struct RemoveTrailingBreakTags { + @Test( + "trims trailing
runs (and surrounding whitespace) but keeps interior ones", + arguments: [ + // Anchor case. + ("

test


test



", "

test


test

"), + // Single trailing break, various spellings. + ("text
", "text"), + ("text
", "text"), + ("text
", "text"), + ("text
", "text"), + // Runs. + ("text


", "text"), + ("

", ""), + // Interior break is preserved. + ("a
b", "a
b"), + // Non-BMP prefix: trailing
still trimmed, and no out-of-bounds crash. + ("πŸ˜€
", "πŸ˜€"), + ("πŸ˜€πŸ˜€πŸ˜€πŸ˜€πŸ˜€
", "πŸ˜€πŸ˜€πŸ˜€πŸ˜€πŸ˜€"), + // Leading/trailing whitespace is trimmed even with no break. + (" spaced ", "spaced"), + ("no breaks", "no breaks"), + ("", "") + ] + ) + func trims(input: String, expected: String) { + #expect(RichContentFormatter.removeTrailingBreakTags(input) == expected) + } + } + + // MARK: - formatVideoTags + + @Suite("formatVideoTags") + struct FormatVideoTags { + @Test( + "adds a controls attribute only when absent", + arguments: [ + ("

x

y

", "

x

y

"), + ("", ""), + // Already has controls -> untouched. + ("", ""), + // `controls` inside an attribute value/name is not the controls attribute. + ( + "", + "" + ), + ("", ""), + ("", ""), + // Existing CONTROLS (case-insensitive) is not duplicated. + ("", ""), + // Must not over-match a different element. + ("", ""), + // No video -> untouched. + ("

no video

", "

no video

"), + ("", "") + ] + ) + func addsControls(input: String, expected: String) { + #expect(RichContentFormatter.formatVideoTags(input) == expected) + } + + @Test("preserves the opening tag's original casing when inserting controls") + func preservesOpeningTagCasing() { + #expect(RichContentFormatter.formatVideoTags("") == "") + } + } + + // MARK: - parseValueForAttribute + + @Suite("parseValueForAttribute") + struct ParseValueForAttribute { + @Test( + "returns the double-quoted value of an attribute, or empty when absent", + arguments: [ + ("src", "", "http://example.com/a.jpg"), + ( + "data-orig-file", "", + "http://example.com/o.jpg" + ), + // Missing attribute. + ("href", "", ""), + // Present but empty. + ("src", "", ""), + // First match wins. + ("src", "", "a") + ] + ) + func parses(attribute: String, element: String, expected: String) { + #expect(RichContentFormatter.parseValueForAttribute(attribute, inElement: element) == expected) + } + + @Test("Regression: an unterminated attribute quote returns empty instead of crashing") + func unterminatedQuoteReturnsEmpty() { + // Previously `ending.location == NSNotFound` was fed into `substringWithRange:`, + // throwing NSInvalidArgumentException. Reachable from adversarial gallery HTML via + // `resizeGalleryImageURL`. Now guarded to return "". + #expect(RichContentFormatter.parseValueForAttribute("src", inElement: "").isEmpty) + } + + @Test("does not match an attribute name as a substring of another attribute") + func doesNotMatchSubstringAttributeName() { + // "rc" must not match inside "src", nor "orig-file" inside "data-orig-file". + #expect(RichContentFormatter.parseValueForAttribute("rc", inElement: "").isEmpty) + #expect( + RichContentFormatter.parseValueForAttribute("orig-file", inElement: "") + .isEmpty + ) + } + } + + // MARK: - formatGutenbergGallery + + @Suite("formatGutenbergGallery") + struct FormatGutenbergGallery { + @Test( + "leaves non-gallery content untouched", + arguments: [ + "

hello

", + "
  • plain list
", + "" + ] + ) + func noOp(input: String) { + #expect(RichContentFormatter.formatGutenbergGallery(input) == input) + } + + @Test("removes gallery UL/LI markup while keeping the figures inside") + func stripsGalleryMarkupKeepsFigures() { + let input = + "Before. After." + let output = RichContentFormatter.formatGutenbergGallery(input) as NSString + + #expect(output.range(of: "block-gallery").location == NSNotFound) + #expect(output.range(of: "blocks-gallery").location == NSNotFound) + #expect(output.range(of: "
").location != NSNotFound) + #expect(output.range(of: "figcaption").location != NSNotFound) + #expect(output.range(of: "https://example.com/1.jpg").location != NSNotFound) + #expect(output.range(of: "https://example.com/2.jpg").location != NSNotFound) + } + } +} diff --git a/Modules/Tests/WordPressSharedTests/RichContentFormatterTests.swift b/Modules/Tests/WordPressSharedTests/RichContentFormatterTests.swift index b594c5482908..bdc88c7910d7 100644 --- a/Modules/Tests/WordPressSharedTests/RichContentFormatterTests.swift +++ b/Modules/Tests/WordPressSharedTests/RichContentFormatterTests.swift @@ -1,68 +1,201 @@ -import XCTest +import Foundation +import Testing + @testable import WordPressShared -class RichContentFormatterTests: XCTestCase { +struct RichContentFormatterTests { - func testRemoveInlineStyles() { + @Test func testRemoveInlineStyles() { let str = "

test

test

" let styleStr = "

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..2fbf53a960c5 100644 --- a/Modules/Tests/WordPressSharedTests/RichContentFormatterUITests.swift +++ b/Modules/Tests/WordPressSharedTests/RichContentFormatterUITests.swift @@ -1,10 +1,39 @@ -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) + + // 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("πŸ˜€πŸ˜€πŸ˜€πŸ˜€πŸ˜€")) + } - func testResizeGalleryImageURLsForContentEmptyString() { - XCTAssertTrue("" == RichContentFormatter.resizeGalleryImageURL("", isPrivateSite: false)) + @Test func testResizeGalleryImageURLLeavesSrcsetIntact() { + // The src value also appears in srcset; only the src attribute should be rewritten. + let srcset = "srcset=\"https://example.com/a.jpg 1x, https://example.com/b.jpg 2x\"" + let input = + "" + let output = RichContentFormatter.resizeGalleryImageURL(input, isPrivateSite: false) + #expect(output.contains(srcset), "srcset must be left intact when the src is resized") } }