Ctrl K
ComunidadGuías…

EchoUITests/BotsUITests.swift

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
import XCTest

/// Bots on the demo roster (`-echo.demoBots`, no server): the chat list's Bots section, a bot's
/// chat named after its bot, and the bots offered while a tag is typed there.
@MainActor
final class BotsUITests: XCTestCase {
    func testTheListShowsTheBotsAndABotsChatOffersTheOthersToTag() {
        continueAfterFailure = false
        let app = XCUIApplication()
        app.launchArguments = ["-echo.demoProjects", "-echo.demoBots", "-echo.clearDrafts", "-displayName", "Redde"]
            + XCUIApplication.chatsSection + XCUIApplication.straightToChat
        app.launch()
        // The open conversation is Quill's chat, and goes by its name.
        XCTAssertTrue(app.text(containing: "tighten the opening").waitForExistence(timeout: 20), "the demo bot's chat isn't open")
        XCTAssertTrue(app.text(containing: "Quill").exists, "the header doesn't name the bot")
        XCTAssertTrue(app.text(containing: "@writer").exists, "the header doesn't give the bot's handle")

        app.buttons["Conversations"].firstMatch.tap()
        // The bots as faces, side by side, New Bot after them.
        let list = app.collectionViews.firstMatch
        XCTAssertTrue(list.buttons["Scout"].waitForExistence(timeout: 10), "Scout isn't in the bots' row")
        XCTAssertTrue(list.buttons["Fix-it"].exists, "a bot without a chat yet isn't listed")
        XCTAssertTrue(list.buttons["New Bot"].exists, "no New Bot at the end of the row")
        XCTAssertLessThan(list.buttons["Scout"].frame.minX, list.buttons["New Bot"].frame.minX, "New Bot isn't after the bots")
        XCTAssertEqual(list.buttons["Scout"].frame.midY, list.buttons["Fix-it"].frame.midY, accuracy: 2, "the bots aren't in one row")
        XCTAssertFalse(app.text(containing: "Three trails under two hours").exists, "a bot's last line still shows")
        // Below the bots and the group chats: scrolled to, as a list only builds the rows on screen.
        let letThem = app.text(containing: "Let bots message each other")
        if !letThem.exists { app.collectionViews.firstMatch.swipeUp() }
        XCTAssertTrue(letThem.waitForExistence(timeout: 3), "nothing offers to let them message each other")
        // The chat beside the panel closes it.
        let close = app.otherElements["Close conversations"].firstMatch
        if close.exists { close.tap() } else { app.buttons["Close conversations"].firstMatch.tap() }

        let field = app.textFields.firstMatch
        XCTAssertTrue(field.waitForExistence(timeout: 10))
        field.tap()
        field.typeText("Ask @")
        let scout = app.buttons.containing(NSPredicate(format: "label CONTAINS %@", "@scout")).firstMatch
        XCTAssertTrue(scout.waitForExistence(timeout: 5), "the other bots aren't offered")
        XCTAssertTrue(app.buttons.containing(NSPredicate(format: "label CONTAINS %@", "@hermes")).firstMatch.exists, "the main profile isn't offered")
        XCTAssertFalse(app.buttons.containing(NSPredicate(format: "label CONTAINS %@", "@quill")).firstMatch.exists, "the bot being talked to is offered")
        field.typeText("fi")
        let fixit = app.buttons.containing(NSPredicate(format: "label CONTAINS %@", "@fix-it")).firstMatch
        XCTAssertTrue(fixit.waitForExistence(timeout: 5), "typing doesn't narrow the bots down")
        XCTAssertFalse(scout.exists)
        fixit.tap()
        XCTAssertEqual(field.value as? String, "Ask @fix-it ", "picking a bot doesn't finish the tag")
        XCTAssertFalse(fixit.exists, "the bots are still offered once the tag is finished")
    }

