import XCTest // The notification-tap route, end to end in the simulator (2026-07-28): a // tapped banner must land IN the chat, cold launch included. No script can // press a real springboard banner, so the app's DEBUG tap door // (LCHAT_TEST_TAP_THREAD, PushManager) replays the tap's exact effect — // forceScrollThreadID + pendingThreadID — a beat after launch, and this test // drives the two launches around it. Runs against the local dev server like // FeedMenuUITests: pass TEST_RUNNER_DEV_LINK and TEST_RUNNER_THREAD_ID (both // from .lchat-dev.json) on the xcodebuild invocation; without them it skips. final class NotificationTapUITests: XCTestCase { override func setUpWithError() throws { continueAfterFailure = false } func testColdLaunchNotificationTapLandsInThread() throws { let env = ProcessInfo.processInfo.environment guard let link = env["DEV_LINK"], !link.isEmpty, let threadID = env["THREAD_ID"], !threadID.isEmpty else { throw XCTSkip("Set TEST_RUNNER_DEV_LINK + TEST_RUNNER_THREAD_ID from .lchat-dev.json.") } // ---- run 1: sign in and visit the chat once ------------------------ // A real phone that receives pushes has been in the space: the thread // list refresh parks the SharedThreadKeys entries the tap route // resolves against, and opening the space stamps lastInstanceID. let app = XCUIApplication() app.launchEnvironment["LCHAT_DEV_JOIN"] = link app.launch() allowNotificationsIfAsked(app) // The dev-join claim lands on the spaces home screen — enter the space // (its row wears the dev server's host as its name). let devRow = app.staticTexts["Dev chat"].firstMatch if !devRow.waitForExistence(timeout: 5) { let spaceRow = app.staticTexts["127.0.0.1"].firstMatch if spaceRow.waitForExistence(timeout: 25) { spaceRow.tap() } } XCTAssertTrue(devRow.waitForExistence(timeout: 25), "Dev chat not in thread list") devRow.tap() let bubble = app.staticTexts .containing(NSPredicate(format: "label CONTAINS %@", "seeded by lchat-dev-up")) .firstMatch XCTAssertTrue(bubble.waitForExistence(timeout: 20), "seeded bubble never rendered") shot(app, "tap-01-visited") app.terminate() // ---- run 2: cold launch with the tap waiting ----------------------- // The door fires 1s after launch — didReceive's timing on a cold // launch (delivered right after the first frame). app.launchEnvironment["LCHAT_TEST_TAP_THREAD"] = threadID app.launchEnvironment["LCHAT_TEST_TAP_DELAY"] = "1.0" app.launch() shot(app, "tap-02-relaunched") // The verdict: the tapped chat's feed is on screen — and stays there. let landed = bubble.waitForExistence(timeout: 20) shot(app, landed ? "tap-03-landed" : "tap-03-STRANDED") XCTAssertTrue(landed, "notification tap never landed in the chat") // A wedged NavigationStack still shows the feed it dropped into — // prove the UI is alive by leaving the chat and coming back. let back = app.navigationBars.buttons.firstMatch XCTAssertTrue(back.waitForExistence(timeout: 5), "no back button on the chat screen") back.tap() let row = app.staticTexts["Dev chat"].firstMatch let listAlive = row.waitForExistence(timeout: 10) shot(app, listAlive ? "tap-04-back-alive" : "tap-04-back-DEAD") XCTAssertTrue(listAlive, "back from the tapped chat never reached the list") } private func allowNotificationsIfAsked(_ app: XCUIApplication) { let springboard = XCUIApplication(bundleIdentifier: "com.apple.springboard") let allow = springboard.alerts.buttons["Allow"].firstMatch if allow.waitForExistence(timeout: 5) { allow.tap() } // Whether or not the alert showed, poke the app so the run continues. app.activate() } private func shot(_ app: XCUIApplication, _ name: String) { let a = XCTAttachment(screenshot: app.screenshot()) a.name = name a.lifetime = .keepAlways add(a) } }