From b9648c07c60f86186971b22cd247d70e323922ed Mon Sep 17 00:00:00 2001 From: NuPlay <73557895+NuPlay@users.noreply.github.com> Date: Sat, 29 Aug 2026 12:18:02 +0900 Subject: [PATCH 1/3] test: pin down percent escaping in the formatted CSS constants Every CSS constant is rendered through `String(format:)`, which consumes a bare `%`. `imageCSS` was missing the `%%` escapes, so `max-width: 100%` was emitted as `max-width: 100` - an invalid length that WebKit discards, leaving images unconstrained by the web view width. Add two regression tests: one asserting the generated CSS still carries its percent signs, and one scanning the format strings themselves so a future edit that forgets to double a `%` fails at test time rather than in a layout bug. Requires #76. --- .../RichTextSwiftTestingTests.swift | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/Tests/RichTextTests/RichTextSwiftTestingTests.swift b/Tests/RichTextTests/RichTextSwiftTestingTests.swift index 75137b6..6bb359c 100644 --- a/Tests/RichTextTests/RichTextSwiftTestingTests.swift +++ b/Tests/RichTextTests/RichTextSwiftTestingTests.swift @@ -76,6 +76,56 @@ struct RichTextAllTests { #expect(css.contains("line-height: 150.0%")) #expect(css.contains(expectedAlignment)) } + + @Test("Percent signs survive String(format:) based CSS generation") + func percentSignsSurviveFormatting() { + let css = Configuration(lineHeight: 150, imageRadius: 5).css(isLight: true, alignment: .leading) + + // Every CSS constant runs through `String(format:)`, which consumes a bare `%`. + // A literal percent sign therefore has to be written as `%%` in the constant. + // Missing one is silent: `max-width: 100%` becomes `max-width: 100`, an invalid + // length that WebKit drops, and images then overflow the web view instead of + // being constrained to its width. + #expect(css.contains("max-width: 100%")) + #expect(css.contains("max-height: 100%")) + #expect(css.contains("width:100%;")) + #expect(css.contains("line-height: 150.0%")) + + #expect(!css.contains("max-width: 100;")) + #expect(!css.contains("max-height: 100;")) + #expect(!css.contains("width:100;")) + } + + @Test("Percent based CSS constants are escaped for String(format:)") + func percentBasedConstantsAreEscaped() { + let formattedConstants = [ + RichTextConstants.imageCSS, + RichTextConstants.textCSS, + RichTextConstants.iframeCSS + ] + + for constant in formattedConstants { + // Walk the constant and make sure no `%` is followed by something that + // `String(format:)` would treat as a conversion other than the intended + // `%%`, `%@` and `%d`. + let characters = Array(constant) + var index = 0 + + while index < characters.count { + guard characters[index] == "%" else { + index += 1 + continue + } + + let next = index + 1 < characters.count ? characters[index + 1] : nil + #expect( + next == "%" || next == "@" || next == "d", + "Unescaped percent sign in CSS constant: \(constant)" + ) + index += 2 + } + } + } } @Suite("ColorSet Tests") From 1c99eb9a4be47c9c74fcb4c24d8c6a05a2e01fa2 Mon Sep 17 00:00:00 2001 From: NuPlay <73557895+NuPlay@users.noreply.github.com> Date: Sat, 29 Aug 2026 13:01:38 +0900 Subject: [PATCH 2/3] test: scan every format string, not just three of them Addresses Copilot review feedback on #81. The input check only walked `imageCSS`, `textCSS` and `iframeCSS`, which left `linkCSS`, `cssTemplate`, `mediaCSSTemplate` and `htmlTemplate` unguarded even though they all go through `String(format:)` too. Turn the check into a parameterised test over the full set, and name the offending constant in the failure message. Also corrects the comment: not every CSS constant is formatted, only the ones carrying substitutions, and the previous wording would have misled the next person into escaping the wrong things. --- .../RichTextSwiftTestingTests.swift | 66 ++++++++++--------- 1 file changed, 34 insertions(+), 32 deletions(-) diff --git a/Tests/RichTextTests/RichTextSwiftTestingTests.swift b/Tests/RichTextTests/RichTextSwiftTestingTests.swift index 6bb359c..e0e6297 100644 --- a/Tests/RichTextTests/RichTextSwiftTestingTests.swift +++ b/Tests/RichTextTests/RichTextSwiftTestingTests.swift @@ -81,11 +81,11 @@ struct RichTextAllTests { func percentSignsSurviveFormatting() { let css = Configuration(lineHeight: 150, imageRadius: 5).css(isLight: true, alignment: .leading) - // Every CSS constant runs through `String(format:)`, which consumes a bare `%`. - // A literal percent sign therefore has to be written as `%%` in the constant. - // Missing one is silent: `max-width: 100%` becomes `max-width: 100`, an invalid - // length that WebKit drops, and images then overflow the web view instead of - // being constrained to its width. + // The CSS constants that carry substitutions are rendered with `String(format:)`, + // which consumes a bare `%`. A literal percent sign therefore has to be written as + // `%%` in those constants. Missing one is silent: `max-width: 100%` becomes + // `max-width: 100`, an invalid length that WebKit drops, and images then overflow + // the web view instead of being constrained to its width. #expect(css.contains("max-width: 100%")) #expect(css.contains("max-height: 100%")) #expect(css.contains("width:100%;")) @@ -96,34 +96,36 @@ struct RichTextAllTests { #expect(!css.contains("width:100;")) } - @Test("Percent based CSS constants are escaped for String(format:)") - func percentBasedConstantsAreEscaped() { - let formattedConstants = [ - RichTextConstants.imageCSS, - RichTextConstants.textCSS, - RichTextConstants.iframeCSS - ] - - for constant in formattedConstants { - // Walk the constant and make sure no `%` is followed by something that - // `String(format:)` would treat as a conversion other than the intended - // `%%`, `%@` and `%d`. - let characters = Array(constant) - var index = 0 - - while index < characters.count { - guard characters[index] == "%" else { - index += 1 - continue - } - - let next = index + 1 < characters.count ? characters[index + 1] : nil - #expect( - next == "%" || next == "@" || next == "d", - "Unescaped percent sign in CSS constant: \(constant)" - ) - index += 2 + @Test("Percent based CSS constants are escaped for String(format:)", arguments: [ + ("imageCSS", RichTextConstants.imageCSS), + ("textCSS", RichTextConstants.textCSS), + ("iframeCSS", RichTextConstants.iframeCSS), + ("linkCSS", RichTextConstants.linkCSS), + ("cssTemplate", RichTextConstants.cssTemplate), + ("mediaCSSTemplate", RichTextConstants.mediaCSSTemplate), + ("htmlTemplate", RichTextConstants.htmlTemplate) + ]) + func percentBasedConstantsAreEscaped(name: String, constant: String) { + // Covers every constant that `Configuration.css(isLight:alignment:)`, + // `WebView.generateCSS()` and `WebView.generateHTML()` pass to `String(format:)`. + // + // Walk the constant and make sure no `%` is followed by something other than the + // intended `%%`, `%@` and `%d` conversions. + let characters = Array(constant) + var index = 0 + + while index < characters.count { + guard characters[index] == "%" else { + index += 1 + continue } + + let next = index + 1 < characters.count ? characters[index + 1] : nil + #expect( + next == "%" || next == "@" || next == "d", + "Unescaped percent sign in \(name): a literal % must be written as %%" + ) + index += 2 } } } From c5622e22ccf47f8b385f858b6b7057b3e710d03d Mon Sep 17 00:00:00 2001 From: NuPlay <73557895+NuPlay@users.noreply.github.com> Date: Sat, 29 Aug 2026 13:23:01 +0900 Subject: [PATCH 3/3] test: check the shape of the bug instead of whitelisting conversions Addresses the second round of Copilot review feedback on #81. The scanner required every `%` to be followed by `%`, `@` or `d`. That pins the conversion vocabulary down as a side effect: `iframeCSS` passes a Swift `Int` to `%d`, which reads 32 bits of a 64-bit argument, and widening it to `%ld` would have been rejected by this test even though it is the more correct spelling. Test the failure mode instead. A literal percent sign in CSS is always followed by a delimiter (`100%;`, `100% }`, `100%,`), while a conversion is followed by a specifier or a length modifier, so flagging the delimiters catches a missing `%%` without constraining which conversions are allowed. Verified against all seven formatted constants: they pass, the pre-#76 `imageCSS` still reports its three unescaped percent signs, and `%d`, `%ld`, `%s` and `%@` are all accepted. --- .../RichTextSwiftTestingTests.swift | 20 +++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/Tests/RichTextTests/RichTextSwiftTestingTests.swift b/Tests/RichTextTests/RichTextSwiftTestingTests.swift index e0e6297..99421ae 100644 --- a/Tests/RichTextTests/RichTextSwiftTestingTests.swift +++ b/Tests/RichTextTests/RichTextSwiftTestingTests.swift @@ -109,8 +109,13 @@ struct RichTextAllTests { // Covers every constant that `Configuration.css(isLight:alignment:)`, // `WebView.generateCSS()` and `WebView.generateHTML()` pass to `String(format:)`. // - // Walk the constant and make sure no `%` is followed by something other than the - // intended `%%`, `%@` and `%d` conversions. + // Look for the shape the bug actually takes rather than whitelisting conversion + // characters. A literal percent sign in CSS is always followed by a delimiter - + // `100%;`, `100% }`, `100%,` - while a conversion is followed by a specifier or a + // length modifier. Checking the delimiters catches a missing `%%` without pinning + // down which conversions the constants are allowed to use, so `%d` can still be + // widened to `%ld` later without this test standing in the way. + let cssDelimiters: Set = [";", "}", ",", ")", " ", "\n", "\t"] let characters = Array(constant) var index = 0 @@ -121,11 +126,18 @@ struct RichTextAllTests { } let next = index + 1 < characters.count ? characters[index + 1] : nil + + // `%%` is an escaped literal percent, which is exactly what we want to see. + if next == "%" { + index += 2 + continue + } + #expect( - next == "%" || next == "@" || next == "d", + next != nil && !cssDelimiters.contains(next!), "Unescaped percent sign in \(name): a literal % must be written as %%" ) - index += 2 + index += 1 } } }