| 1 | import XCTest |
| 2 | |
| 3 | // The notification-tap route, end to end in the simulator (2026-07-28): a |
| 4 | // tapped banner must land IN the chat, cold launch included. No script can |
| 5 | // press a real springboard banner, so the app's DEBUG tap door |
| 6 | // (LCHAT_TEST_TAP_THREAD, PushManager) replays the tap's exact effect — |
| 7 | // forceScrollThreadID + pendingThreadID — a beat after launch, and this test |
| 8 | // drives the two launches around it. Runs against the local dev server like |
| 9 | // FeedMenuUITests: pass TEST_RUNNER_DEV_LINK and TEST_RUNNER_THREAD_ID (both |
| 10 | // from .lchat-dev.json) on the xcodebuild invocation; without them it skips. |
| 11 | final class NotificationTapUITests: XCTestCase { |
| 12 | |
| 13 | override func setUpWithError() throws { |
| 14 | continueAfterFailure = false |
| 15 | } |
| 16 | |
| 17 | func testColdLaunchNotificationTapLandsInThread() throws { |
| 18 | let env = ProcessInfo.processInfo.environment |
| 19 | guard let link = env["DEV_LINK"], !link.isEmpty, |
| 20 | let threadID = env["THREAD_ID"], !threadID.isEmpty else { |
| 21 | throw XCTSkip("Set TEST_RUNNER_DEV_LINK + TEST_RUNNER_THREAD_ID from .lchat-dev.json.") |
| 22 | } |
| 23 | |
| 24 | // ---- run 1: sign in and visit the chat once ------------------------ |
| 25 | // A real phone that receives pushes has been in the space: the thread |
| 26 | // list refresh parks the SharedThreadKeys entries the tap route |
| 27 | // resolves against, and opening the space stamps lastInstanceID. |
| 28 | let app = XCUIApplication() |
| 29 | app.launchEnvironment["LCHAT_DEV_JOIN"] = link |
| 30 | app.launch() |
| 31 | allowNotificationsIfAsked(app) |
| 32 | |
| 33 | // The dev-join claim lands on the spaces home screen — enter the space |
| 34 | // (its row wears the dev server's host as its name). |
| 35 | let devRow = app.staticTexts["Dev chat"].firstMatch |
| 36 | if !devRow.waitForExistence(timeout: 5) { |
| 37 | let spaceRow = app.staticTexts["127.0.0.1"].firstMatch |
| 38 | if spaceRow.waitForExistence(timeout: 25) { spaceRow.tap() } |
| 39 | } |
| 40 | XCTAssertTrue(devRow.waitForExistence(timeout: 25), "Dev chat not in thread list") |
| 41 | devRow.tap() |
| 42 | let bubble = app.staticTexts |
| 43 | .containing(NSPredicate(format: "label CONTAINS %@", "seeded by lchat-dev-up")) |
| 44 | .firstMatch |
| 45 | XCTAssertTrue(bubble.waitForExistence(timeout: 20), "seeded bubble never rendered") |
| 46 | shot(app, "tap-01-visited") |
| 47 | app.terminate() |
| 48 | |
| 49 | // ---- run 2: cold launch with the tap waiting ----------------------- |
| 50 | // The door fires 1s after launch — didReceive's timing on a cold |
| 51 | // launch (delivered right after the first frame). |
| 52 | app.launchEnvironment["LCHAT_TEST_TAP_THREAD"] = threadID |
| 53 | app.launchEnvironment["LCHAT_TEST_TAP_DELAY"] = "1.0" |
| 54 | app.launch() |
| 55 | shot(app, "tap-02-relaunched") |
| 56 | |
| 57 | // The verdict: the tapped chat's feed is on screen — and stays there. |
| 58 | let landed = bubble.waitForExistence(timeout: 20) |
| 59 | shot(app, landed ? "tap-03-landed" : "tap-03-STRANDED") |
| 60 | XCTAssertTrue(landed, "notification tap never landed in the chat") |
| 61 | |
| 62 | // A wedged NavigationStack still shows the feed it dropped into — |
| 63 | // prove the UI is alive by leaving the chat and coming back. |
| 64 | let back = app.navigationBars.buttons.firstMatch |
| 65 | XCTAssertTrue(back.waitForExistence(timeout: 5), "no back button on the chat screen") |
| 66 | back.tap() |
| 67 | let row = app.staticTexts["Dev chat"].firstMatch |
| 68 | let listAlive = row.waitForExistence(timeout: 10) |
| 69 | shot(app, listAlive ? "tap-04-back-alive" : "tap-04-back-DEAD") |
| 70 | XCTAssertTrue(listAlive, "back from the tapped chat never reached the list") |
| 71 | } |
| 72 | |
| 73 | private func allowNotificationsIfAsked(_ app: XCUIApplication) { |
| 74 | let springboard = XCUIApplication(bundleIdentifier: "com.apple.springboard") |
| 75 | let allow = springboard.alerts.buttons["Allow"].firstMatch |
| 76 | if allow.waitForExistence(timeout: 5) { allow.tap() } |
| 77 | // Whether or not the alert showed, poke the app so the run continues. |
| 78 | app.activate() |
| 79 | } |
| 80 | |
| 81 | private func shot(_ app: XCUIApplication, _ name: String) { |
| 82 | let a = XCTAttachment(screenshot: app.screenshot()) |
| 83 | a.name = name |
| 84 | a.lifetime = .keepAlways |
| 85 | add(a) |
| 86 | } |
| 87 | } |
| 88 | |