    /// Mute from a bot's face in the bots' row: for an hour, it then reads as muted, and Unmute,
    /// from the same menu, lifts it. The same for a group chat's row.
    func testABotAndAGroupChatCanBeMutedFromTheirRows() {
        continueAfterFailure = false
        let app = XCUIApplication()
        app.launchArguments = ["-echo.demoProjects", "-echo.demoBots", "-echo.clearDrafts", "-displayName", "Redde",
                               "-echo.screen", "sessions", "-mutes", "<>"] + XCUIApplication.chatsSection + XCUIApplication.straightToChat
        app.launch()
        let list = app.collectionViews.firstMatch
        // A bot's face in the row is named for the bot; a group chat's row starts with its name.
        func row(_ name: String) -> XCUIElement {
            list.buttons.matching(NSPredicate(format: "label == %@ OR label BEGINSWITH %@", name, name + ",")).firstMatch
        }
        for name in ["Scout", "Weekend hike"] {
            let target = row(name)
            XCTAssertTrue(target.waitForExistence(timeout: 20), "no \(name) row")
            XCTAssertFalse((target.value as? String ?? "").contains("Muted"), "\(name) is muted already")
            target.press(forDuration: 1.0)
            let mute = app.buttons["Mute"].firstMatch
            XCTAssertTrue(mute.waitForExistence(timeout: 5), "no Mute in \(name)'s menu")
            mute.tap()
            let hour = app.buttons["For 1 Hour"].firstMatch
            XCTAssertTrue(hour.waitForExistence(timeout: 5), "Mute offers no spans")
            hour.tap()
            XCTAssertTrue(NSPredicate(format: "value CONTAINS %@", "Muted").evaluate(with: row(name)) || {
                sleep(1); return (row(name).value as? String ?? "").contains("Muted")
            }(), "\(name)'s row doesn't read as muted")
            row(name).press(forDuration: 1.0)
            let unmute = app.buttons.matching(NSPredicate(format: "label BEGINSWITH %@", "Unmute")).firstMatch
            XCTAssertTrue(unmute.waitForExistence(timeout: 5), "no Unmute once muted")
            unmute.tap()
            sleep(1)
            XCTAssertFalse((row(name).value as? String ?? "").contains("Muted"), "\(name) is still muted")
        }
    }

    /// New Bot from a template: its name and what it does are filled in; another template replaces
    /// a name a template gave, and Blank keeps a name the person typed.
    func testNewBotFromATemplateFillsItIn() {
        continueAfterFailure = false
        let app = XCUIApplication()
        app.launchArguments = ["-echo.demoProjects", "-echo.demoBots", "-echo.clearDrafts", "-displayName", "Redde",
                               "-echo.screen", "sessions", "-echo.botEditor", "new"] + XCUIApplication.chatsSection
        app.launch()
        let writer = app.buttons["Writer"].firstMatch
        XCTAssertTrue(writer.waitForExistence(timeout: 20), "no templates in New Bot")
        writer.tap()
        // (The Name field, by its placeholder: the chat's own field is behind the sheet.)
        let name = app.textFields.matching(NSPredicate(format: "placeholderValue == %@", "Scout")).firstMatch
        XCTAssertEqual(name.value as? String, "Writer", "the template didn't name it")
        XCTAssertTrue(app.textViews.containing(NSPredicate(format: "value CONTAINS %@", "in your voice")).firstMatch.exists
                      || app.textFields.matching(NSPredicate(format: "value CONTAINS %@", "in your voice")).firstMatch.exists,
                      "what it does isn't filled in")
        app.buttons["Researcher"].firstMatch.tap()
        XCTAssertEqual(name.value as? String, "Researcher", "a name the template gave stays")
        name.tap()
        name.typeText(" Bee")
        app.buttons["Blank"].firstMatch.tap()
        XCTAssertEqual(name.value as? String, "Researcher Bee", "a name the person typed went")
    }

