Ctrl K
ComunidadGuías…

EchoUITests/PromoVideoUITests.swift

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

Archivo original ↗Preguntar a KiBot
import XCTest

/// Drives the App Store preview's shots while the simulator records (see design/video). Skipped
/// unless the runner sets PROMO_VIDEO=1 (`TEST_RUNNER_PROMO_VIDEO=1 xcodebuild test …`). Each shot
/// prints when it starts and ends, so the recording can be cut to exactly that span.
@MainActor
final class PromoVideoUITests: XCTestCase {
    private func launch(_ extra: [String]) throws -> XCUIApplication {
        try XCTSkipUnless(ProcessInfo.processInfo.environment["PROMO_VIDEO"] == "1", "Promo video shots run on request")
        continueAfterFailure = false
        let app = XCUIApplication()
        app.launchArguments = XCUIApplication.chatsSection + XCUIApplication.straightToChat
            + ["-echo.demoHosts", "-transport", "chatCompletions"] + extra
        app.launch()
        return app
    }

    private func mark(_ what: String) { print("PROMO \(what) \(Date().timeIntervalSince1970)") }
    private func hold(_ seconds: Double) { RunLoop.current.run(until: Date().addingTimeInterval(seconds)) }

    private func drag(_ app: XCUIApplication, from: CGVector, to: CGVector, velocity: XCUIGestureVelocity = .slow) {
        let w = app.windows.firstMatch
        w.coordinate(withNormalizedOffset: from)
            .press(forDuration: 0.05, thenDragTo: w.coordinate(withNormalizedOffset: to), withVelocity: velocity, thenHoldForDuration: 0.1)
    }

    // MARK: BOTS! (design/video, `make_preview.py bots`)

    /// The demo bots in the Code theme, dark, as every BOTS! take is.
    private func bots(_ extra: [String]) throws -> XCUIApplication {
        try launch(["-echo.demoProjects", "-echo.demoBots", "-echo.clearDrafts", "-displayName", "Redde",
                    "-theme", "claudeCode", "-appearance", "dark"] + extra)
    }

    /// New Bot from a template: Researcher, then Writer, then Coder fill it in; then its face,
    /// changed with Surprise Me.
    func testBotsTemplate() throws {
        let app = try bots(["-echo.screen", "sessions", "-echo.botEditor", "new"])
        let researcher = app.buttons["Researcher"].firstMatch
        XCTAssertTrue(researcher.waitForExistence(timeout: 20))
        mark("start")
        hold(1.2)
        for name in ["Researcher", "Writer", "Coder"] {
            app.buttons[name].firstMatch.tap()
            mark(name.lowercased())
            hold(1.7)
        }
        app.buttons["Change Face"].firstMatch.tap()
        XCTAssertTrue(app.staticTexts["Shape"].firstMatch.waitForExistence(timeout: 5))
        mark("faces")
        hold(1.0)
        // The parts at the top of the picker, each drawn as the bot with it.
        for part in ["Star", "Blob", "Ghost", "Sparkle", "Happy"] {
            let button = app.buttons[part].firstMatch
            if button.exists { button.tap(); hold(0.75) }
        }
        mark("end")
        hold(1.0)
    }

    /// In a bot's chat, Scout and Quill having messaged each other; then an @ offers the other
    /// bots, and one picked, the question goes on.
    func testBotsMention() throws {
        let app = try bots(["-echo.demoMarks"])
        XCTAssertTrue(app.text(containing: "Lost Lake").waitForExistence(timeout: 20))
        let field = app.textFields.firstMatch
        XCTAssertTrue(field.waitForExistence(timeout: 10))
        mark("start")
        hold(1.0)
        field.tap()
        hold(0.5)
        field.typeText("Ask @")
        let scout = app.buttons.containing(NSPredicate(format: "label CONTAINS %@", "@scout")).firstMatch
        XCTAssertTrue(scout.waitForExistence(timeout: 5))
        mark("offered")
        hold(1.6)
        scout.tap()
        field.typeText("which trails are open this weekend")
        mark("end")
        hold(1.5)
    }

    /// The group's voice screen: one question, and Redde, Scout and Quill answering it in turn,
    /// each in its own voice (Kokoro's, at `TEST_RUNNER_KOKORO_URL`, when given). Marks as each begins.
    func testBotsVoiceRound() throws {
        var voice: [String] = []
        if let url = ProcessInfo.processInfo.environment["KOKORO_URL"], !url.isEmpty {
            voice = ["-replyVoice", "kokoro", "-kokoroURL", url, "-voiceSpeed", "1"]
        }
        let app = try bots(["-echo.screen", "sessions", "-conversations.section", "sessions", "-mutes", "<>",
                            "-echo.openGroup", "-echo.demoRound", "-echo.groupVoice"] + voice)
        var label: XCUIElement?
        for name in ["Redde", "Scout", "Quill"] {
            label = app.staticTexts.matching(NSPredicate(format: "label ==[c] %@", "\(name) is speaking")).firstMatch
            XCTAssertTrue(label!.waitForExistence(timeout: 40), "\(name) never spoke")
            mark(name.lowercased())
        }
        if let label { wait(for: [XCTNSPredicateExpectation(predicate: NSPredicate(format: "exists == false"), object: label)], timeout: 30) }
        mark("end")
        hold(1.5)
    }

