From da1bf0604ece07bd98ae495aee7f962218ac884f Mon Sep 17 00:00:00 2001 From: Rouser <17075090+Jim-Rouse@users.noreply.github.com> Date: Wed, 3 Jun 2026 09:41:09 -0500 Subject: [PATCH 1/2] fix(QueueItViewManager): dedupe onQueuePassed callbacks per queue presentation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit During the queue-exit redirect chain, `WKWebView.decidePolicyFor` can fire multiple times before `hideQueue()`'s `about:blank` swap takes effect. Each navigation step the URI overrider classifies as `.passed` invokes `queueListener.onQueuePassed`, so consumers observed 2–3 callbacks per pass — each carrying a distinct token with a fresh `ts_…/h_…` segment. Add a one-shot `hasFiredQueuePassed` flag on `QueueItViewManager`: - Cleared in `showQueue(...)` so re-presentation still triggers exactly one. - Gated in the `.passed` branch of `decidePolicyFor`: subsequent intercepts are still cancelled but the listener is not re-invoked. Net result: exactly one `onQueuePassed` per queue presentation. --- QueueItKit/view/QueueItViewManager.swift | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/QueueItKit/view/QueueItViewManager.swift b/QueueItKit/view/QueueItViewManager.swift index 9d10479..ba88f2d 100644 --- a/QueueItKit/view/QueueItViewManager.swift +++ b/QueueItKit/view/QueueItViewManager.swift @@ -22,6 +22,11 @@ public class QueueItViewManager: NSObject, ObservableObject, WKNavigationDelegat private var userId: String? var isShowQueueCalled: Bool = false + // One-shot guard so onQueuePassed is delivered to the listener only once per + // queue presentation, even if the URI overrider classifies multiple navigation + // steps as `.passed` during the queue-exit redirect chain. Reset in showQueue + // so re-presentation works. + private var hasFiredQueuePassed: Bool = false @MainActor init(queueListener: QueueListener?, options: QueueItEngineOptions, webView: WKWebView?, waitingRoomDomain: String? = nil, queuePathPrefix: String? = nil) { @@ -57,6 +62,7 @@ public class QueueItViewManager: NSObject, ObservableObject, WKNavigationDelegat public func showQueue(_ queueTryPassResult: QueueTryPassResult) { isShowQueueCalled = true + hasFiredQueuePassed = false guard let queueUrl = queueTryPassResult.queueUrl else { QueueItLogger.error(QueueItViewManager.TAG, "Cannot show queue, Queue URL is missing.") self.queueListener?.onError(QueueError(.invalidResponse), errorMessage: "Missing Queue URL for display") @@ -160,6 +166,11 @@ public class QueueItViewManager: NSObject, ObservableObject, WKNavigationDelegat case .passed: QueueItLogger.info(QueueItViewManager.TAG, "Intercept: Queue Passed. Token: \(result.queueItToken ?? "nil")") decisionHandler(.cancel, preferences) + guard !self.hasFiredQueuePassed else { + QueueItLogger.debug(QueueItViewManager.TAG, "Intercept: Queue Passed already fired; suppressing duplicate listener callback.") + return + } + self.hasFiredQueuePassed = true self.hideQueue() let queuePassedInfo = QueuePassedInfo(queueItToken: result.queueItToken) self.queueListener?.onQueuePassed(queuePassedInfo) From cf32043eb2f1db6939573cc8887a741c347b0cbc Mon Sep 17 00:00:00 2001 From: Rouser <17075090+Jim-Rouse@users.noreply.github.com> Date: Wed, 3 Jun 2026 09:49:09 -0500 Subject: [PATCH 2/2] test(QueueItViewManager): cover onQueuePassed dedupe across multiple .passed intercepts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a regression test that drives `webView(_:decidePolicyFor:preferences:decisionHandler:)` three times with `?queueittoken=…` URLs and asserts the listener receives exactly one `onQueuePassed` callback. The token from the *first* intercept is the one delivered; subsequent ones are suppressed. Also extends MockListener with `onQueuePassedCallCount` for invocation counting. The existing `onQueuePassedCalled` boolean is left intact for backwards compatibility with the other test suites. Test stub doubles for `WKNavigationAction` / `WKFrameInfo` are file-private to QueueItViewManagerTests since no other suite needs them. --- QueueItKitTests/MockListener.swift | 2 + QueueItKitTests/QueueItViewManagerTests.swift | 84 ++++++++++++++++++- 2 files changed, 84 insertions(+), 2 deletions(-) diff --git a/QueueItKitTests/MockListener.swift b/QueueItKitTests/MockListener.swift index afcc585..633831e 100644 --- a/QueueItKitTests/MockListener.swift +++ b/QueueItKitTests/MockListener.swift @@ -5,6 +5,7 @@ class MockListener: QueueListener { var expectation: XCTestExpectation? var onQueuePassedCalled = false + var onQueuePassedCallCount = 0 var lastQueuePassedInfo: QueuePassedInfo? var onQueueDisabledCalled = false @@ -23,6 +24,7 @@ class MockListener: QueueListener { func onQueuePassed(_ info: QueuePassedInfo) { onQueuePassedCalled = true + onQueuePassedCallCount += 1 lastQueuePassedInfo = info expectation?.fulfill() } diff --git a/QueueItKitTests/QueueItViewManagerTests.swift b/QueueItKitTests/QueueItViewManagerTests.swift index 1e8c0a3..186dad6 100644 --- a/QueueItKitTests/QueueItViewManagerTests.swift +++ b/QueueItKitTests/QueueItViewManagerTests.swift @@ -1,4 +1,5 @@ import XCTest +import WebKit @testable import QueueItKit class QueueItViewManagerTests: XCTestCase { @@ -138,7 +139,7 @@ class QueueItViewManagerTests: XCTestCase { // Given let options = QueueItEngineOptions() let waitingRoomDomain = "queue.mydomain.com" - + // When let viewManagerWithProxy = QueueItViewManager( queueListener: listener, @@ -146,10 +147,89 @@ class QueueItViewManagerTests: XCTestCase { webView: nil, waitingRoomDomain: waitingRoomDomain ) - + // Then XCTAssertNotNil(viewManagerWithProxy) XCTAssertNotNil(viewManagerWithProxy.webView) } + + // MARK: - onQueuePassed dedupe + + /// Three consecutive `.passed` navigation intercepts during a single queue + /// presentation should result in exactly one `onQueuePassed` callback to the + /// listener. Reproduces the case where WKWebView's queue-exit redirect chain + /// hits decidePolicyFor multiple times before hideQueue's about:blank load + /// suppresses further navigations. + @MainActor + func test_decidePolicyFor_passed_firesListenerOnceAcrossMultipleIntercepts() async { + // Given a viewManager configured with a known target URL so the URI + // overrider classifies ?queueittoken=... as `.passed`. + let targetUrl = "https://shop.example.com/checkout" + let queueResult = QueueTryPassResult( + queueItToken: "~rt_queue~initialToken", + queueUrl: "https://example.queue-it.net/queue", + targetUrl: targetUrl, + isPassedThrough: false, + redirectType: .queue, + urlTTLInMinutes: 5 + ) + viewManager?.showQueue(queueResult) + + // When three queue-exit navigations arrive in sequence, each carrying a + // fresh queueittoken (mimicking the live SDK's behavior where every + // redirect step yields a regenerated token). + let tokens = [ + "e_test~ts_1~q_abc~rt_queue~h_aaa", + "e_test~ts_2~q_abc~rt_queue~h_bbb", + "e_test~ts_3~q_abc~rt_queue~h_ccc" + ] + // The delegate method ignores the first arg beyond identity; a throwaway + // WKWebView keeps the test independent of viewManager's own webView property. + let stubWebView = WKWebView() + for token in tokens { + guard let url = URL(string: "\(targetUrl)?queueittoken=\(token)") else { + XCTFail("Could not construct test URL") + return + } + let action = StubMainFrameNavigationAction(url: url) + let expect = expectation(description: "decisionHandler called for \(token)") + viewManager?.webView( + stubWebView, + decidePolicyFor: action, + preferences: WKWebpagePreferences() + ) { _, _ in + expect.fulfill() + } + await fulfillment(of: [expect], timeout: 1.0) + } + + // Then the listener was called exactly once and remembers the token + // from the *first* intercept (subsequent ones are suppressed). + XCTAssertEqual(listener.onQueuePassedCallCount, 1, + "Expected onQueuePassed to fire once across multiple .passed intercepts") + XCTAssertEqual(listener.lastQueuePassedInfo?.queueItToken, tokens.first) + } +} + +// MARK: - Test doubles + +/// Minimal WKNavigationAction stub that supplies a request URL and reports the +/// target frame as the main frame so the ViewManager's intercept logic runs. +private final class StubMainFrameNavigationAction: WKNavigationAction { + private let stubRequest: URLRequest + private let stubFrame: StubMainFrame + + init(url: URL) { + self.stubRequest = URLRequest(url: url) + self.stubFrame = StubMainFrame() + super.init() + } + + override var request: URLRequest { stubRequest } + override var targetFrame: WKFrameInfo? { stubFrame } +} + +private final class StubMainFrame: WKFrameInfo { + override var isMainFrame: Bool { true } }