    /// A bot's schedules: listed in words, one opened shows what it does and when, and a new one
    /// offers each kind of when.
    func testABotsSchedulesOpenAndANewOneOffersWhen() {
        continueAfterFailure = false
        let app = XCUIApplication()
        app.launchArguments = ["-echo.demoProjects", "-echo.demoBots", "-echo.clearDrafts", "-displayName", "Redde",
                               "-echo.screen", "sessions", "-echo.botSchedules", "scout"] + XCUIApplication.chatsSection
        app.launch()
        let wear = app.buttons.containing(NSPredicate(format: "label CONTAINS %@", "What to wear")).firstMatch
        XCTAssertTrue(wear.waitForExistence(timeout: 20), "Scout's schedules aren't listed")
        XCTAssertTrue(app.text(containing: "Weekdays at").exists, "when isn't in words")
        wear.tap()
        let name = app.textFields.matching(NSPredicate(format: "placeholderValue == %@", "What to wear")).firstMatch
        XCTAssertTrue(name.waitForExistence(timeout: 5))
        XCTAssertEqual(name.value as? String, "What to wear", "the schedule didn't open with its name")
        app.buttons["Cancel"].firstMatch.tap()
        app.buttons["New Schedule"].firstMatch.tap()
        XCTAssertTrue(app.text(containing: "Repeat").waitForExistence(timeout: 5), "no Repeat for a new schedule")
        // (In the sheet's own bar: the chat behind it has an Add button of its own.)
        let add = app.navigationBars["New Schedule"].buttons["Add"]
        XCTAssertTrue(add.exists, "no Add in New Schedule")
        XCTAssertFalse(add.isEnabled, "it can be added with nothing to do")
    }

    /// More than six bots: one row, the first four and "more", which shows them all, and Fewer
    /// folds them again; folded or not is kept for the next launch.
    func testMoreThanSixBotsFoldAndStayAsLeft() {
        continueAfterFailure = false
        let app = XCUIApplication()
        app.launchArguments = ["-echo.demoProjects", "-echo.demoBots", "-echo.demoBotCount", "9", "-echo.clearDrafts", "-displayName", "Redde",
                               "-echo.screen", "sessions", "-botOrder", "<>"]
            + XCUIApplication.chatsSection + XCUIApplication.straightToChat
        app.launch()
        let list = app.collectionViews.firstMatch
        // (Folded or not as an earlier run left it: an argument would hold it for the launch.)
        XCTAssertTrue(list.buttons["Redde"].waitForExistence(timeout: 20), "no bots")
        if list.buttons["Fewer"].exists { list.buttons["Fewer"].tap() }
        let more = list.buttons["5 more"]
        XCTAssertTrue(more.waitForExistence(timeout: 5), "nine bots didn't fold to four and \"5 more\"")
        XCTAssertFalse(list.buttons["New Bot"].exists, "New Bot shows while the bots are folded")
        XCTAssertEqual(list.buttons["Redde"].frame.midY, more.frame.midY, accuracy: 2, "the folded bots aren't one row")
        more.tap()
        let fewer = list.buttons["Fewer"]
        XCTAssertTrue(fewer.waitForExistence(timeout: 5), "\"more\" didn't show them all")
        for name in ["Home", "Chef", "Coach", "Atlas", "Penny", "New Bot"] {
            XCTAssertTrue(list.buttons[name].exists, "\(name) isn't shown with them all")
        }
        let attachment = XCTAttachment(screenshot: XCUIScreen.main.screenshot())
        attachment.name = "nine bots"
        attachment.lifetime = .keepAlways
        add(attachment)

        // Kept: the next launch shows them all still.
        app.terminate()
        app.launch()
        XCTAssertTrue(list.buttons["Fewer"].waitForExistence(timeout: 20), "showing them all wasn't kept")
        list.buttons["Fewer"].tap()
        XCTAssertTrue(list.buttons["5 more"].waitForExistence(timeout: 5), "Fewer didn't fold them")
    }