    /// The 2.0 store screenshots' raw shots, as pictures in SHOT_DIR (`ph-` or `pad-` by the
    /// device; design/screenshots): the chat list's Bots, and a group chat's voice screen once the
    /// round has come to Quill, the third bot, with the others' answers above it.
    func testBotsShots() throws {
        let dir = URL(fileURLWithPath: try XCTUnwrap(ProcessInfo.processInfo.environment["SHOT_DIR"], "SHOT_DIR is where they go"))
        let kind = UIDevice.current.userInterfaceIdiom == .pad ? "pad" : "ph"
        var voice: [String] = []
        if let url = ProcessInfo.processInfo.environment["KOKORO_URL"], !url.isEmpty {
            voice = ["-replyVoice", "kokoro", "-kokoroURL", url, "-voiceSpeed", "1"]
        }
        // The iPad's sidebar shows the library under the bots: the sample one, as its other shots.
        let screen = kind == "ph" ? ["-echo.screen", "sessions"] : ["-echo.demoLibrary"]
        let list = try bots(screen + ["-conversations.section", "sessions", "-echo.demoMarks", "-mutes", "<>"])
        XCTAssertTrue(list.staticTexts["Weekend hike"].firstMatch.waitForExistence(timeout: 20))
        hold(3)
        try XCUIScreen.main.screenshot().pngRepresentation.write(to: dir.appending(path: "\(kind)-bots.png"))
        let room = try bots(screen + ["-conversations.section", "sessions", "-mutes", "<>",
                                      "-echo.openGroup", "-echo.demoRound", "-echo.groupVoice"] + voice)
        let quill = room.staticTexts.matching(NSPredicate(format: "label ==[c] %@", "Quill is speaking")).firstMatch
        XCTAssertTrue(quill.waitForExistence(timeout: 60), "the round never came to Quill")
        hold(1.5)
        try XCUIScreen.main.screenshot().pngRepresentation.write(to: dir.appending(path: "\(kind)-crew.png"))
    }

    /// Scout muted from its row, then its schedules, one opened.
    func testBotsMuteAndSchedules() throws {
        let app = try bots(["-echo.screen", "sessions", "-mutes", "<>"])
        let list = app.collectionViews.firstMatch
        let scout = list.buttons["Scout"].firstMatch
        XCTAssertTrue(scout.waitForExistence(timeout: 20))
        mark("start")
        hold(1.0)
        scout.press(forDuration: 1.0)
        app.buttons["Mute"].firstMatch.tap()
        hold(0.6)
        app.buttons["For 8 Hours"].firstMatch.tap()
        mark("muted")
        hold(1.8)
        scout.press(forDuration: 1.0)
        app.buttons["Schedules…"].firstMatch.tap()
        let wear = app.buttons.containing(NSPredicate(format: "label CONTAINS %@", "What to wear")).firstMatch
        XCTAssertTrue(wear.waitForExistence(timeout: 10))
        mark("schedules")
        hold(1.8)
        wear.tap()
        mark("opened")
        hold(2.2)
        mark("end")
    }

    /// Answers with substance: read up through the checklist, table, diagram and math.
    func testRichAnswers() throws {
        let app = try launch(["-echo.demo", "-echo.demoLibrary", "-theme", "claudeCode", "-appearance", "dark"])
        XCTAssertTrue(app.staticTexts["Where are we on the kitchen project?"].waitForExistence(timeout: 20))
        // On iPad the transcript is the right-hand column, beside the sidebar.
        let x: CGFloat = app.windows.firstMatch.frame.width > 600 ? 0.66 : 0.5
        // Diagrams and math draw when they first scroll into view: pass over them all once, then
        // start the take from the top of the transcript.
        for _ in 0..<4 { drag(app, from: CGVector(dx: x, dy: 0.8), to: CGVector(dx: x, dy: 0.2), velocity: .fast); hold(0.8) }
        hold(1.5)
        for _ in 0..<5 { drag(app, from: CGVector(dx: x, dy: 0.25), to: CGVector(dx: x, dy: 0.85), velocity: .fast) }
        hold(1.5)
        mark("start")
        hold(0.8)
        for _ in 0..<3 {
            drag(app, from: CGVector(dx: x, dy: 0.78), to: CGVector(dx: x, dy: 0.38), velocity: 250)
            hold(0.5)
        }
        hold(0.8)
        mark("end")
    }

    /// Every chat, cron job and board: swipe the conversations in, then open the Kanban board.
    func testConversationsAndKanban() throws {
        let app = try launch(["-echo.demo", "-echo.demoLibrary", "-echo.demoKanban", "-theme", "claudeCode", "-appearance", "dark"])
        XCTAssertTrue(app.buttons["Conversations"].waitForExistence(timeout: 20))
        hold(2.5)
        mark("start")
        hold(0.7)
        drag(app, from: CGVector(dx: 0.08, dy: 0.5), to: CGVector(dx: 0.85, dy: 0.52), velocity: 700)
        hold(1.6)
        // By position: the Kanban segment in the panel's first row. (Accessibility lookups found
        // the hidden ⌘3 shortcut button of the same name, or nothing.)
        app.windows.firstMatch.coordinate(withNormalizedOffset: CGVector(dx: 0.69, dy: 0.262)).tap()
        hold(2.4)
        mark("end")
    }

    /// Phone tools: the agent reads the calendar, the phone asks before the reminder is added,
    /// and a photo is found by what it shows (`-echo.demoPhone`). One take, with marks at the
    /// question's arrival and at its answer.
    func testPhoneTools() throws {
        let app = try launch(["-echo.demoPhone", "-theme", "claudeCode", "-appearance", "dark"])
        XCTAssertTrue(app.navigationBars.buttons["New conversation"].firstMatch.waitForExistence(timeout: 20))
        mark("start")
        let allow = app.alerts.buttons["Allow"]
        XCTAssertTrue(allow.waitForExistence(timeout: 20), "Phone tools didn't ask about the reminder")
        mark("asked")
        hold(2.0)
        allow.tap()
        mark("allowed")
        XCTAssertTrue(app.staticTexts.containing(NSPredicate(format: "label CONTAINS %@", "bowled")).firstMatch.waitForExistence(timeout: 30))
        hold(3.0)
        mark("end")
    }

    /// Phone tools reaching a person: a call the agent starts and a message it writes
    /// (`-echo.demoPhone reach`). In a simulator there is no phone for iOS to ask about and no
    /// Messages to open, so the take shows the conversation and not those two screens of iOS's.
    func testPhoneToolsReach() throws {
        let app = try launch(["-echo.demoPhone", "reach", "-theme", "claudeCode", "-appearance", "dark"])
        XCTAssertTrue(app.navigationBars.buttons["New conversation"].firstMatch.waitForExistence(timeout: 20))
        mark("start")
        XCTAssertTrue(app.staticTexts.containing(NSPredicate(format: "label CONTAINS %@", "Tap Call")).firstMatch.waitForExistence(timeout: 30))
        mark("called")
        XCTAssertTrue(app.staticTexts.containing(NSPredicate(format: "label CONTAINS %@", "You sent it")).firstMatch.waitForExistence(timeout: 30))
        mark("sent")
        hold(2.5)
        mark("end")
    }

