feat(keyboard): harden polish/clipboard guards and ship 1.6.6 (build 59)
Keep marketing version at 1.6.6 and bump build to 59. Strengthen never-answer polish safeguards, clipboard reply-intent continuity, voice undo UX, device UITests harness, eval fixtures, and What's New assets.
This commit is contained in:
@@ -0,0 +1,277 @@
|
||||
// ClipboardCommandUITests.swift
|
||||
// OSGKeyboard · UI Tests (device reproduction harness)
|
||||
//
|
||||
// Drives the real long-press → 「允许粘贴」 → clipboard-command round on a
|
||||
// physical device, which is the only path Apple exposes for injecting touches
|
||||
// outside the Simulator. Pair with an Instruments capture so the unified log of
|
||||
// both the host app and the keyboard extension can be replayed per round:
|
||||
//
|
||||
// xcrun xctrace record --device <UDID> --template Logging --all-processes \
|
||||
// --output round.trace
|
||||
//
|
||||
// Knobs (environment, read at runtime):
|
||||
// OSG_ROUNDS number of long-press rounds (default 5)
|
||||
// OSG_ALLOW_DELAY seconds to leave the paste alert up (default 1.0)
|
||||
// OSG_SETTLE seconds to observe after allowing (default 12)
|
||||
// OSG_PICK_KEYBOARD set to 1 to long-press globe and pick OSGKeyboard
|
||||
|
||||
import XCTest
|
||||
import UIKit
|
||||
import os
|
||||
|
||||
@MainActor
|
||||
final class ClipboardCommandUITests: XCTestCase {
|
||||
|
||||
private let log = Logger(subsystem: "com.osgkeyboard.uitest", category: "round")
|
||||
private lazy var app = XCUIApplication()
|
||||
private lazy var springboard = XCUIApplication(bundleIdentifier: "com.apple.springboard")
|
||||
|
||||
private var rounds: Int { Int(env("OSG_ROUNDS") ?? "") ?? 5 }
|
||||
private var allowDelay: TimeInterval { Double(env("OSG_ALLOW_DELAY") ?? "") ?? 1.0 }
|
||||
private var settle: TimeInterval { Double(env("OSG_SETTLE") ?? "") ?? 12 }
|
||||
|
||||
private func env(_ key: String) -> String? {
|
||||
ProcessInfo.processInfo.environment[key]
|
||||
}
|
||||
|
||||
override func setUpWithError() throws {
|
||||
continueAfterFailure = true
|
||||
}
|
||||
|
||||
// MARK: - Test
|
||||
|
||||
func testClipboardLongPressRounds() throws {
|
||||
app.launch()
|
||||
XCTAssertTrue(app.wait(for: .runningForeground, timeout: 20), "host app did not foreground")
|
||||
mark("session.begin rounds=\(rounds) allowDelay=\(allowDelay)")
|
||||
|
||||
attach("launch")
|
||||
mark("launch.tree textViews=\(app.textViews.count) textFields=\(app.textFields.count) "
|
||||
+ "keyboards=\(app.keyboards.count) buttons=\(app.buttons.count) "
|
||||
+ "tabs=\(app.tabBars.buttons.count)")
|
||||
|
||||
focusPreviewField()
|
||||
attach("after-focus")
|
||||
try selectOSGKeyboardIfRequested()
|
||||
|
||||
for round in 1...rounds {
|
||||
runRound(round)
|
||||
}
|
||||
mark("session.end")
|
||||
}
|
||||
|
||||
// MARK: - Round
|
||||
|
||||
private func runRound(_ round: Int) {
|
||||
let text = "第\(round)轮剪贴板命令测试文本,请把它改写得更正式一些。"
|
||||
|
||||
// Recording chrome only listens for taps (long-press is idle-only).
|
||||
// Clear any leftover「指令录制中」before we seed the next round.
|
||||
focusPreviewField()
|
||||
stopRecordingIfNeeded(tag: "round.\(round).preclear")
|
||||
|
||||
// UITest Runner on device cannot write UIPasteboard.general (PBErrorDomain
|
||||
// Code=10/11). Seed via the host text field + system edit menu instead.
|
||||
let seeded = seedClipboardViaHostField(text)
|
||||
mark("round.\(round).pasteboardSeeded=\(seeded)")
|
||||
|
||||
focusPreviewField()
|
||||
attach("r\(round)-before-press")
|
||||
mark("round.\(round).longPress.begin")
|
||||
mic.press(forDuration: 1.2)
|
||||
|
||||
let allowed = handlePasteAlert(round: round)
|
||||
mark("round.\(round).pasteAlert allowed=\(allowed)")
|
||||
|
||||
let deadline = Date().addingTimeInterval(settle)
|
||||
var frame = 0
|
||||
while Date() < deadline {
|
||||
Thread.sleep(forTimeInterval: 2.0)
|
||||
frame += 1
|
||||
attach("r\(round)-settle-\(frame)")
|
||||
}
|
||||
|
||||
// Tap-to-stop after observation — exercises the exit path users use.
|
||||
stopRecordingIfNeeded(tag: "round.\(round).poststop")
|
||||
mark("round.\(round).end")
|
||||
dismissKeyboard()
|
||||
}
|
||||
|
||||
/// While phase is recording/processing the mic only has `onTapGesture`.
|
||||
/// A XCUITest long-press will not stop an active clipboard utterance.
|
||||
private func stopRecordingIfNeeded(tag: String) {
|
||||
attach("\(tag)-before-tap")
|
||||
mic.tap()
|
||||
Thread.sleep(forTimeInterval: 1.2)
|
||||
attach("\(tag)-after-tap")
|
||||
mark("\(tag) mic.tap")
|
||||
}
|
||||
|
||||
/// The system paste-consent alert is owned by SpringBoard, not the app.
|
||||
private func handlePasteAlert(round: Int) -> Bool {
|
||||
let allow = springboard.buttons.matching(
|
||||
NSPredicate(format: "label IN {'允许粘贴', 'Allow Paste'}")
|
||||
).firstMatch
|
||||
// Also try the host app hierarchy — some iOS builds host the sheet there.
|
||||
let allowInApp = app.buttons.matching(
|
||||
NSPredicate(format: "label IN {'允许粘贴', 'Allow Paste'}")
|
||||
).firstMatch
|
||||
let button: XCUIElement
|
||||
if allow.waitForExistence(timeout: 4) {
|
||||
button = allow
|
||||
} else if allowInApp.waitForExistence(timeout: 4) {
|
||||
button = allowInApp
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
|
||||
attach("r\(round)-alert")
|
||||
// Hold the alert open so wall-clock deadlines armed before the press
|
||||
// can be pushed past their expiry — the failure mode under test.
|
||||
Thread.sleep(forTimeInterval: allowDelay)
|
||||
mark("round.\(round).allowTap")
|
||||
button.tap()
|
||||
return true
|
||||
}
|
||||
|
||||
// MARK: - Clipboard seeding
|
||||
|
||||
/// Put `text` on the device pasteboard by typing into the host preview field
|
||||
/// and using Select All → Copy. Returns whether Copy appeared and was tapped.
|
||||
@discardableResult
|
||||
private func seedClipboardViaHostField(_ text: String) -> Bool {
|
||||
let field = previewField()
|
||||
guard field.waitForExistence(timeout: 8) else {
|
||||
mark("seed.fail noPreviewField")
|
||||
return false
|
||||
}
|
||||
field.tap()
|
||||
Thread.sleep(forTimeInterval: 0.4)
|
||||
|
||||
// Clear whatever is already there so typeText does not append forever.
|
||||
clearPreviewField(field)
|
||||
|
||||
field.typeText(text)
|
||||
Thread.sleep(forTimeInterval: 0.4)
|
||||
attach("seed-typed")
|
||||
|
||||
// Long-press to summon the edit menu.
|
||||
field.press(forDuration: 1.0)
|
||||
Thread.sleep(forTimeInterval: 0.5)
|
||||
|
||||
if !tapMenuItem(labels: ["全选", "Select All"]) {
|
||||
// Double-tap often selects a word; try again for Select All.
|
||||
field.doubleTap()
|
||||
Thread.sleep(forTimeInterval: 0.4)
|
||||
_ = tapMenuItem(labels: ["全选", "Select All"])
|
||||
}
|
||||
Thread.sleep(forTimeInterval: 0.3)
|
||||
|
||||
let copied = tapMenuItem(labels: ["拷贝", "Copy"])
|
||||
mark("seed.copy tapped=\(copied)")
|
||||
attach("seed-after-copy")
|
||||
|
||||
// Tap elsewhere to dismiss the menu, then clear the field so the next
|
||||
// long-press exercises clipboard content rather than field text.
|
||||
app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.20)).tap()
|
||||
Thread.sleep(forTimeInterval: 0.3)
|
||||
field.tap()
|
||||
clearPreviewField(field)
|
||||
return copied
|
||||
}
|
||||
|
||||
private func clearPreviewField(_ field: XCUIElement) {
|
||||
// Select-all + delete via menu when possible; fall back to delete key events.
|
||||
let current = field.value as? String ?? ""
|
||||
guard !current.isEmpty, current != "点这里试试键盘" else { return }
|
||||
field.press(forDuration: 1.0)
|
||||
if tapMenuItem(labels: ["全选", "Select All"]) {
|
||||
Thread.sleep(forTimeInterval: 0.2)
|
||||
field.typeText(XCUIKeyboardKey.delete.rawValue)
|
||||
return
|
||||
}
|
||||
// Fallback: spam delete for the current length (capped).
|
||||
let n = min(current.count, 80)
|
||||
let deletes = String(repeating: XCUIKeyboardKey.delete.rawValue, count: n)
|
||||
field.typeText(deletes)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
private func tapMenuItem(labels: [String]) -> Bool {
|
||||
for label in labels {
|
||||
let item = app.menuItems[label]
|
||||
if item.waitForExistence(timeout: 1.5) {
|
||||
item.tap()
|
||||
return true
|
||||
}
|
||||
let springItem = springboard.menuItems[label]
|
||||
if springItem.waitForExistence(timeout: 0.5) {
|
||||
springItem.tap()
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// MARK: - Keyboard plumbing
|
||||
|
||||
// A custom keyboard extension is not published as `app.keyboards` on a
|
||||
// physical device, so every key is addressed by normalized screen offset.
|
||||
// Measured against the OSG voice surface on iPhone 15 Pro Max.
|
||||
private var mic: XCUICoordinate {
|
||||
app.coordinate(withNormalizedOffset: CGVector(dx: 0.50, dy: 0.78))
|
||||
}
|
||||
|
||||
private var globe: XCUICoordinate {
|
||||
app.coordinate(withNormalizedOffset: CGVector(dx: 0.098, dy: 0.958))
|
||||
}
|
||||
|
||||
private func previewField() -> XCUIElement {
|
||||
if app.textFields.firstMatch.exists { return app.textFields.firstMatch }
|
||||
return app.textViews.firstMatch
|
||||
}
|
||||
|
||||
private func focusPreviewField() {
|
||||
let field = previewField()
|
||||
if field.waitForExistence(timeout: 10) {
|
||||
field.tap()
|
||||
} else {
|
||||
app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.60)).tap()
|
||||
}
|
||||
Thread.sleep(forTimeInterval: 1.5)
|
||||
}
|
||||
|
||||
private func dismissKeyboard() {
|
||||
app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.12)).tap()
|
||||
Thread.sleep(forTimeInterval: 1.0)
|
||||
}
|
||||
|
||||
/// Only needed when the device is not already left on the OSG keyboard.
|
||||
private func selectOSGKeyboardIfRequested() throws {
|
||||
guard env("OSG_PICK_KEYBOARD") == "1" else { return }
|
||||
globe.press(forDuration: 1.3)
|
||||
let entry = app.buttons["OSGKeyboard"].exists
|
||||
? app.buttons["OSGKeyboard"]
|
||||
: springboard.buttons["OSGKeyboard"]
|
||||
if entry.waitForExistence(timeout: 6) {
|
||||
entry.tap()
|
||||
}
|
||||
Thread.sleep(forTimeInterval: 2.0)
|
||||
attach("keyboard-selected")
|
||||
mark("keyboard.picked")
|
||||
}
|
||||
|
||||
// MARK: - Evidence
|
||||
|
||||
private func mark(_ message: String) {
|
||||
log.info("[uitest] \(message, privacy: .public)")
|
||||
print("[uitest] \(message)")
|
||||
}
|
||||
|
||||
private func attach(_ name: String) {
|
||||
let shot = XCTAttachment(screenshot: XCUIScreen.main.screenshot())
|
||||
shot.name = name
|
||||
shot.lifetime = .keepAlways
|
||||
add(shot)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>$(PRODUCT_NAME)</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>BNDL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1</string>
|
||||
</dict>
|
||||
</plist>
|
||||
Reference in New Issue
Block a user