    /// Change Face in New Bot, in a bot's settings and in the main agent's opens the face picker.
    func testChangeFaceInTheBotEditorOpensThePicker() {
        continueAfterFailure = false
        for which in ["new", "scout", "default"] {
            let app = XCUIApplication()
            app.launchArguments = ["-echo.demoProjects", "-echo.demoBots", "-echo.clearDrafts", "-displayName", "Redde",
                                   "-echo.screen", "sessions", "-echo.botEditor", which] + XCUIApplication.chatsSection
            app.launch()
            let change = app.buttons["Change Face"].firstMatch
            XCTAssertTrue(change.waitForExistence(timeout: 20), "\(which): no Change Face")
            change.tap()
            XCTAssertTrue(app.staticTexts["Shape"].firstMatch.waitForExistence(timeout: 5), "\(which): Change Face opened nothing")
            // Redde's own bubble is offered to the main agent alone; the ghost to everyone.
            XCTAssertEqual(app.buttons["Bubble"].firstMatch.exists, which == "default", "\(which): the bubble offered wrongly")
            XCTAssertTrue(app.buttons["Ghost"].firstMatch.exists, "\(which): no ghost")
            app.terminate()
        }
    }

    /// Reorder Bots, from a bot's menu: a bot dragged above the others stays there, the next
    /// launch too (first in the bots' row), a group chat goes among the bots, and Sort Bots by
    /// Recent puts the row back.
    func testBotsAndGroupChatsCanBeDraggedIntoAnOrder() {
        continueAfterFailure = false
        let app = XCUIApplication()
        app.launchArguments = ["-echo.demoProjects", "-echo.demoBots", "-echo.clearDrafts", "-displayName", "Redde",
                               "-echo.screen", "sessions"] + XCUIApplication.chatsSection + XCUIApplication.straightToChat
        // No order left from an earlier run: an empty one, for this launch only.
        app.launchArguments += ["-botOrder", "<>"]
        app.launch()
        let list = app.collectionViews.firstMatch
        // Matched on the row's own label: a group chat's names its bots ("Fix-it and Scout"). A bot
        // is its face in the bots' row, named for it, and a row of its own while being put in order.
        func row(_ name: String) -> XCUIElement {
            list.buttons.matching(NSPredicate(format: "label == %@ OR label BEGINSWITH %@", name, name + ",")).firstMatch
        }
        /// Drags a row by its handle onto another's place. (A handle is named for its row:
        /// "Reorder Fix-it", or for the whole row, "Reorder Fix-it, Looks after the home server".)
        func handle(_ name: String) -> XCUIElement {
            list.buttons.matching(NSPredicate(format: "label == %@ OR label BEGINSWITH %@", "Reorder " + name, "Reorder " + name + ",")).firstMatch
        }
        func drag(_ name: String, onto other: String) {
            let from = handle(name).coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5))
            let to = handle(other).coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.3))
            from.press(forDuration: 0.4, thenDragTo: to, withVelocity: .slow, thenHoldForDuration: 0.5)
            sleep(1)
        }
        let fixit = row("Fix-it"), quill = row("Quill"), garage = row("Garage project")
        XCTAssertTrue(fixit.waitForExistence(timeout: 20), "no Fix-it in the bots' row")
        XCTAssertTrue(garage.exists, "no group chat row")
        XCTAssertLessThan(quill.frame.minX, fixit.frame.minX, "the most recent bot isn't before one never talked to")

        fixit.press(forDuration: 1.0)
        let reorder = app.buttons["Reorder Bots"].firstMatch
        XCTAssertTrue(reorder.waitForExistence(timeout: 5), "no Reorder Bots in a bot's menu")
        reorder.tap()
        XCTAssertTrue(app.text(containing: "into the order you want").waitForExistence(timeout: 5), "Reorder Bots didn't start")
        XCTAssertFalse(app.text(containing: "New Bot").exists, "the section's actions show while reordering")
        drag("Fix-it", onto: "Quill")
        XCTAssertLessThan(fixit.frame.minY, quill.frame.minY, "Fix-it didn't move above Quill")
        drag("Garage project", onto: "Fix-it")
        XCTAssertLessThan(garage.frame.minY, fixit.frame.minY, "the group chat didn't move among the bots")
        app.buttons["Done"].firstMatch.tap()
        XCTAssertTrue(app.text(containing: "New Bot").waitForExistence(timeout: 5), "Done didn't end reordering")

        app.terminate()
        app.launchArguments.removeLast(2)
        app.launch()
        XCTAssertTrue(fixit.waitForExistence(timeout: 20))
        XCTAssertLessThan(fixit.frame.minX, quill.frame.minX, "the order wasn't kept in the bots' row")
        fixit.press(forDuration: 1.0)
        app.buttons["Reorder Bots"].firstMatch.tap()
        XCTAssertTrue(app.text(containing: "into the order you want").waitForExistence(timeout: 5))
        XCTAssertLessThan(garage.frame.minY, fixit.frame.minY, "the order wasn't kept")
        XCTAssertLessThan(fixit.frame.minY, quill.frame.minY, "the order wasn't kept")
        app.buttons["Done"].firstMatch.tap()

        quill.press(forDuration: 1.0)
        let sort = app.buttons["Sort Bots by Recent"].firstMatch
        XCTAssertTrue(sort.waitForExistence(timeout: 5), "no Sort Bots by Recent")
        sort.tap()
        sleep(1)
        XCTAssertLessThan(quill.frame.minX, fixit.frame.minX, "the row didn't go back to the most recent first")
    }

    /// iPhone: a group chat takes the chat's place, so a swipe to the right opens the list from
    /// it as from a conversation, and a conversation picked there takes its place again.
    func testAGroupChatIsASwipeFromTheList() throws {
        continueAfterFailure = false
        let app = XCUIApplication()
        app.launchArguments = ["-echo.demoProjects", "-echo.demoBots", "-echo.openGroup", "-echo.clearDrafts", "-displayName", "Redde",
                               "-echo.screen", "sessions"] + XCUIApplication.chatsSection + XCUIApplication.straightToChat
        app.launch()
        try XCTSkipIf(app.windows.firstMatch.frame.width > 600, "iPad keeps the list in the sidebar")
        XCTAssertTrue(app.textFields["Message the group"].waitForExistence(timeout: 20), "the group chat didn't open")
        XCTAssertFalse(app.buttons["Done"].exists, "the group chat is still a cover with Done")

        let window = app.windows.firstMatch
        window.coordinate(withNormalizedOffset: CGVector(dx: 0.1, dy: 0.45))
            .press(forDuration: 0.05, thenDragTo: window.coordinate(withNormalizedOffset: CGVector(dx: 0.9, dy: 0.47)),
                   withVelocity: .fast, thenHoldForDuration: 0)
        XCTAssertTrue(app.navigationBars["Conversations"].waitForExistence(timeout: 5), "swiping right didn't open the list")

        let quill = app.collectionViews.firstMatch.buttons["Quill"].firstMatch
        XCTAssertTrue(quill.waitForExistence(timeout: 5))
        quill.tap()
        XCTAssertTrue(app.text(containing: "tighten the opening").waitForExistence(timeout: 10), "Quill's chat didn't take the group chat's place")
        XCTAssertFalse(app.textFields["Message the group"].exists)
    }

    /// New Bot and Topology fold into one More tile (his ask 2026-10-22), and stay folded, until More.
    func testNewBotAndTopologyFoldIntoOneTileAndStaySo() {
        continueAfterFailure = false
        let app = XCUIApplication()
        app.launchArguments = ["-echo.demoProjects", "-echo.demoBots", "-echo.clearDrafts", "-displayName", "Redde"]
            + XCUIApplication.chatsSection + XCUIApplication.straightToChat
        func openList() {
            XCTAssertTrue(app.text(containing: "tighten the opening").waitForExistence(timeout: 20), "the demo bot's chat isn't open")
            if app.windows.firstMatch.frame.width < 600 { app.buttons["Conversations"].firstMatch.tap() }
        }
        app.launch()
        openList()
        let list = app.collectionViews.firstMatch
        XCTAssertTrue(list.buttons["Topology"].waitForExistence(timeout: 5), "New Bot and Topology aren't shown at first")
        list.buttons["Less"].tap()
        XCTAssertTrue(list.buttons["More"].waitForExistence(timeout: 3), "no More once folded")
        XCTAssertFalse(list.buttons["New Bot"].exists || list.buttons["Topology"].exists, "they still show folded")

        app.launch()
        openList()
        XCTAssertTrue(list.buttons["More"].waitForExistence(timeout: 5), "folding them wasn't kept")
        list.buttons["More"].tap()
        XCTAssertTrue(list.buttons["New Bot"].waitForExistence(timeout: 3) && list.buttons["Topology"].exists, "More doesn't bring them back")
    }

    /// The Topology map from the Bots section: you, the group chats, the bots with what each is
    /// doing, the models they run on; a bot there opens its chat.
    func testTopologyShowsTheBotsTheirGroupChatsAndModels() {
        continueAfterFailure = false
        let app = XCUIApplication()
        app.launchArguments = ["-echo.demoProjects", "-echo.demoBots", "-echo.demoMarks", "-echo.demoClaudeBot", "-echo.clearDrafts", "-displayName", "Redde"]
            + XCUIApplication.chatsSection + XCUIApplication.straightToChat
        app.launch()
        XCTAssertTrue(app.text(containing: "tighten the opening").waitForExistence(timeout: 20), "the demo bot's chat isn't open")
        if app.windows.firstMatch.frame.width < 600 { app.buttons["Conversations"].firstMatch.tap() }

        let list = app.collectionViews.firstMatch
        let topology = list.buttons["Topology"]
        XCTAssertTrue(topology.waitForExistence(timeout: 5), "no Topology in the Bots section")
        // Beside New Bot, a tile the size of a bot's (five bots: New Bot and it on the second row).
        let newBot = list.buttons["New Bot"], scoutTile = list.buttons["Scout"]
        XCTAssertEqual(topology.frame.midY, newBot.frame.midY, accuracy: 2, "Topology isn't beside New Bot")
        XCTAssertGreaterThan(topology.frame.minX, newBot.frame.minX, "Topology isn't after New Bot")
        XCTAssertEqual(topology.frame.width, scoutTile.frame.width, accuracy: 2, "Topology isn't a bot's size")
        XCTAssertEqual(topology.frame.height, scoutTile.frame.height, accuracy: 4, "Topology isn't a bot's size")
        topology.tap()

        XCTAssertTrue(app.navigationBars["Topology"].waitForExistence(timeout: 5), "the map didn't open")
        XCTAssertTrue(app.buttons.matching(NSPredicate(format: "label BEGINSWITH %@", "Weekend hike")).firstMatch.exists,
                      "the group chat isn't on the map")
        // (Scout on the map, not Scout's face in the list behind it.)
        let scout = app.buttons.matching(NSPredicate(format: "label BEGINSWITH %@", "Scout, runs on")).firstMatch
        XCTAssertTrue(scout.exists, "Scout isn't on the map")
        XCTAssertEqual(scout.value as? String, "Working, hands work to Quill", "Scout's work, and who it asks, aren't shown")
        XCTAssertTrue(app.text(containing: "claude-sonnet-4.5").exists, "Quill's model isn't on the map")
        XCTAssertTrue(app.text(containing: "qwen3.6-35b-a3b").exists, "the shared model isn't on the map")
        XCTAssertTrue(app.text(containing: "Claude Code").exists, "the bot that works through Claude Code doesn't show it")
        XCTAssertTrue(app.buttons.matching(NSPredicate(format: "label BEGINSWITH %@ AND value CONTAINS %@", "Redde, runs on", "hands work to"))
                        .firstMatch.exists, "Redde's handoffs aren't told")
        let shot = XCTAttachment(screenshot: app.screenshot())
        shot.name = "topology"
        shot.lifetime = .keepAlways
        add(shot)

        scout.tap()
        XCTAssertFalse(app.navigationBars["Topology"].waitForExistence(timeout: 2), "the map stayed open")
    }

    override func tearDownWithError() throws {
        // What was typed is a draft, and drafts outlast the app.
        MainActor.assumeIsolated {
            let app = XCUIApplication()
            app.launchArguments = ["-echo.clearDrafts", "-echo.forget", "Can you tighten"] + XCUIApplication.straightToChat
            app.launch()
            _ = app.textFields.firstMatch.waitForExistence(timeout: 10)
            sleep(1)
            app.terminate()
        }
    }
}