Skip to content

Commit d9a6be9

Browse files
authored
fix(ios): typography audit rejects localized accessibility labels (#106092)
* test(ios): exempt accessibility metadata from typography audit * test(ios): cover adjacent visual text audit * test(ios): tighten accessibility typography exemption * test(ios): preserve strict accessibility typography audit * test(ios): handle multiline accessibility closures * test(ios): classify accessibility typography per line * test(ios): parse nested accessibility metadata
1 parent 79cc3c3 commit d9a6be9

1 file changed

Lines changed: 219 additions & 2 deletions

File tree

apps/ios/Tests/OpenClawTypographyTests.swift

Lines changed: 219 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -355,6 +355,55 @@ struct OpenClawTypographyTests {
355355
#expect(offenders.isEmpty, Comment(rawValue: offenders.joined(separator: "\n")))
356356
}
357357

358+
@Test func `accessibility metadata text does not require visual typography`() throws {
359+
let accessibilityTextSamples = [
360+
".accessibilityLabel(Text(title))",
361+
"Image(systemName: \"circle\").accessibilityLabel(Text(title))",
362+
".accessibilityLabel(Text(title)).accessibilityIdentifier(\"status\")",
363+
"Image(systemName: \"circle\").accessibilityLabel(Text(title)).accessibilityHint(Text(hint))",
364+
".accessibilityLabel(Text(self.statusLabel ?? LocalizedStringResource(\"Allowed\")))",
365+
".accessibilityLabel(Text(title))\n)",
366+
".accessibilityLabel(\n Text(title)\n)",
367+
"Image(systemName: \"circle\")\n .accessibilityLabel(\n Text(title)\n )",
368+
".accessibilityValue(\n Text(value))",
369+
".accessibilityHint(\n\n Text(hint))",
370+
]
371+
for source in accessibilityTextSamples {
372+
let lines = source.components(separatedBy: .newlines)
373+
let idx = try #require(lines.firstIndex { Self.isTextOrLabelCall($0) })
374+
#expect(Self.isAccessibilityMetadataTextCall(at: idx, in: lines))
375+
}
376+
377+
#expect(!Self.isAccessibilityMetadataTextCall(at: 0, in: ["Text(title)"]))
378+
#expect(!Self.isAccessibilityMetadataTextCall(at: 0, in: [".accessibilityLabel(title)"]))
379+
380+
let nearbyVisualText = [
381+
".accessibilityLabel(Text(title))",
382+
"Text(body)",
383+
]
384+
#expect(!Self.isAccessibilityMetadataTextCall(at: 1, in: nearbyVisualText))
385+
386+
let sameLineVisualLabel = [
387+
"Label(\"Status\", systemImage: \"circle\").accessibilityLabel(Text(status))",
388+
]
389+
#expect(!Self.isAccessibilityMetadataTextCall(at: 0, in: sameLineVisualLabel))
390+
391+
let trailingVisualText = [".accessibilityLabel(Text(status)); Text(body)"]
392+
#expect(!Self.isAccessibilityMetadataTextCall(at: 0, in: trailingVisualText))
393+
394+
let modifierTextInString = [
395+
"let sample = \".accessibilityLabel(\"",
396+
"Text(body)",
397+
]
398+
#expect(!Self.isAccessibilityMetadataTextCall(at: 1, in: modifierTextInString))
399+
400+
let modifierTextInComment = [
401+
"// Example: .accessibilityLabel(",
402+
"Text(body)",
403+
]
404+
#expect(!Self.isAccessibilityMetadataTextCall(at: 1, in: modifierTextInComment))
405+
}
406+
358407
@Test func `secure fields do not use platform placeholder text`() throws {
359408
let offenders = try Self.swiftSourcesForTypographyAudit().flatMap { url -> [String] in
360409
let source = try String(contentsOf: url, encoding: .utf8)
@@ -440,10 +489,11 @@ struct OpenClawTypographyTests {
440489
private static func unbrandedTextCallOffenders() throws -> [String] {
441490
let fontTokens = ["OpenClawType", "OpenClawChatTypography"]
442491
// Accessibility-only Text is spoken, never rendered, so no branded font applies.
443-
let allowedFragments = [".navigationTitle(", ".alert(\"", ".tabItem { Label(", ".accessibilityLabel(Text("]
492+
let allowedFragments = [".navigationTitle(", ".alert(\"", ".tabItem { Label("]
444493
return try self.swiftSourcesForTypographyAudit().flatMap { url -> [String] in
445494
let source = try String(contentsOf: url, encoding: .utf8)
446495
let lines = source.components(separatedBy: .newlines)
496+
let accessibilityMetadataTextLines = self.accessibilityMetadataTextLines(in: lines)
447497
return lines.indices.compactMap { idx -> String? in
448498
let rawLine = lines[idx]
449499
let line = rawLine.trimmingCharacters(in: .whitespaces)
@@ -454,7 +504,10 @@ struct OpenClawTypographyTests {
454504
let hasLocalFont = fontTokens.contains { window.contains($0) }
455505
|| self.hasAllowedBrandedFontParameter(window, line: rawLine, in: url)
456506

457-
if self.isTextOrLabelCall(rawLine), !hasLocalFont {
507+
if self.isTextOrLabelCall(rawLine),
508+
!accessibilityMetadataTextLines.contains(idx),
509+
!hasLocalFont
510+
{
458511
return "\(self.relativePath(url)):\(idx + 1): \(line)"
459512
}
460513

@@ -471,6 +524,170 @@ struct OpenClawTypographyTests {
471524
line.range(of: #"\b(Text|Label)\s*\("#, options: .regularExpression) != nil
472525
}
473526

527+
private static func isAccessibilityMetadataTextCall(at idx: Int, in lines: [String]) -> Bool {
528+
self.accessibilityMetadataTextLines(in: lines).contains(idx)
529+
}
530+
531+
private static func accessibilityMetadataTextLines(in lines: [String]) -> Set<Int> {
532+
let code = self.maskedSwiftCode(lines.joined(separator: "\n"))
533+
let textToken = Array("Text".utf8)
534+
let labelToken = Array("Label".utf8)
535+
let modifiers = [
536+
Array(".accessibilityLabel".utf8),
537+
Array(".accessibilityValue".utf8),
538+
Array(".accessibilityHint".utf8),
539+
]
540+
var callCounts: [Int: Int] = [:]
541+
var metadataCallCounts: [Int: Int] = [:]
542+
var line = 0
543+
544+
for offset in code.indices {
545+
if code[offset] == 10 {
546+
line += 1
547+
continue
548+
}
549+
if self.callOpeningParenthesis(after: textToken, at: offset, in: code) != nil ||
550+
self.callOpeningParenthesis(after: labelToken, at: offset, in: code) != nil
551+
{
552+
callCounts[line, default: 0] += 1
553+
}
554+
555+
for modifier in modifiers {
556+
guard let modifierOpen = self.callOpeningParenthesis(after: modifier, at: offset, in: code)
557+
else { continue }
558+
let textOffset = self.skippingWhitespace(in: code, after: modifierOpen)
559+
guard let textOpen = self.callOpeningParenthesis(after: textToken, at: textOffset, in: code),
560+
let textClose = self.matchingParenthesis(in: code, openingAt: textOpen)
561+
else { continue }
562+
let metadataClose = self.skippingWhitespace(in: code, after: textClose)
563+
guard metadataClose < code.count, code[metadataClose] == 41 else { continue }
564+
565+
// Exempt only the direct Text argument; visible calls sharing its source line stay audited.
566+
let textLine = line + code[offset..<textOffset].count(where: { $0 == 10 })
567+
metadataCallCounts[textLine, default: 0] += 1
568+
}
569+
}
570+
571+
return Set(metadataCallCounts.compactMap { line, count in
572+
callCounts[line] == count ? line : nil
573+
})
574+
}
575+
576+
private static func callOpeningParenthesis(
577+
after token: [UInt8],
578+
at offset: Int,
579+
in code: [UInt8]) -> Int?
580+
{
581+
guard offset + token.count <= code.count,
582+
code[offset..<(offset + token.count)].elementsEqual(token),
583+
token.first == 46 || offset == 0 || !self.isSwiftIdentifierByte(code[offset - 1])
584+
else { return nil }
585+
let afterToken = offset + token.count
586+
guard afterToken == code.count || !self.isSwiftIdentifierByte(code[afterToken]) else { return nil }
587+
let opening = self.skippingWhitespace(in: code, after: afterToken - 1)
588+
return opening < code.count && code[opening] == 40 ? opening : nil
589+
}
590+
591+
private static func skippingWhitespace(in code: [UInt8], after offset: Int) -> Int {
592+
var cursor = offset + 1
593+
while cursor < code.count, code[cursor] == 9 || code[cursor] == 10 || code[cursor] == 13 || code[cursor] == 32 {
594+
cursor += 1
595+
}
596+
return cursor
597+
}
598+
599+
private static func matchingParenthesis(in code: [UInt8], openingAt offset: Int) -> Int? {
600+
var depth = 0
601+
for cursor in offset..<code.count {
602+
if code[cursor] == 40 {
603+
depth += 1
604+
} else if code[cursor] == 41 {
605+
depth -= 1
606+
if depth == 0 { return cursor }
607+
}
608+
}
609+
return nil
610+
}
611+
612+
private static func isSwiftIdentifierByte(_ byte: UInt8) -> Bool {
613+
byte == 95 || (48...57).contains(byte) || (65...90).contains(byte) || (97...122).contains(byte) || byte >= 128
614+
}
615+
616+
private static func maskedSwiftCode(_ source: String) -> [UInt8] {
617+
let sourceBytes = Array(source.utf8)
618+
var code = sourceBytes
619+
var cursor = 0
620+
621+
func mask(_ range: Range<Int>) {
622+
for offset in range where code[offset] != 10 && code[offset] != 13 {
623+
code[offset] = 32
624+
}
625+
}
626+
627+
while cursor < sourceBytes.count {
628+
if cursor + 1 < sourceBytes.count, sourceBytes[cursor] == 47, sourceBytes[cursor + 1] == 47 {
629+
let start = cursor
630+
while cursor < sourceBytes.count, sourceBytes[cursor] != 10 {
631+
cursor += 1
632+
}
633+
mask(start..<cursor)
634+
continue
635+
}
636+
if cursor + 1 < sourceBytes.count, sourceBytes[cursor] == 47, sourceBytes[cursor + 1] == 42 {
637+
let start = cursor
638+
var depth = 1
639+
cursor += 2
640+
while cursor < sourceBytes.count, depth > 0 {
641+
if cursor + 1 < sourceBytes.count, sourceBytes[cursor] == 47, sourceBytes[cursor + 1] == 42 {
642+
depth += 1
643+
cursor += 2
644+
} else if cursor + 1 < sourceBytes.count,
645+
sourceBytes[cursor] == 42,
646+
sourceBytes[cursor + 1] == 47
647+
{
648+
depth -= 1
649+
cursor += 2
650+
} else {
651+
cursor += 1
652+
}
653+
}
654+
mask(start..<cursor)
655+
continue
656+
}
657+
658+
let start = cursor
659+
var hashCount = 0
660+
while cursor < sourceBytes.count, sourceBytes[cursor] == 35 {
661+
hashCount += 1
662+
cursor += 1
663+
}
664+
guard cursor < sourceBytes.count, sourceBytes[cursor] == 34 else {
665+
cursor = start + 1
666+
continue
667+
}
668+
let quoteCount = cursor + 2 < sourceBytes.count &&
669+
sourceBytes[cursor + 1] == 34 && sourceBytes[cursor + 2] == 34 ? 3 : 1
670+
cursor += quoteCount
671+
while cursor < sourceBytes.count {
672+
if hashCount == 0, quoteCount == 1, sourceBytes[cursor] == 92 {
673+
cursor = min(sourceBytes.count, cursor + 2)
674+
continue
675+
}
676+
let closingEnd = cursor + quoteCount + hashCount
677+
if closingEnd <= sourceBytes.count,
678+
sourceBytes[cursor..<(cursor + quoteCount)].allSatisfy({ $0 == 34 }),
679+
sourceBytes[(cursor + quoteCount)..<closingEnd].allSatisfy({ $0 == 35 })
680+
{
681+
cursor = closingEnd
682+
break
683+
}
684+
cursor += 1
685+
}
686+
mask(start..<cursor)
687+
}
688+
return code
689+
}
690+
474691
private static func isShorthandControlCall(_ line: String) -> Bool {
475692
line.range(
476693
of: #"\b(Button|Link|Picker|Toggle|TextField|SecureField|Menu|DisclosureGroup|LabeledContent)\s*\(""#,

0 commit comments

Comments
 (0)