From 2bf543e6485d004a4f46d02aa702d94d566f9fb7 Mon Sep 17 00:00:00 2001 From: Senad Date: Fri, 2 Oct 2026 18:56:33 +0200 Subject: [PATCH 1/4] Leave sign-in and verification fields alone Cotabby offered completions in Google's "Email or phone" box: a guess at the user's identity, one Tab away from being typed into a login form. Browsers mark only passwords as secure, so password fields were already skipped but email, username, phone and code fields were not. CredentialFieldDetector (pure) blocks single-line fields whose label (title, description, placeholder) or DOM id names a sign-in or verification input, and any single-line field whose whole value is an email address. Multi-line fields are never blocked, so an email body still gets completions. The resolver makes the four attribute reads only for text fields and combo boxes. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LptEE4YbaWL55YwQRnTt74 --- Cotabby.xcodeproj/project.pbxproj | 10 +++ .../Resolution/FocusSnapshotResolver.swift | 15 ++++ .../CredentialFieldDetector.swift | 83 +++++++++++++++++++ .../CredentialFieldDetectorTests.swift | 47 +++++++++++ 4 files changed, 155 insertions(+) create mode 100644 Cotabby/Support/Accessibility/CredentialFieldDetector.swift create mode 100644 CotabbyTests/Support/Accessibility/CredentialFieldDetectorTests.swift diff --git a/Cotabby.xcodeproj/project.pbxproj b/Cotabby.xcodeproj/project.pbxproj index ddd90ac8..a3d34f4a 100644 --- a/Cotabby.xcodeproj/project.pbxproj +++ b/Cotabby.xcodeproj/project.pbxproj @@ -161,6 +161,7 @@ 257302D78C5AE9951C63FCEE /* SuggestionAnchorCacheTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE7DB9EE77511823EDA7B52E /* SuggestionAnchorCacheTests.swift */; }; 25F7E6EC713F8F71DEEEAAA3 /* SystemUIFocusShadowPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2EC384A90F3D8B71584B3449 /* SystemUIFocusShadowPolicy.swift */; }; 26067524E60D738791E983CD /* SOURCES.md in Resources */ = {isa = PBXBuildFile; fileRef = 054987E76CA9D1FA4F81EA8F /* SOURCES.md */; }; + 263CF31EDC4FAD8041831291 /* CredentialFieldDetector.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8D9E5C3F2354CC40C0B2051B /* CredentialFieldDetector.swift */; }; 26EA96EB13B94A68276FA15E /* MenuBarRecoveryPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E267F3BB7FC8DEAEB5E841B /* MenuBarRecoveryPolicy.swift */; }; 2740742B866BC12043B81268 /* TypefaceEvidence.swift in Sources */ = {isa = PBXBuildFile; fileRef = B616CB46A8624BC4E4FBB01A /* TypefaceEvidence.swift */; }; 27A09D81E47FA601F279EF11 /* FocusCapabilityResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56B8D2232F271197468CBC11 /* FocusCapabilityResolver.swift */; }; @@ -595,6 +596,7 @@ 998168DC04A6A13D7D1F3165 /* ModelDownloadManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03CA4BBA3C54F840546033E0 /* ModelDownloadManagerTests.swift */; }; 9A2D50EF4911E45EEB4556D6 /* Aria2OutputParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83457CCF1A50CE83428C363D /* Aria2OutputParser.swift */; }; 9A55EDAF0F5D5127A39351C5 /* ContextBufferNavigationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 024DDE8C1CE9BF00DE055990 /* ContextBufferNavigationTests.swift */; }; + 9AAD623DEBAD8AF488C37818 /* CredentialFieldDetector.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8D9E5C3F2354CC40C0B2051B /* CredentialFieldDetector.swift */; }; 9AE3398FB0E4696C89550C04 /* EmojiMatcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA8311FAC345FE431FA89855 /* EmojiMatcher.swift */; }; 9B6C176547D2B6D118572E41 /* BaseCompletionPromptRenderer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C1AE4120FA68524700C9324 /* BaseCompletionPromptRenderer.swift */; }; 9B7FE4C9ED6959A6D5181EF5 /* EngineAndModelPaneView+Endpoint.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A184538FD926947EBB88D9E /* EngineAndModelPaneView+Endpoint.swift */; }; @@ -971,6 +973,7 @@ FCD81796FE4DC55778D57686 /* ConfidenceSuppressionPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 122298AE151ECEEC175878BF /* ConfidenceSuppressionPolicy.swift */; }; FCEE05402A708C33F9719D7F /* OpenAICompatibleSuggestionEngineTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CE9156494BFC0A1E12E0B6C /* OpenAICompatibleSuggestionEngineTests.swift */; }; FDA59446E91261744C6DDFDA /* TypingCadenceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEA3558520A71033BBF30879 /* TypingCadenceTests.swift */; }; + FDE4A159994BDD6605AFD9E9 /* CredentialFieldDetectorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 365AE69E4E1A3DD3486D203A /* CredentialFieldDetectorTests.swift */; }; FDF71F83A24FBE17F5B63C68 /* ApplicationBundleMetadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD1E28CF46BF59ABDC3056BF /* ApplicationBundleMetadata.swift */; }; FE0922970524121DEC4EF2D9 /* OpenAICompatibleEndpointModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1ABCE733783332BE52E43D67 /* OpenAICompatibleEndpointModels.swift */; }; FE4F4A0778E7D2ABC9EEC155 /* EngineAndModelPaneView+Power.swift in Sources */ = {isa = PBXBuildFile; fileRef = DF5872EC7795CC1EFF6D0D04 /* EngineAndModelPaneView+Power.swift */; }; @@ -1124,6 +1127,7 @@ 353191D1D8A1C655E1B5F562 /* CapturedInputEventTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CapturedInputEventTests.swift; sourceTree = ""; }; 35AA2C8F42B510F013D86C3C /* InsertedTextAdvanceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InsertedTextAdvanceTests.swift; sourceTree = ""; }; 35C0B587D81D87ACB952C95E /* SuggestionSettingsStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionSettingsStoreTests.swift; sourceTree = ""; }; + 365AE69E4E1A3DD3486D203A /* CredentialFieldDetectorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CredentialFieldDetectorTests.swift; sourceTree = ""; }; 36652DB88C5948AA4A31524A /* ModelFileValidator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModelFileValidator.swift; sourceTree = ""; }; 3680E1B8FA712A888F509640 /* ClipboardContentDistillerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClipboardContentDistillerTests.swift; sourceTree = ""; }; 377A0BBB59988043005A138A /* FoundationModelSuggestionEngineTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FoundationModelSuggestionEngineTests.swift; sourceTree = ""; }; @@ -1339,6 +1343,7 @@ 8BE5F414704A8264C2946A50 /* TypoGateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TypoGateTests.swift; sourceTree = ""; }; 8C151BF4D39485E5CFACFECB /* ApplicationBundleMetadataTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApplicationBundleMetadataTests.swift; sourceTree = ""; }; 8D881FED12A85FFC20F2C9D7 /* DisplayCoordinateConverterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DisplayCoordinateConverterTests.swift; sourceTree = ""; }; + 8D9E5C3F2354CC40C0B2051B /* CredentialFieldDetector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CredentialFieldDetector.swift; sourceTree = ""; }; 8DAD5637347E83DDCF515568 /* SuggestionCoordinator+HostMarkedText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SuggestionCoordinator+HostMarkedText.swift"; sourceTree = ""; }; 8E542E57459488F3D39A9053 /* PhrasePredictionScoringTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhrasePredictionScoringTests.swift; sourceTree = ""; }; 8E89746E8CE7E9487337EE6F /* InsertionSafetyGate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InsertionSafetyGate.swift; sourceTree = ""; }; @@ -2419,6 +2424,7 @@ 5B5D1A7D938F633C6EE094F7 /* AXHelper.swift */, 82420F9505E69AE2ADE9F583 /* BlockBreakAlignment.swift */, 3FE7D19D22434E3E24804999 /* CalendarAccessibilityCapturePolicy.swift */, + 8D9E5C3F2354CC40C0B2051B /* CredentialFieldDetector.swift */, 47F1A6BCCA20EBE7314B79D3 /* MailHeaderFieldDetector.swift */, E286B9A912808088FDA6B4C4 /* PermissionOverlayTracker.swift */, 1EE99C84483D3A58D561C61D /* SecureFieldDetector.swift */, @@ -2621,6 +2627,7 @@ 6E2AA5BD865E0BCFB53DC699 /* AXHelperTests.swift */, D681DDF8E81F868C1BC92CB4 /* BlockBreakAlignmentTests.swift */, 863B5A1DC3C4B9668F620245 /* CalendarAccessibilityCapturePolicyTests.swift */, + 365AE69E4E1A3DD3486D203A /* CredentialFieldDetectorTests.swift */, B68F9EDFE2903996F0E5524E /* MailHeaderFieldDetectorTests.swift */, AD003D4EBE530DC0E2B87C24 /* PermissionOverlayTrackerTests.swift */, B8EBC9F1890DD2FFC4884A5C /* SecureFieldDetectorTests.swift */, @@ -3892,6 +3899,7 @@ AF55F1ABEDEA10C76C307CEC /* CotabbyAppEnvironment.swift in Sources */, 4E7F611941736F526C3B9C1B /* CotabbyBrand.swift in Sources */, A5D76116479357C29E2D8405 /* CotabbyDebugOptions.swift in Sources */, + 9AAD623DEBAD8AF488C37818 /* CredentialFieldDetector.swift in Sources */, B803D0491F5CF19735F23B65 /* CurrencyEvaluator.swift in Sources */, 81870F2D46C12CA1E6B19523 /* CurrentWordExtractor.swift in Sources */, 378EE9C111040353A6335454 /* CurrentWordSpellChecker.swift in Sources */, @@ -4219,6 +4227,7 @@ FCC571EC239846F06007BFCA /* CotabbyAppEnvironment.swift in Sources */, 085BB87581DFFA260A630E24 /* CotabbyBrand.swift in Sources */, 7A31E6395C535FF017A1EFE1 /* CotabbyDebugOptions.swift in Sources */, + 263CF31EDC4FAD8041831291 /* CredentialFieldDetector.swift in Sources */, 1F39EE1D5FA0F5D32AFFB028 /* CurrencyEvaluator.swift in Sources */, EA353CCECBFB4D297C865447 /* CurrentWordExtractor.swift in Sources */, C56ABA04AE27A9943368035C /* CurrentWordSpellChecker.swift in Sources */, @@ -4533,6 +4542,7 @@ 57BCDFE786675C9793F3E08E /* ControlTokenMarkersTests.swift in Sources */, F8D1C3FD1A1ACAE87D885D29 /* CotabbyDebugOptionsTests.swift in Sources */, 65D20F8E6309CED34A638D35 /* CotabbyTestFixtures.swift in Sources */, + FDE4A159994BDD6605AFD9E9 /* CredentialFieldDetectorTests.swift in Sources */, 15BE5127E4BE29F6CBEEAA0E /* CurrencyEvaluatorTests.swift in Sources */, 99334CDC1399D03019202E85 /* CurrentWordExtractorTests.swift in Sources */, 81073963BC57B5CA9151B0EC /* CustomRulesTests.swift in Sources */, diff --git a/Cotabby/Services/Focus/Resolution/FocusSnapshotResolver.swift b/Cotabby/Services/Focus/Resolution/FocusSnapshotResolver.swift index 63f5c035..bd5c07b4 100644 --- a/Cotabby/Services/Focus/Resolution/FocusSnapshotResolver.swift +++ b/Cotabby/Services/Focus/Resolution/FocusSnapshotResolver.swift @@ -378,6 +378,21 @@ struct FocusSnapshotResolver { return MailHeaderFieldDetector.blockedReason } + // Email, username, phone and code boxes: four attribute reads, only for single-line fields. + if CredentialFieldDetector.mightBeCredentialField(role: candidate.role), + CredentialFieldDetector.isCredentialField( + role: candidate.role, + labels: [ + AXHelper.stringValue(for: kAXTitleAttribute as CFString, on: candidate.element), + AXHelper.stringValue(for: kAXDescriptionAttribute as CFString, on: candidate.element), + AXHelper.stringValue(for: kAXPlaceholderValueAttribute as CFString, on: candidate.element) + ], + domIdentifier: AXHelper.stringValue(for: "AXDOMIdentifier" as CFString, on: candidate.element), + text: candidate.textValue + ) { + return CredentialFieldDetector.blockedReason + } + guard selection.length > 0 else { return nil } if BrowserAppDetector.isChromiumBrowser(bundleIdentifier: bundleIdentifier) { CotabbyLogger.focus.debug( diff --git a/Cotabby/Support/Accessibility/CredentialFieldDetector.swift b/Cotabby/Support/Accessibility/CredentialFieldDetector.swift new file mode 100644 index 00000000..e3a186f2 --- /dev/null +++ b/Cotabby/Support/Accessibility/CredentialFieldDetector.swift @@ -0,0 +1,83 @@ +import ApplicationServices +import Foundation + +/// File overview: +/// Recognizes sign-in and verification fields (email, username, phone, one-time codes, card +/// numbers) where Cotabby stands down, alongside password fields, which arrive as secure fields +/// and are already blocked by the resolver. +/// +/// Why: a completion in "Email or phone" guesses at the user's identity. Accepting one types a +/// wrong address into a login form, and showing one paints a guessed address beside the real one. +/// Nothing in such a field is prose a writer wants continued. Browsers mark only passwords as +/// secure, so these fields are recognized from what the page says about them: its label (title, +/// description, placeholder) and its DOM id, plus the typed text itself looking like an address. +/// +/// Scope: single-line fields only (text fields and combo boxes). A multi-line field labelled +/// "email" is an email *body*, which is exactly where completions belong. Pure: the resolver reads +/// the attributes and asks here. +enum CredentialFieldDetector { + static let blockedReason = "Sign-in and verification fields are left alone." + + /// Words in a field's label that name a credential or verification input, matched as whole + /// words in the lowercased label ("pin" matches "Enter PIN", not "shipping"). + static let labelKeywords: [String] = [ + "email", "e-mail", "username", "user name", "user id", "userid", "login", "log in", "sign in", + "phone", "mobile number", "password", "passcode", "pin", "one-time", "one time code", + "verification code", "security code", "otp", "2fa", "two-factor", "authentication code", + "card number", "cvc", "cvv", "expiry", "expiration" + ] + + /// DOM ids used by common sign-in forms (Google's `identifierId`, and the generic names). + static let identifierKeywords: [String] = [ + "identifierid", "username", "userid", "email", "login", "passwd", "password", "otp", "totp", "phone" + ] + + /// Cheap pre-check so labels are only fetched for single-line fields. + static func mightBeCredentialField(role: String) -> Bool { + role == kAXTextFieldRole as String || role == kAXComboBoxRole as String + } + + static func isCredentialField( + role: String, + labels: [String?], + domIdentifier: String?, + text: String? + ) -> Bool { + guard mightBeCredentialField(role: role) else { return false } + + for label in labels.compactMap({ $0?.lowercased() }) where !label.isEmpty { + if labelKeywords.contains(where: { containsWord($0, in: label) }) { + return true + } + } + + if let id = domIdentifier?.lowercased(), !id.isEmpty, + identifierKeywords.contains(where: { id.contains($0) }) { + return true + } + + return looksLikeEmailAddress(text) + } + + /// The whole value is one address-shaped token ("name@domain.tld"), as typed into a login box. + static func looksLikeEmailAddress(_ text: String?) -> Bool { + guard let text = text?.trimmingCharacters(in: .whitespacesAndNewlines), !text.isEmpty else { return false } + return text.range(of: #"^[^\s@]+@[^\s@]+\.[^\s@]+$"#, options: .regularExpression) != nil + } + + /// `keyword` appears in `label` with no letter or digit directly before or after it, so "pin" + /// matches "Enter PIN" but not "shipping". + private static func containsWord(_ keyword: String, in label: String) -> Bool { + var searchRange = label.startIndex.. Bool = { $0.map { !$0.isLetter && !$0.isNumber } ?? true } + if isBoundary(before) && isBoundary(after) { + return true + } + searchRange = found.upperBound.. Date: Sun, 4 Oct 2026 19:43:29 -0400 Subject: [PATCH 2/4] Match sign-in ids by whole parts and catch addresses mid-typing The DOM-id check matched substrings, so "phonebook-search" and "emailSearch" were blocked like sign-in boxes. An id now names a credential only when the whole id splits into sign-in vocabulary ("user_email", "loginEmail", "phonenumber", "identifierId") with at least one part that names a credential on its own. Labels that describe writing about email ("Email subject", "Email preview text", "Search email") no longer count as credential labels. The typed-text check now catches an address before it is finished ("alice@"), so an unlabelled login box does not get a guessed domain while the user types theirs. A slash or colon before the "@" marks a URL, not an address. Co-Authored-By: Claude Opus 5.5 --- ARCHITECTURE.md | 4 +- .../CredentialFieldDetector.swift | 93 ++++++++++++++++--- .../CredentialFieldDetectorTests.swift | 58 +++++++++++- 3 files changed, 139 insertions(+), 16 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 8dd17af5..52278b98 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -201,7 +201,9 @@ event payloads as complete field state. FocusSnapshotResolver finds a usable editable candidate, blocks secure/unsupported surfaces (and Mail's compose header rows, [MailHeaderFieldDetector.swift](Cotabby/Support/Accessibility/MailHeaderFieldDetector.swift): -Tab is the way from To to Subject to body there, not an accept), bounds +Tab is the way from To to Subject to body there, not an accept; and single-line sign-in and +verification fields, [CredentialFieldDetector.swift](Cotabby/Support/Accessibility/CredentialFieldDetector.swift): +a completion there is a guess at the user's identity), bounds text on both sides of the caret, resolves the focused process, and publishes stable domain values. Chromium/Electron require accessibility priming, cursor hit-test recovery, and out-of-process iframe handling. All fallbacks are revalidated and yield to a valid system-focused element. diff --git a/Cotabby/Support/Accessibility/CredentialFieldDetector.swift b/Cotabby/Support/Accessibility/CredentialFieldDetector.swift index e3a186f2..4bfc8c4e 100644 --- a/Cotabby/Support/Accessibility/CredentialFieldDetector.swift +++ b/Cotabby/Support/Accessibility/CredentialFieldDetector.swift @@ -14,7 +14,11 @@ import Foundation /// /// Scope: single-line fields only (text fields and combo boxes). A multi-line field labelled /// "email" is an email *body*, which is exactly where completions belong. Pure: the resolver reads -/// the attributes and asks here. +/// the attributes (once per focus session) and asks here on every poll. +/// +/// Every signal is matched by whole words or whole id parts, never by raw substring. Blocking +/// costs the writer their completions, so "phonebook-search", "emailSearch" and "Email subject" +/// must stay ordinary fields even though they contain a credential word. enum CredentialFieldDetector { static let blockedReason = "Sign-in and verification fields are left alone." @@ -27,11 +31,34 @@ enum CredentialFieldDetector { "card number", "cvc", "cvv", "expiry", "expiration" ] - /// DOM ids used by common sign-in forms (Google's `identifierId`, and the generic names). - static let identifierKeywords: [String] = [ - "identifierid", "username", "userid", "email", "login", "passwd", "password", "otp", "totp", "phone" + /// Words that make a label describe writing *about* email or phone rather than an identity + /// input: "Email subject", "Email preview text", "Search email". Such a label is not a + /// credential label even though it contains a keyword. Whole words, like the keywords. + static let proseLabelWords: [String] = [ + "subject", "message", "body", "preview", "title", "signature", "template", + "comment", "note", "notes", "reply", "search" + ] + + /// Id parts that name a credential on their own. A few compounds whose pieces are only + /// qualifiers ("identifierId" is Google's sign-in box, "onetimecode") are listed whole. + static let credentialIdentifierWords: [String] = [ + "email", "mail", "username", "userid", "login", "logon", "signin", "passwd", "password", "pwd", + "passcode", "otp", "totp", "mfa", "2fa", "phone", "telephone", "tel", "mobile", "msisdn", "pin", + "cvc", "cvv", "cardnumber", "identifierid", "onetimecode", "verificationcode", "securitycode", "authcode" + ] + + /// Id parts that commonly sit next to a credential word ("user_email", "txtPassword", + /// "phoneNumber", "login_field") but name nothing on their own. + static let qualifierIdentifierWords: [String] = [ + "user", "account", "address", "addr", "id", "identifier", "name", "number", "num", "no", "code", + "field", "input", "txt", "tb", "verification", "verify", "security", "auth", "onetime", "confirm", + "current", "new", "primary", "your", "enter" ] + /// Ids longer than this are generated names (framework hashes, long paths), not a sign-in + /// field's name, and are not worth segmenting on every poll. + static let maximumIdentifierLength = 64 + /// Cheap pre-check so labels are only fetched for single-line fields. static func mightBeCredentialField(role: String) -> Bool { role == kAXTextFieldRole as String || role == kAXComboBoxRole as String @@ -45,26 +72,64 @@ enum CredentialFieldDetector { ) -> Bool { guard mightBeCredentialField(role: role) else { return false } - for label in labels.compactMap({ $0?.lowercased() }) where !label.isEmpty { - if labelKeywords.contains(where: { containsWord($0, in: label) }) { - return true - } + if labels.contains(where: { $0.map(labelNamesCredential) ?? false }) { + return true } - if let id = domIdentifier?.lowercased(), !id.isEmpty, - identifierKeywords.contains(where: { id.contains($0) }) { + if let domIdentifier, domIdentifierNamesCredential(domIdentifier) { return true } - return looksLikeEmailAddress(text) + return looksLikeAddressEntry(text) } - /// The whole value is one address-shaped token ("name@domain.tld"), as typed into a login box. - static func looksLikeEmailAddress(_ text: String?) -> Bool { + /// A label names a credential input when it contains a keyword as a whole word and no word + /// that turns it into a description of prose ("Email" yes, "Email subject" no). + static func labelNamesCredential(_ label: String) -> Bool { + let label = label.lowercased() + guard labelKeywords.contains(where: { containsWord($0, in: label) }) else { return false } + return !proseLabelWords.contains(where: { containsWord($0, in: label) }) + } + + /// A DOM id names a credential input when the whole id splits into known id parts and at least + /// one of them names a credential: "user_email", "loginEmail", "phonenumber" and "identifierId" + /// do; "emailSearch" and "phonebook-search" do not, because "search" and "phonebook" are not + /// sign-in vocabulary. Case, separators and digits are ignored, so "user_email", "userEmail" + /// and "useremail2" read the same; that is also why lowercase run-together ids still match. + static func domIdentifierNamesCredential(_ identifier: String) -> Bool { + let characters = Array(identifier.lowercased().filter { $0.isLetter || $0.isNumber }) + guard !characters.isEmpty, characters.count <= maximumIdentifierLength else { return false } + + // Word-break over the vocabulary. `reach[i]` is nil when no split of the first `i` + // characters exists, false when one exists, and true when one exists that includes a + // credential word. Digits are skipped one at a time (numbered ids such as "email2"). + var reach = [Bool?](repeating: nil, count: characters.count + 1) + reach[0] = false + for start in characters.indices { + guard let namesCredential = reach[start] else { continue } + if characters[start].isNumber { + reach[start + 1] = (reach[start + 1] ?? false) || namesCredential + } + for entry in identifierVocabulary where characters[start...].starts(with: entry.word) { + let end = start + entry.word.count + reach[end] = (reach[end] ?? false) || namesCredential || entry.namesCredential + } + } + return reach[characters.count] == true + } + + /// The whole value is one address-shaped token, finished or still being typed ("alice@", + /// "alice@gmail.com"), as in a login box. Matching the unfinished address matters: a field with + /// no telling label or id would otherwise get a guessed domain while the user types theirs. A + /// slash or colon before the "@" marks a URL ("medium.com/@alice"), not an address. + static func looksLikeAddressEntry(_ text: String?) -> Bool { guard let text = text?.trimmingCharacters(in: .whitespacesAndNewlines), !text.isEmpty else { return false } - return text.range(of: #"^[^\s@]+@[^\s@]+\.[^\s@]+$"#, options: .regularExpression) != nil + return text.range(of: #"^[^\s@/:]+@[^\s@/:]*$"#, options: .regularExpression) != nil } + private static let identifierVocabulary: [(word: [Character], namesCredential: Bool)] = + credentialIdentifierWords.map { (Array($0), true) } + qualifierIdentifierWords.map { (Array($0), false) } + /// `keyword` appears in `label` with no letter or digit directly before or after it, so "pin" /// matches "Enter PIN" but not "shipping". private static func containsWord(_ keyword: String, in label: String) -> Bool { diff --git a/CotabbyTests/Support/Accessibility/CredentialFieldDetectorTests.swift b/CotabbyTests/Support/Accessibility/CredentialFieldDetectorTests.swift index 41299f2b..d0c26705 100644 --- a/CotabbyTests/Support/Accessibility/CredentialFieldDetectorTests.swift +++ b/CotabbyTests/Support/Accessibility/CredentialFieldDetectorTests.swift @@ -23,11 +23,66 @@ final class CredentialFieldDetectorTests: XCTestCase { } } + func test_comboBoxSignInFieldIsBlocked() { + XCTAssertTrue(CredentialFieldDetector.isCredentialField( + role: kAXComboBoxRole as String, labels: ["Email address"], domIdentifier: nil, text: nil + )) + } + + func test_labelsAboutEmailWritingAreNotBlocked() { + for label in ["Email subject", "Email preview text", "Search email", "Message to phone"] { + XCTAssertFalse( + CredentialFieldDetector.isCredentialField(role: textField, labels: [label], domIdentifier: nil, text: nil), + label + ) + } + } + + func test_signInDomIdentifiersAreBlocked() { + let identifiers = [ + "username", "email", "passwd", "otp", "login_field", "user_login", "user_email", "loginEmail", + "txtUserName", "phoneNumber", "phonenumber", "emailaddress", "otp-3", "OTPCode", "one-time-code" + ] + for identifier in identifiers { + XCTAssertTrue(CredentialFieldDetector.domIdentifierNamesCredential(identifier), identifier) + } + } + + func test_domIdentifiersThatOnlyContainACredentialWordAreNotBlocked() { + let identifiers = [ + "phonebook-search", "emailSearch", "email-subject", "userInput", "shipping", "mailbox", "identifier", "q", "" + ] + for identifier in identifiers { + XCTAssertFalse(CredentialFieldDetector.domIdentifierNamesCredential(identifier), identifier) + XCTAssertFalse( + CredentialFieldDetector.isCredentialField( + role: textField, labels: ["Search"], domIdentifier: identifier, text: "hello" + ), + identifier + ) + } + } + func test_typedAddressIsBlockedEvenWithoutLabel() { XCTAssertTrue(CredentialFieldDetector.isCredentialField( role: textField, labels: [], domIdentifier: nil, text: "senad@imperum.io" )) - XCTAssertFalse(CredentialFieldDetector.looksLikeEmailAddress("mail senad@imperum.io today")) + XCTAssertFalse(CredentialFieldDetector.looksLikeAddressEntry("mail senad@imperum.io today")) + } + + func test_addressBeingTypedIsBlockedBeforeItIsComplete() { + // A neutral "Account" box with a neutral id: only the typed text gives it away. + for text in ["alice@", "alice@gm", "alice@gmail.com"] { + XCTAssertTrue( + CredentialFieldDetector.isCredentialField( + role: textField, labels: ["Account"], domIdentifier: "identifier", text: text + ), + text + ) + } + for text in ["@alice", "alice", "medium.com/@alice", "ping alice@", "a@b@c"] { + XCTAssertFalse(CredentialFieldDetector.looksLikeAddressEntry(text), text) + } } func test_ordinaryFieldsAreNotBlocked() { @@ -43,5 +98,6 @@ final class CredentialFieldDetectorTests: XCTestCase { XCTAssertFalse(CredentialFieldDetector.isCredentialField( role: kAXTextAreaRole as String, labels: ["Email body"], domIdentifier: "email", text: "a@b.co" )) + XCTAssertFalse(CredentialFieldDetector.mightBeCredentialField(role: kAXTextAreaRole as String)) } } From 0efd8be39d76f2e1ef5a2bb37b1409598806a0af Mon Sep 17 00:00:00 2001 From: akramj13 <125495000+akramj13@users.noreply.github.com> Date: Sun, 4 Oct 2026 19:43:31 -0400 Subject: [PATCH 3/4] Read sign-in field labels once per focus session The credential check made up to four synchronous AX reads on every poll of every single-line field. The title, description, placeholder and DOM id do not change while focus stays in one field, so they are now read once per focus session (FocusSessionScopedCache, like the secure-field verdict); the typed text is still judged on every poll. The DOM id is only asked of web content, since native fields never vend one. An all-empty reading from a web field is not cached, so a page that fills in its field's name a moment late is still recognized. Live resolver tests cover the label block, the per-session cache, and an unlabelled field blocked once "alice@" is typed. Co-Authored-By: Claude Opus 5.5 --- .../Resolution/FocusSnapshotResolver.swift | 94 +++++++++++++--- .../FocusSnapshotResolverLiveTests.swift | 104 ++++++++++++++++++ 2 files changed, 180 insertions(+), 18 deletions(-) diff --git a/Cotabby/Services/Focus/Resolution/FocusSnapshotResolver.swift b/Cotabby/Services/Focus/Resolution/FocusSnapshotResolver.swift index bd5c07b4..ae417ea9 100644 --- a/Cotabby/Services/Focus/Resolution/FocusSnapshotResolver.swift +++ b/Cotabby/Services/Focus/Resolution/FocusSnapshotResolver.swift @@ -45,6 +45,11 @@ struct FocusSnapshotResolver { /// fields (see `FocusSessionScopedCache`). private let secureFieldVerdictCache = FocusSessionScopedCache() private let terminalDetectionCache = FocusSessionScopedCache() + /// The label and DOM-id reads behind `CredentialFieldDetector`: up to four AX round trips that + /// would otherwise repeat on every poll of every single-line field, for values that do not + /// change while focus stays in one field. A navigation that reuses the element changes the URL + /// or title, which starts a new focus session and so a fresh read. + private let credentialLabelCache = FocusSessionScopedCache() /// The text margin the caret's paragraph wraps to, which a field's `AXFrame` does not reveal /// (Word's frame is the page edge, not the text margin). Up to three AX round trips, so each /// result is cached per focus session *and* per paragraph: the margin changes between an indented @@ -336,8 +341,12 @@ struct FocusSnapshotResolver { hostMarkedTextRange: resolvedCandidate.markedTextRange ?? chromiumCompletionRange ?? smartComposeRange ) - if let reason = Self.blockedReason( - for: resolvedCandidate, bundleIdentifier: bundleIdentifier, selection: selection, rawSelection: rawSelection + if let reason = blockedReason( + for: resolvedCandidate, + bundleIdentifier: bundleIdentifier, + selection: selection, + rawSelection: rawSelection, + focusChangeSequence: focusChangeSequence ) { return FocusSnapshot( applicationName: applicationName, @@ -356,12 +365,14 @@ struct FocusSnapshotResolver { } /// Why a field Cotabby can read is still one it must not complete in, or nil when it may: a - /// secure field, one of Mail's header rows, or a field with text selected. - private static func blockedReason( + /// secure field, one of Mail's header rows, a sign-in or verification field, or a field with + /// text selected. + private func blockedReason( for candidate: AXFocusCandidate, bundleIdentifier: String, selection: NSRange, - rawSelection: NSRange + rawSelection: NSRange, + focusChangeSequence: UInt64 ) -> String? { if candidate.isSecure { return "Secure text input is active." @@ -378,19 +389,19 @@ struct FocusSnapshotResolver { return MailHeaderFieldDetector.blockedReason } - // Email, username, phone and code boxes: four attribute reads, only for single-line fields. - if CredentialFieldDetector.mightBeCredentialField(role: candidate.role), - CredentialFieldDetector.isCredentialField( - role: candidate.role, - labels: [ - AXHelper.stringValue(for: kAXTitleAttribute as CFString, on: candidate.element), - AXHelper.stringValue(for: kAXDescriptionAttribute as CFString, on: candidate.element), - AXHelper.stringValue(for: kAXPlaceholderValueAttribute as CFString, on: candidate.element) - ], - domIdentifier: AXHelper.stringValue(for: "AXDOMIdentifier" as CFString, on: candidate.element), - text: candidate.textValue - ) { - return CredentialFieldDetector.blockedReason + // Email, username, phone and code boxes, single-line fields only. The labels are read once + // per focus session; the typed text is checked on every poll, since typing "alice@" is + // what reveals an unlabelled address box. + if CredentialFieldDetector.mightBeCredentialField(role: candidate.role) { + let labelReading = credentialLabelReading(for: candidate, focusChangeSequence: focusChangeSequence) + if CredentialFieldDetector.isCredentialField( + role: candidate.role, + labels: labelReading.labels, + domIdentifier: labelReading.domIdentifier, + text: candidate.textValue + ) { + return CredentialFieldDetector.blockedReason + } } guard selection.length > 0 else { return nil } @@ -1569,6 +1580,53 @@ struct FocusSnapshotResolver { descriptionLabel: AXHelper.stringValue(for: kAXDescriptionAttribute as CFString, on: element) ) } + + /// What a single-line field says about itself (title, description, placeholder, DOM id), read + /// once per focus session through `credentialLabelCache`. + /// + /// An all-empty reading from web content is returned but not cached: a web field can be read + /// before the page has filled in its name, and caching that would leave a real sign-in box + /// unrecognized for the whole session. Such a field keeps paying the reads until it answers. + /// A native field's attributes are there as soon as it is, so its empty reading is final. + private func credentialLabelReading( + for candidate: AXFocusCandidate, + focusChangeSequence: UInt64 + ) -> CredentialFieldLabelReading { + if let cached = credentialLabelCache.cachedValue( + forKey: candidate.elementIdentifier, focusChangeSequence: focusChangeSequence + ) { + return cached + } + + let reading = CredentialFieldLabelReading( + labels: [ + AXHelper.stringValue(for: kAXTitleAttribute as CFString, on: candidate.element), + AXHelper.stringValue(for: kAXDescriptionAttribute as CFString, on: candidate.element), + AXHelper.stringValue(for: kAXPlaceholderValueAttribute as CFString, on: candidate.element) + ], + // Only web content vends DOM ids; asking a native field is a wasted round trip. + domIdentifier: candidate.vendsDOMAttributes + ? AXHelper.stringValue(for: "AXDOMIdentifier" as CFString, on: candidate.element) + : nil + ) + if !reading.isEmpty || !candidate.vendsDOMAttributes { + credentialLabelCache.store( + reading, forKey: candidate.elementIdentifier, focusChangeSequence: focusChangeSequence + ) + } + return reading + } +} + +/// The attributes `CredentialFieldDetector` judges a field by, cached per focus session. +private struct CredentialFieldLabelReading { + let labels: [String?] + let domIdentifier: String? + + /// True when the field answered nothing usable, so a web field's reading may simply be early. + var isEmpty: Bool { + (labels + [domIdentifier]).allSatisfy { ($0 ?? "").isEmpty } + } } private struct FocusCandidateResolution { diff --git a/CotabbyTests/Services/Focus/Resolution/FocusSnapshotResolverLiveTests.swift b/CotabbyTests/Services/Focus/Resolution/FocusSnapshotResolverLiveTests.swift index 7cb544b5..63b91112 100644 --- a/CotabbyTests/Services/Focus/Resolution/FocusSnapshotResolverLiveTests.swift +++ b/CotabbyTests/Services/Focus/Resolution/FocusSnapshotResolverLiveTests.swift @@ -201,3 +201,107 @@ final class FocusSnapshotResolverLiveTests: XCTestCase { XCTAssertEqual(context.trailingText, "tail") } } + +/// The sign-in field gate (`CredentialFieldDetector`) wired through the live resolver: a real +/// single-line AppKit field, read over AX, so the attribute reads, the per-session label cache, and +/// the per-poll typed-text check are exercised together rather than only as pure strings. +@MainActor +final class FocusSnapshotResolverCredentialLiveTests: XCTestCase { + private static let fieldIdentifier = "cotabby-resolver-credential-field" + + private var window: NSWindow? + private var field: NSTextField? + + override func tearDown() { + window?.orderOut(nil) + window = nil + field = nil + super.tearDown() + } + + /// Hosts one editing `NSTextField` and returns its AX element, or skips where self-process AX + /// is unavailable. + private func makeFieldElement(placeholder: String, text: String) throws -> AXUIElement { + let window = NSWindow( + contentRect: NSRect(x: 220, y: 220, width: 360, height: 80), + styleMask: [.titled], + backing: .buffered, + defer: false + ) + window.isReleasedWhenClosed = false + let field = NSTextField(frame: NSRect(x: 10, y: 28, width: 300, height: 24)) + field.placeholderString = placeholder + field.setAccessibilityIdentifier(Self.fieldIdentifier) + window.contentView?.addSubview(field) + window.orderFrontRegardless() + self.window = window + self.field = field + setText(text) + + let appElement = AXUIElementCreateApplication(ProcessInfo.processInfo.processIdentifier) + let deadline = Date().addingTimeInterval(3) + while Date() < deadline { + if let element = Self.findField(under: appElement, depth: 0) { + return element + } + RunLoop.main.run(until: Date().addingTimeInterval(0.05)) + } + throw XCTSkip("Self-process AX is unavailable in this environment") + } + + /// Replaces the text the way typing would leave it: field editor active, caret at the end. + private func setText(_ text: String) { + guard let window, let field else { return } + window.makeFirstResponder(nil) + field.stringValue = text + window.makeFirstResponder(field) + field.currentEditor()?.selectedRange = NSRange(location: (text as NSString).length, length: 0) + } + + private static func findField(under element: AXUIElement, depth: Int) -> AXUIElement? { + guard depth <= 8 else { return nil } + if AXHelper.stringValue(for: kAXRoleAttribute as CFString, on: element) == (kAXTextFieldRole as String), + AXHelper.accessibilityIdentifier(of: element) == fieldIdentifier { + return element + } + for child in AXHelper.childElements(of: element) { + if let found = findField(under: child, depth: depth + 1) { + return found + } + } + return nil + } + + private func capability(of element: AXUIElement, focusChangeSequence: UInt64, resolver: FocusSnapshotResolver) -> FocusCapability { + resolver.resolveSnapshot( + focusedElement: element, + application: NSRunningApplication.current, + focusChangeSequence: focusChangeSequence + ).capability + } + + func test_signInPlaceholderBlocksTheField_andLabelsAreReadOncePerFocusSession() throws { + let element = try makeFieldElement(placeholder: "Email or phone", text: "realdeepdark") + let resolver = FocusSnapshotResolver() + let credentialBlock = FocusCapability.blocked(CredentialFieldDetector.blockedReason) + + XCTAssertEqual(capability(of: element, focusChangeSequence: 7, resolver: resolver), credentialBlock) + + // The label is session-invariant and cached: a later poll in the same session does not + // re-read it, and a new focus session does. + field?.placeholderString = "Notes" + XCTAssertEqual(capability(of: element, focusChangeSequence: 7, resolver: resolver), credentialBlock) + XCTAssertNotEqual(capability(of: element, focusChangeSequence: 8, resolver: resolver), credentialBlock) + } + + func test_typedAddressBlocksAnUnlabelledField_onTheNextPoll() throws { + let element = try makeFieldElement(placeholder: "Account", text: "alice") + let resolver = FocusSnapshotResolver() + let credentialBlock = FocusCapability.blocked(CredentialFieldDetector.blockedReason) + + XCTAssertNotEqual(capability(of: element, focusChangeSequence: 3, resolver: resolver), credentialBlock) + // Same focus session: the typed text is judged on every poll, unlike the cached labels. + setText("alice@") + XCTAssertEqual(capability(of: element, focusChangeSequence: 3, resolver: resolver), credentialBlock) + } +} From 3a9cf3bebe60baae54d865b4d38c33a2af70a625 Mon Sep 17 00:00:00 2001 From: akramj13 <125495000+akramj13@users.noreply.github.com> Date: Sun, 4 Oct 2026 19:51:58 -0400 Subject: [PATCH 4/4] Refresh cached sign-in labels after a second A field relabelled in place (a reused input whose aria-label or id turns it into a code box) changes neither URL, title, placeholder nor frame, so it stays in one focus session and the cached reading would hide the relabel for the rest of it. A reading is now trusted for one second (credentialLabelRefreshInterval), which bounds that window while keeping the reads about a dozen times rarer than the 80 ms active poll. The resolver takes an injectable uptime clock so the live test can step time: cached within the interval, re-read after it, and read at once in a new focus session. Co-Authored-By: Claude Opus 5.5 --- .../Resolution/FocusSnapshotResolver.swift | 40 ++++++++++++++----- .../FocusSnapshotResolverLiveTests.swift | 18 ++++++--- 2 files changed, 42 insertions(+), 16 deletions(-) diff --git a/Cotabby/Services/Focus/Resolution/FocusSnapshotResolver.swift b/Cotabby/Services/Focus/Resolution/FocusSnapshotResolver.swift index ae417ea9..ff5940b0 100644 --- a/Cotabby/Services/Focus/Resolution/FocusSnapshotResolver.swift +++ b/Cotabby/Services/Focus/Resolution/FocusSnapshotResolver.swift @@ -46,10 +46,16 @@ struct FocusSnapshotResolver { private let secureFieldVerdictCache = FocusSessionScopedCache() private let terminalDetectionCache = FocusSessionScopedCache() /// The label and DOM-id reads behind `CredentialFieldDetector`: up to four AX round trips that - /// would otherwise repeat on every poll of every single-line field, for values that do not - /// change while focus stays in one field. A navigation that reuses the element changes the URL - /// or title, which starts a new focus session and so a fresh read. + /// would otherwise repeat on every poll of every single-line field, for values that rarely + /// change while focus stays in one field. A navigation that reuses the element changes the URL, + /// title or placeholder, which starts a new focus session and so a fresh read. A field + /// relabelled in place (a reused input whose aria-label turns it into a code box) changes none + /// of those, so a reading is also refreshed after `credentialLabelRefreshInterval`. private let credentialLabelCache = FocusSessionScopedCache() + /// How long a credential label reading is trusted within one focus session. One second bounds + /// how long an in-place relabel goes unnoticed, while the reads run about a dozen times less + /// often than the 80 ms active poll. + static let credentialLabelRefreshInterval: TimeInterval = 1 /// The text margin the caret's paragraph wraps to, which a field's `AXFrame` does not reveal /// (Word's frame is the page edge, not the text margin). Up to three AX round trips, so each /// result is cached per focus session *and* per paragraph: the margin changes between an indented @@ -77,8 +83,15 @@ struct FocusSnapshotResolver { /// answers no width query (Chromium contenteditables, Electron composers); see /// `CaretAdvanceSampler`. One sampler follows the focused field; a new field starts a new one. private let caretAdvanceSamples = CaretAdvanceSampleStore() - init(geometryResolver: AXTextGeometryResolver? = nil) { + /// Seconds since boot, for the credential label refresh. Injected so tests can step time. + private let uptime: () -> TimeInterval + + init( + geometryResolver: AXTextGeometryResolver? = nil, + uptime: @escaping () -> TimeInterval = { ProcessInfo.processInfo.systemUptime } + ) { self.geometryResolver = geometryResolver ?? AXTextGeometryResolver() + self.uptime = uptime } /// Drops the cached static-text-run walk so the next capture pays a fresh one. Called through @@ -389,9 +402,9 @@ struct FocusSnapshotResolver { return MailHeaderFieldDetector.blockedReason } - // Email, username, phone and code boxes, single-line fields only. The labels are read once - // per focus session; the typed text is checked on every poll, since typing "alice@" is - // what reveals an unlabelled address box. + // Email, username, phone and code boxes, single-line fields only. The labels are read at + // most once a second per field; the typed text is checked on every poll, since typing + // "alice@" is what reveals an unlabelled address box. if CredentialFieldDetector.mightBeCredentialField(role: candidate.role) { let labelReading = credentialLabelReading(for: candidate, focusChangeSequence: focusChangeSequence) if CredentialFieldDetector.isCredentialField( @@ -1582,23 +1595,26 @@ struct FocusSnapshotResolver { } /// What a single-line field says about itself (title, description, placeholder, DOM id), read - /// once per focus session through `credentialLabelCache`. + /// through `credentialLabelCache`: once per focus session, refreshed after + /// `credentialLabelRefreshInterval`. /// /// An all-empty reading from web content is returned but not cached: a web field can be read /// before the page has filled in its name, and caching that would leave a real sign-in box - /// unrecognized for the whole session. Such a field keeps paying the reads until it answers. - /// A native field's attributes are there as soon as it is, so its empty reading is final. + /// unrecognized until the next refresh. Such a field keeps paying the reads until it answers. + /// A native field's attributes are there as soon as it is, so its empty reading is kept. private func credentialLabelReading( for candidate: AXFocusCandidate, focusChangeSequence: UInt64 ) -> CredentialFieldLabelReading { + let now = uptime() if let cached = credentialLabelCache.cachedValue( forKey: candidate.elementIdentifier, focusChangeSequence: focusChangeSequence - ) { + ), now - cached.readAt < Self.credentialLabelRefreshInterval { return cached } let reading = CredentialFieldLabelReading( + readAt: now, labels: [ AXHelper.stringValue(for: kAXTitleAttribute as CFString, on: candidate.element), AXHelper.stringValue(for: kAXDescriptionAttribute as CFString, on: candidate.element), @@ -1620,6 +1636,8 @@ struct FocusSnapshotResolver { /// The attributes `CredentialFieldDetector` judges a field by, cached per focus session. private struct CredentialFieldLabelReading { + /// `uptime()` when the attributes were read, so the reading can be refreshed. + let readAt: TimeInterval let labels: [String?] let domIdentifier: String? diff --git a/CotabbyTests/Services/Focus/Resolution/FocusSnapshotResolverLiveTests.swift b/CotabbyTests/Services/Focus/Resolution/FocusSnapshotResolverLiveTests.swift index 63b91112..62c9976d 100644 --- a/CotabbyTests/Services/Focus/Resolution/FocusSnapshotResolverLiveTests.swift +++ b/CotabbyTests/Services/Focus/Resolution/FocusSnapshotResolverLiveTests.swift @@ -280,18 +280,26 @@ final class FocusSnapshotResolverCredentialLiveTests: XCTestCase { ).capability } - func test_signInPlaceholderBlocksTheField_andLabelsAreReadOncePerFocusSession() throws { + func test_signInPlaceholderBlocksTheField_andLabelsAreReadAtMostOncePerInterval() throws { let element = try makeFieldElement(placeholder: "Email or phone", text: "realdeepdark") - let resolver = FocusSnapshotResolver() + var now: TimeInterval = 100 + let resolver = FocusSnapshotResolver(uptime: { now }) let credentialBlock = FocusCapability.blocked(CredentialFieldDetector.blockedReason) + let interval = FocusSnapshotResolver.credentialLabelRefreshInterval XCTAssertEqual(capability(of: element, focusChangeSequence: 7, resolver: resolver), credentialBlock) - // The label is session-invariant and cached: a later poll in the same session does not - // re-read it, and a new focus session does. + // Within the interval a poll reuses the reading, so a relabel is not seen yet... field?.placeholderString = "Notes" + now += interval / 2 XCTAssertEqual(capability(of: element, focusChangeSequence: 7, resolver: resolver), credentialBlock) - XCTAssertNotEqual(capability(of: element, focusChangeSequence: 8, resolver: resolver), credentialBlock) + // ...and once it has passed, the same session reads the field again. + now += interval + XCTAssertNotEqual(capability(of: element, focusChangeSequence: 7, resolver: resolver), credentialBlock) + + // A new focus session reads at once, however fresh the last reading is. + field?.placeholderString = "Email or phone" + XCTAssertEqual(capability(of: element, focusChangeSequence: 8, resolver: resolver), credentialBlock) } func test_typedAddressBlocksAnUnlabelledField_onTheNextPoll() throws {