    // MARK: The second Phone tools video: a day with the agent (`-echo.demoFun`)

    private func fun(_ scene: String, _ extra: [String] = []) throws -> XCUIApplication {
        try launch(["-echo.demoFun", scene, "-theme", "claudeCode", "-appearance", "dark"] + extra)
    }

    private func says(_ app: XCUIApplication, _ words: String) -> Bool {
        app.staticTexts.containing(NSPredicate(format: "label CONTAINS %@", words)).firstMatch.waitForExistence(timeout: 30)
    }

    private func started(_ app: XCUIApplication) {
        XCTAssertTrue(app.navigationBars.buttons["New conversation"].firstMatch.waitForExistence(timeout: 20))
        mark("start")
    }

    /// Readies a simulator for the takes, unrecorded: iOS asked for Alarms and answered, and the
    /// agent's widget on the Home Screen. Wants an iOS 26.3 simulator (27.0's refuses alarms)
    /// on which Redde has been opened a minute or more before (the widget gallery is slow to
    /// find a new app's widgets).
    func testFunReady() throws {
        let app = try launch(["-echo.demoAlarm", "5", "-echo.demoBoard", "clear"])
        XCTAssertTrue(app.navigationBars.buttons["New conversation"].firstMatch.waitForExistence(timeout: 20))
        let springboard = XCUIApplication(bundleIdentifier: "com.apple.springboard")
        let allow = springboard.alerts.buttons["Allow"]
        if allow.waitForExistence(timeout: 8) { allow.tap() }
        hold(8)
        let stop = springboard.buttons["Stop"]
        if stop.waitForExistence(timeout: 6) { stop.tap() }
        XCUIDevice.shared.press(.home)
        hold(2)
        springboard.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.70)).press(forDuration: 1.6)
        XCTAssertTrue(springboard.buttons["Edit"].waitForExistence(timeout: 8), "no Edit")
        springboard.buttons["Edit"].tap()
        XCTAssertTrue(springboard.buttons["Add Widget"].waitForExistence(timeout: 8), "no Add Widget")
        springboard.buttons["Add Widget"].tap()
        let search = springboard.searchFields["Search Widgets"]
        XCTAssertTrue(search.waitForExistence(timeout: 8), "no search field")
        search.tap()
        search.typeText("Redde")
        let cell = springboard.cells.containing(NSPredicate(format: "label CONTAINS 'Redde'")).firstMatch
        XCTAssertTrue(cell.waitForExistence(timeout: 8), "no Redde in the gallery")
        cell.tap()
        hold(2)
        let wanted = springboard.staticTexts["From Your Agent"]
        for _ in 0 ..< 12 where !(wanted.exists && wanted.isHittable) { springboard.swipeLeft(); hold(1) }
        XCTAssertTrue(wanted.exists, "the agent's widget isn't in the gallery")
        springboard.swipeLeft()   // its second size, the wide one
        hold(1)
        let add = springboard.buttons.matching(NSPredicate(format: "label CONTAINS 'Add Widget'")).firstMatch
        XCTAssertTrue(add.waitForExistence(timeout: 5), "no Add Widget button")
        add.tap()
        hold(1)
        if springboard.buttons["Done"].waitForExistence(timeout: 5) { springboard.buttons["Done"].tap() }
        hold(2)
        tidyHomeScreen(springboard)
    }

    /// The test runner's own icon into the Utilities folder, out of the Home Screen takes, and the
    /// Lock Screen cleared.
    private func tidyHomeScreen(_ springboard: XCUIApplication) {
        let runner = springboard.icons.matching(NSPredicate(format: "label BEGINSWITH 'EchoUITests'")).firstMatch
        let folder = springboard.icons["Utilities"]
        if runner.exists, runner.isHittable, folder.exists {
            runner.press(forDuration: 1.2, thenDragTo: folder, withVelocity: .slow, thenHoldForDuration: 1.5)
            hold(2)
        }
        XCUIDevice.shared.press(.home)
        hold(1)
        if springboard.buttons["Done"].exists { springboard.buttons["Done"].tap() }
        XCUIDevice.shared.press(.home)
        hold(1)
        // A new simulator's own notification ("Ready for Apple Intelligence") off the Lock Screen.
        XCUIDevice.shared.perform(NSSelectorFromString("pressLockButton"))
        hold(1)
        XCUIDevice.shared.perform(NSSelectorFromString("pressLockButton"))
        hold(2)
        let note = springboard.descendants(matching: .any).matching(NSPredicate(format: "label CONTAINS 'Apple Intelligence'")).firstMatch
        if note.waitForExistence(timeout: 4) {
            note.swipeLeft()
            hold(1)
            let clear = springboard.buttons.matching(NSPredicate(format: "label == 'Clear' OR label == 'Clear All'")).firstMatch
            if clear.waitForExistence(timeout: 3) { clear.tap() }
        }
        hold(1)
        XCUIDevice.shared.press(.home)
        hold(1)
    }

    func testFunTidy() throws {
        _ = try launch([])
        XCUIDevice.shared.press(.home)
        hold(2)
        tidyHomeScreen(XCUIApplication(bundleIdentifier: "com.apple.springboard"))
    }

    /// The Phone tools store screenshot's two insets, as pictures in SHOT_DIR: the agent's widget
    /// on the Home Screen (as it was left: the `widget` take writes the shopping list on it) and a
    /// nine-minute timer counting down on the Lock Screen. On the simulator `testFunReady`
    /// readied, with no status bar override (one hides the countdown).
    func testToolsShotInsets() throws {
        let app = try launch(["-echo.demoAlarm", "540"])
        let dir = URL(fileURLWithPath: try XCTUnwrap(ProcessInfo.processInfo.environment["SHOT_DIR"], "SHOT_DIR is where they go"))
        XCTAssertTrue(app.navigationBars.buttons["New conversation"].firstMatch.waitForExistence(timeout: 20))
        hold(2)
        XCUIDevice.shared.press(.home)
        hold(3)
        try XCUIScreen.main.screenshot().pngRepresentation.write(to: dir.appending(path: "home-widget.png"))
        XCUIDevice.shared.perform(NSSelectorFromString("pressLockButton"))
        hold(1)
        XCUIDevice.shared.perform(NSSelectorFromString("pressLockButton"))
        hold(4)
        try XCUIScreen.main.screenshot().pngRepresentation.write(to: dir.appending(path: "lock-timer.png"))
        XCUIDevice.shared.press(.home)
        let clear = XCUIApplication()
        clear.launchArguments = XCUIApplication.straightToChat + ["-echo.demoAlarm", "clear"]
        clear.launch()
        hold(2)
    }

    /// Morning: the day from the calendar, then a reminder, which Phone tools asks about.
    func testFunMorning() throws {
        let app = try fun("morning")
        started(app)
        let allow = app.alerts.buttons["Allow"]
        XCTAssertTrue(allow.waitForExistence(timeout: 30), "Phone tools didn't ask about the reminder")
        mark("asked")
        hold(1.6)
        allow.tap()
        mark("allowed")
        XCTAssertTrue(says(app, "nudge you"))
        hold(2.5)
        mark("end")
    }

    /// A real timer, then the Lock Screen with it counting down. Cancelled again after.
    func testFunTimer() throws {
        let app = try fun("timer")
        started(app)
        XCTAssertTrue(says(app, "rings even"))
        hold(1.8)
        mark("locking")
        XCUIDevice.shared.perform(NSSelectorFromString("pressLockButton"))
        hold(1.0)
        XCUIDevice.shared.perform(NSSelectorFromString("pressLockButton"))
        mark("locked")
        hold(4.5)
        mark("end")
        XCUIDevice.shared.press(.home)
        let clear = XCUIApplication()
        clear.launchArguments = XCUIApplication.straightToChat + ["-echo.demoAlarm", "clear"]
        clear.launch()
        hold(2)
    }

    /// The list saved to Files and put on the widget, then the Home Screen with the widget.
    func testFunWidget() throws {
        let app = try fun("widget")
        started(app)
        XCTAssertTrue(says(app, "forget the parmesan"))
        hold(1.6)
        mark("home")
        XCUIDevice.shared.press(.home)
        hold(4.0)
        mark("end")
    }

    func testFunPhoto() throws {
        let app = try fun("photo")
        started(app)
        XCTAssertTrue(says(app, "Personal best"))
        hold(2.8)
        mark("end")
    }

    /// Voice mode with the camera on, then the question it went with and the answer.
    func testFunLook() throws {
        let app = try fun("look", ["-echo.voiceView", "-echo.voiceLook", "-echo.voiceDemo", "Is my plant okay? It looks kind of sad."])
        let camera = app.descendants(matching: .any).matching(NSPredicate(format: "label == %@", "Camera")).firstMatch
        XCTAssertTrue(camera.waitForExistence(timeout: 30), "No camera button on the voice screen")
        hold(1.0)
        mark("start")
        hold(0.6)
        camera.tap()
        mark("camera")
        hold(2.8)
        // The voice screen's: the conversation beneath has a Stop of its own.
        let stop = app.buttons.matching(identifier: "Stop").allElementsBoundByIndex.last { $0.isHittable }
        XCTAssertNotNil(stop, "No Stop on the voice screen")
        stop?.tap()
        mark("closed")
        XCTAssertTrue(says(app, "perk up"))
        hold(2.5)
        mark("end")
    }

    func testFunReach() throws {
        let app = try fun("reach")
        started(app)
        XCTAssertTrue(says(app, "Tap Call"))
        mark("called")
        XCTAssertTrue(says(app, "Dana knows"))
        mark("sent")
        hold(2.5)
        mark("end")
    }

    /// Phone tools in Settings: each area with its own switch, and one of them being chosen.
    func testPhoneToolsSettings() throws {
        let app = try launch(["-echo.demoPhone", "settings", "-echo.screen", "settings", "-echo.demoPlugin", "there",
                              "-theme", "claudeCode", "-appearance", "dark"])
        XCTAssertTrue(app.navigationBars["Settings"].waitForExistence(timeout: 20))
        app.openSettingsPage("Agent")
        let row = app.buttons.matching(NSPredicate(format: "label BEGINSWITH 'Phone tools'")).firstMatch
        for _ in 0 ..< 14 where !(row.exists && row.isHittable) { app.swipeUp() }
        hold(0.6)
        row.tap()
        XCTAssertTrue(app.navigationBars["Phone Tools"].waitForExistence(timeout: 10))
        hold(1.2)
        mark("start")
        hold(2.2)
        app.buttons.matching(NSPredicate(format: "label BEGINSWITH 'Contacts'")).firstMatch.tap()
        hold(1.6)
        app.buttons["Allow reading"].tap()
        hold(1.8)
        mark("end")
    }

    /// Set up by code: Settings shows this connection as a QR code for another device to scan.
    func testSetUpByCode() throws {
        let app = try launch(["-echo.demo", "-echo.screen", "settings", "-theme", "claudeCode", "-appearance", "dark"])
        XCTAssertTrue(app.navigationBars["Settings"].waitForExistence(timeout: 20))
        let button = app.buttons["Set up another device…"].firstMatch
        for _ in 0 ..< 6 where !button.isHittable { app.swipeUp(velocity: .slow) }
        hold(1.5)
        mark("start")
        hold(0.8)
        button.tap()
        XCTAssertTrue(app.images["setup-code"].waitForExistence(timeout: 5))
        hold(3)
        mark("end")
    }

    /// Make it yours: Settings with the app icons open, scrolled gently.
    func testMakeItYours() throws {
        let app = try launch(["-echo.demo", "-echo.screen", "settings", "-echo.expandAppIcons", "-theme", "standard", "-appearance", "light"])
        XCTAssertTrue(app.navigationBars["Appearance"].waitForExistence(timeout: 20), "Settings → Appearance didn't open on the icons")
        hold(2)
        mark("start")
        hold(1.2)
        // On iPad Settings is a sheet in the middle of the screen: a drag from lower down starts outside it.
        let wide = app.windows.firstMatch.frame.width > 600
        drag(app, from: CGVector(dx: 0.5, dy: wide ? 0.62 : 0.75), to: CGVector(dx: 0.5, dy: wide ? 0.42 : 0.5), velocity: 180)
        hold(2)
        mark("end")
    }

    // MARK: A day with Redde (design/video, `make_day.py`)

    /// The day's agents (Sol, Home, Coder) and chats, canned answers (`DemoDay`), in the Code
    /// theme, dark, as the other takes are.
    private func day(_ scene: String, _ extra: [String] = []) throws -> XCUIApplication {
        try launch(["-echo.demoProjects", "-echo.demoBots", "-echo.demoDay", scene, "-displayName", "Sol", "-echo.clearDrafts",
                    "-sessions.botActionsShown", "NO", "-projectsInChatList", "NO",
                    "-theme", "claudeCode", "-appearance", "dark"] + extra)
    }

    /// iOS asking to send notifications, answered (a fresh install's first launch of a scene that posts one).
    private func allowNotifications() {
        let allow = XCUIApplication(bundleIdentifier: "com.apple.springboard").alerts.buttons["Allow"]
        if allow.waitForExistence(timeout: 12) { allow.tap() }
        mark("allowed")
    }

    /// A slow drag from an element's middle, held at the end so what it shows there is seen.
    private func slide(_ element: XCUIElement, by dx: CGFloat, hold seconds: TimeInterval = 0.7) {
        let from = element.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5))
        from.press(forDuration: 0.08, thenDragTo: from.withOffset(CGVector(dx: dx, dy: 0)), withVelocity: 320, thenHoldForDuration: seconds)
    }

    /// A Form picker's menu, and one of its choices.
    private func pick(_ app: XCUIApplication, _ picker: String, _ choice: String) {
        let row = app.buttons.matching(NSPredicate(format: "label BEGINSWITH %@", picker)).firstMatch
        XCTAssertTrue(row.waitForExistence(timeout: 5), "no \(picker)")
        row.tap()
        hold(0.9)
        let item = app.buttons.matching(NSPredicate(format: "label == %@", choice)).firstMatch
        XCTAssertTrue(item.waitForExistence(timeout: 5), "no \(choice) in \(picker)")
        item.tap()
        mark(choice.lowercased().replacingOccurrences(of: " ", with: "-"))
    }

    /// The test runner's icon off the Home Screen (into the App Library), for the arrive-home take.
    func testDayTidy() throws {
        _ = try day("tidy")
        XCUIDevice.shared.press(.home)
        hold(1.5)
        let springboard = XCUIApplication(bundleIdentifier: "com.apple.springboard")
        let runner = springboard.icons.matching(NSPredicate(format: "label BEGINSWITH 'EchoUITests'")).firstMatch
        XCTAssertTrue(runner.waitForExistence(timeout: 5), "no runner icon")
        runner.press(forDuration: 1.4)
        let remove = springboard.buttons.matching(NSPredicate(format: "label CONTAINS 'Remove'")).firstMatch
        XCTAssertTrue(remove.waitForExistence(timeout: 5), "no Remove App")
        remove.tap()
        let fromHome = springboard.buttons.matching(NSPredicate(format: "label CONTAINS 'Home Screen'")).firstMatch
        XCTAssertTrue(fromHome.waitForExistence(timeout: 5), "no Remove from Home Screen")
        fromHome.tap()
        hold(1.5)
        XCUIDevice.shared.press(.home)
        hold(1)
    }

    /// A briefing's card on the deck at the top of the chat list (its words read as one element).
    private func deckCard(_ app: XCUIApplication, _ words: String) -> XCUIElement {
        app.descendants(matching: .any).matching(NSPredicate(format: "label CONTAINS %@", words)).firstMatch
    }

    /// 7:00: the chat list, the Week Ahead waiting in Briefings; the dentist's heads-up comes, as a
    /// banner and on top of the deck (its time, Leave by, the address); opened: the event card and
    /// the map.
    func testDayMorning() throws {
        let app = try day("morning", ["-echo.screen", "sessions", "-echo.demoDayAt", "3"])
        allowNotifications()
        let card = deckCard(app, "Leave by")
        XCTAssertTrue(card.waitForExistence(timeout: 20), "the heads-up didn't come")
        mark("arrived")
        hold(3.2)
        card.tap()
        XCTAssertTrue(says(app, "Open in Calendar"), "no event card")
        mark("opened")
        XCTAssertTrue(app.buttons["Directions"].firstMatch.waitForExistence(timeout: 15), "no map")
        mark("map")
        hold(5.5)
        mark("end")
    }

    /// 7:02: the Week Ahead on top of the deck, its strip of days; opened: the week as a calendar,
    /// scrolled to its end.
    func testDayWeek() throws {
        let app = try day("week", ["-echo.screen", "sessions"])
        let card = deckCard(app, "Week ahead")
        XCTAssertTrue(card.waitForExistence(timeout: 20), "no Week Ahead")
        hold(1.0)
        mark("start")
        hold(1.8)
        card.tap()
        XCTAssertTrue(says(app, "Team planning"), "no calendar")
        mark("opened")
        hold(2.2)
        drag(app, from: CGVector(dx: 0.5, dy: 0.8), to: CGVector(dx: 0.5, dy: 0.45), velocity: 260)
        mark("scrolled")
        hold(2.5)
        mark("end")
    }

    /// Idea cards: one put away with a swipe left, one taken with a swipe right, and Sol doing it.
    func testDayIdeas() throws {
        let app = try day("ideas", ["-echo.screen", "sessions"])
        let first = app.staticTexts["Learn the bagpipes"].firstMatch
        XCTAssertTrue(first.waitForExistence(timeout: 20), "no idea cards")
        hold(1.0)
        mark("start")
        hold(1.2)
        slide(first, by: -230)
        mark("notnow")
        hold(1.4)
        let second = app.staticTexts["Book the vet for Pickles"].firstMatch
        XCTAssertTrue(second.waitForExistence(timeout: 5))
        slide(second, by: 230)
        mark("doit")
        XCTAssertTrue(says(app, "tomorrow at 3:30"))
        mark("booked")
        hold(3.0)
        mark("end")
    }

    /// @Home in a chat with Sol: the bots offered, Home picked, the question sent, Home's answer
    /// here under its face.
    func testDayAside() throws {
        let app = try day("aside", ["-echo.fresh"])
        let field = app.textFields.firstMatch
        XCTAssertTrue(field.waitForExistence(timeout: 20))
        hold(1.5)
        mark("start")
        hold(0.8)
        field.tap()
        hold(0.5)
        field.typeText("@Ho")
        let home = app.buttons.containing(NSPredicate(format: "label CONTAINS %@", "@home")).firstMatch
        XCTAssertTrue(home.waitForExistence(timeout: 5), "Home wasn't offered")
        mark("offered")
        hold(1.2)
        home.tap()
        field.typeText("is the garage shut?")
        hold(0.6)
        app.buttons.matching(identifier: "Send").allElementsBoundByIndex.last { $0.isHittable }?.tap()
        mark("sent")
        XCTAssertTrue(says(app, "8:12"))
        mark("answered")
        hold(3.0)
        mark("end")
    }

    /// A question typed to Sol and sent; the reply's own words, once they show.
    private func ask(_ app: XCUIApplication, _ question: String) {
        let field = app.textFields.firstMatch
        XCTAssertTrue(field.waitForExistence(timeout: 20))
        hold(1.5)
        mark("start")
        hold(0.6)
        field.tap()
        hold(0.4)
        field.typeText(question)
        hold(0.5)
        app.buttons.matching(identifier: "Send").allElementsBoundByIndex.last { $0.isHittable }?.tap()
        mark("sent")
    }

    /// 6:30 PM: "Where should we go for dinner?", Sol's choice card, one picked, and Sol on it, with
    /// a map of the place's address.
    func testDayDinner() throws {
        let app = try day("dinner", ["-echo.fresh"])
        ask(app, "Where should we go for dinner?")
        let lupa = app.buttons.containing(NSPredicate(format: "label CONTAINS 'Lupa Trattoria'")).firstMatch
        XCTAssertTrue(lupa.waitForExistence(timeout: 15), "no choice card")
        mark("card")
        XCTAssertTrue(says(app, "all open now"))
        hold(2.0)
        lupa.tap()
        mark("picked")
        XCTAssertTrue(says(app, "Table for four"))
        mark("confirmed")
        // Its address gets a map (`AddressMaps`), under the reply: brought up over the composer.
        XCTAssertTrue(app.buttons["Directions"].firstMatch.waitForExistence(timeout: 15), "no map")
        mark("map")
        hold(1.2)
        drag(app, from: CGVector(dx: 0.5, dy: 0.72), to: CGVector(dx: 0.5, dy: 0.52), velocity: 220)
        mark("scrolled")
        hold(3.5)
        mark("end")
    }

    /// 8:00 PM: the cabin's packing list as a checklist card, the last three things ticked off.
    func testDayPacking() throws {
        let app = try day("packing", ["-echo.fresh"])
        ask(app, "Make me a packing list for the cabin")
        XCTAssertTrue(says(app, "Cabin weekend"), "no checklist card")
        mark("card")
        XCTAssertTrue(says(app, "packed already"))
        hold(1.6)
        for item in ["Rain jackets", "S'mores supplies", "Fishing rods"] {
            // (a toggle since the 10-30 redesign, not a button)
            let row = app.descendants(matching: .any).matching(NSPredicate(format: "label CONTAINS %@", item)).firstMatch
            XCTAssertTrue(row.waitForExistence(timeout: 5), "no \(item)")
            row.tap()
            mark("ticked")
            hold(0.9)
        }
        mark("done")
        hold(3.0)
        mark("end")
    }

    // MARK: The 2.3 day (design/video, `make_day23.py`): the new cards and proactive moments

    /// 7:30: "What's my day look like?", today as a timeline (the walk over, a free stretch, the
    /// now line); the dentist tapped open in place.
    func testDayToday() throws {
        let app = try day("today", ["-echo.fresh"])
        ask(app, "What's my day look like?")
        XCTAssertTrue(says(app, "Free for"), "no timeline")
        mark("card")
        XCTAssertTrue(says(app, "when to leave"))
        mark("answered")
        hold(2.6)
        let dentist = app.buttons.containing(NSPredicate(format: "label CONTAINS 'Dentist'")).firstMatch
        if dentist.waitForExistence(timeout: 5) { dentist.tap() }
        mark("opened")
        hold(3.0)
        mark("end")
    }

    /// 10:15: "Good coffee near me?", answered from where the phone is: the places on a map,
    /// walks measured from here.
    func testDayCoffee() throws {
        let app = try day("coffee", ["-echo.fresh"])
        ask(app, "Good coffee near me?")
        XCTAssertTrue(app.buttons.containing(NSPredicate(format: "label CONTAINS 'Directions to'")).firstMatch.waitForExistence(timeout: 30), "no places card")
        mark("card")
        hold(5.0)
        mark("end")
    }

    /// 12:10: "Will it rain this afternoon?", the weather card with the storms called out.
    func testDayRain() throws {
        let app = try day("rain", ["-echo.fresh"])
        ask(app, "Will it rain this afternoon?")
        XCTAssertTrue(says(app, "Storms likely"), "no weather card")
        mark("card")
        hold(5.0)
        mark("end")
    }

    /// 1:30 PM: the dentist's heads-up arriving (banner, deck), opened: the event card and the map.
    func testDayAfternoon() throws {
        let app = try day("afternoon", ["-echo.screen", "sessions", "-echo.demoDayAt", "3"])
        allowNotifications()
        let card = deckCard(app, "Leave by")
        XCTAssertTrue(card.waitForExistence(timeout: 20), "the heads-up didn't come")
        mark("arrived")
        hold(3.2)
        card.tap()
        XCTAssertTrue(says(app, "Open in Calendar"), "no event card")
        mark("opened")
        XCTAssertTrue(app.buttons["Directions"].firstMatch.waitForExistence(timeout: 15), "no map")
        mark("map")
        hold(5.5)
        mark("end")
    }

    /// 2:00 PM: the Leave-By Alert, worked out on the phone, as a banner over the chat list; then
    /// held open, with Open in Maps.
    func testDayLeave() throws {
        _ = try day("leave", ["-echo.screen", "sessions", "-echo.demoDayAt", "3"])
        allowNotifications()
        let springboard = XCUIApplication(bundleIdentifier: "com.apple.springboard")
        let banner = springboard.descendants(matching: .any).matching(NSPredicate(format: "label CONTAINS 'Time to leave'")).firstMatch
        XCTAssertTrue(banner.waitForExistence(timeout: 20), "no Leave-By Alert")
        mark("banner")
        hold(3.0)
        if ProcessInfo.processInfo.environment["PROMO_EXPAND"] == "1" {
            banner.press(forDuration: 1.0)
            mark("expanded")
            hold(3.5)
        }
        mark("end")
    }

    /// 7:50, again: the timeline's free stretch tapped, Apple's new-event editor at its start, a
    /// title typed, Add.
    func testDayAddEvent() throws {
        let app = try day("today", ["-echo.fresh"])
        ask(app, "What's my day look like?")
        let free = app.buttons.containing(NSPredicate(format: "label CONTAINS 'Free for'")).firstMatch
        XCTAssertTrue(free.waitForExistence(timeout: 30), "no free stretch")
        XCTAssertTrue(says(app, "when to leave"))
        mark("card")
        hold(2.0)
        free.tap()
        mark("tapped")
        let title = app.textFields["Title"].firstMatch
        XCTAssertTrue(title.waitForExistence(timeout: 10), "no event editor")
        mark("editor")
        hold(1.2)
        title.tap()
        title.typeText("Walk by the lake")
        mark("typed")
        hold(1.0)
        // Add: the editor's tick, top right (Apple's editor; its button isn't found by name here).
        app.windows.firstMatch.coordinate(withNormalizedOffset: CGVector(dx: 0.89, dy: 0.105)).tap()
        mark("added")
        hold(2.5)
        mark("end")
    }

    /// 10:40: a watch and a follow-up asked for in words.
    func testDayWatchAsk() throws {
        let app = try day("watchask", ["-echo.fresh"])
        ask(app, "Let me know when the bike part ships. I'll call the plumber tomorrow.")
        XCTAssertTrue(says(app, "plumber tomorrow at 9"), "no answer")
        mark("answered")
        hold(3.5)
        mark("end")
    }

    /// 1:45 PM: the traffic re-check, heavier: leave by 1:55 now.
    func testDayTraffic() throws {
        _ = try day("traffic", ["-echo.screen", "sessions", "-echo.demoDayAt", "3"])
        allowNotifications()
        let springboard = XCUIApplication(bundleIdentifier: "com.apple.springboard")
        let banner = springboard.descendants(matching: .any).matching(NSPredicate(format: "label CONTAINS 'traffic is heavier'")).firstMatch
        XCTAssertTrue(banner.waitForExistence(timeout: 20), "no re-check")
        mark("banner")
        hold(4.5)
        mark("end")
    }

    /// 4:20 PM: the bike part's watch says it shipped, as a banner over Watches & Follow-ups, and
    /// its row says so too, with the plumber's follow-up below.
    func testDayShipped() throws {
        let app = try day("shipped", ["-echo.screen", "watches", "-echo.demoDayAt", "4"])
        allowNotifications()
        XCTAssertTrue(app.navigationBars["Watches & Follow-ups"].waitForExistence(timeout: 20), "no Watches")
        mark("start")
        let springboard = XCUIApplication(bundleIdentifier: "com.apple.springboard")
        let banner = springboard.descendants(matching: .any).matching(NSPredicate(format: "label CONTAINS 'bike part'")).firstMatch
        XCTAssertTrue(banner.waitForExistence(timeout: 20), "no watch note")
        mark("banner")
        XCTAssertTrue(says(app, "Arriving Thursday"), "the row didn't change")
        mark("row")
        hold(3.5)
        mark("end")
    }

    /// 11:30: Coder's approval card in plain words (the delete in red); Allow Once.
    func testDayApproval() throws {
        let app = try day("approval")
        let allow = app.buttons["Allow Once"].firstMatch
        XCTAssertTrue(allow.waitForExistence(timeout: 20), "no approval card")
        hold(1.0)
        mark("start")
        hold(3.0)
        allow.tap()
        mark("allowed")
        hold(3.0)
        mark("end")
    }

    /// 3:35 PM: "Tell Ana I'm running 15 minutes late for pickup", the email drawn as itself; Send.
    func testDayEmail() throws {
        let app = try day("email", ["-echo.fresh"])
        ask(app, "Tell Ana I'm running 15 minutes late for pickup")
        XCTAssertTrue(says(app, "Running late for pickup"), "no email card")
        mark("card")
        XCTAssertTrue(says(app, "tap Send"))
        hold(2.8)
        let send = app.buttons.matching(NSPredicate(format: "label == 'Send'")).allElementsBoundByIndex.last { $0.isHittable }
        XCTAssertNotNil(send, "no Send on the card")
        send?.tap()
        mark("sent")
        XCTAssertTrue(says(app, "writes back"))
        mark("answered")
        hold(3.0)
        mark("end")
    }

    /// Triggers from Your iPhone, down to its ideas (arriving home first among them).
    func testDayTriggers() throws {
        let app = try day("triggers", ["-echo.screen", "triggers"])
        XCTAssertTrue(app.navigationBars["Triggers from Your iPhone"].waitForExistence(timeout: 20))
        hold(1.0)
        mark("start")
        hold(1.0)
        drag(app, from: CGVector(dx: 0.5, dy: 0.78), to: CGVector(dx: 0.5, dy: 0.42), velocity: 260)
        mark("scrolled")
        hold(3.0)
        mark("end")
    }

    /// Arriving home: Redde in the background, as an automation runs it, and Home's answer comes
    /// as Redde's notification over the Home Screen.
    func testDayHome() throws {
        let app = try day("home", ["-echo.demoDayAt", "7"])
        allowNotifications()
        XCTAssertTrue(app.textFields.firstMatch.waitForExistence(timeout: 20))
        hold(0.8)
        XCUIDevice.shared.press(.home)
        mark("home")
        let springboard = XCUIApplication(bundleIdentifier: "com.apple.springboard")
        let banner = springboard.descendants(matching: .any).matching(NSPredicate(format: "label CONTAINS 'Welcome home'")).firstMatch
        XCTAssertTrue(banner.waitForExistence(timeout: 20), "no notification")
        mark("banner")
        hold(4.0)
        mark("end")
    }

    /// 21:00: the Evening wrap-up on top of the Briefings deck, opened.
    func testDayEvening() throws {
        let app = try day("evening", ["-echo.screen", "sessions"])
        let row = deckCard(app, "Evening wrap-up")
        XCTAssertTrue(row.waitForExistence(timeout: 20), "no wrap-up")
        hold(1.0)
        mark("start")
        hold(1.8)
        row.tap()
        XCTAssertTrue(says(app, "Waiting on you"))
        mark("opened")
        hold(3.5)
        mark("end")
    }

    /// Make This a Routine over the dinner plan: every week, on Sundays.
    func testDayRoutine() throws {
        let app = try day("routine")
        XCTAssertTrue(app.navigationBars["Make This a Routine"].waitForExistence(timeout: 20))
        hold(0.8)
        mark("start")
        hold(0.8)
        pick(app, "Run It", "Every week")
        hold(0.8)
        pick(app, "Day", "Sunday")
        hold(2.0)
        mark("end")
    }

    /// Agent Activity, the day so far.
    func testDayActivity() throws {
        let app = try day("activity", ["-echo.screen", "activity"])
        XCTAssertTrue(app.navigationBars["Agent Activity"].waitForExistence(timeout: 20))
        XCTAssertTrue(says(app, "nothing new"))
        hold(0.8)
        mark("start")
        hold(3.5)
        mark("end")
    }

    /// Plugin Updates: three, all updated at once.
    func testDayPlugins() throws {
        let app = try day("plugins", ["-echo.screen", "pluginUpdates"])
        let all = app.buttons["Update All"].firstMatch
        XCTAssertTrue(all.waitForExistence(timeout: 20))
        hold(0.8)
        mark("start")
        hold(1.0)
        all.tap()
        mark("updating")
        hold(3.2)
        mark("end")
    }

    /// New Watch on the garage door: how often, Home watching, a change handed to Sol.
    func testDayWatch() throws {
        let app = try day("watch")
        XCTAssertTrue(app.navigationBars["New Watch"].waitForExistence(timeout: 20))
        hold(0.8)
        mark("start")
        hold(0.6)
        pick(app, "How often", "Every 15 minutes")
        hold(0.6)
        pick(app, "Who Watches", "Home")
        hold(0.6)
        pick(app, "When It Changes", "Hand It to Sol")
        hold(2.2)
        mark("end")
    }

    // MARK: Redde 2.4 (design/video, `make_24.py`)

    /// 2.4's sample data in the standard theme, dark, as the 2.4 screenshots: the inbox, two goals,
    /// the briefings, the bots and the made-up Coming Up day, on the chat list.
    private func v24(_ extra: [String] = []) throws -> XCUIApplication {
        try launch(["-echo.demoInbox", "two", "-echo.demoGoals", "-echo.demoBriefings", "-echo.demoComingUp",
                    "-echo.demoProjects", "-echo.demoBots", "-displayName", "Sol", "-echo.clearDrafts",
                    "-theme", "standard", "-appearance", "dark"] + extra)
    }

    /// The goal's tile in the Today row tapped: its page, then down to the plan.
    func testV24Goal() throws {
        let app = try v24(["-echo.screen", "sessions"])
        let tile = app.staticTexts["Run a 10K by March"].firstMatch
        XCTAssertTrue(tile.waitForExistence(timeout: 20), "no goal tile")
        hold(1.5)
        mark("start")
        hold(1.0)
        tile.tap()
        XCTAssertTrue(app.staticTexts["Last Check-in"].firstMatch.waitForExistence(timeout: 10))
        mark("open")
        hold(2.5)
        drag(app, from: CGVector(dx: 0.5, dy: 0.8), to: CGVector(dx: 0.5, dy: 0.35))
        mark("plan")
        hold(2.5)
        mark("end")
    }

    /// The Inbox's tile tapped: Important, then Needs Reply, then an email in the reader.
    func testV24Inbox() throws {
        let app = try v24(["-echo.screen", "sessions"])
        let tile = app.staticTexts["Inbox"].firstMatch
        XCTAssertTrue(tile.waitForExistence(timeout: 20), "no inbox tile")
        hold(1.5)
        mark("start")
        hold(1.0)
        tile.tap()
        let reply = app.buttons.containing(NSPredicate(format: "label BEGINSWITH %@", "Needs Reply")).firstMatch
        XCTAssertTrue(reply.waitForExistence(timeout: 10), "no piles")
        mark("open")
        hold(2.0)
        reply.tap()
        mark("reply")
        hold(2.0)
        let row = app.cells.firstMatch
        XCTAssertTrue(row.waitForExistence(timeout: 5), "no email")
        row.tap()
        mark("email")
        hold(3.0)
        mark("end")
    }

    /// The Today row slid along, then Planned: what the agent will do next.
    func testV24Today() throws {
        let app = try v24(["-echo.screen", "sessions"])
        let tile = app.staticTexts["Inbox"].firstMatch
        XCTAssertTrue(tile.waitForExistence(timeout: 20), "no Today row")
        hold(1.5)
        mark("start")
        hold(1.2)
        slide(tile, by: -320, hold: 0.4)
        mark("slid")
        hold(1.6)
        // (the segmented tab: a hidden ⌘2 button is also called Planned)
        app.segmentedControls.buttons["Planned"].firstMatch.tap()
        mark("planned")
        hold(3.5)
        mark("end")
    }

    /// Memory: a fact added in words and saved.
    func testV24Memory() throws {
        // (only the memory's sample data: with the rest, something presented at launch closes the
        // page again a few seconds in)
        let app = try launch(["-echo.demoMemory", "-echo.screen", "memory", "-echo.clearDrafts", "-theme", "standard", "-appearance", "dark"])
        let add = app.navigationBars["Memory"].waitForExistence(timeout: 20) ? app.collectionViews.buttons["Add"].firstMatch : app.buttons["no memory page"]
        XCTAssertTrue(add.waitForExistence(timeout: 20), "no memory page")
        hold(1.2)
        mark("start")
        hold(1.0)
        add.tap()
        hold(0.6)
        app.typeText("Training for a 10K in March.")   // (the new row has the focus)
        mark("typed")
        hold(1.0)
        let save = app.navigationBars.buttons["Save"].firstMatch
        XCTAssertTrue(save.waitForExistence(timeout: 5), "no Save")
        save.tap()
        mark("saved")
        hold(2.5)
        mark("end")
    }
}