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() } } }