EchoUITests/ComposerUITests.swift
Rama main · réplica de lectura del repositorio original.
import XCTest
/// The message field's conveniences, with no server: a draft per conversation, Return that sends
/// when asked to, the page of its own for a long message, the microphone, and a pasted picture.
@MainActor
final class ComposerUITests: XCTestCase {
private let springboard = XCUIApplication(bundleIdentifier: "com.apple.springboard")
/// On the OpenAI-compatible connection, pointed at nothing: messages can be sent (and fail),
/// and the conversation list is the phone's own.
private func launch(_ extra: [String] = []) -> XCUIApplication {
let app = XCUIApplication()
app.launchArguments = XCUIApplication.straightToChat + XCUIApplication.chatsSection
+ ["-transport", "chatCompletions", "-fastLaneURL", "http://127.0.0.1:9", "-fastLaneModel", "test"] + extra
if extra.contains("-echo.fresh") { app.launchArguments.append("-echo.clearDrafts") } // nothing left by an earlier test
app.launch()
return app
}
/// The message field (on an iPad the sidebar's search field comes first).
private func field(_ app: XCUIApplication) -> XCUIElement {
let field = app.textFields["composer"]
XCTAssertTrue(field.waitForExistence(timeout: 20), "No message field")
return field
}
private func keep(_ name: String) {
let shot = XCTAttachment(screenshot: XCUIScreen.main.screenshot())
shot.name = name
shot.lifetime = .keepAlways
add(shot)
}
/// Taps the field until it has the keyboard's focus: right after a launch the first tap can
/// land while the app is still settling. (Not "until the keyboard is on screen": with a
/// hardware keyboard attached to the simulator there may be none to see.)
private func focus(_ field: XCUIElement, in app: XCUIApplication) {
for _ in 0 ..< 4 {
field.tap()
for _ in 0 ..< 6 {
if (field.value(forKey: "hasKeyboardFocus") as? Bool) == true || app.keyboards.firstMatch.exists { return }
usleep(500_000)
}
}
XCTFail("The message field didn't take the keyboard's focus")
}
private func type(_ text: String, into field: XCUIElement, in app: XCUIApplication) {
focus(field, in: app)
field.typeText(text)
}
private func clear(_ field: XCUIElement) {
let text = field.value as? String ?? ""
guard !text.isEmpty, text != field.placeholderValue else { return }
field.tap()
field.typeText(String(repeating: XCUIKeyboardKey.delete.rawValue, count: text.count + 2))
}
/// Removes a conversation a test made, so the next run starts as this one did.
private func delete(conversation title: String, in app: XCUIApplication) {
app.buttons["Conversations"].firstMatch.tap()
app.deleteConversation(titled: title)
}
/// Taps a conversation's row in the list: the one on screen, not the open conversation's
/// header of the same title behind the list. (After the relaunch the hike is the open one,
/// unless some newer conversation is left on the simulator.)
private func tapRow(_ title: String, in app: XCUIApplication) {
guard let row = app.staticTexts.matching(identifier: title).hittable(within: 5) else { return XCTFail("No row titled \(title) to tap") }
row.tap()
}
func testEachConversationKeepsItsOwnDraft() {
continueAfterFailure = false
var app = launch(["-echo.fresh", "-echo.demoLibrary"])
var composer = field(app)
clear(composer)
type("for a new conversation", into: composer, in: app)
// Another conversation: its own draft, empty so far.
app.buttons["Conversations"].firstMatch.tap()
let hike = app.staticTexts["Plan the weekend hike"]
XCTAssertTrue(hike.waitForExistence(timeout: 10), "The demo conversations aren't in the list")
tapRow("Plan the weekend hike", in: app)
composer = field(app)
XCTAssertTrue(app.navigationBars.staticTexts["Plan the weekend hike"].waitForExistence(timeout: 10))
clear(composer)
XCTAssertNotEqual(composer.value as? String, "for a new conversation", "The new conversation's draft followed into another one")
type("about the hike", into: composer, in: app)
keep("a draft in an existing conversation")
// Back to a new conversation: what was typed there is there.
app.buttons["New conversation"].firstMatch.tap()
XCTAssertEqual(field(app).value as? String, "for a new conversation")
// And the hike's is still with the hike, after the app has been closed too. The list
// says which conversation has something unsent.
app.buttons["Conversations"].firstMatch.tap()
XCTAssertTrue(hike.waitForExistence(timeout: 10))
XCTAssertTrue(app.staticTexts["Has a draft"].waitForExistence(timeout: 5), "The list doesn't mark the conversation with a draft")
XCTAssertEqual(app.staticTexts.matching(identifier: "Has a draft").count, 1, "Only the one conversation has a draft")
keep("a draft marked in the list")
tapRow("Plan the weekend hike", in: app)
XCTAssertEqual(field(app).value as? String, "about the hike")
XCUIDevice.shared.press(.home) // drafts are written as the app leaves the screen
sleep(1)
app.terminate()
app = launch()
_ = field(app)
app.buttons["Conversations"].firstMatch.tap()
XCTAssertTrue(hike.waitForExistence(timeout: 10))
tapRow("Plan the weekend hike", in: app)
XCTAssertTrue(app.navigationBars.staticTexts["Plan the weekend hike"].waitForExistence(timeout: 10))
XCTAssertEqual(field(app).value as? String, "about the hike", "The draft didn't survive the app being closed")
clear(field(app))
}
func testReturnSendsOnlyWhenSetTo() {
continueAfterFailure = false
var app = launch(["-echo.fresh", "-returnSends", "NO"])
var composer = field(app)
clear(composer)
type("first line\nsecond line", into: composer, in: app)
XCTAssertEqual(composer.value as? String, "first line\nsecond line", "Return didn't start a new line")
clear(composer)
app.terminate()
app = launch(["-echo.fresh", "-returnSends", "YES"])
composer = field(app)
clear(composer)
type("sent by Return\n", into: composer, in: app)
// The message is in the conversation (the header takes its first words) and the field is empty again.
XCTAssertTrue(app.navigationBars.staticTexts["sent by Return"].waitForExistence(timeout: 10), "Return didn't send")
let left = composer.value as? String ?? ""
XCTAssertTrue(left.isEmpty || left == composer.placeholderValue, "The field kept \(left)")
keep("sent by Return")
delete(conversation: "sent by Return", in: app)
}
func testALongMessageOpensOnAPageOfItsOwn() {
continueAfterFailure = false
let app = launch(["-echo.fresh"])
let composer = field(app)
clear(composer)
XCTAssertFalse(app.buttons["Open the editor"].exists, "The editor button shows for an empty field")
type(String(repeating: "A long message needs room. ", count: 8), into: composer, in: app)
let open = app.buttons["Open the editor"]
XCTAssertTrue(open.waitForExistence(timeout: 5), "No editor button for a long message")
open.tap()
XCTAssertTrue(app.navigationBars["Message"].waitForExistence(timeout: 10), "The editor didn't open")
let editor = app.textViews["Message"]
XCTAssertTrue(editor.waitForExistence(timeout: 5))
XCTAssertTrue((editor.value as? String ?? "").hasPrefix("A long message needs room."), "The editor doesn't hold the draft")
editor.typeText("\nAnd a second paragraph.")
keep("the editor")
app.navigationBars["Message"].buttons["Done"].tap()
XCTAssertTrue((field(app).value as? String ?? "").hasSuffix("And a second paragraph."), "What was written in the editor didn't come back to the field")
clear(field(app))
}
func testTheMicrophoneInTheField() {
continueAfterFailure = false
let app = launch(["-echo.fresh"])
_ = field(app)
let dictate = app.buttons["Dictate"]
XCTAssertTrue(dictate.waitForExistence(timeout: 10), "No Dictate button in the field")
dictate.tap()
for label in ["Allow", "OK"] where springboard.alerts.buttons[label].waitForExistence(timeout: 4) { springboard.alerts.buttons[label].tap() }
// In the simulator the speech model may never arrive: either it listens, or it says why
// not. Both leave the button a tap away from where it started.
let stop = app.buttons["Stop dictating"]
if stop.waitForExistence(timeout: 6) {
keep("dictating")
stop.tap()
}
XCTAssertTrue(dictate.waitForExistence(timeout: 15), "The microphone button didn't come back")
}
func testAPictureOnTheClipboardPastesAsAnAttachment() {
continueAfterFailure = false
// A picture, and nothing else, on the clipboard.
let renderer = UIGraphicsImageRenderer(size: CGSize(width: 240, height: 160))
UIPasteboard.general.image = renderer.image { context in
UIColor.systemTeal.setFill()
context.fill(CGRect(x: 0, y: 0, width: 240, height: 160))
}
let app = launch(["-echo.fresh"])
let composer = field(app)
clear(composer)
focus(composer, in: app)
sleep(1)
composer.press(forDuration: 1.2)
let paste = app.menuItems["Paste"]
XCTAssertTrue(paste.waitForExistence(timeout: 5), "The field offers no Paste for a picture")
paste.tap()
for label in ["Allow Paste", "Allow"] where springboard.alerts.buttons[label].waitForExistence(timeout: 3) { springboard.alerts.buttons[label].tap() }
let remove = app.buttons.matching(NSPredicate(format: "label BEGINSWITH %@", "Remove ")).firstMatch
XCTAssertTrue(remove.waitForExistence(timeout: 10), "The pasted picture isn't attached")
let left = composer.value as? String ?? ""
XCTAssertTrue(left.isEmpty || left == composer.placeholderValue, "Pasting a picture put text in the field: \(left)")
keep("a pasted picture")
remove.tap()
}
}