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: "", options: .caseInsensitive) - static let scriptTags = try! NSRegularExpression(pattern: "", options: .caseInsensitive) + static let styleTags = try! NSRegularExpression(pattern: "|$)", options: .caseInsensitive) + static let scriptTags = try! NSRegularExpression(pattern: "|$)", options: .caseInsensitive) static let gutenbergComments = try! NSRegularExpression(pattern: "
]*>[\\s\\S]*?
, with and without trailing newline. + ("
plain paragraph
x
1
2
t
no style here
test
\n\ntest\n\n
a
b
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\na\nb\ny", "xa\nby"), + // Multiple PRE blocks. + ("1\n2\n3\n4\n", "1\n23\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. + ("testtest ", "testtest"), + // Single trailing break, various spellings. + ("text", "text"), + ("text", "text"), + ("text", "text"), + ("text", "text"), + // Runs. + ("text", "text"), + ("", ""), + // Interior break is preserved. + ("ab", "ab"), + // 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: [ + ("xy", "xy"), + ("", ""), + // 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", + "
a\nb
1\n2
3\n4
y
no video
hello
Some text.
even after a skin-tone emoji. + let out = RichContentFormatter.normalizeParagraphs("ππ½
") + } + + @Test func testNFDCombiningDivEndNotConvertedInTail() { + //
is collapsed to a single
. + let out = RichContentFormatter.normalizeParagraphs("π
") + #expect(out == "π
") + } + + @Test func testNormalizeParagraphsMergesTrailingDoubleCloseParagraph() { + // A redundant
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("π¨βπ©βπ§βπ¦\na\nb\nπ\nc\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: "") + #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") } }
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("π¨βπ©βπ§βπ¦\na\nb\nπ\nc\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: "") + #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") } }
\n
blocks: newlines inside them are kept, newlines outside are removed. + let out = RichContentFormatter.filterNewLines("π¨βπ©βπ§βπ¦\na\nb\nπ\nc\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: "") + #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") } }
c\nd