Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -492,7 +492,7 @@ class EnhancedTrackingProtectionMenuVC: UIViewController, Themeable {
// Setting x based on window calculation because we don't want
// users to move the frame side ways, only straight up or down
view.frame.origin = CGPoint(x: originalXPosition,
y: self.pointOrigin!.y + translation.y)
y: (self.pointOrigin?.y ?? originalYPosition) + translation.y)

if sender.state == .ended {
let dragVelocity = sender.velocity(in: view)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -704,7 +704,7 @@ class TrackingProtectionViewController: UIViewController,
// Setting x based on window calculation because we don't want
// users to move the frame side ways, only straight up or down
view.frame.origin = CGPoint(x: originalXPosition,
y: self.pointOrigin!.y + translation.y)
y: (self.pointOrigin?.y ?? originalYPosition) + translation.y)

if sender.state == .ended {
let dragVelocity = sender.velocity(in: view)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at http://mozilla.org/MPL/2.0/

import XCTest
import Common
@testable import Client

@MainActor
final class EnhancedTrackingProtectionVCTests: XCTestCase {
override func setUp() async throws {
try await super.setUp()
await DependencyHelperMock().bootstrapDependencies()
}

override func tearDown() async throws {
DependencyHelperMock().reset()
try await super.tearDown()
}

/// Regression test: prior to the fix, `panGestureRecognizerAction` force-unwrapped `pointOrigin`,
/// which is only set inside `viewDidLayoutSubviews`. If the pan gesture fired before the first
/// layout pass (e.g. after a memory warning or a rapid present/dismiss cycle), the app crashed
/// with `EXC_BAD_INSTRUCTION`. The handler must now tolerate a nil `pointOrigin`.
func testPanGestureRecognizerAction_beforeLayoutPass_doesNotCrash() {
let subject = makeSUT()
subject.loadViewIfNeeded()
// Intentionally do NOT call viewDidLayoutSubviews -- pointOrigin stays nil.

let mockGesture = MockPanGestureRecognizer()
mockGesture.mockState = .changed
mockGesture.mockTranslation = CGPoint(x: 0, y: 50)
mockGesture.mockVelocity = .zero

subject.panGestureRecognizerAction(sender: mockGesture)

XCTAssertNotNil(subject.view, "View should still exist after gesture without prior layout pass")
}

/// The .ended branch should also be safe when pointOrigin is still nil β€” both unwrap sites
/// must handle the nil case consistently.
func testPanGestureRecognizerAction_endedStateBeforeLayout_doesNotCrash() {
let subject = makeSUT()
subject.loadViewIfNeeded()

let mockGesture = MockPanGestureRecognizer()
mockGesture.mockState = .ended
mockGesture.mockTranslation = CGPoint(x: 0, y: 20)
mockGesture.mockVelocity = .zero

subject.panGestureRecognizerAction(sender: mockGesture)

XCTAssertNotNil(subject.view)
}

// MARK: - Helpers

private func makeSUT() -> EnhancedTrackingProtectionMenuVC {
let viewModel = EnhancedTrackingProtectionMenuVM(
url: URL(string: "https://example.com")!,
displayTitle: "example.com",
connectionSecure: true,
globalETPIsEnabled: true,
contentBlockerStatus: .noBlockedURLs
)
return EnhancedTrackingProtectionMenuVC(
viewModel: viewModel,
windowUUID: .XCTestDefaultUUID
)
}
}

/// Test double allowing us to drive the pan gesture handler deterministically.
private final class MockPanGestureRecognizer: UIPanGestureRecognizer {
var mockState: UIGestureRecognizer.State = .began
var mockTranslation: CGPoint = .zero
var mockVelocity: CGPoint = .zero

init() {
super.init(target: nil, action: nil)
}

override var state: UIGestureRecognizer.State {
get { mockState }
set { mockState = newValue }
}

override func translation(in view: UIView?) -> CGPoint {
return mockTranslation
}

override func velocity(in view: UIView?) -> CGPoint {
return mockVelocity
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at http://mozilla.org/MPL/2.0/

import XCTest
import Common
@testable import Client

@MainActor
final class TrackingProtectionViewControllerTests: XCTestCase {
private var mockProfile: MockProfile!

override func setUp() async throws {
try await super.setUp()
await DependencyHelperMock().bootstrapDependencies()
mockProfile = MockProfile()
}

override func tearDown() async throws {
mockProfile = nil
DependencyHelperMock().reset()
try await super.tearDown()
}

/// Regression test: prior to the fix, `panGestureRecognizerAction` force-unwrapped `pointOrigin`,
/// which is only set inside `viewDidLayoutSubviews`. If the pan gesture fired before the first
/// layout pass (e.g. after a memory warning or a rapid present/dismiss cycle), the app crashed
/// with `EXC_BAD_INSTRUCTION`. The handler must now tolerate a nil `pointOrigin`.
func testPanGestureRecognizerAction_beforeLayoutPass_doesNotCrash() {
let subject = makeSUT()
subject.loadViewIfNeeded()
// Intentionally do NOT call viewDidLayoutSubviews -- pointOrigin stays nil.

let mockGesture = MockPanGestureRecognizer()
mockGesture.mockState = .changed
mockGesture.mockTranslation = CGPoint(x: 0, y: 50)
mockGesture.mockVelocity = .zero

subject.panGestureRecognizerAction(sender: mockGesture)

XCTAssertNotNil(subject.view, "View should still exist after gesture without prior layout pass")
}

/// The .ended branch should also be safe when pointOrigin is still nil.
func testPanGestureRecognizerAction_endedStateBeforeLayout_doesNotCrash() {
let subject = makeSUT()
subject.loadViewIfNeeded()

let mockGesture = MockPanGestureRecognizer()
mockGesture.mockState = .ended
mockGesture.mockTranslation = CGPoint(x: 0, y: 20)
mockGesture.mockVelocity = .zero

subject.panGestureRecognizerAction(sender: mockGesture)

XCTAssertNotNil(subject.view)
}

// MARK: - Helpers

private func makeSUT() -> TrackingProtectionViewController {
let model = TrackingProtectionModel(
userDefaults: nil,
url: URL(string: "https://example.com")!,
displayTitle: "example.com",
connectionSecure: true,
globalETPIsEnabled: true,
contentBlockerStatus: .noBlockedURLs,
contentBlockerStats: nil,
selectedTab: nil
)
return TrackingProtectionViewController(
viewModel: model,
profile: mockProfile,
windowUUID: .XCTestDefaultUUID
)
}
}

/// Test double allowing us to drive the pan gesture handler deterministically.
private final class MockPanGestureRecognizer: UIPanGestureRecognizer {
var mockState: UIGestureRecognizer.State = .began
var mockTranslation: CGPoint = .zero
var mockVelocity: CGPoint = .zero

init() {
super.init(target: nil, action: nil)
}

override var state: UIGestureRecognizer.State {
get { mockState }
set { mockState = newValue }
}

override func translation(in view: UIView?) -> CGPoint {
return mockTranslation
}

override func velocity(in view: UIView?) -> CGPoint {
return mockVelocity
}
}