diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 91ed621..a51a1d1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,8 +23,12 @@ jobs: - name: Show toolchain run: xcodebuild -version - - name: Install XcodeGen - run: brew install xcodegen + # uv runs the demo server; ffmpeg gives the demo its voice notes and videos, which the live playback test reads. + - name: Install XcodeGen, uv and ffmpeg + env: + HOMEBREW_NO_AUTO_UPDATE: "1" + HOMEBREW_NO_INSTALL_CLEANUP: "1" + run: brew install xcodegen uv ffmpeg - name: Generate the Xcode project run: xcodegen generate @@ -32,11 +36,38 @@ jobs: - name: Check for undeclared Required Reason APIs run: scripts/check-required-reason-apis.sh --self-test && scripts/check-required-reason-apis.sh - - name: Pick a simulator + - name: Start the demo server + env: + DATA_DIR: ${{ runner.temp }}/demo-data + SRC_DIR: ${{ runner.temp }}/demo-src + run: scripts/demo-server.sh start + + # The store screenshots need a 6.9-inch iPhone: the newest Pro Max on the newest runtime. The status bar + # is pinned so every screenshot shows the same time, signal and battery. + - name: Pick a 6.9-inch simulator id: sim - run: echo "udid=$(scripts/pick-simulator.sh)" >> "$GITHUB_OUTPUT" + run: | + udid=$(xcrun simctl list devices available --json | python3 -c ' + import json, sys + devices = json.load(sys.stdin)["devices"] + version = lambda runtime: [int(p) for p in runtime.split("iOS-")[1].split("-")] + for runtime in sorted((r for r in devices if ".iOS-" in r), key=version, reverse=True): + for device in sorted(devices[runtime], key=lambda d: d["name"], reverse=True): + if device["name"].startswith("iPhone") and device["name"].endswith("Pro Max"): + print(device["udid"]); sys.exit(0) + sys.exit("no iPhone Pro Max simulator available") + ') + xcrun simctl bootstatus "$udid" -b >/dev/null + xcrun simctl status_bar "$udid" override --time 9:41 --dataNetwork wifi --wifiMode active --wifiBars 3 \ + --cellularMode active --cellularBars 4 --batteryState charged --batteryLevel 100 + xcrun simctl list devices | grep "$udid" + echo "udid=$udid" >> "$GITHUB_OUTPUT" + # xcodebuild hands TEST_RUNNER_ to the test processes as . - name: Build and run unit and UI tests + env: + TEST_RUNNER_TGARCHIVE_DEMO_URL: http://127.0.0.1:8000 + TEST_RUNNER_TGARCHIVE_SCREENSHOT_DIR: ${{ github.workspace }}/build/screenshots run: | set -o pipefail mkdir -p build @@ -49,19 +80,39 @@ jobs: | grep -E "error:|warning: .*\.swift|Test Case .*(passed|failed)|Test .*(passed|failed) after|Executed [0-9]+ tests?|\*\* TEST" # A run that executes no tests also ends in TEST SUCCEEDED. Read the counts from the result bundle. - # 219 = 218 unit tests (the live-server tests count while skipped without TGARCHIVE_DEMO_URL) plus the - # launch UI test; raise it when a stage adds tests. + # 224 = 218 unit tests plus 6 UI tests (launch, viewer browse, viewer search, wrong password, share link, + # store screenshots); raise it when a stage adds tests. With the demo running nothing may skip: a skipped + # live or UI test would mean the demo or the 6.9-inch simulator was missing. - name: Gate on the test count run: | xcrun xcresulttool get test-results summary --path build/results.xcresult --compact \ | python3 -c ' import json, sys s = json.load(sys.stdin) - total, failed = s.get("totalTestCount", 0), s.get("failedTests", 0) - print(f"totalTestCount={total} failedTests={failed}") - sys.exit(0 if total >= 219 and failed == 0 else 1) + total, failed, skipped = s.get("totalTestCount", 0), s.get("failedTests", 0), s.get("skippedTests", 0) + print(f"totalTestCount={total} failedTests={failed} skippedTests={skipped}") + sys.exit(0 if total >= 224 and failed == 0 and skipped == 0 else 1) ' + - name: Check the store screenshots + run: | + shopt -s nullglob + shots=(build/screenshots/*.png) + echo "${#shots[@]} screenshots" + [[ ${#shots[@]} -eq 5 ]] || { echo "expected 5 store screenshots" >&2; exit 1; } + for shot in "${shots[@]}"; do + size=$(sips -g pixelWidth -g pixelHeight "$shot" | awk '/pixel(Width|Height)/ { printf "%s ", $2 }') + echo "$shot: $size" + [[ "$size" == "1320 2868 " ]] || { echo "$shot is not 1320 x 2868" >&2; exit 1; } + done + + - name: Keep the store screenshots + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: store-screenshots + path: build/screenshots/*.png + retention-days: 30 + - name: Build the Release configuration run: | set -o pipefail @@ -71,6 +122,13 @@ jobs: CODE_SIGNING_ALLOWED=NO \ | grep -E "error:|\*\* BUILD" + - name: Stop the demo server + if: always() + env: + DATA_DIR: ${{ runner.temp }}/demo-data + SRC_DIR: ${{ runner.temp }}/demo-src + run: scripts/demo-server.sh stop + - name: Keep the test results if: failure() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 @@ -79,4 +137,5 @@ jobs: path: | build/results.xcresult build/test.log + ${{ runner.temp }}/demo-data/viewer.log retention-days: 7 diff --git a/TGArchiveUITests/DemoTestCase.swift b/TGArchiveUITests/DemoTestCase.swift new file mode 100644 index 0000000..b697616 --- /dev/null +++ b/TGArchiveUITests/DemoTestCase.swift @@ -0,0 +1,243 @@ +import XCTest + +/// The demo server the UI tests read (scripts/demo-server.sh). The logins are the public demo constants of the +/// server project's generator, and each can be overridden from the environment. +enum Demo { + static var url: String? { env("TGARCHIVE_DEMO_URL") } + static var masterUsername: String { env("TGARCHIVE_DEMO_USERNAME") ?? "admin" } + static var masterPassword: String { env("TGARCHIVE_DEMO_PASSWORD") ?? "demo-admin-not-a-secret" } + static let viewerUsername = "family" + static var viewerPassword: String { env("TGARCHIVE_DEMO_VIEWER_PASSWORD") ?? "demo-viewer-not-a-secret" } + static var shareToken: String { env("TGARCHIVE_DEMO_SHARE_TOKEN") ?? "demo-share-link-not-a-secret" } + + static func env(_ name: String) -> String? { + let value = ProcessInfo.processInfo.environment[name] ?? "" + return value.isEmpty ? nil : value + } +} + +/// A UI test against the live demo server. Each test starts signed out and ends signed out, so it never leaves +/// a server session behind (each user holds at most ten). Every test signs in once at most: sign-in and +/// share-link sign-in share a limit of 15 attempts per client IP in 5 minutes. +@MainActor +class DemoTestCase: XCTestCase { + var app: XCUIApplication! + var demoURL = "" + + override func setUpWithError() throws { + continueAfterFailure = false + } + + /// Launches the app signed out against the demo, and signs out again when the test ends, however it ends. + /// Called first by every test; it skips the test when no demo server is set. + func launchSignedOut() throws { + guard let url = Demo.url else { + throw XCTSkip("TGARCHIVE_DEMO_URL is not set; start the demo with scripts/demo-server.sh") + } + demoURL = url + let app = XCUIApplication() + app.launchArguments += ["-AppleLanguages", "(en)", "-AppleLocale", "en_US"] + self.app = app + addTeardownBlock { @MainActor in + // A fresh launch leaves whatever screen a failure stopped on, then the session is ended. + app.launch() + app.signOutIfSignedIn() + } + app.launch() + app.signOutIfSignedIn() + } + + // MARK: Elements + + func element(_ identifier: String) -> XCUIElement { + app.descendants(matching: .any)[identifier].firstMatch + } + + func elements(prefixed prefix: String) -> XCUIElementQuery { + app.descendants(matching: .any).matching(NSPredicate(format: "identifier BEGINSWITH %@", prefix)) + } + + func elements(containing text: String) -> XCUIElementQuery { + app.descendants(matching: .any).matching(NSPredicate(format: "label CONTAINS %@", text)) + } + + var tabBar: XCUIElement { app.tabBars.firstMatch } + + /// Waits until one of the elements exists and returns it. + @discardableResult + func waitForAny(_ candidates: [XCUIElement], timeout: TimeInterval = 20, + file: StaticString = #filePath, line: UInt = #line) -> XCUIElement? { + let deadline = Date().addingTimeInterval(timeout) + repeat { + if let found = candidates.first(where: { $0.exists }) { return found } + RunLoop.current.run(until: Date().addingTimeInterval(0.25)) + } while Date() < deadline + XCTFail("none of \(candidates.map(\.description)) appeared within \(Int(timeout)) s", file: file, line: line) + return nil + } + + /// The ids of the elements whose identifier starts with `prefix`, in screen order, without repeats. + func identifiers(prefixed prefix: String) -> [String] { + var seen = Set() + return elements(prefixed: prefix).allElementsBoundByIndex + .map { $0.identifier } + .filter { seen.insert($0).inserted } + } + + // MARK: Session + + /// Connects to the demo server and opens its sign-in screen. + func openSignIn(file: StaticString = #filePath, line: UInt = #line) { + waitForAny([element("connect.address"), element("signin.submit")], file: file, line: line) + if element("signin.changeServer").exists { + element("signin.changeServer").tap() + } + let address = app.textFields["connect.address"] + XCTAssertTrue(address.waitForExistence(timeout: 10), file: file, line: line) + replaceText(in: address, with: demoURL) + app.buttons["connect.continue"].tap() + XCTAssertTrue(element("signin.submit").waitForExistence(timeout: 20), "the demo server did not answer", + file: file, line: line) + XCTAssertEqual(element("signin.server").label, URL(string: demoURL)?.host(), file: file, line: line) + } + + /// Signs in with a username and password and fails unless the archive opens. + func signIn(username: String, password: String, file: StaticString = #filePath, line: UInt = #line) { + openSignIn(file: file, line: line) + app.segmentedControls.buttons["Account"].tap() + replaceText(in: app.textFields["signin.username"], with: username) + replaceText(in: app.secureTextFields["signin.password"], with: password) + submitSignIn(file: file, line: line) + } + + /// Signs in with a share link and fails unless the archive opens. + func signIn(token: String, file: StaticString = #filePath, line: UInt = #line) { + openSignIn(file: file, line: line) + app.segmentedControls.buttons["Share link"].tap() + let field = app.textViews["signin.token"].exists ? app.textViews["signin.token"] : app.textFields["signin.token"] + replaceText(in: field, with: token) + submitSignIn(file: file, line: line) + } + + private func submitSignIn(file: StaticString, line: UInt) { + let submit = app.buttons["signin.submit"] + XCTAssertTrue(submit.isEnabled, "the sign-in button is disabled", file: file, line: line) + submit.tap() + let banner = element("banner.message") + waitForAny([tabBar, banner], file: file, line: line) + XCTAssertTrue(tabBar.exists, "not signed in: \(banner.exists ? banner.label : "no tab bar")", + file: file, line: line) + } + + // MARK: Input and scrolling + + /// Clears a text field and types `text` into it. + func replaceText(in field: XCUIElement, with text: String, file: StaticString = #filePath, line: UInt = #line) { + XCTAssertTrue(field.waitForExistence(timeout: 10), "no field \(field)", file: file, line: line) + // Tap after the last character, so the deletes below clear everything. A tap made while the screen is + // still settling can be lost, so tap until the field has the keyboard focus. + for _ in 0..<5 where !hasFocus(field) { + field.coordinate(withNormalizedOffset: CGVector(dx: 0.97, dy: 0.5)).tap() + _ = field.waitForExistence(timeout: 0.5) + } + XCTAssertTrue(hasFocus(field), "\(field) never took the keyboard focus", file: file, line: line) + let current = field.value as? String ?? "" + if !current.isEmpty && current != field.placeholderValue { + field.typeText(String(repeating: XCUIKeyboardKey.delete.rawValue, count: current.count)) + } + field.typeText(text) + } + + private func hasFocus(_ field: XCUIElement) -> Bool { + field.value(forKey: "hasKeyboardFocus") as? Bool ?? false + } + + /// Swipes down (towards older messages) until `target` is on screen. + @discardableResult + func scrollThread(downUntil target: XCUIElement, attempts: Int = 20) -> Bool { + var left = attempts + while !(target.exists && target.isHittable) && left > 0 { + app.scrollViews.firstMatch.swipeDown() + left -= 1 + } + return target.exists && target.isHittable + } + + /// Scrolls an open thread up until the oldest message stays first: no older page comes any more. Returns the + /// identifier of that first message, so a caller can check it is the chat's oldest and not the oldest of a + /// page that never asked for the one before it. + @discardableResult + func scrollThreadToTop(file: StaticString = #filePath, line: UInt = #line) -> String? { + let messages = elements(prefixed: "message.") + XCTAssertTrue(messages.firstMatch.waitForExistence(timeout: 20), "the thread shows no message", + file: file, line: line) + var top = messages.firstMatch.identifier + var unchanged = 0 + for _ in 0..<60 { + app.scrollViews.firstMatch.swipeDown(velocity: .fast) + let now = messages.firstMatch.identifier + unchanged = now == top ? unchanged + 1 : 0 + top = now + if unchanged == 2 { + XCTAssertFalse(element("banner.message").exists, + "an older page failed: \(element("banner.message").label)", file: file, line: line) + return top + } + } + XCTFail("the thread never reached its first message", file: file, line: line) + return nil + } + + /// Opens the chat whose row shows `title`. + func openChat(titled title: String, file: StaticString = #filePath, line: UInt = #line) { + let row = elements(prefixed: "chat.row.").matching(NSPredicate(format: "label CONTAINS %@", title)).firstMatch + XCTAssertTrue(row.waitForExistence(timeout: 20), "no chat \(title)", file: file, line: line) + row.tap() + XCTAssertTrue(elements(prefixed: "message.").firstMatch.waitForExistence(timeout: 20), + "\(title) shows no message", file: file, line: line) + } + + func goBack() { + app.navigationBars.firstMatch.buttons.element(boundBy: 0).tap() + } + + /// The first element of `query` that is on screen. + func firstHittable(_ query: XCUIElementQuery) -> XCUIElement? { + query.allElementsBoundByIndex.first { $0.exists && $0.isHittable } + } +} + +extension XCUIApplication { + /// Ends the session when the app opened signed in, and returns whether it did. The app is then on the + /// sign-in screen; a fresh launch shows the connect screen. + @discardableResult + func signOutIfSignedIn(file: StaticString = #filePath, line: UInt = #line) -> Bool { + let tabBar = tabBars.firstMatch + let deadline = Date().addingTimeInterval(20) + let screens = [tabBar, descendants(matching: .any)["connect.address"].firstMatch, + descendants(matching: .any)["signin.submit"].firstMatch] + while !screens.contains(where: { $0.exists }) && Date() < deadline { + RunLoop.current.run(until: Date().addingTimeInterval(0.25)) + } + XCTAssertTrue(screens.contains(where: { $0.exists }), "the app shows no known screen", file: file, line: line) + guard tabBar.exists else { return false } + // Settings is the third tab; by position, since a launch without the English arguments may be in Spanish. + tabBar.buttons.element(boundBy: 2).tap() + let signOut = descendants(matching: .any)["settings.signOut"].firstMatch + XCTAssertTrue(signOut.waitForExistence(timeout: 10), file: file, line: line) + for _ in 0..<8 where !signOut.isHittable { + collectionViews.firstMatch.swipeUp() + } + // The floating tab bar can cover the middle of the row: tap its leading end. + signOut.coordinate(withNormalizedOffset: CGVector(dx: 0.1, dy: 0.4)).tap() + let confirm = buttons.matching(identifier: "settings.signOut.confirm").firstMatch + guard confirm.waitForExistence(timeout: 10) else { + XCTFail("no sign-out confirmation", file: file, line: line) + return false + } + confirm.tap() + XCTAssertTrue(descendants(matching: .any)["signin.submit"].firstMatch.waitForExistence(timeout: 20), + "sign-out did not finish", file: file, line: line) + return true + } +} diff --git a/TGArchiveUITests/LaunchTests.swift b/TGArchiveUITests/LaunchTests.swift index eec66b7..5383503 100644 --- a/TGArchiveUITests/LaunchTests.swift +++ b/TGArchiveUITests/LaunchTests.swift @@ -9,6 +9,10 @@ final class LaunchTests: XCTestCase { func testLaunchShowsTheTitle() throws { let app = XCUIApplication() app.launch() + // A session an earlier UI test run left behind opens the archive: end it, and relaunch to connect afresh. + if app.signOutIfSignedIn() { + app.launch() + } let title = app.staticTexts["root.title"] XCTAssertTrue(title.waitForExistence(timeout: 10)) XCTAssertEqual(title.label, "TG Archive") diff --git a/TGArchiveUITests/ShareLinkTests.swift b/TGArchiveUITests/ShareLinkTests.swift new file mode 100644 index 0000000..6ef8a46 --- /dev/null +++ b/TGArchiveUITests/ShareLinkTests.swift @@ -0,0 +1,27 @@ +import XCTest + +/// The demo's share link: one chat, downloads off. +final class ShareLinkTests: DemoTestCase { + func testShareLinkReadsItsChatWithDownloadsOff() throws { + try launchSignedOut() + signIn(token: Demo.shareToken) + + let rows = elements(prefixed: "chat.row.") + XCTAssertTrue(rows.firstMatch.waitForExistence(timeout: 20)) + let chats = identifiers(prefixed: "chat.row.") + XCTAssertEqual(chats.count, 1, "the demo's share link opens one chat: \(chats)") + XCTAssertTrue(rows.firstMatch.label.contains("Weekend Hikers"), rows.firstMatch.label) + + openChat(titled: "Weekend Hikers") + let tile = elements(containing: "Downloads are off for this login").firstMatch + XCTAssertTrue(scrollThread(downUntil: tile), "no downloads-off tile in Weekend Hikers") + // The thread holds photos, and none of them is offered: no thumbnail is ever asked for. + XCTAssertFalse(app.buttons.matching(NSPredicate(format: "label == 'Photo'")).firstMatch.exists) + goBack() + + tabBar.buttons["Settings"].tap() + XCTAssertTrue(element("settings.noDownload").waitForExistence(timeout: 10)) + XCTAssertTrue(element("settings.signedInAs").label.contains("Hike photos"), + element("settings.signedInAs").label) + } +} diff --git a/TGArchiveUITests/StoreScreenshotTests.swift b/TGArchiveUITests/StoreScreenshotTests.swift new file mode 100644 index 0000000..0ed58e9 --- /dev/null +++ b/TGArchiveUITests/StoreScreenshotTests.swift @@ -0,0 +1,105 @@ +import XCTest + +/// The App Store screenshots, from the demo's invented archive only. They need a 6.9-inch iPhone (1320 x 2868 +/// pixels). With `TGARCHIVE_SCREENSHOT_DIR` set the PNGs are written there and another screen size fails; +/// without it the test skips on other screens and keeps the shots as attachments in the result bundle. +final class StoreScreenshotTests: DemoTestCase { + static let storeSize = CGSize(width: 1320, height: 2868) + + private var directory: URL? + private var index = 0 + + func testStoreScreenshots() throws { + try launchSignedOut() + let size = Self.pixelSize(XCUIScreen.main.screenshot()) + directory = Demo.env("TGARCHIVE_SCREENSHOT_DIR").map { URL(fileURLWithPath: $0, isDirectory: true) } + if size != Self.storeSize { + if directory != nil { + XCTFail("store screenshots need a 6.9-inch iPhone (1320 x 2868 px), this one is \(size)") + } + throw XCTSkip("store screenshots need a 6.9-inch iPhone (1320 x 2868 px), this one is \(size)") + } + if let directory { + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + } + + signIn(username: Demo.masterUsername, password: Demo.masterPassword) + + // The chat list, with its avatars loaded. + XCTAssertTrue(elements(prefixed: "chat.row.").firstMatch.waitForExistence(timeout: 20)) + settle() + shoot("chats") + + // A thread with a poll, a voice note and its transcript. The nearest photo is two stickers further on, + // below the screen, so it gets its own shot in the viewer. + openChat(titled: "Weekend Hikers") + let poll = elements(containing: "Where should we go next Sunday?").firstMatch + XCTAssertTrue(scrollThread(downUntil: poll), "no poll in Weekend Hikers") + settle() + shoot("thread") + let photos = app.buttons.matching(NSPredicate(format: "label == 'Photo'")) + for _ in 0..<4 where firstHittable(photos) == nil { + app.scrollViews.firstMatch.swipeUp() + } + let photo = try XCTUnwrap(firstHittable(photos), "no photo after the poll") + + // The photo, full screen. + photo.tap() + XCTAssertTrue(element("media.viewer.photo").waitForExistence(timeout: 20)) + settle() + shoot("photo") + element("media.viewer.done").tap() + goBack() + + // A venue card in a private chat. + openChat(titled: "Juniper") + let venue = elements(containing: "Elm Street Bakery").firstMatch + XCTAssertTrue(scrollThread(downUntil: venue), "no venue in Juniper") + // Bring the card to the upper third, so the location and live location under it show too. Hold at the end + // of the drag so the thread does not fling on. + let screen = app.frame + let shift = min(max((venue.frame.minY - screen.minY) / screen.height - 0.22, -0.4), 0.4) + if abs(shift) > 0.05 { + let start = app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5 + shift / 2)) + let end = app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5 - shift / 2)) + start.press(forDuration: 0.1, thenDragTo: end, withVelocity: .slow, thenHoldForDuration: 0.5) + } + XCTAssertTrue(venue.isHittable, "the venue card left the screen") + settle() + shoot("location") + goBack() + + // Search results across every chat. + tabBar.buttons["Search"].tap() + let field = app.searchFields.firstMatch + XCTAssertTrue(field.waitForExistence(timeout: 10)) + field.tap() + field.typeText("trail\n") + XCTAssertTrue(elements(prefixed: "search.row.").firstMatch.waitForExistence(timeout: 20)) + settle() + shoot("search") + } + + /// Lets thumbnails and avatars arrive and animations end. + private func settle() { + RunLoop.current.run(until: Date().addingTimeInterval(2)) + } + + private func shoot(_ name: String) { + index += 1 + let fileName = String(format: "%02d-%@", index, name) + let screenshot = XCUIScreen.main.screenshot() + let attachment = XCTAttachment(screenshot: screenshot) + attachment.name = fileName + attachment.lifetime = .keepAlways + add(attachment) + guard let directory else { return } + let url = directory.appendingPathComponent("\(fileName).png") + XCTAssertNoThrow(try screenshot.pngRepresentation.write(to: url), "could not write \(url.path)") + } + + static func pixelSize(_ screenshot: XCUIScreenshot) -> CGSize { + let image = screenshot.image + return CGSize(width: image.size.width * image.scale, height: image.size.height * image.scale) + } +} diff --git a/TGArchiveUITests/ViewerTests.swift b/TGArchiveUITests/ViewerTests.swift new file mode 100644 index 0000000..379e874 --- /dev/null +++ b/TGArchiveUITests/ViewerTests.swift @@ -0,0 +1,82 @@ +import XCTest + +/// The demo's `family` viewer: three chats, downloads on. +final class ViewerTests: DemoTestCase { + /// Opens every chat the viewer sees, reads each back to its first message, then opens a photo. + func testViewerReadsEveryChatToTheTopAndOpensAPhoto() throws { + try launchSignedOut() + signIn(username: Demo.viewerUsername, password: Demo.viewerPassword) + + let rows = elements(prefixed: "chat.row.") + XCTAssertTrue(rows.firstMatch.waitForExistence(timeout: 20)) + let chats = identifiers(prefixed: "chat.row.") + XCTAssertEqual(chats.count, 3, "the demo's family viewer sees three chats: \(chats)") + // The oldest message of each chat in the demo generator. Weekend Hikers (76 messages) and Juniper (51) + // hold more than one 50-message page, so reaching these ids proves older pages load. + let oldest = ["Weekend Hikers": "message.1200", "Juniper": "message.880", "Book Club": "message.200"] + var checked = 0 + for chat in chats { + let row = element(chat) + let title = row.label + row.tap() + let top = scrollThreadToTop() + if let expected = oldest.first(where: { title.contains($0.key) })?.value { + XCTAssertEqual(top, expected, "\(title) did not page back to its first message") + checked += 1 + } + goBack() + XCTAssertTrue(element(chat).waitForExistence(timeout: 10)) + } + XCTAssertEqual(checked, oldest.count, "not every demo chat was read to its first message") + + openChat(titled: "Weekend Hikers") + let photos = app.buttons.matching(NSPredicate(format: "label == 'Photo'")) + XCTAssertTrue(scrollThread(downUntil: photos.firstMatch), "no photo near the end of Weekend Hikers") + try XCTUnwrap(firstHittable(photos), "no photo on screen").tap() + XCTAssertTrue(element("media.viewer.photo").waitForExistence(timeout: 20), "the photo did not open") + element("media.viewer.done").tap() + XCTAssertFalse(element("media.viewer.done").waitForExistence(timeout: 2)) + } + + /// Searches every chat, opens a hit, and finds the thread opened at that message. + func testViewerSearchOpensTheChatAtTheHit() throws { + try launchSignedOut() + signIn(username: Demo.viewerUsername, password: Demo.viewerPassword) + + tabBar.buttons["Search"].tap() + let field = app.searchFields.firstMatch + XCTAssertTrue(field.waitForExistence(timeout: 10)) + field.tap() + field.typeText("trailhead") + + let hits = elements(prefixed: "search.row.") + XCTAssertTrue(hits.firstMatch.waitForExistence(timeout: 20), "no hit for trailhead") + let hit = hits.firstMatch + XCTAssertTrue(hit.label.localizedCaseInsensitiveContains("trailhead"), hit.label) + // search.row..; a ref never holds a dot. + let messageID = try XCTUnwrap(hit.identifier.split(separator: ".").last) + hit.tap() + + let anchor = element("message.\(messageID)") + XCTAssertTrue(anchor.waitForExistence(timeout: 20), "the thread did not open at message \(messageID)") + XCTAssertTrue(anchor.isHittable, "message \(messageID) is not on screen") + } + + /// A wrong password is refused with the 401 sentence, and the sign-in check this suite relies on fails: + /// proof that a broken sign-in turns the other tests red. + func testWrongPasswordIsRefused() throws { + try launchSignedOut() + continueAfterFailure = true + // Only the sign-in check's own failure is expected; anything else, a lost tap included, still fails. + let options = XCTExpectedFailure.Options() + options.issueMatcher = { $0.compactDescription.contains("not signed in") } + XCTExpectFailure("a wrong password must fail the sign-in check", options: options) { + signIn(username: Demo.viewerUsername, password: "not-the-demo-password") + } + let banner = element("banner.message") + XCTAssertTrue(banner.waitForExistence(timeout: 10)) + XCTAssertEqual(banner.label, "Wrong username or password.") + XCTAssertFalse(tabBar.exists) + XCTAssertTrue(app.buttons["signin.submit"].exists) + } +} diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 37348c0..f984998 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -196,6 +196,8 @@ What it serves, as checked against the live script: 15 chat rows for the master CI starts the demo on the `macos-latest` runner (`brew install uv`), runs the unit tests against the fixtures and the UI tests against `http://127.0.0.1:8000` (loopback, allowed by `NSAllowsLocalNetworking`), and stops it. The public demo for App Review is the same script on a host with a public hostname and https; its address is never written in this repo. +Stage 9 note: CI also installs ffmpeg, because the live playback test reads a voice note and a video that exist only when ffmpeg made them. It runs on the newest iPhone Pro Max simulator with the status bar pinned, so the screenshot test writes 6.9-inch (1320 x 2868) PNGs, which CI checks and uploads as the `store-screenshots` artifact. + ## 6. App Review plan **What the reviewer gets** (all in App Review Notes, never in the repo): the public demo server's https URL, the `family` viewer login, the share link built as `https:///#token=` (so the reviewer also sees the share-link path and the downloads-off state), and the physical-device screen recording of connect, sign in, browse a thread, open a photo, search, open a hit, sign out. The demo archive is regenerated before each submission so the share link has not expired, the host has a hostname (never an IP literal; Apple reviews from an IPv6-only network), and `TRUST_PROXY_HEADERS=true` is set behind the proxy so the reviewer's attempts do not share the rate-limit bucket with everyone else. "Sign-in required" is ticked. @@ -219,6 +221,8 @@ Stage 10 note: the subtitle names the server project instead, "Read your Telegra **Screenshots (2.3.3)**: captured by a UI test from the synthetic archive only: chat list, a thread with a photo and a poll, a location card, search results, the media viewer. Never a real archive. +Stage 9 note: in the demo the poll and its nearest photo do not fit on one screen (two stickers sit between them), so the thread shot shows the poll with a voice note and its transcript, and the photo gets its own shot in the media viewer. + **Age rating**: the questionnaire answered as none for every listed content category, no unrestricted web access, no user-to-user communication in the app; the result is 4+. **GPL ([NOTICE](../NOTICE))**: the About screen links the public repo, and the store description names it, so every store recipient can get the Corresponding Source.