From a9f01ed36bb288e5978715ebab3df3857183e2c7 Mon Sep 17 00:00:00 2001 From: Adam Mischke Date: Mon, 5 Oct 2026 12:47:23 -0500 Subject: [PATCH 1/4] Hide gallery search and counter until tapped --- docs/ipad-testing.md | 6 + docs/iphone-duo-testing.md | 6 + swiftchan.xcodeproj/project.pbxproj | 4 +- swiftchan/Services/ThreadUITestFixture.swift | 29 ++++- .../Views/Media/Gallery/GalleryView.swift | 35 +++--- swiftchanUITests/BrowsingUITests.swift | 117 +++++++++++++++++- 6 files changed, 177 insertions(+), 20 deletions(-) diff --git a/docs/ipad-testing.md b/docs/ipad-testing.md index 15e2b66..486ecaa 100644 --- a/docs/ipad-testing.md +++ b/docs/ipad-testing.md @@ -32,3 +32,9 @@ The shared workspace review is `../iphone-duo-review/2026-10-04-current/review/` 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. + +## Gallery controls + +Build 1.3 (6) opens each gallery page without the image/thumbnail search button or media counter. A single media tap reveals both, and another hides them. The optional thumbnail strip follows the same tap. Paging, zooming, and seeking clear the overlays; the close button and long-press media actions remain available when not zoomed. Check preview enabled and disabled, double-tap zoom, vertical paging, and returning to the selected post. + +The gallery UI regressions use invented image artwork and a local silent WebM fixture. For native recordings, run `testPrepareGalleryCaptureOrientation` with `TEST_RUNNER_GALLERY_CAPTURE_ORIENTATION=portrait` or `landscapeLeft` before starting the recorder; the walkthrough also asserts the app's actual window orientation. Set `TEST_RUNNER_DUO_CAPTURE=1` to pause at capture markers. Leave the orientation override unset when preserving a verified Duo pose. diff --git a/docs/iphone-duo-testing.md b/docs/iphone-duo-testing.md index 43f6922..b84ef17 100644 --- a/docs/iphone-duo-testing.md +++ b/docs/iphone-duo-testing.md @@ -98,3 +98,9 @@ Read the complete relevant Apple documents and upstream tool instructions when c - [Apple: Interacting with your app in the iOS or iPadOS Simulator](https://developer.apple.com/documentation/xcode/interacting-with-your-app-in-the-ios-or-ipados-simulator) - [MobileBuildMCP 2.7.0](https://github.com/getsentry/MobileBuildMCP/releases/tag/v2.7.0) and [2.7.1](https://github.com/getsentry/MobileBuildMCP/releases/tag/v2.7.1) - [Agent Device foldable-panel ADR](https://github.com/callstack/agent-device/blob/main/docs/adr/0025-foldable-apple-panels.md), especially the 2026-09-22 headless amendment and the accepted quarter-turn evidence gap + +## Gallery controls + +Build 1.3 (6) opens each gallery page without the image/thumbnail search button or media counter. A single media tap reveals both, and another hides them. The optional thumbnail strip follows the same tap. Paging, zooming, and seeking clear the overlays; the close button and long-press media actions remain available when not zoomed. Check preview enabled and disabled, double-tap zoom, vertical paging, and returning to the selected post. + +The gallery UI regressions use invented image artwork and a local silent WebM fixture. For native recordings, run `testPrepareGalleryCaptureOrientation` with `TEST_RUNNER_GALLERY_CAPTURE_ORIENTATION=portrait` or `landscapeLeft` before starting the recorder; the walkthrough also asserts the app's actual window orientation. Set `TEST_RUNNER_DUO_CAPTURE=1` to pause at capture markers. Leave the orientation override unset when preserving a verified Duo pose. diff --git a/swiftchan.xcodeproj/project.pbxproj b/swiftchan.xcodeproj/project.pbxproj index 3e6674a..8db056c 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 = 5; + CURRENT_PROJECT_VERSION = 6; 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 = 5; + CURRENT_PROJECT_VERSION = 6; DEVELOPMENT_ASSET_PATHS = "\"swiftchan/Preview Content\""; DEVELOPMENT_TEAM = 8Q3RG3ULSU; ENABLE_BITCODE = NO; diff --git a/swiftchan/Services/ThreadUITestFixture.swift b/swiftchan/Services/ThreadUITestFixture.swift index 8750d2e..b9d99e1 100644 --- a/swiftchan/Services/ThreadUITestFixture.swift +++ b/swiftchan/Services/ThreadUITestFixture.swift @@ -23,13 +23,15 @@ let timestamp = 9_000_000_000 + number post["tim"] = timestamp post["filename"] = "collection-study-\(number - id + 1)" - post["ext"] = ".png" + let isVideo = arguments.contains("--ui-video-media-fixture") && number == id + 2 + post["ext"] = isVideo ? ".webm" : ".png" post["w"] = 1200 post["h"] = 900 post["tn_w"] = 250 post["tn_h"] = 188 post["fsize"] = 100_000 seedMedia(timestamp: timestamp, index: number - id) + if isVideo { seedVideo(timestamp: timestamp) } } if arguments.contains("--ui-quote-fixture"), (103...105).contains(number) { post["com"] = ">>\(number - 1)
Quoted reply \(number)" @@ -69,6 +71,31 @@ ImageCache.default.store(image, forKey: "https://i.4cdn.org/biz/\(timestamp)s.jpg", toDisk: false) } + /// Two seconds of silent teal WebM; avoids network-dependent video gallery tests. + private static func seedVideo(timestamp: Int) { + let encoded = """ + GkXfo59ChoEBQveBAULygQRC84EIQoKEd2VibUKHgQJChYECGFOAZwEAAAAAAAJ+EU2bdLpNu4tTq4QVSalmU6yBoU27i1OrhBZU + rmtTrIHWTbuMU6uEElTDZ1OsggEyTbuMU6uEHFO7a1OsggJo7AEAAAAAAABZAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAVSalmsCrXsYMPQkBNgIxM + YXZmNjMuMS4xMDFXQYxMYXZmNjMuMS4xMDFEiYhAn0AAAAAAABZUrmvXrgEAAAAAAABO14EBc8WI5n7UnqvrF9WcgQAitZyDdW5k + iIEAhoVWX1ZQOIOBASPjg4QdzWUA4JCwgaC6gXiagQJVsIRVuYEBVe6BAOwBAAAAAAAAAgAAElTDZ/pzc59jwIBnyJlFo4dFTkNP + REVSRIeMTGF2ZjYzLjEuMTAxc3PVY8CLY8WI5n7UnqvrF9VnyKBFo4dFTkNPREVSRIeTTGF2YzYzLjEuMTAxIGxpYnZweGfIoUWj + iERVUkFUSU9ORIeTMDA6MDA6MDIuMDAwMDAwMDAwAB9DtnVAseeBAKPegQAAgBAHAJ0BKqAAeAAARwiFhYiFhIgCAgJ1qgP4AgaT + kRV2lQnFLSoTilpUJxS0qE4paVCcUtKhOKWlQnFLSoTilpSgAP77aJf/PTNeYP8nP/25H+3I/25H/7bCAKOYgQH0ABECAAEQEAAY + ABhYL/QACICBDLAAo5iBA+gAEQIAARAQABgAGFgv9AAIgIEMsACjmIEF3AARAgABEBAAGAAYWC/0AAiAgQywABxTu2uRu4+zgQC3 + iveBAfGCAbHwgQM= + """ + guard let data = Data(base64Encoded: encoded, options: .ignoreUnknownCharacters), + let url = URL(string: "https://i.4cdn.org/biz/\(timestamp).webm") else { return } + let file = CacheManager.shared.cacheURL(url) + do { + try FileManager.default.createDirectory(at: file.deletingLastPathComponent(), withIntermediateDirectories: true) + try data.write(to: file, options: .atomic) + } catch { + assertionFailure("Unable to seed video fixture: \(error)") + } + } + static func catalog(board: String) throws -> Catalog? { let arguments = ProcessInfo.processInfo.arguments guard board == "biz" else { return nil } diff --git a/swiftchan/Views/Media/Gallery/GalleryView.swift b/swiftchan/Views/Media/Gallery/GalleryView.swift index 7c4bb39..44805a6 100644 --- a/swiftchan/Views/Media/Gallery/GalleryView.swift +++ b/swiftchan/Views/Media/Gallery/GalleryView.swift @@ -19,8 +19,7 @@ struct GalleryView: View { @State private var selection: Int @State private var canPage = true - @State private var canShowPreview = true - @State private var showPreview = false + @State private var showControls = false @State private var canShowContextMenu = true @State private var isSeeking = false @State private var isZoomed = false @@ -43,7 +42,6 @@ struct GalleryView: View { .onDisappear { restorePagerScrolling() } - .gesture(canShowPreview && showGalleryPreview ? showPreviewTap() : nil) // Block the zoom transition's pan-to-dismiss while pinch-zoomed into // media or scrubbing video, so those gestures keep priority. .interactiveDismissDisabled(isZoomed || isSeeking) @@ -51,9 +49,17 @@ struct GalleryView: View { } private var gallerySurface: some View { - ZStack { galleryPager; galleryChrome } + ZStack { + galleryPager + .simultaneousGesture(galleryControlsTap()) + galleryChrome + } } + private var canShowControls: Bool { !isZoomed && !isSeeking } + + private var showPreview: Bool { showControls && showGalleryPreview } + private var galleryPager: some View { VerticalPagerView( selection: $selection, @@ -119,6 +125,7 @@ struct GalleryView: View { VStack { Spacer() GalleryPreviewView(selection: $state.galleryIndex) + .accessibilityIdentifier("Gallery Preview") .transition(.opacity) .padding(.bottom, 60) } @@ -127,19 +134,17 @@ struct GalleryView: View { .frame(maxWidth: .infinity, maxHeight: .infinity) .overlay(alignment: .topLeading) { closeButton } .overlay(alignment: .topTrailing) { - if viewModel.media.indices.contains(selection) { + if showControls && canShowControls && viewModel.media.indices.contains(selection) { ReverseImageSearchMenu(media: viewModel.media[selection], identifier: "Gallery Image Search") .font(.subheadline.weight(.semibold)) .foregroundStyle(.white) .padding(12) .background(.black.opacity(0.65), in: Capsule()) .padding(16) - .opacity(isZoomed || isSeeking ? 0 : 1) - .allowsHitTesting(!isZoomed && !isSeeking) } } .overlay(alignment: .bottomTrailing) { - if viewModel.media.indices.contains(selection) { + if showControls && canShowControls && viewModel.media.indices.contains(selection) { Text("\(selection + 1) of \(viewModel.media.count)") .font(.caption.monospacedDigit().weight(.semibold)) .foregroundStyle(.white) @@ -148,7 +153,6 @@ struct GalleryView: View { .background(.black.opacity(0.65), in: Capsule()) .padding(16) .padding(.bottom, positionBottomPadding) - .opacity(isZoomed || isSeeking ? 0 : 1) .allowsHitTesting(false) .accessibilityLabel("Media \(selection + 1) of \(viewModel.media.count)") .accessibilityIdentifier("Gallery Position") @@ -188,18 +192,19 @@ struct GalleryView: View { .onMediaChanged { zoomed in isZoomed = zoomed refreshPagingState() - canShowPreview = !zoomed canShowContextMenu = !zoomed if zoomed { - showPreview = false + showControls = false } onMediaChanged?(zoomed) } .onSeekChanged { seeking in isSeeking = seeking refreshPagingState() - canShowPreview = !seeking canShowContextMenu = !seeking + if seeking { + showControls = false + } } .mediaDownloadMenu(url: media.url, thumbnailURL: media.thumbnailUrl, canShowContextMenu: $canShowContextMenu) .accessibilityIdentifier( @@ -215,6 +220,7 @@ struct GalleryView: View { onPageDragChanged?(.zero) canShowContextMenu = true + showControls = false isZoomed = false isSeeking = false refreshPagingState() @@ -240,11 +246,12 @@ struct GalleryView: View { viewModel.prefetch(currentIndex: index) } - func showPreviewTap() -> some Gesture { + private func galleryControlsTap() -> some Gesture { TapGesture() .onEnded { + guard canShowControls else { return } withAnimation(.linear(duration: 0.2)) { - showPreview.toggle() + showControls.toggle() } } } diff --git a/swiftchanUITests/BrowsingUITests.swift b/swiftchanUITests/BrowsingUITests.swift index 87c5b31..e95f06c 100644 --- a/swiftchanUITests/BrowsingUITests.swift +++ b/swiftchanUITests/BrowsingUITests.swift @@ -168,6 +168,13 @@ final class BrowsingUITests: XCTestCase { private func captureCatalogNavigation(_ app: XCUIApplication, _ name: String) { if ProcessInfo.processInfo.environment["DUO_CAPTURE"] == "1" { + let frame = app.windows.firstMatch.frame + if ProcessInfo.processInfo.environment["GALLERY_CAPTURE_ORIENTATION"] == "landscapeLeft" { + XCTAssertGreaterThan(frame.width, frame.height) + } else if ProcessInfo.processInfo.environment["GALLERY_CAPTURE_ORIENTATION"] == "portrait" { + XCTAssertGreaterThan(frame.height, frame.width) + } + print("GALLERY_CAPTURE_WINDOW: \(frame)") captureDuo(app, name) } else { attachScreenshot(name) @@ -284,7 +291,7 @@ final class BrowsingUITests: XCTestCase { } func testImageSearchIsAvailableFromPostsAndPagedGalleryMedia() { - let app = launchThreadFixture(extraArguments: ["--ui-media-fixture"]) + let app = launchThreadFixture(extraArguments: ["--ui-media-fixture", "-showGalleryPreview", "NO"]) openFixtureThread(in: app) let search = app.buttons["Image Search Post 100"] XCTAssertTrue(search.isHittable) @@ -296,14 +303,24 @@ final class BrowsingUITests: XCTestCase { app.tapThumbnailMedia(0) XCTAssertTrue(app.buttons["Close gallery"].waitForExistence(timeout: 5)) let position = app.staticTexts["Gallery Position"] + assertGalleryControlsHidden(in: app) + attachScreenshot("Gallery Opens Without Search Or Counter") + app.galleryMediaImage(0).tap() + XCTAssertTrue(position.waitForExistence(timeout: 5)) + XCTAssertTrue(app.buttons["Gallery Image Search"].isHittable) XCTAssertEqual(position.label, "Media 1 of 3") - attachScreenshot("Gallery Search Button And Position") + app.galleryMediaImage(0).tap() + assertGalleryControlsHidden(in: app) + app.galleryMediaImage(0).tap() app.buttons["Gallery Image Search"].tap() assertImageSearchProviders(in: app) - attachScreenshot("Gallery Image Search") position.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap() app.galleryMediaImage(0).swipeUp() XCTAssertTrue(app.galleryMediaImage(1).waitForExistence(timeout: 5)) + assertGalleryControlsHidden(in: app) + attachScreenshot("Next Gallery Page Has No Search Or Counter") + app.galleryMediaImage(1).tap() + XCTAssertTrue(position.waitForExistence(timeout: 5)) let nextPosition = NSPredicate(format: "label == %@", "Media 2 of 3") XCTAssertEqual(XCTWaiter.wait(for: [XCTNSPredicateExpectation(predicate: nextPosition, object: position)], timeout: 5), .completed) app.buttons["Gallery Image Search"].tap() @@ -328,6 +345,87 @@ final class BrowsingUITests: XCTestCase { assertImageSearchProviders(in: app) } + func testPrepareGalleryCaptureOrientation() throws { + let orientation = ProcessInfo.processInfo.environment["GALLERY_CAPTURE_ORIENTATION"] + try XCTSkipUnless(orientation == "portrait" || orientation == "landscapeLeft", "Native gallery capture preparation only.") + prepareGalleryCaptureOrientation() + let app = launchThreadFixture(extraArguments: ["--ui-media-fixture"]) + let frame = app.windows.firstMatch.frame + XCTAssertEqual(frame.height > frame.width, orientation == "portrait") + print("GALLERY_ORIENTATION_READY: \(frame)") + } + + func testGalleryTapControlsPreservePreviewZoomAndLongPressActions() { + prepareGalleryCaptureOrientation() + let app = launchThreadFixture(extraArguments: ["--ui-media-fixture", "-showGalleryPreview", "YES"]) + openFixtureThread(in: app) + app.tapThumbnailMedia(0) + let close = app.buttons["Close gallery"] + let search = app.buttons["Gallery Image Search"] + let position = app.staticTexts["Gallery Position"] + let preview = app.descendants(matching: .any)["Gallery Preview"].firstMatch + XCTAssertTrue(close.waitForExistence(timeout: 5)) + assertGalleryControlsHidden(in: app) + XCTAssertFalse(preview.exists) + captureCatalogNavigation(app, "gallery_01_clean") + app.galleryMediaImage(0).tap() + XCTAssertTrue(search.waitForExistence(timeout: 5)) + XCTAssertTrue(preview.exists) + XCTAssertEqual(position.label, "Media 1 of 3") + captureCatalogNavigation(app, "gallery_02_controls_revealed") + app.galleryMediaImage(0).tap() + assertGalleryControlsHidden(in: app) + XCTAssertFalse(preview.exists) + captureCatalogNavigation(app, "gallery_03_controls_hidden") + let originalWidth = app.galleryMediaImage(0).frame.width + app.galleryMediaImage(0).doubleTap() + XCTAssertGreaterThan(app.galleryMediaImage(0).frame.width, originalWidth * 1.5) + assertGalleryControlsHidden(in: app) + Thread.sleep(forTimeInterval: 0.6) + app.galleryMediaImage(0).doubleTap() + let unzoomed = NSPredicate { _, _ in abs(app.galleryMediaImage(0).frame.width - originalWidth) < 1 } + XCTAssertEqual(XCTWaiter.wait(for: [XCTNSPredicateExpectation(predicate: unzoomed, object: nil)], timeout: 5), .completed) + app.galleryMediaImage(0).press(forDuration: 1) + XCTAssertTrue(app.buttons["Reverse Image Search"].waitForExistence(timeout: 5)) + XCTAssertTrue(app.buttons["Open Original"].exists) + captureCatalogNavigation(app, "gallery_04_long_press_actions") + close.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap() + app.galleryMediaImage(0).swipeUp() + XCTAssertTrue(app.galleryMediaImage(1).waitForExistence(timeout: 5)) + assertGalleryControlsHidden(in: app) + captureCatalogNavigation(app, "gallery_05_next_page_clean") + close.tap() + app.tapThumbnailMedia(1) + XCTAssertTrue(close.waitForExistence(timeout: 5)) + assertGalleryControlsHidden(in: app) + close.tap() + } + + func testVideoGalleryHidesThumbnailSearchUntilTapped() { + prepareGalleryCaptureOrientation() + let app = launchThreadFixture(extraArguments: ["--ui-media-fixture", "--ui-video-media-fixture", "-showGalleryPreview", "NO"]) + openFixtureThread(in: app) + app.tapThumbnailMedia(2) + let close = app.buttons["Close gallery"] + XCTAssertTrue(close.waitForExistence(timeout: 5)) + assertGalleryControlsHidden(in: app) + captureCatalogNavigation(app, "gallery_video_01_clean") + let media = app.descendants(matching: .any)["2 Gallery Media Image"].firstMatch + XCTAssertTrue(media.waitForExistence(timeout: 5)) + media.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.4)).tap() + let search = app.buttons["Gallery Image Search"] + XCTAssertTrue(search.waitForExistence(timeout: 5)) + XCTAssertEqual(search.label, "Search Thumbnail") + XCTAssertEqual(app.staticTexts["Gallery Position"].label, "Media 3 of 3") + XCTAssertTrue(app.sliders.firstMatch.exists) + captureCatalogNavigation(app, "gallery_video_02_controls_revealed") + media.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.4)).tap() + assertGalleryControlsHidden(in: app) + captureCatalogNavigation(app, "gallery_video_03_controls_hidden") + close.tap() + XCTAssertFalse(close.exists) + } + func testCopyQuoteUsesTheSelectedPostAndKeepsReadingPosition() { let app = launchThreadFixture() openFixtureThread(in: app, anchor: "#p105") @@ -357,6 +455,19 @@ final class BrowsingUITests: XCTestCase { attachScreenshot("Copied Quote Pasted Into Reply Draft") } + private func prepareGalleryCaptureOrientation() { + switch ProcessInfo.processInfo.environment["GALLERY_CAPTURE_ORIENTATION"] { + case "portrait": XCUIDevice.shared.orientation = .portrait + case "landscapeLeft": XCUIDevice.shared.orientation = .landscapeLeft + default: break + } + } + + private func assertGalleryControlsHidden(in app: XCUIApplication) { + XCTAssertFalse(app.staticTexts["Gallery Position"].exists) + XCTAssertFalse(app.buttons["Gallery Image Search"].exists) + } + private func assertImageSearchProviders(in app: XCUIApplication) { for provider in ["Google Lens", "Yandex", "TinEye", "SauceNAO", "IQDB", "trace.moe"] { XCTAssertTrue(app.buttons["Image Search \(provider)"].isHittable) From 8e595fb827ca34074bc4bfdec18d74cd0a3f5a34 Mon Sep 17 00:00:00 2001 From: Adam Mischke Date: Mon, 5 Oct 2026 12:54:30 -0500 Subject: [PATCH 2/4] Scope capture orientation checks to gallery walkthroughs --- docs/ipad-testing.md | 2 ++ docs/iphone-duo-testing.md | 2 ++ swiftchanUITests/BrowsingUITests.swift | 24 +++++++++++++++--------- 3 files changed, 19 insertions(+), 9 deletions(-) diff --git a/docs/ipad-testing.md b/docs/ipad-testing.md index 486ecaa..07c992b 100644 --- a/docs/ipad-testing.md +++ b/docs/ipad-testing.md @@ -38,3 +38,5 @@ The full-screen navigation review is `../iphone-duo-review/2026-10-05-fullscreen Build 1.3 (6) opens each gallery page without the image/thumbnail search button or media counter. A single media tap reveals both, and another hides them. The optional thumbnail strip follows the same tap. Paging, zooming, and seeking clear the overlays; the close button and long-press media actions remain available when not zoomed. Check preview enabled and disabled, double-tap zoom, vertical paging, and returning to the selected post. The gallery UI regressions use invented image artwork and a local silent WebM fixture. For native recordings, run `testPrepareGalleryCaptureOrientation` with `TEST_RUNNER_GALLERY_CAPTURE_ORIENTATION=portrait` or `landscapeLeft` before starting the recorder; the walkthrough also asserts the app's actual window orientation. Set `TEST_RUNNER_DUO_CAPTURE=1` to pause at capture markers. Leave the orientation override unset when preserving a verified Duo pose. + +With `xcodebuild`, the `TEST_RUNNER_` prefix is stripped when forwarding these variables to XCTest: the test reads `GALLERY_CAPTURE_ORIENTATION` and `DUO_CAPTURE`. Orientation assertions apply only to gallery captures. Record simulator walkthroughs sequentially; concurrent capture runs against shared test products produced stale display frames and unstable automation sessions. The build 6 gallery review is `../iphone-duo-review/2026-10-05-gallery/`. diff --git a/docs/iphone-duo-testing.md b/docs/iphone-duo-testing.md index b84ef17..aba52a5 100644 --- a/docs/iphone-duo-testing.md +++ b/docs/iphone-duo-testing.md @@ -104,3 +104,5 @@ Read the complete relevant Apple documents and upstream tool instructions when c Build 1.3 (6) opens each gallery page without the image/thumbnail search button or media counter. A single media tap reveals both, and another hides them. The optional thumbnail strip follows the same tap. Paging, zooming, and seeking clear the overlays; the close button and long-press media actions remain available when not zoomed. Check preview enabled and disabled, double-tap zoom, vertical paging, and returning to the selected post. The gallery UI regressions use invented image artwork and a local silent WebM fixture. For native recordings, run `testPrepareGalleryCaptureOrientation` with `TEST_RUNNER_GALLERY_CAPTURE_ORIENTATION=portrait` or `landscapeLeft` before starting the recorder; the walkthrough also asserts the app's actual window orientation. Set `TEST_RUNNER_DUO_CAPTURE=1` to pause at capture markers. Leave the orientation override unset when preserving a verified Duo pose. + +With `xcodebuild`, the `TEST_RUNNER_` prefix is stripped when forwarding these variables to XCTest: the test reads `GALLERY_CAPTURE_ORIENTATION` and `DUO_CAPTURE`. Orientation assertions apply only to gallery captures. Record simulator walkthroughs sequentially; concurrent capture runs against shared test products produced stale display frames and unstable automation sessions. The build 6 gallery review is `../iphone-duo-review/2026-10-05-gallery/`. diff --git a/swiftchanUITests/BrowsingUITests.swift b/swiftchanUITests/BrowsingUITests.swift index e95f06c..0c87a12 100644 --- a/swiftchanUITests/BrowsingUITests.swift +++ b/swiftchanUITests/BrowsingUITests.swift @@ -166,7 +166,7 @@ final class BrowsingUITests: XCTestCase { XCTAssertTrue(anchor.isHittable) } - private func captureCatalogNavigation(_ app: XCUIApplication, _ name: String) { + private func captureGallery(_ app: XCUIApplication, _ name: String) { if ProcessInfo.processInfo.environment["DUO_CAPTURE"] == "1" { let frame = app.windows.firstMatch.frame if ProcessInfo.processInfo.environment["GALLERY_CAPTURE_ORIENTATION"] == "landscapeLeft" { @@ -175,6 +175,12 @@ final class BrowsingUITests: XCTestCase { XCTAssertGreaterThan(frame.height, frame.width) } print("GALLERY_CAPTURE_WINDOW: \(frame)") + } + captureCatalogNavigation(app, name) + } + + private func captureCatalogNavigation(_ app: XCUIApplication, _ name: String) { + if ProcessInfo.processInfo.environment["DUO_CAPTURE"] == "1" { captureDuo(app, name) } else { attachScreenshot(name) @@ -367,16 +373,16 @@ final class BrowsingUITests: XCTestCase { XCTAssertTrue(close.waitForExistence(timeout: 5)) assertGalleryControlsHidden(in: app) XCTAssertFalse(preview.exists) - captureCatalogNavigation(app, "gallery_01_clean") + captureGallery(app, "gallery_01_clean") app.galleryMediaImage(0).tap() XCTAssertTrue(search.waitForExistence(timeout: 5)) XCTAssertTrue(preview.exists) XCTAssertEqual(position.label, "Media 1 of 3") - captureCatalogNavigation(app, "gallery_02_controls_revealed") + captureGallery(app, "gallery_02_controls_revealed") app.galleryMediaImage(0).tap() assertGalleryControlsHidden(in: app) XCTAssertFalse(preview.exists) - captureCatalogNavigation(app, "gallery_03_controls_hidden") + captureGallery(app, "gallery_03_controls_hidden") let originalWidth = app.galleryMediaImage(0).frame.width app.galleryMediaImage(0).doubleTap() XCTAssertGreaterThan(app.galleryMediaImage(0).frame.width, originalWidth * 1.5) @@ -388,12 +394,12 @@ final class BrowsingUITests: XCTestCase { app.galleryMediaImage(0).press(forDuration: 1) XCTAssertTrue(app.buttons["Reverse Image Search"].waitForExistence(timeout: 5)) XCTAssertTrue(app.buttons["Open Original"].exists) - captureCatalogNavigation(app, "gallery_04_long_press_actions") + captureGallery(app, "gallery_04_long_press_actions") close.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap() app.galleryMediaImage(0).swipeUp() XCTAssertTrue(app.galleryMediaImage(1).waitForExistence(timeout: 5)) assertGalleryControlsHidden(in: app) - captureCatalogNavigation(app, "gallery_05_next_page_clean") + captureGallery(app, "gallery_05_next_page_clean") close.tap() app.tapThumbnailMedia(1) XCTAssertTrue(close.waitForExistence(timeout: 5)) @@ -409,7 +415,7 @@ final class BrowsingUITests: XCTestCase { let close = app.buttons["Close gallery"] XCTAssertTrue(close.waitForExistence(timeout: 5)) assertGalleryControlsHidden(in: app) - captureCatalogNavigation(app, "gallery_video_01_clean") + captureGallery(app, "gallery_video_01_clean") let media = app.descendants(matching: .any)["2 Gallery Media Image"].firstMatch XCTAssertTrue(media.waitForExistence(timeout: 5)) media.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.4)).tap() @@ -418,10 +424,10 @@ final class BrowsingUITests: XCTestCase { XCTAssertEqual(search.label, "Search Thumbnail") XCTAssertEqual(app.staticTexts["Gallery Position"].label, "Media 3 of 3") XCTAssertTrue(app.sliders.firstMatch.exists) - captureCatalogNavigation(app, "gallery_video_02_controls_revealed") + captureGallery(app, "gallery_video_02_controls_revealed") media.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.4)).tap() assertGalleryControlsHidden(in: app) - captureCatalogNavigation(app, "gallery_video_03_controls_hidden") + captureGallery(app, "gallery_video_03_controls_hidden") close.tap() XCTAssertFalse(close.exists) } From aaf1e7afaed8a92384b5222e158669c5173fca52 Mon Sep 17 00:00:00 2001 From: Adam Mischke Date: Mon, 5 Oct 2026 12:58:08 -0500 Subject: [PATCH 3/4] Verify iPad window orientation before video presentation --- docs/ipad-testing.md | 4 ++- docs/iphone-duo-testing.md | 4 ++- swiftchanUITests/BrowsingUITests.swift | 46 ++++++++++++-------------- 3 files changed, 28 insertions(+), 26 deletions(-) diff --git a/docs/ipad-testing.md b/docs/ipad-testing.md index 07c992b..3d0212d 100644 --- a/docs/ipad-testing.md +++ b/docs/ipad-testing.md @@ -37,6 +37,8 @@ The full-screen navigation review is `../iphone-duo-review/2026-10-05-fullscreen Build 1.3 (6) opens each gallery page without the image/thumbnail search button or media counter. A single media tap reveals both, and another hides them. The optional thumbnail strip follows the same tap. Paging, zooming, and seeking clear the overlays; the close button and long-press media actions remain available when not zoomed. Check preview enabled and disabled, double-tap zoom, vertical paging, and returning to the selected post. -The gallery UI regressions use invented image artwork and a local silent WebM fixture. For native recordings, run `testPrepareGalleryCaptureOrientation` with `TEST_RUNNER_GALLERY_CAPTURE_ORIENTATION=portrait` or `landscapeLeft` before starting the recorder; the walkthrough also asserts the app's actual window orientation. Set `TEST_RUNNER_DUO_CAPTURE=1` to pause at capture markers. Leave the orientation override unset when preserving a verified Duo pose. +The gallery UI regressions use invented image artwork and a local silent WebM fixture. For native recordings, run `testPrepareGalleryCaptureOrientation` with `TEST_RUNNER_GALLERY_CAPTURE_ORIENTATION=portrait` or `landscapeLeft` before starting the recorder; the walkthrough also asserts the app's actual window orientation before opening the gallery. Set `TEST_RUNNER_DUO_CAPTURE=1` to pause at capture markers. Leave the orientation override unset when preserving a verified Duo pose. With `xcodebuild`, the `TEST_RUNNER_` prefix is stripped when forwarding these variables to XCTest: the test reads `GALLERY_CAPTURE_ORIENTATION` and `DUO_CAPTURE`. Orientation assertions apply only to gallery captures. Record simulator walkthroughs sequentially; concurrent capture runs against shared test products produced stale display frames and unstable automation sessions. The build 6 gallery review is `../iphone-duo-review/2026-10-05-gallery/`. + +During iPad video presentation, XCTest can report a zero-sized accessibility window while the gallery controls remain present. Check the real app window before opening media, then verify every native PNG's orientation in the capture harness. Do not treat a zero accessibility frame as the video display's geometry. diff --git a/docs/iphone-duo-testing.md b/docs/iphone-duo-testing.md index aba52a5..05b64d1 100644 --- a/docs/iphone-duo-testing.md +++ b/docs/iphone-duo-testing.md @@ -103,6 +103,8 @@ Read the complete relevant Apple documents and upstream tool instructions when c Build 1.3 (6) opens each gallery page without the image/thumbnail search button or media counter. A single media tap reveals both, and another hides them. The optional thumbnail strip follows the same tap. Paging, zooming, and seeking clear the overlays; the close button and long-press media actions remain available when not zoomed. Check preview enabled and disabled, double-tap zoom, vertical paging, and returning to the selected post. -The gallery UI regressions use invented image artwork and a local silent WebM fixture. For native recordings, run `testPrepareGalleryCaptureOrientation` with `TEST_RUNNER_GALLERY_CAPTURE_ORIENTATION=portrait` or `landscapeLeft` before starting the recorder; the walkthrough also asserts the app's actual window orientation. Set `TEST_RUNNER_DUO_CAPTURE=1` to pause at capture markers. Leave the orientation override unset when preserving a verified Duo pose. +The gallery UI regressions use invented image artwork and a local silent WebM fixture. For native recordings, run `testPrepareGalleryCaptureOrientation` with `TEST_RUNNER_GALLERY_CAPTURE_ORIENTATION=portrait` or `landscapeLeft` before starting the recorder; the walkthrough also asserts the app's actual window orientation before opening the gallery. Set `TEST_RUNNER_DUO_CAPTURE=1` to pause at capture markers. Leave the orientation override unset when preserving a verified Duo pose. With `xcodebuild`, the `TEST_RUNNER_` prefix is stripped when forwarding these variables to XCTest: the test reads `GALLERY_CAPTURE_ORIENTATION` and `DUO_CAPTURE`. Orientation assertions apply only to gallery captures. Record simulator walkthroughs sequentially; concurrent capture runs against shared test products produced stale display frames and unstable automation sessions. The build 6 gallery review is `../iphone-duo-review/2026-10-05-gallery/`. + +During iPad video presentation, XCTest can report a zero-sized accessibility window while the gallery controls remain present. Check the real app window before opening media, then verify every native PNG's orientation in the capture harness. Do not treat a zero accessibility frame as the video display's geometry. diff --git a/swiftchanUITests/BrowsingUITests.swift b/swiftchanUITests/BrowsingUITests.swift index 0c87a12..4b32e52 100644 --- a/swiftchanUITests/BrowsingUITests.swift +++ b/swiftchanUITests/BrowsingUITests.swift @@ -166,19 +166,6 @@ final class BrowsingUITests: XCTestCase { XCTAssertTrue(anchor.isHittable) } - private func captureGallery(_ app: XCUIApplication, _ name: String) { - if ProcessInfo.processInfo.environment["DUO_CAPTURE"] == "1" { - let frame = app.windows.firstMatch.frame - if ProcessInfo.processInfo.environment["GALLERY_CAPTURE_ORIENTATION"] == "landscapeLeft" { - XCTAssertGreaterThan(frame.width, frame.height) - } else if ProcessInfo.processInfo.environment["GALLERY_CAPTURE_ORIENTATION"] == "portrait" { - XCTAssertGreaterThan(frame.height, frame.width) - } - print("GALLERY_CAPTURE_WINDOW: \(frame)") - } - captureCatalogNavigation(app, name) - } - private func captureCatalogNavigation(_ app: XCUIApplication, _ name: String) { if ProcessInfo.processInfo.environment["DUO_CAPTURE"] == "1" { captureDuo(app, name) @@ -356,14 +343,14 @@ final class BrowsingUITests: XCTestCase { try XCTSkipUnless(orientation == "portrait" || orientation == "landscapeLeft", "Native gallery capture preparation only.") prepareGalleryCaptureOrientation() let app = launchThreadFixture(extraArguments: ["--ui-media-fixture"]) - let frame = app.windows.firstMatch.frame - XCTAssertEqual(frame.height > frame.width, orientation == "portrait") - print("GALLERY_ORIENTATION_READY: \(frame)") + assertGalleryCaptureOrientation(in: app) + print("GALLERY_ORIENTATION_READY") } func testGalleryTapControlsPreservePreviewZoomAndLongPressActions() { prepareGalleryCaptureOrientation() let app = launchThreadFixture(extraArguments: ["--ui-media-fixture", "-showGalleryPreview", "YES"]) + assertGalleryCaptureOrientation(in: app) openFixtureThread(in: app) app.tapThumbnailMedia(0) let close = app.buttons["Close gallery"] @@ -373,16 +360,16 @@ final class BrowsingUITests: XCTestCase { XCTAssertTrue(close.waitForExistence(timeout: 5)) assertGalleryControlsHidden(in: app) XCTAssertFalse(preview.exists) - captureGallery(app, "gallery_01_clean") + captureCatalogNavigation(app, "gallery_01_clean") app.galleryMediaImage(0).tap() XCTAssertTrue(search.waitForExistence(timeout: 5)) XCTAssertTrue(preview.exists) XCTAssertEqual(position.label, "Media 1 of 3") - captureGallery(app, "gallery_02_controls_revealed") + captureCatalogNavigation(app, "gallery_02_controls_revealed") app.galleryMediaImage(0).tap() assertGalleryControlsHidden(in: app) XCTAssertFalse(preview.exists) - captureGallery(app, "gallery_03_controls_hidden") + captureCatalogNavigation(app, "gallery_03_controls_hidden") let originalWidth = app.galleryMediaImage(0).frame.width app.galleryMediaImage(0).doubleTap() XCTAssertGreaterThan(app.galleryMediaImage(0).frame.width, originalWidth * 1.5) @@ -394,12 +381,12 @@ final class BrowsingUITests: XCTestCase { app.galleryMediaImage(0).press(forDuration: 1) XCTAssertTrue(app.buttons["Reverse Image Search"].waitForExistence(timeout: 5)) XCTAssertTrue(app.buttons["Open Original"].exists) - captureGallery(app, "gallery_04_long_press_actions") + captureCatalogNavigation(app, "gallery_04_long_press_actions") close.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap() app.galleryMediaImage(0).swipeUp() XCTAssertTrue(app.galleryMediaImage(1).waitForExistence(timeout: 5)) assertGalleryControlsHidden(in: app) - captureGallery(app, "gallery_05_next_page_clean") + captureCatalogNavigation(app, "gallery_05_next_page_clean") close.tap() app.tapThumbnailMedia(1) XCTAssertTrue(close.waitForExistence(timeout: 5)) @@ -410,12 +397,13 @@ final class BrowsingUITests: XCTestCase { func testVideoGalleryHidesThumbnailSearchUntilTapped() { prepareGalleryCaptureOrientation() let app = launchThreadFixture(extraArguments: ["--ui-media-fixture", "--ui-video-media-fixture", "-showGalleryPreview", "NO"]) + assertGalleryCaptureOrientation(in: app) openFixtureThread(in: app) app.tapThumbnailMedia(2) let close = app.buttons["Close gallery"] XCTAssertTrue(close.waitForExistence(timeout: 5)) assertGalleryControlsHidden(in: app) - captureGallery(app, "gallery_video_01_clean") + captureCatalogNavigation(app, "gallery_video_01_clean") let media = app.descendants(matching: .any)["2 Gallery Media Image"].firstMatch XCTAssertTrue(media.waitForExistence(timeout: 5)) media.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.4)).tap() @@ -424,10 +412,10 @@ final class BrowsingUITests: XCTestCase { XCTAssertEqual(search.label, "Search Thumbnail") XCTAssertEqual(app.staticTexts["Gallery Position"].label, "Media 3 of 3") XCTAssertTrue(app.sliders.firstMatch.exists) - captureGallery(app, "gallery_video_02_controls_revealed") + captureCatalogNavigation(app, "gallery_video_02_controls_revealed") media.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.4)).tap() assertGalleryControlsHidden(in: app) - captureGallery(app, "gallery_video_03_controls_hidden") + captureCatalogNavigation(app, "gallery_video_03_controls_hidden") close.tap() XCTAssertFalse(close.exists) } @@ -461,6 +449,16 @@ final class BrowsingUITests: XCTestCase { attachScreenshot("Copied Quote Pasted Into Reply Draft") } + private func assertGalleryCaptureOrientation(in app: XCUIApplication) { + let orientation = ProcessInfo.processInfo.environment["GALLERY_CAPTURE_ORIENTATION"] + guard orientation == "portrait" || orientation == "landscapeLeft" else { return } + let frame = app.windows.firstMatch.frame + XCTAssertGreaterThan(frame.width, 0) + XCTAssertGreaterThan(frame.height, 0) + XCTAssertEqual(frame.height > frame.width, orientation == "portrait") + print("GALLERY_CAPTURE_WINDOW: \(frame)") + } + private func prepareGalleryCaptureOrientation() { switch ProcessInfo.processInfo.environment["GALLERY_CAPTURE_ORIENTATION"] { case "portrait": XCUIDevice.shared.orientation = .portrait From 5bb028f15ee221b908f66c327794fe7f350bd08d Mon Sep 17 00:00:00 2001 From: Adam Mischke Date: Mon, 5 Oct 2026 13:46:54 -0500 Subject: [PATCH 4/4] Wait for cold Files picker initialization in export UI test --- AGENTS.md | 2 +- swiftchanUITests/BrowsingUITests.swift | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index ef38f27..4e054fa 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 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. +- 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. Allow up to 60 seconds for the first Files picker on a cold hosted simulator; captured failures showed its remote view empty after the earlier 20-second wait. 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 4b32e52..940ef2d 100644 --- a/swiftchanUITests/BrowsingUITests.swift +++ b/swiftchanUITests/BrowsingUITests.swift @@ -613,7 +613,8 @@ final class BrowsingUITests: XCTestCase { app.buttons["Export Favorites"].tap() // 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)) + // A cold hosted simulator can take over 30 seconds to initialize Files. + XCTAssertTrue(app.otherElements["Browse View (Picker)"].firstMatch.waitForExistence(timeout: 60)) let filename = app.textFields["DOCPicker.filenameTextField"] let ready = NSPredicate { _, _ in filename.exists || app.buttons["Save"].exists } XCTAssertEqual(XCTWaiter.wait(for: [XCTNSPredicateExpectation(predicate: ready, object: nil)], timeout: 20), .completed)