From b4954aaac677be153f49f1fd2db038e1564104b1 Mon Sep 17 00:00:00 2001 From: Adam Mischke Date: Mon, 5 Oct 2026 07:30:06 -0500 Subject: [PATCH 1/5] Open catalog threads full screen on Duo and iPad --- AGENTS.md | 4 +- docs/ipad-testing.md | 8 +- docs/iphone-duo-testing.md | 10 +- swiftchan.xcodeproj/project.pbxproj | 4 +- .../Views/Boards/Catalog/CatalogView.swift | 156 ++--------- .../Boards/Catalog/Thread/ThreadView.swift | 6 +- swiftchanUITests/BrowsingUITests.swift | 247 +++++++++--------- 7 files changed, 164 insertions(+), 271 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index bd59863..89e3216 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,13 +6,13 @@ Swiftchan is a SwiftUI imageboard app using MVVM, SwiftData, and Swift Package M ## iPad testing -Read [docs/ipad-testing.md](docs/ipad-testing.md) for regular-width reading/workspaces, native orientation preparation, fixture safety, and simulator videos. Check iPad mini portrait and iPad Pro landscape, and adapt to the available window. Capture native main display 1 and verify actual window/PNG geometry. Use `uv run` for Python helpers; keep `CLAUDE.md` linked to this file. +Read [docs/ipad-testing.md](docs/ipad-testing.md) for regular-width reading and catalog navigation, native orientation preparation, fixture safety, and simulator videos. Check iPad mini portrait and iPad Pro landscape, and adapt to the available window. Capture native main display 1 and verify actual window/PNG geometry. Use `uv run` for Python helpers; keep `CLAUDE.md` linked to this file. ## iPhone Duo testing Read [docs/iphone-duo-testing.md](docs/iphone-duo-testing.md) before Duo layout, pose, or recording work. Native `agent-device@0.21.20` hinge control is verified for open, book, and closed; use the scoped Duo toolchain and confirm angle plus app-visible geometry. A manual tabletop quarter-turn is verified; automated physical rotation remains unverified. Capture the lit panel explicitly and distinguish live continuity from saved-state reopening. Use `uv run` for Python helpers. Keep `CLAUDE.md` as the compatibility symlink to this file. -Keep the catalog's OP card grid in both compact navigation and board/thread workspaces. Compact phones, the closed Duo, and an active native Duo division use two columns. With the Duo flat or on iPad, derive additional columns from the catalog pane's available width; a narrow iPad sidebar may still fit only two. Check the real two-to-more-to-two transition without relaunching or losing the selected thread and visible reply. +Keep the catalog's OP card grid across devices. Compact phones, the closed Duo, and an active native Duo division use two columns; flat Duo and iPad derive additional columns from the full catalog width. Tapping a card pushes a full-screen thread on every device, including Tabletop and iPad. Do not keep the catalog above or beside the selected thread. Back must restore the catalog's scroll position. Check the real two-to-more-to-two fold transition and background/foreground restoration of a visible reply. ## Setup and validation diff --git a/docs/ipad-testing.md b/docs/ipad-testing.md index 4d96532..15e2b66 100644 --- a/docs/ipad-testing.md +++ b/docs/ipad-testing.md @@ -19,14 +19,16 @@ Python capture/package helpers run with `uv run --with pillow python`. Run one U ## App behavior and validation -Regular-width windows at least 580 pt wide use a board and thread workspace. Compact windows retain pushed thread navigation. Keep catalog selection visible, the selected thread readable, and the catalog title inline in the workspace. The sidebar uses the same OP card grid as compact navigation; the detail shows the selected thread and its media/actions. Compact phones and active Duo divisions keep two catalog columns. Flat Duo and regular iPad layouts add columns when the actual catalog pane has room, with approximately 140 pt per card; a narrow iPad sidebar still uses two. Selected cards have an accent outline. On a native horizontal Duo division, the catalog sits above the thread, with the system division and both 20 pt margins kept clear. iPad uses the balanced NavigationSplitView. Thread scrolling tracks post identity with scrollPosition(id:anchor:), so a window resize can keep the reading post visible. Build 1.3 (4) is the app version. +Build 1.3 (5) uses the full available catalog width on iPad, with approximately 140 pt per OP card and at least two columns. Tapping a card pushes a full-screen thread. Back returns to the existing catalog and scroll position. This same navigation applies to compact phones and Duo, including Tabletop. Compact phones, the closed Duo, and active Duo divisions keep two catalog columns. The catalog is not a persistent sidebar or upper pane. Thread scrolling tracks post identity with `scrollPosition(id:anchor:)`, so resizing and activation can retain the reading post. -Use debug-only in-memory catalog/thread/media fixtures. Check switching threads, returning to boards, gallery next-image/filmstrip/dismissal, favorites, settings, following a general, and post actions. The normal regression suite passes 95 unit tests and 20 UI tests on iPhone Air 26.5; pose-only cases skip there. After adding the post-ID scroll binding, 95 unit tests and eight focused quote, jump, resume, unread, recent-thread, and linked-post UI regressions pass on the final source. Mini portrait and Pro landscape walkthroughs both pass on that source. +Use debug-only in-memory catalog/thread/media fixtures. `testCatalogOpensThreadsFullScreenAndReturnsToGrid` scrolls to the last invented card, opens its thread, checks that the thread fills the window, and returns to the same visible card. The opt-in walkthrough also checks returning to boards, gallery next-image/filmstrip/dismissal, favorites, settings, following a general, and post actions. The new twelve-card fixture is `--ui-catalog-grid-fixture`. Do not enable Duo pose-test flags on ordinary CI phones. -Those counts describe build 3. Build 4 adds `testCompactCatalogKeepsTwoColumnsAndOpensThreads`, which checks real card frames and pushed navigation, and the opt-in `testDuoCatalogGridExpandsAndRefoldsWithoutLosingThread`. The native grid transition passes with two columns at 130 degrees, four at 180 degrees, and two after returning to 130 degrees, while thread 200 and reply 205 remain visible. Use `--ui-catalog-grid-fixture` for twelve invented catalog cards. Do not enable pose-test flags on ordinary CI phones. +Historical build 3 and build 4 validation counts describe the former workspace design. Current full-screen captures and validation are kept separately in the review below. ## Review artifacts The shared workspace review is `../iphone-duo-review/2026-10-04-current/review/`, with per-app `*-ipad-mini` and `*-ipad-pro` MP4/JSON/PNG packets. `validation.json` records passing cases and source provenance; use it to distinguish historical Duo footage from the current build. The local gallery is http://127.0.0.1:8179/ when its server is running. These artifacts do not indicate an App Store Connect or TestFlight submission. The follow-up catalog grid review is `../iphone-duo-review/2026-10-04-grid/`. Keep its build 4 clips and validation separate from the earlier build 3 Swiftchan footage. + +The full-screen navigation review is `../iphone-duo-review/2026-10-05-fullscreen/`, for build 1.3 (5). Its mini portrait and Pro landscape captures verify the actual full-width thread and Back restoring the scrolled catalog. diff --git a/docs/iphone-duo-testing.md b/docs/iphone-duo-testing.md index 70070c4..43f6922 100644 --- a/docs/iphone-duo-testing.md +++ b/docs/iphone-duo-testing.md @@ -53,7 +53,7 @@ The hinge helper depends on private simulator HID behavior and the selected simu `orientation portrait` and `orientation landscape-right` returned success here, but the live native probe still reported the same 951 x 669 pt viewport and active vertical division. Therefore automated tabletop rotation is **unverified**. Do not label a book capture as tabletop, or treat an orientation command's success message as proof. A tabletop check must observe an active horizontal division in the app and inspect the actual rendered controls. Use the simulator's physical rotation control when available, and retain this manual requirement until app-visible geometry confirms a working automation path. -The user's physical quarter-turn was verified on 2026-10-04: the native viewport became 669 x 951 pt, with an active horizontal division at y=455.5, height=40, width=669 pt, and 20 pt top/bottom margins. This proves the manual tabletop geometry; it does not prove an automated rotation command. Capture harnesses must preserve that orientation instead of setting landscape again during setup. Native arrangement views can then place primary and secondary content above and below the fold. +The user's physical quarter-turn was verified on 2026-10-04: the native viewport became 669 x 951 pt, with an active horizontal division at y=455.5, height=40, width=669 pt, and 20 pt top/bottom margins. This proves the manual tabletop geometry; it does not prove an automated rotation command. Capture harnesses must preserve that orientation instead of setting landscape again during setup. The full-screen catalog/thread navigation must preserve this physical orientation during capture. ## Screenshots, video, and continuity evidence @@ -79,11 +79,13 @@ On 2026-10-04 a scoped Xcode 27.1 XCTest set XCUIDevice.shared.orientation to la ## App-specific checks -Use the workspace. Verify the board catalog beside the selected thread, a visible scrolled reply, and background/foreground restoration. Background snapshot geometry must not push a standalone thread route. Once the device exposes a division (including inactive regions), retain the native ArrangementView container across folding and background snapshots. Keep the remembered workspace mode while inactive. Use a post-ID scroll position binding so pane resizing keeps the reading post visible rather than preserving a stale pixel offset. Keep navigation outside the arrangement. +Use the workspace. Build 1.3 (5) uses a full-screen catalog and ordinary pushed thread navigation on every device. Tapping a card replaces the catalog with the thread; Back returns to the same scrolled catalog. Tabletop must not retain a catalog above a half-height thread, and iPad must not retain a catalog sidebar. Keep the post-ID scroll position binding for reading restoration. Navigation no longer depends on workspace mode or transient background geometry. -The live native Tabletop-to-flat check passes with thread 200 and reply 205 visible before and after background reactivation. All four app process IDs remain unchanged; the other assertions cover Bible chapters 3/4, Mango reading page 2, and advancing real Earmark playback. The Tabletop tour also passes catalog/thread switching, all primary routes, and media actions. +The closed outer display and an active native division keep two catalog columns. Flat Duo derives more columns from the full catalog width. For an active vertical division, use its native frame and margins to keep the two card columns clear of the hinge. The twelve-card fixture (`--ui-catalog-grid-fixture`) is debug-only. -Build 1.3 (4) preserves the OP card catalog grid in the workspace. The closed outer display and an active native division keep two columns. Flat Duo uses the actual catalog pane width to add columns. `testDuoCatalogGridExpandsAndRefoldsWithoutLosingThread` passes a foreground 130-to-180-to-130-degree transition: two columns, four columns, then two, with thread 200 and reply 205 still visible. Run it with `TEST_RUNNER_CATALOG_GRID_CAPTURE=1`, preserve the verified manual tabletop quarter-turn, and use native hinge watchers at `CATALOG_GRID_READY_TO_UNFOLD` and `CATALOG_GRID_READY_TO_FOLD`. The twelve-card fixture is debug-only. The follow-up videos and native PNGs live in `../iphone-duo-review/2026-10-04-grid/`; earlier build 3 clips retain their original provenance. +`testCatalogOpensThreadsFullScreenAndReturnsToGrid` checks a scrolled catalog, full-screen thread geometry, and Back restoring the same visible card. `testDuoCatalogGridExpandsAndRefoldsThenOpensThreadFullScreen` additionally checks a live 130-to-180-to-130-degree transition, opening and returning from threads, and reply 205 surviving background activation. Run it with `TEST_RUNNER_CATALOG_GRID_CAPTURE=1`, preserve the verified manual tabletop quarter-turn, and use native hinge watchers at `CATALOG_GRID_READY_TO_UNFOLD` and `CATALOG_GRID_READY_TO_FOLD`. Other opt-in cases cover full-screen Tabletop, background activation, and a vertical catalog hinge gutter. Do not enable native pose flags on ordinary CI phones. + +Historical build 3 and build 4 catalog/thread workspace checks remain in `../iphone-duo-review/2026-10-04-current/` and `../iphone-duo-review/2026-10-04-grid/`. They show the previous design. The current full-screen review belongs in `../iphone-duo-review/2026-10-05-fullscreen/`; keep its version, source, test results, and native PNG/video provenance separate. Installed app bundle ID: `vanities.swiftchan`. diff --git a/swiftchan.xcodeproj/project.pbxproj b/swiftchan.xcodeproj/project.pbxproj index 1997817..3e6674a 100644 --- a/swiftchan.xcodeproj/project.pbxproj +++ b/swiftchan.xcodeproj/project.pbxproj @@ -1101,7 +1101,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 4; + CURRENT_PROJECT_VERSION = 5; DEVELOPMENT_ASSET_PATHS = "\"swiftchan/Preview Content\""; DEVELOPMENT_TEAM = 8Q3RG3ULSU; ENABLE_BITCODE = NO; @@ -1133,7 +1133,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 4; + CURRENT_PROJECT_VERSION = 5; DEVELOPMENT_ASSET_PATHS = "\"swiftchan/Preview Content\""; DEVELOPMENT_TEAM = 8Q3RG3ULSU; ENABLE_BITCODE = NO; diff --git a/swiftchan/Views/Boards/Catalog/CatalogView.swift b/swiftchan/Views/Boards/Catalog/CatalogView.swift index 543137f..fbc3522 100644 --- a/swiftchan/Views/Boards/Catalog/CatalogView.swift +++ b/swiftchan/Views/Boards/Catalog/CatalogView.swift @@ -13,8 +13,6 @@ struct CatalogView: View { @AppStorage("hideTabOnBoards") var hideTabOnBoards = false @Environment(AppState.self) var appState - @Environment(\.dismiss) private var dismiss - @Environment(\.scenePhase) private var scenePhase @Environment(\.horizontalSizeClass) private var horizontalSizeClass var boardName: String @@ -24,8 +22,6 @@ struct CatalogView: View { @State var showAddRecurringSheet: Bool = false @State private var selectedThread: SwiftchanPost? - @State private var usesThreadWorkspace = false - @State private var usesDuoArrangement = false @State private var scene: SKScene = { let s = SnowScene() @@ -56,57 +52,11 @@ struct CatalogView: View { } case .loaded: GeometryReader { geometry in - let layout = workspaceLayout(in: geometry) let twoColumnGrid = hasActiveDuoDivision(in: geometry) || (horizontalSizeClass == .compact && !hasDuoDivision(in: geometry)) - Group { - if usesThreadWorkspace { -#if IPHONE_DUO_LAYOUTS - if #available(iOS 27.1, *), usesDuoArrangement { - let insets = duoDivisionInsets(in: geometry) - ArrangementView { - catalogPosts(filteredPosts, workspace: true, twoColumns: twoColumnGrid) - .padding(insets.primary) - } secondary: { - workspaceThread - .padding(insets.secondary) - } - .arrangementViewStyle(.split) - .accessibilityIdentifier("BoardThreadWorkspace") - .navigationBarBackButtonHidden(true) - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button("Back to boards", systemImage: "chevron.left") { dismiss() } - } - } - } else { - sideBySideWorkspace(filteredPosts, layout: layout) - } -#else - sideBySideWorkspace(filteredPosts, layout: layout) -#endif - } else { - catalogPosts(filteredPosts, workspace: false, twoColumns: twoColumnGrid) - } - } - .onChange(of: scenePhase) { _, phase in - if phase == .active { usesThreadWorkspace = wantsWorkspace(in: geometry) } - } - .onGeometryChange(for: Bool.self) { hasDuoDivision(in: $0) } action: { hasDivision in - // Background snapshots may omit reserved regions. Once the - // device exposes a division, retain its container identity. - if hasDivision { usesDuoArrangement = true } - } - } - .onGeometryChange(for: Bool.self) { wantsWorkspace(in: $0) } action: { isWide in - // Background snapshots can briefly use compact dimensions. Keep - // them from pushing the selected thread onto the navigation stack. - if scenePhase == .active { usesThreadWorkspace = isWide } + catalogPosts(filteredPosts, twoColumns: twoColumnGrid) } - .navigationDestination(item: Binding( - get: { usesThreadWorkspace ? nil : selectedThread }, - set: { if !usesThreadWorkspace { selectedThread = $0 } } - )) { post in + .navigationDestination(item: $selectedThread) { post in ThreadView(boardName: post.boardName, postNumber: post.id) } .overlay(alignment: .bottom) { @@ -138,7 +88,7 @@ struct CatalogView: View { catalogViewModel.stopPrefetching() } .navigationBarTitle(boardName) - .navigationBarTitleDisplayMode(usesThreadWorkspace ? .inline : .automatic) + .navigationBarTitleDisplayMode(.automatic) .navigationBarItems( trailing: settingsButton @@ -192,32 +142,6 @@ struct CatalogView: View { } } - private func wantsWorkspace(in geometry: GeometryProxy) -> Bool { - geometry.size.width >= 760 || (horizontalSizeClass == .regular && geometry.size.width >= 580) - } - - private func duoDivisionInsets(in geometry: GeometryProxy) -> (primary: EdgeInsets, secondary: EdgeInsets) { -#if IPHONE_DUO_LAYOUTS - if #available(iOS 27.1, *) { - // Keep the same container when the hinge becomes inactive. Replacing - // it with NavigationSplitView can hide the catalog and recreate the thread. - var primary = EdgeInsets() - var secondary = EdgeInsets() - if let fold = geometry.reservedRegions(kind: .division).first { - if fold.frame.width > fold.frame.height { - primary.bottom = fold.margins.bottom - secondary.top = fold.margins.top - } else { - primary.trailing = fold.margins.trailing - secondary.leading = fold.margins.leading - } - } - return (primary, secondary) - } -#endif - return (EdgeInsets(), EdgeInsets()) - } - private func hasDuoDivision(in geometry: GeometryProxy) -> Bool { #if IPHONE_DUO_LAYOUTS if #available(iOS 27.1, *) { @@ -236,66 +160,27 @@ struct CatalogView: View { return false } - @ViewBuilder - private var workspaceThread: some View { - if let selectedThread { - ThreadView(boardName: selectedThread.boardName, postNumber: selectedThread.id, - showsNavigationTitle: false) - .id(selectedThread.id) - .accessibilityIdentifier("CatalogThreadDetail") - } else { - ContentUnavailableView { - Label("Open a Thread", systemImage: "text.bubble") - } description: { - Text("Choose a discussion from the board. Browse the board while you read.") - } - } - } - - private func sideBySideWorkspace(_ posts: [SwiftchanPost], layout: (sidebarWidth: CGFloat?, hingeGap: CGFloat)) -> some View { - NavigationSplitView { - catalogPosts(posts, workspace: true, twoColumns: horizontalSizeClass == .compact) - .navigationSplitViewColumnWidth( - min: layout.sidebarWidth ?? 260, - ideal: layout.sidebarWidth ?? 320, - max: layout.sidebarWidth ?? 400 - ) - } detail: { - NavigationStack { - workspaceThread - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button("Back to boards", systemImage: "chevron.left") { dismiss() } - } - } - } - .padding(.leading, layout.hingeGap) + private func catalogColumns(in geometry: GeometryProxy, twoColumns: Bool) -> [GridItem] { +#if IPHONE_DUO_LAYOUTS + if #available(iOS 27.1, *), + let fold = geometry.reservedRegions(kind: .division).first(where: { + $0.frame.height > $0.frame.width && $0.frame.minX > 0 && $0.frame.maxX < geometry.size.width + }) { + // Keep both catalog columns on their usable side of a vertical fold. + // The system controls can make those sides different widths. + let leadingWidth = max(0, fold.frame.minX - fold.margins.leading) + let gap = fold.frame.width + fold.margins.leading + fold.margins.trailing + return [GridItem(.fixed(leadingWidth), spacing: gap, alignment: .top), + GridItem(.flexible(minimum: 0), spacing: 0, alignment: .top)] } - .navigationSplitViewStyle(.balanced) - .accessibilityIdentifier("BoardThreadWorkspace") - } - - private func workspaceLayout(in geometry: GeometryProxy) -> (sidebarWidth: CGFloat?, hingeGap: CGFloat) { - #if IPHONE_DUO_LAYOUTS - if #available(iOS 27.1, *), - let fold = geometry.reservedRegions(kind: .division).first(where: { - $0.frame.height > $0.frame.width && $0.frame.minX > 0 && $0.frame.maxX < geometry.size.width - }) { - // Preserve the same navigation containers across pose changes, - // while moving both columns' content clear of the physical hinge. - let leadingWidth = fold.frame.minX - fold.margins.leading - let gap = fold.frame.width + fold.margins.leading + fold.margins.trailing - return (leadingWidth, gap) - } - #endif - return (nil, 0) +#endif + let count = twoColumns ? 2 : max(2, Int(geometry.size.width / 140)) + return Array(repeating: GridItem(.flexible(), spacing: 0, alignment: .top), count: count) } - private func catalogPosts(_ posts: [SwiftchanPost], workspace: Bool, twoColumns: Bool) -> some View { + private func catalogPosts(_ posts: [SwiftchanPost], twoColumns: Bool) -> some View { GeometryReader { geometry in - let inset: CGFloat = workspace ? 10 : 0 - let count = twoColumns ? 2 : max(2, Int((geometry.size.width - inset * 2) / 140)) - let columns = Array(repeating: GridItem(.flexible(), spacing: 0, alignment: .top), count: count) + let columns = catalogColumns(in: geometry, twoColumns: twoColumns) ScrollViewReader { reader in ScrollView(.vertical) { LazyVGrid(columns: columns, alignment: .center, spacing: 0) { @@ -319,7 +204,6 @@ struct CatalogView: View { } } } - .padding(inset) } .accessibilityIdentifier("Catalog Threads") .onChange(of: catalogViewModel.currentSearchResultIndex) { _, _ in diff --git a/swiftchan/Views/Boards/Catalog/Thread/ThreadView.swift b/swiftchan/Views/Boards/Catalog/Thread/ThreadView.swift index dcebeb8..3d6de7b 100644 --- a/swiftchan/Views/Boards/Catalog/Thread/ThreadView.swift +++ b/swiftchan/Views/Boards/Catalog/Thread/ThreadView.swift @@ -40,7 +40,6 @@ struct ThreadView: View { @State private var draftPost: DraftPost? @State private var recentlyHidden: HiddenPost? private let initialPostID: Int? - private let showsNavigationTitle: Bool @State private var isThreadVisible = false @State private var isSearching: Bool = false @Namespace private var galleryNamespace @@ -56,9 +55,8 @@ struct ThreadView: View { @State private var isFavorited: Bool = false - init(boardName: String, postNumber: PostNumber, postID: Int? = nil, showsNavigationTitle: Bool = true) { + init(boardName: String, postNumber: PostNumber, postID: Int? = nil) { initialPostID = postID - self.showsNavigationTitle = showsNavigationTitle self._viewModel = State( wrappedValue: ThreadViewModel( boardName: boardName, @@ -290,7 +288,7 @@ struct ThreadView: View { } .environment(presentationState) .environment(\.galleryNamespace, galleryNamespace) - .navigationTitle(showsNavigationTitle ? viewModel.title : "") + .navigationTitle(viewModel.title) .searchable(text: $viewModel.searchText, isPresented: $isSearching) .onChange(of: viewModel.searchText) { _, _ in viewModel.updateSearchResults() diff --git a/swiftchanUITests/BrowsingUITests.swift b/swiftchanUITests/BrowsingUITests.swift index 139741a..7b8b48c 100644 --- a/swiftchanUITests/BrowsingUITests.swift +++ b/swiftchanUITests/BrowsingUITests.swift @@ -2,48 +2,110 @@ import XCTest @MainActor final class BrowsingUITests: XCTestCase { - func testCompactCatalogKeepsTwoColumnsAndOpensThreads() throws { + func testCatalogOpensThreadsFullScreenAndReturnsToGrid() { let app = launchThreadFixture(extraArguments: ["--ui-catalog-grid-fixture", "--ui-media-fixture"]) - try XCTSkipUnless(app.windows.firstMatch.frame.width < 580, "Run the compact catalog check on a phone.") openGridCatalog(in: app) - assertTwoCatalogColumns(in: app) - attachScreenshot("Compact Two Column Catalog") - app.buttons["CatalogThread200"].tap() - XCTAssertTrue(app.staticTexts["#200"].waitForExistence(timeout: 10)) - XCTAssertFalse(app.descendants(matching: .any)["BoardThreadWorkspace"].firstMatch.exists) - } - - func testDuoCatalogGridExpandsAndRefoldsWithoutLosingThread() throws { + if app.windows.firstMatch.frame.width < 580 { assertTwoCatalogColumns(in: app) } + let catalog = app.scrollViews["Catalog Threads"] + let last = app.buttons["CatalogThread1200"] + for _ in 0..<8 { + if last.isHittable { break } + catalog.swipeUp() + } + XCTAssertTrue(last.isHittable) + captureCatalogNavigation(app, "fullscreen_01_scrolled_catalog") + last.tap() + assertFullScreenThread(in: app, postID: 1200) + captureCatalogNavigation(app, "fullscreen_02_selected_thread") + returnToCatalog(in: app) + XCTAssertTrue(last.waitForExistence(timeout: 10)) + XCTAssertTrue(last.isHittable, "Back should retain the catalog's scroll position.") + captureCatalogNavigation(app, "fullscreen_03_returned_catalog") + } + + func testDuoCatalogGridExpandsAndRefoldsThenOpensThreadFullScreen() throws { try XCTSkipUnless(ProcessInfo.processInfo.environment["CATALOG_GRID_CAPTURE"] == "1", "Run on a partially folded Duo with native hinge control.") let app = launchThreadFixture(extraArguments: ["--ui-catalog-grid-fixture", "--ui-media-fixture"]) openGridCatalog(in: app) assertTwoCatalogColumns(in: app) - app.buttons["CatalogThread200"].tap() - let anchor = app.staticTexts["#205"] - let scroll = app.scrollViews["CatalogThreadDetail"] - for _ in 0..<40 { - if anchor.isHittable { break } - let start = scroll.coordinate(withNormalizedOffset: CGVector(dx: 0.7, dy: 0.8)) - let end = scroll.coordinate(withNormalizedOffset: CGVector(dx: 0.7, dy: 0.55)) - start.press(forDuration: 0.1, thenDragTo: end, withVelocity: .slow, thenHoldForDuration: 0.1) - } - XCTAssertTrue(anchor.isHittable) - captureDuo(app, "grid_01_folded_two_columns") + captureDuo(app, "fullscreen_grid_01_folded_catalog") print("CATALOG_GRID_READY_TO_UNFOLD") let third = app.buttons["CatalogThread300"] let first = app.buttons["CatalogThread100"] let expanded = NSPredicate { _, _ in third.exists && abs(third.frame.minY - first.frame.minY) < 2 } XCTAssertEqual(XCTWaiter.wait(for: [XCTNSPredicateExpectation(predicate: expanded, object: nil)], timeout: 90), .completed) - XCTAssertTrue(app.buttons["CatalogThread200"].isHittable) - XCTAssertTrue(anchor.isHittable) - captureDuo(app, "grid_02_unfolded_more_columns") + captureDuo(app, "fullscreen_grid_02_flat_catalog") + app.buttons["CatalogThread200"].tap() + assertFullScreenThread(in: app, postID: 200) + captureDuo(app, "fullscreen_grid_03_flat_thread") + returnToCatalog(in: app) + XCTAssertTrue(app.buttons["CatalogThread200"].waitForExistence(timeout: 10)) + captureDuo(app, "fullscreen_grid_04_returned_catalog") print("CATALOG_GRID_READY_TO_FOLD") let refolded = NSPredicate { _, _ in third.exists && third.frame.minY > first.frame.minY + 10 } XCTAssertEqual(XCTWaiter.wait(for: [XCTNSPredicateExpectation(predicate: refolded, object: nil)], timeout: 90), .completed) assertTwoCatalogColumns(in: app) - XCTAssertTrue(anchor.isHittable) - captureDuo(app, "grid_03_refolded_two_columns") + captureDuo(app, "fullscreen_grid_05_refolded_catalog") + app.buttons["CatalogThread200"].tap() + assertFullScreenThread(in: app, postID: 200) + scrollToReadingAnchor(in: app) + captureDuo(app, "fullscreen_grid_06_refolded_thread") + XCUIDevice.shared.press(.home) + app.activate() + XCTAssertTrue(app.staticTexts["#205"].waitForExistence(timeout: 10)) + XCTAssertTrue(app.staticTexts["#205"].isHittable) + XCTAssertFalse(app.buttons["CatalogThread100"].isHittable) + assertThreadFillsWindow(in: app) + captureDuo(app, "fullscreen_grid_07_resumed_thread") + returnToCatalog(in: app) + assertTwoCatalogColumns(in: app) + captureDuo(app, "fullscreen_grid_08_back_to_catalog") + } + + func testTabletopThreadOpensFullScreen() throws { + try XCTSkipUnless(ProcessInfo.processInfo.environment["DUO_TABLETOP"] == "1", + "Requires a verified native horizontal Duo division.") + let app = launchThreadFixture(extraArguments: ["--ui-media-fixture", "--ui-catalog-grid-fixture"]) + openGridCatalog(in: app) + app.buttons["CatalogThread100"].tap() + assertFullScreenThread(in: app, postID: 100) + captureDuo(app, "tabletop_fullscreen_thread") + returnToCatalog(in: app) + assertTwoCatalogColumns(in: app) + } + + func testDuoFullScreenThreadSurvivesBackgroundActivation() throws { + try XCTSkipUnless(ProcessInfo.processInfo.environment["DUO_CAPTURE"] == "1", + "Run on Duo's inner display.") + let app = launchThreadFixture(extraArguments: ["--ui-media-fixture", "--ui-catalog-grid-fixture"]) + openGridCatalog(in: app) + app.buttons["CatalogThread200"].tap() + assertFullScreenThread(in: app, postID: 200) + scrollToReadingAnchor(in: app) + captureDuo(app, "fullscreen_before_background") + XCUIDevice.shared.press(.home) + app.activate() + XCTAssertTrue(app.staticTexts["#205"].waitForExistence(timeout: 10)) + XCTAssertTrue(app.staticTexts["#205"].isHittable) + assertThreadFillsWindow(in: app) + captureDuo(app, "fullscreen_after_background") + } + + func testFoldedCatalogKeepsCardsClearOfVerticalHinge() throws { + try XCTSkipUnless(ProcessInfo.processInfo.environment["DUO_CAPTURE"] == "1" + && ProcessInfo.processInfo.environment["DUO_FOLDED"] == "1", + "Run with the Duo's active vertical hinge region.") + let app = launchThreadFixture(extraArguments: ["--ui-media-fixture", "--ui-catalog-grid-fixture"]) + openGridCatalog(in: app) + assertTwoCatalogColumns(in: app) + let first = app.buttons["CatalogThread100"] + let second = app.buttons["CatalogThread200"] + XCTAssertGreaterThanOrEqual(second.frame.minX - first.frame.maxX, 40, + "Keep both card columns clear of the active vertical division.") + captureDuo(app, "folded_catalog_hinge_gutter") + second.tap() + assertFullScreenThread(in: app, postID: 200) } private func openGridCatalog(in app: XCUIApplication) { @@ -68,104 +130,47 @@ final class BrowsingUITests: XCTestCase { print("CATALOG_GRID_FRAMES: first=\(first.frame); second=\(second.frame); third=\(third.frame)") } - func testTabletopBoardAndThreadWorkspace() throws { - try XCTSkipUnless(ProcessInfo.processInfo.environment["DUO_TABLETOP"] == "1", - "Requires a verified native horizontal Duo division.") - continueAfterFailure = false - let app = launchThreadFixture(extraArguments: ["--ui-media-fixture", "--ui-catalog-fixture"]) - app.buttons["Open Link Button"].tap() - let link = app.textFields["Open Link URL"] - XCTAssertTrue(link.waitForExistence(timeout: 5)) - link.tap() - link.typeText("https://boards.4chan.org/biz/") - app.buttons["Open Link Confirm"].tap() - let row = app.buttons["CatalogThread100"] - XCTAssertTrue(row.waitForExistence(timeout: 10)) - row.tap() - let post = app.staticTexts["#100"] - XCTAssertTrue(post.waitForExistence(timeout: 10)) - let workspace = app.descendants(matching: .any)["BoardThreadWorkspace"].firstMatch - XCTAssertTrue(workspace.exists) - print("DUO_TABLETOP_WORKSPACE:catalog=\(row.frame);post=\(post.frame);window=\(app.windows.firstMatch.frame)") - XCTAssertLessThanOrEqual(row.frame.maxY, 436.5) - XCTAssertGreaterThanOrEqual(post.frame.minY, 514.5) - captureDuo(app, "tabletop_01_board_and_thread") - app.buttons["CatalogThread200"].tap() - let selected = app.staticTexts["#200"] - XCTAssertTrue(selected.waitForExistence(timeout: 10)) - XCTAssertTrue(selected.isHittable) - captureDuo(app, "tabletop_02_switch_thread") - XCUIDevice.shared.press(.home) - app.activate() - XCTAssertTrue(workspace.waitForExistence(timeout: 10)) - XCTAssertTrue(app.buttons["CatalogThread200"].isHittable) - XCTAssertTrue(selected.isHittable) - XCTAssertGreaterThanOrEqual(selected.frame.minY, 514.5) - captureDuo(app, "tabletop_03_retained_selection") + private func assertFullScreenThread(in app: XCUIApplication, postID: Int) { + XCTAssertTrue(app.staticTexts["#\(postID)"].waitForExistence(timeout: 10)) + XCTAssertFalse(app.buttons["CatalogThread100"].isHittable) + XCTAssertFalse(app.descendants(matching: .any)["BoardThreadWorkspace"].firstMatch.exists) + assertThreadFillsWindow(in: app) } - func testDuoWorkspaceSurvivesBackgroundActivation() throws { - try XCTSkipUnless(ProcessInfo.processInfo.environment["DUO_CAPTURE"] == "1", - "Run on Duo's open inner display.") - XCUIDevice.shared.orientation = .landscapeLeft - let app = launchThreadFixture(extraArguments: ["--ui-media-fixture", "--ui-catalog-fixture"]) - app.buttons["Open Link Button"].tap() - let link = app.textFields["Open Link URL"] - XCTAssertTrue(link.waitForExistence(timeout: 5)) - link.tap() - link.typeText("https://boards.4chan.org/biz/") - app.buttons["Open Link Confirm"].tap() - XCTAssertTrue(app.buttons["CatalogThread200"].waitForExistence(timeout: 10)) - app.buttons["CatalogThread200"].tap() - XCTAssertTrue(app.staticTexts["#200"].waitForExistence(timeout: 10)) - let workspace = app.descendants(matching: .any)["BoardThreadWorkspace"].firstMatch - XCTAssertTrue(workspace.exists) - let readingAnchor = app.staticTexts["#205"] - for _ in 0..<12 { - if readingAnchor.isHittable { break } - app.scrollViews["CatalogThreadDetail"].swipeUp() + private func assertThreadFillsWindow(in app: XCUIApplication) { + let scroll = app.scrollViews["Thread Posts"] + XCTAssertTrue(scroll.waitForExistence(timeout: 10)) + let window = app.windows.firstMatch.frame + XCTAssertGreaterThan(scroll.frame.width, window.width * 0.8) + XCTAssertGreaterThan(scroll.frame.height, window.height * 0.6) + print("FULLSCREEN_THREAD_FRAMES: thread=\(scroll.frame); window=\(window)") + } + + private func returnToCatalog(in app: XCUIApplication) { + let back = app.navigationBars.buttons.firstMatch + XCTAssertTrue(back.waitForExistence(timeout: 5)) + back.tap() + } + + private func scrollToReadingAnchor(in app: XCUIApplication) { + let anchor = app.staticTexts["#205"] + for _ in 0..<20 { + if anchor.isHittable { break } + app.scrollViews["Thread Posts"].swipeUp() } - XCTAssertTrue(readingAnchor.isHittable) - captureDuo(app, "workspace-before-background") - XCUIDevice.shared.press(.home) - app.activate() - captureDuo(app, "workspace-after-background") - XCTAssertTrue(workspace.waitForExistence(timeout: 10)) - XCTAssertTrue(app.buttons["CatalogThread200"].isHittable) - XCTAssertTrue(readingAnchor.waitForExistence(timeout: 10)) - XCTAssertTrue(readingAnchor.isHittable) + XCTAssertTrue(anchor.isHittable) } - func testFoldedWorkspaceKeepsThreadBeyondHinge() throws { - try XCTSkipUnless(ProcessInfo.processInfo.environment["DUO_CAPTURE"] == "1" - && ProcessInfo.processInfo.environment["DUO_FOLDED"] == "1", - "Run with the Duo's active vertical 40-point hinge region.") - continueAfterFailure = false - XCUIDevice.shared.orientation = .landscapeLeft - let app = launchThreadFixture(extraArguments: ["--ui-media-fixture", "--ui-catalog-fixture"]) - app.buttons["Open Link Button"].tap() - let link = app.textFields["Open Link URL"] - XCTAssertTrue(link.waitForExistence(timeout: 5)) - link.tap() - link.typeText("https://boards.4chan.org/biz/") - app.buttons["Open Link Confirm"].tap() - let boardRow = app.buttons["CatalogThread100"] - XCTAssertTrue(boardRow.waitForExistence(timeout: 10)) - boardRow.tap() - XCTAssertTrue(app.staticTexts["#100"].waitForExistence(timeout: 10)) - let detail = app.thumbnailMediaImage(0) - XCTAssertTrue(detail.exists) - print("DUO_WORKSPACE_FRAMES: board=\(boardRow.frame); detail=\(detail.frame)") - captureDuo(app, "folded-hinge-workspace") - XCTAssertLessThanOrEqual(boardRow.frame.maxX, 455.5, - "Keep board rows before the active hinge region.") - XCTAssertGreaterThanOrEqual(detail.frame.minX, 495.5, - "The selected thread must begin beyond the active hinge region.") + private func captureCatalogNavigation(_ app: XCUIApplication, _ name: String) { + if ProcessInfo.processInfo.environment["DUO_CAPTURE"] == "1" { + captureDuo(app, name) + } else { + attachScreenshot(name) + } } func testDuoWalkthroughAndGallery() throws { try XCTSkipUnless(ProcessInfo.processInfo.environment["DUO_CAPTURE"] == "1", "Run on an open Duo with TEST_RUNNER_DUO_CAPTURE=1.") - XCUIDevice.shared.orientation = .landscapeLeft let app = launchThreadFixture(extraArguments: ["--ui-media-fixture", "--ui-catalog-fixture", "-showGalleryPreview", "YES"]) captureDuo(app, "01-boards") app.buttons["Favorites"].tap() @@ -184,12 +189,14 @@ final class BrowsingUITests: XCTestCase { XCTAssertTrue(app.buttons["CatalogThread100"].waitForExistence(timeout: 10)) app.buttons["CatalogThread100"].tap() XCTAssertTrue(app.staticTexts["#100"].waitForExistence(timeout: 10)) - XCTAssertTrue(app.descendants(matching: .any)["BoardThreadWorkspace"].firstMatch.exists) - captureDuo(app, "04b-board-and-thread") + assertFullScreenThread(in: app, postID: 100) + captureDuo(app, "04b-fullscreen-thread") + returnToCatalog(in: app) app.buttons["CatalogThread200"].tap() XCTAssertTrue(app.staticTexts["#200"].waitForExistence(timeout: 10)) - captureDuo(app, "04c-switch-thread") - app.buttons["Back to boards"].tap() + captureDuo(app, "04c-fullscreen-second-thread") + returnToCatalog(in: app) + returnToCatalog(in: app) openFixtureThread(in: app) captureDuo(app, "05-thread") app.tapThumbnailMedia(0) @@ -205,7 +212,7 @@ final class BrowsingUITests: XCTestCase { app.buttons["Follow This General"].tap() captureDuo(app, "09-follow-general") app.buttons["Cancel"].tap() - app.buttons["Post Options 100"].tap() + app.buttons["Post Options 101"].tap() captureDuo(app, "10-post-actions") } From f7563bf2d5d20f3b2db357f1ad008b84fe38408d Mon Sep 17 00:00:00 2001 From: Adam Mischke Date: Mon, 5 Oct 2026 07:35:02 -0500 Subject: [PATCH 2/5] Find native Duo side-toolbar Back in UI checks --- swiftchanUITests/BrowsingUITests.swift | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/swiftchanUITests/BrowsingUITests.swift b/swiftchanUITests/BrowsingUITests.swift index 7b8b48c..84fc479 100644 --- a/swiftchanUITests/BrowsingUITests.swift +++ b/swiftchanUITests/BrowsingUITests.swift @@ -147,9 +147,14 @@ final class BrowsingUITests: XCTestCase { } private func returnToCatalog(in app: XCUIApplication) { - let back = app.navigationBars.buttons.firstMatch - XCTAssertTrue(back.waitForExistence(timeout: 5)) - back.tap() + let sideBack = app.buttons["BackButton"].firstMatch + if sideBack.waitForExistence(timeout: 1) { + sideBack.tap() + } else { + let back = app.navigationBars.buttons.firstMatch + XCTAssertTrue(back.waitForExistence(timeout: 5)) + back.tap() + } } private func scrollToReadingAnchor(in app: XCUIApplication) { @@ -173,13 +178,13 @@ final class BrowsingUITests: XCTestCase { try XCTSkipUnless(ProcessInfo.processInfo.environment["DUO_CAPTURE"] == "1", "Run on an open Duo with TEST_RUNNER_DUO_CAPTURE=1.") let app = launchThreadFixture(extraArguments: ["--ui-media-fixture", "--ui-catalog-fixture", "-showGalleryPreview", "YES"]) captureDuo(app, "01-boards") - app.buttons["Favorites"].tap() + app.buttons["Favorites"].firstMatch.tap() captureDuo(app, "02-favorites") - app.buttons["Settings"].tap() + app.buttons["Settings"].firstMatch.tap() captureDuo(app, "03-settings") app.buttons["Filters & Highlights"].tap() captureDuo(app, "04-filters") - app.buttons["Boards"].tap() + app.buttons["Boards"].firstMatch.tap() app.buttons["Open Link Button"].tap() let boardLink = app.textFields["Open Link URL"] XCTAssertTrue(boardLink.waitForExistence(timeout: 5)) From 419083672cb62c35f0fd027b1e21856cd5822685 Mon Sep 17 00:00:00 2001 From: Adam Mischke Date: Mon, 5 Oct 2026 08:07:12 -0500 Subject: [PATCH 3/5] Allow cold hosted builds to finish the full UI suite --- .github/workflows/build_and_test.yml | 4 ++-- AGENTS.md | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/build_and_test.yml b/.github/workflows/build_and_test.yml index d87eb1e..197a587 100644 --- a/.github/workflows/build_and_test.yml +++ b/.github/workflows/build_and_test.yml @@ -16,7 +16,7 @@ jobs: build: name: Build and Test runs-on: macos-26 - timeout-minutes: 30 + timeout-minutes: 40 env: DEVELOPER_DIR: /Applications/Xcode_26.6.app/Contents/Developer TEST_DEVICE: iPhone 17 @@ -48,7 +48,7 @@ jobs: xcodebuild -resolvePackageDependencies -workspace swiftchan.xcodeproj/project.xcworkspace -scheme swiftchan -clonedSourcePackagesDirPath build/SourcePackages -onlyUsePackageVersionsFromResolvedFile - name: Build and test - timeout-minutes: 25 + timeout-minutes: 35 run: bash ci_scripts/test_with_diagnostics.sh - name: Upload test results diff --git a/AGENTS.md b/AGENTS.md index 89e3216..4153e8e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -53,6 +53,6 @@ KSPlayer is intentionally pinned to `bb6d367c02f3247ef3f328dc0cfea34e77aef7c5` f - UI tests live in `swiftchanUITests/`. The shared scheme runs `BrowsingUITests` for following/editing generals, link validation, reading restoration, and unread navigation. These tests use a debug-only in-memory favorites store (`--ui-testing`); Thread UI tests opt into a deterministic debug-only fixture with `--ui-thread-fixture`; older live-network UI tests remain excluded. - Add focused regressions for behavior fixes. Prefer fixtures and injected loaders over live network dependencies. - GitHub Actions builds and runs unit and focused UI tests for pull requests. -- The build/test step has a 25-minute limit within the 30-minute job, allowing cold dependency compilation plus the full UI suite. Gallery paging returns to the post associated with the selected media; test that post after dismissing the gallery instead of expecting the first post to remain onscreen. +- The build/test step has a 35-minute limit within the 40-minute job. A cold hosted build plus the full UI suite exceeded the earlier 25-minute limit while tests were still passing; keep time for compilation and slower simulator interaction. Gallery paging returns to the post associated with the selected media; test that post after dismissing the gallery instead of expecting the first post to remain onscreen. - `bundle exec fastlane beta` signs and uploads a TestFlight release, then bumps the build number. Run it only when releasing is requested. - Certificates are managed with Fastlane match (`make renew_certs` / `make get_certs`). From aafad72f90c6b59b37e3799906f164fab629098f Mon Sep 17 00:00:00 2001 From: Adam Mischke Date: Mon, 5 Oct 2026 08:48:45 -0500 Subject: [PATCH 4/5] Verify favorites export independently of Files starting folder --- AGENTS.md | 2 +- swiftchanUITests/BrowsingUITests.swift | 6 +++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 4153e8e..e25ab82 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -53,6 +53,6 @@ KSPlayer is intentionally pinned to `bb6d367c02f3247ef3f328dc0cfea34e77aef7c5` f - UI tests live in `swiftchanUITests/`. The shared scheme runs `BrowsingUITests` for following/editing generals, link validation, reading restoration, and unread navigation. These tests use a debug-only in-memory favorites store (`--ui-testing`); Thread UI tests opt into a deterministic debug-only fixture with `--ui-thread-fixture`; older live-network UI tests remain excluded. - Add focused regressions for behavior fixes. Prefer fixtures and injected loaders over live network dependencies. - GitHub Actions builds and runs unit and focused UI tests for pull requests. -- The build/test step has a 35-minute limit within the 40-minute job. A cold hosted build plus the full UI suite exceeded the earlier 25-minute limit while tests were still passing; keep time for compilation and slower simulator interaction. Gallery paging returns to the post associated with the selected media; test that post after dismissing the gallery instead of expecting the first post to remain onscreen. +- The build/test step has a 35-minute limit within the 40-minute job. A cold hosted build plus the full UI suite exceeded the earlier 25-minute limit while tests were still passing; keep time for compilation and slower simulator interaction. Gallery paging returns to the post associated with the selected media; test that post after dismissing the gallery instead of expecting the first post to remain onscreen. Favorites export checks should identify `DOCPicker.filenameTextField` and the expected backup filename. A fresh Files picker can open at Browse, with Save appearing only after a destination is selected. - `bundle exec fastlane beta` signs and uploads a TestFlight release, then bumps the build number. Run it only when releasing is requested. - Certificates are managed with Fastlane match (`make renew_certs` / `make get_certs`). diff --git a/swiftchanUITests/BrowsingUITests.swift b/swiftchanUITests/BrowsingUITests.swift index 84fc479..cb1cbc8 100644 --- a/swiftchanUITests/BrowsingUITests.swift +++ b/swiftchanUITests/BrowsingUITests.swift @@ -496,7 +496,11 @@ final class BrowsingUITests: XCTestCase { XCTAssertTrue(app.buttons["Export Favorites"].isEnabled) attachScreenshot("Favorites Backup") app.buttons["Export Favorites"].tap() - XCTAssertTrue(app.buttons["Save"].waitForExistence(timeout: 10)) + // A fresh Files picker can start at Browse, without Save until a + // destination is chosen. Verify the export's filename in either state. + let filename = app.textFields["DOCPicker.filenameTextField"] + XCTAssertTrue(filename.waitForExistence(timeout: 20)) + XCTAssertTrue(["Swiftchan Favorites", "Swiftchan Favorites.json"].contains(filename.value as? String ?? "")) attachScreenshot("Export Favorites In Files") } From eae3d8a364eab6376564d2300c9d664eb6aba5b6 Mon Sep 17 00:00:00 2001 From: Adam Mischke Date: Mon, 5 Oct 2026 09:27:47 -0500 Subject: [PATCH 5/5] Recognize both native Files export picker states --- AGENTS.md | 2 +- swiftchanUITests/BrowsingUITests.swift | 12 ++++++++---- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index e25ab82..ef38f27 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -53,6 +53,6 @@ KSPlayer is intentionally pinned to `bb6d367c02f3247ef3f328dc0cfea34e77aef7c5` f - UI tests live in `swiftchanUITests/`. The shared scheme runs `BrowsingUITests` for following/editing generals, link validation, reading restoration, and unread navigation. These tests use a debug-only in-memory favorites store (`--ui-testing`); Thread UI tests opt into a deterministic debug-only fixture with `--ui-thread-fixture`; older live-network UI tests remain excluded. - Add focused regressions for behavior fixes. Prefer fixtures and injected loaders over live network dependencies. - GitHub Actions builds and runs unit and focused UI tests for pull requests. -- The build/test step has a 35-minute limit within the 40-minute job. A cold hosted build plus the full UI suite exceeded the earlier 25-minute limit while tests were still passing; keep time for compilation and slower simulator interaction. Gallery paging returns to the post associated with the selected media; test that post after dismissing the gallery instead of expecting the first post to remain onscreen. Favorites export checks should identify `DOCPicker.filenameTextField` and the expected backup filename. A fresh Files picker can open at Browse, with Save appearing only after a destination is selected. +- The build/test step has a 35-minute limit within the 40-minute job. A cold hosted build plus the full UI suite exceeded the earlier 25-minute limit while tests were still passing; keep time for compilation and slower simulator interaction. Gallery paging returns to the post associated with the selected media; test that post after dismissing the gallery instead of expecting the first post to remain onscreen. Favorites export checks must identify the actual `Browse View (Picker)` plus either `DOCPicker.filenameTextField` or Save. Verify the expected backup filename whenever its field is exposed. Captured hosted runs show that Browse can expose the filename without Save, while a folder can expose Save without the filename field. - `bundle exec fastlane beta` signs and uploads a TestFlight release, then bumps the build number. Run it only when releasing is requested. - Certificates are managed with Fastlane match (`make renew_certs` / `make get_certs`). diff --git a/swiftchanUITests/BrowsingUITests.swift b/swiftchanUITests/BrowsingUITests.swift index cb1cbc8..87c5b31 100644 --- a/swiftchanUITests/BrowsingUITests.swift +++ b/swiftchanUITests/BrowsingUITests.swift @@ -496,11 +496,15 @@ final class BrowsingUITests: XCTestCase { XCTAssertTrue(app.buttons["Export Favorites"].isEnabled) attachScreenshot("Favorites Backup") app.buttons["Export Favorites"].tap() - // A fresh Files picker can start at Browse, without Save until a - // destination is chosen. Verify the export's filename in either state. + // Files exposes different export controls at Browse and within a folder. + // Require the actual save picker and verify its filename when exposed. + XCTAssertTrue(app.otherElements["Browse View (Picker)"].firstMatch.waitForExistence(timeout: 20)) let filename = app.textFields["DOCPicker.filenameTextField"] - XCTAssertTrue(filename.waitForExistence(timeout: 20)) - XCTAssertTrue(["Swiftchan Favorites", "Swiftchan Favorites.json"].contains(filename.value as? String ?? "")) + let ready = NSPredicate { _, _ in filename.exists || app.buttons["Save"].exists } + XCTAssertEqual(XCTWaiter.wait(for: [XCTNSPredicateExpectation(predicate: ready, object: nil)], timeout: 20), .completed) + if filename.exists { + XCTAssertTrue(["Swiftchan Favorites", "Swiftchan Favorites.json"].contains(filename.value as? String ?? "")) + } attachScreenshot("Export Favorites In Files") }