5d7fcdf24e
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.
278 lines
10 KiB
Swift
278 lines
10 KiB
Swift
// 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)
|
|
}
|
|
}
|