From b0fc8908cff14c8945cf12954349bf320d25d237 Mon Sep 17 00:00:00 2001 From: "tuan.dn" Date: Sun, 25 Oct 2026 19:16:38 +0700 Subject: [PATCH 01/14] fix(interceptly): resolve writer restart aliasing, clear fencing, stream error recovery, and search/realtime bounds --- .../lib/src/adapter/http_request_context.dart | 6 + .../src/adapter/http_traffic_collector.dart | 3 + .../lib/src/capture/stream_capture.dart | 15 +- .../lib/src/engine/body_store.dart | 22 +- .../lib/src/engine/capture_pipeline.dart | 26 +- .../lib/src/engine/interceptly_engine.dart | 91 +++++- .../lib/src/engine/realtime_store.dart | 70 +++- .../lib/src/engine/search_service.dart | 204 ++++++++---- .../lib/src/engine/writer_isolate.dart | 28 +- .../lib/src/model/raw_capture.dart | 30 ++ .../http_extension_handlers.dart | 47 ++- .../ui/search/master_search_controller.dart | 46 ++- .../session/core_engine_regressions_test.dart | 309 ++++++++++++++++++ 13 files changed, 798 insertions(+), 99 deletions(-) create mode 100644 packages/interceptly/test/session/core_engine_regressions_test.dart diff --git a/packages/interceptly/lib/src/adapter/http_request_context.dart b/packages/interceptly/lib/src/adapter/http_request_context.dart index 6b3c644..449a875 100644 --- a/packages/interceptly/lib/src/adapter/http_request_context.dart +++ b/packages/interceptly/lib/src/adapter/http_request_context.dart @@ -11,6 +11,7 @@ class HttpRequestContext { this.requestHeaders = const {}, this.requestBodyBytes, this.requestContentType, + this.captureGeneration, }); /// Unique identifier for this request lifecycle. @@ -34,6 +35,9 @@ class HttpRequestContext { /// Content-Type header of the request body, if available. final String? requestContentType; + /// Generation fence captured when this request was initiated. + final int? captureGeneration; + /// String representation of [uri]. String get url => uri.toString(); @@ -44,6 +48,7 @@ class HttpRequestContext { Map? requestHeaders, Uint8List? requestBodyBytes, String? requestContentType, + int? captureGeneration, }) { return HttpRequestContext( id: id, @@ -53,6 +58,7 @@ class HttpRequestContext { requestHeaders: requestHeaders ?? this.requestHeaders, requestBodyBytes: requestBodyBytes ?? this.requestBodyBytes, requestContentType: requestContentType ?? this.requestContentType, + captureGeneration: captureGeneration ?? this.captureGeneration, ); } } diff --git a/packages/interceptly/lib/src/adapter/http_traffic_collector.dart b/packages/interceptly/lib/src/adapter/http_traffic_collector.dart index 791ce88..e91d85e 100644 --- a/packages/interceptly/lib/src/adapter/http_traffic_collector.dart +++ b/packages/interceptly/lib/src/adapter/http_traffic_collector.dart @@ -166,6 +166,7 @@ class DefaultHttpTrafficCollector implements HttpTrafficCollector { requestHeaders: headers ?? const {}, requestBodyBytes: bodyBytes, requestContentType: resolvedContentType, + captureGeneration: engine.httpGeneration, ); guardCapture(() { @@ -269,6 +270,7 @@ class DefaultHttpTrafficCollector implements HttpTrafficCollector { responseContentType: resolvedContentType, requestTruncatedAtStorage: isRequestTruncated, responseTruncatedAtStorage: isResponseTruncated, + captureGeneration: context.captureGeneration, ); engine.record(capture); @@ -335,6 +337,7 @@ class DefaultHttpTrafficCollector implements HttpTrafficCollector { errorMessage: errorMessage ?? error.toString(), requestTruncatedAtStorage: isRequestTruncated, responseTruncatedAtStorage: isResponseTruncated, + captureGeneration: context.captureGeneration, ); engine.record(capture); diff --git a/packages/interceptly/lib/src/capture/stream_capture.dart b/packages/interceptly/lib/src/capture/stream_capture.dart index 6146089..d1473fd 100644 --- a/packages/interceptly/lib/src/capture/stream_capture.dart +++ b/packages/interceptly/lib/src/capture/stream_capture.dart @@ -98,9 +98,11 @@ Stream> teeStreamCapture( Future deliveryChain = Future.value(); void handleChunk(List chunk) { - if (settled || controller.isClosed) return; + if (controller.isClosed) return; controller.add(chunk); - buffer.add(chunk); + if (!settled) { + buffer.add(chunk); + } } sub = source.listen( @@ -133,7 +135,9 @@ Stream> teeStreamCapture( deliveryChain = deliveryChain.then((_) { if (!isCanceled) { finishDone(); - controller.close(); + if (!controller.isClosed) { + controller.close(); + } } }).catchError((_) {}); }, @@ -141,8 +145,9 @@ Stream> teeStreamCapture( deliveryChain = deliveryChain.then((_) { if (!isCanceled) { finishError(e, st); - controller.addError(e, st); - controller.close(); + if (!controller.isClosed) { + controller.addError(e, st); + } } }).catchError((_) {}); }, diff --git a/packages/interceptly/lib/src/engine/body_store.dart b/packages/interceptly/lib/src/engine/body_store.dart index a13a234..3f94ca1 100644 --- a/packages/interceptly/lib/src/engine/body_store.dart +++ b/packages/interceptly/lib/src/engine/body_store.dart @@ -37,14 +37,30 @@ class BodyStore { /// /// [dirPath] must be resolved on the **main isolate** before the /// WriterIsolate is spawned, then passed in via the init message. - Future initForWrite(String dirPath, - {String fileName = kFileName}) async { + /// + /// When [preserveExisting] is `true` and the backing file already exists, + /// writing resumes at the current end-of-file so previously committed byte + /// ranges remain valid across isolate restarts. + Future initForWrite( + String dirPath, { + String fileName = kFileName, + bool preserveExisting = false, + int initialRotationCount = 0, + }) async { _file = File('$dirPath/$fileName'); + if (preserveExisting && await _file.exists()) { + final existingLength = await _file.length(); + _raf = await _file.open(mode: FileMode.writeOnlyAppend); + _currentOffset = existingLength; + _rotationCount = initialRotationCount; + return; + } if (await _file.exists()) await _file.delete(); await _file.create(recursive: true); _raf = await _file.open(mode: FileMode.writeOnlyAppend); _currentOffset = 0; - _rotationCount = 0; + _rotationCount = + preserveExisting ? initialRotationCount + 1 : initialRotationCount; } /// Appends [bytes] to the file. diff --git a/packages/interceptly/lib/src/engine/capture_pipeline.dart b/packages/interceptly/lib/src/engine/capture_pipeline.dart index 762b3cd..03a343a 100644 --- a/packages/interceptly/lib/src/engine/capture_pipeline.dart +++ b/packages/interceptly/lib/src/engine/capture_pipeline.dart @@ -174,6 +174,10 @@ class CapturePipeline { } void _processInMemory(RawCapture capture) { + if (capture.captureGeneration != null && + capture.captureGeneration != _generation) { + return; + } _onEntryReceived?.call(capture.id); final entry = WriterIsolate.processInMemory( capture, @@ -246,27 +250,39 @@ class CapturePipeline { /// Enqueues a capture directly to isolate or pre-init queue (zero allocation). void record(RawCapture capture) { if (_isDisposed) return; + if (capture.captureGeneration != null && + capture.captureGeneration != _generation) { + return; + } + + final stamped = capture.captureGeneration != null + ? capture + : capture.withGeneration(_generation); if (_initFailed || _fallbackMode) { - _processInMemory(capture); + _processInMemory(stamped); return; } if (!_initialized) { - _preInitQueue.add(capture); + _preInitQueue.add(stamped); initialize(); return; } if (_isRespawning || !_writerIsolate.isReady) { - _preInitQueue.add(capture); + _preInitQueue.add(stamped); return; } - _sendCapture(capture); + _sendCapture(stamped); } void _sendCapture(RawCapture capture) { + final captureGen = capture.captureGeneration ?? _generation; + if (captureGen != _generation) { + return; + } if (_inFlight >= _settings.maxQueuedEvents || _inFlightBytes >= maxInFlightBytes) { _droppedCount++; @@ -276,7 +292,7 @@ class CapturePipeline { _inFlight++; _inFlightBytes += captureBytes; _sentBytes[capture.id] = captureBytes; - _sentGenerations[capture.id] = _generation; + _sentGenerations[capture.id] = captureGen; final sent = _writerIsolate.send(capture); if (!sent) { diff --git a/packages/interceptly/lib/src/engine/interceptly_engine.dart b/packages/interceptly/lib/src/engine/interceptly_engine.dart index 15b0c69..cf5f881 100644 --- a/packages/interceptly/lib/src/engine/interceptly_engine.dart +++ b/packages/interceptly/lib/src/engine/interceptly_engine.dart @@ -96,9 +96,14 @@ class InterceptlyEngine extends ChangeNotifier { bool _isDisposed = false; int _entriesVersion = 0; final List _recentChangedEntries = []; + final Map _pendingGenerations = {}; + final Map _activeRealtimeSessions = {}; Timer? _coalesceNotifyTimer; bool _hasPendingCoalescedNotify = false; + /// Current generation number for HTTP capture fencing across clear operations. + int get httpGeneration => _capturePipeline.generation; + InterceptlySettings get settings => _settings; /// Whether this engine has been disposed. @@ -269,6 +274,12 @@ class InterceptlyEngine extends ChangeNotifier { /// Enqueues [capture] for asynchronous background processing. void record(RawCapture capture) { + final pendingGen = _pendingGenerations.remove(capture.id); + final effectiveGen = capture.captureGeneration ?? pendingGen; + if (effectiveGen != null && effectiveGen != _capturePipeline.generation) { + return; + } + final isPending = _memoryIndex.byId(capture.id)?.statusCode == 0; if (!isPending) { if (!_enabled) return; @@ -277,7 +288,9 @@ class InterceptlyEngine extends ChangeNotifier { return; } } - _capturePipeline.record(capture); + final stamped = + effectiveGen != null ? capture.withGeneration(effectiveGen) : capture; + _capturePipeline.record(stamped); } /// Inserts an immediate pending request entry before response completion. @@ -325,6 +338,14 @@ class InterceptlyEngine extends ChangeNotifier { requestContentType: requestContentType, settings: _settings, ); + if (!entry.isRealtime) { + final maxTracked = _settings.maxEntries * 4; + if (_pendingGenerations.length >= maxTracked && + _pendingGenerations.isNotEmpty) { + _pendingGenerations.remove(_pendingGenerations.keys.first); + } + _pendingGenerations[id] = _capturePipeline.generation; + } _recentChangedEntries.add(entry); _scheduleCoalescedNotify(); } @@ -332,8 +353,19 @@ class InterceptlyEngine extends ChangeNotifier { /// Records a captured [RealtimeFrame] into the buffer. Future recordRealtimeFrame(RealtimeFrame frame) { if (!_enabled || _isDisposed) return Future.value(); - final predicate = _settings.shouldCapture; - if (predicate != null) { + final activeSession = _activeRealtimeSessions[frame.sessionId]; + if (activeSession != null) { + if (!activeSession.isCaptured) return Future.value(); + if (_memoryIndex.byId(frame.sessionId) == null) { + _recordPendingEntry( + id: activeSession.id, + method: activeSession.method, + url: activeSession.url, + timestamp: activeSession.timestamp, + requestHeaders: activeSession.headers, + ); + } + } else if (_settings.shouldCapture != null) { final entry = _memoryIndex.byId(frame.sessionId); if (entry == null) return Future.value(); } @@ -377,22 +409,28 @@ class InterceptlyEngine extends ChangeNotifier { RealtimeProtocolType protocolType = RealtimeProtocolType.webSocket, }) { if (!_enabled || _isDisposed) return; - if (!isPreValidated && - _settings.shouldCapture != null && - !_settings.shouldCapture!(url)) { + final effectiveTimestamp = timestamp ?? DateTime.now(); + final isCaptured = isPreValidated || + _settings.shouldCapture == null || + _settings.shouldCapture!(url); + final active = _ActiveRealtimeSession( + id: id, + url: url, + headers: headers, + timestamp: effectiveTimestamp, + protocolType: protocolType, + isCaptured: isCaptured, + ); + _activeRealtimeSessions[id] = active; + if (!isCaptured) { return; } markRealtimeActive(); - final method = switch (protocolType) { - RealtimeProtocolType.webSocket => 'WS', - RealtimeProtocolType.socketIo => 'SIO', - RealtimeProtocolType.sse => 'SSE', - }; _recordPendingEntry( id: id, - method: method, + method: active.method, url: url, - timestamp: timestamp ?? DateTime.now(), + timestamp: effectiveTimestamp, requestHeaders: headers, ); } @@ -404,6 +442,7 @@ class InterceptlyEngine extends ChangeNotifier { String? closeReason, }) { if (_isDisposed) return; + _activeRealtimeSessions.remove(id); _entriesVersion++; _pendingTracker.closeRealtimeConnection( id, @@ -573,6 +612,8 @@ class InterceptlyEngine extends ChangeNotifier { _coalesceNotifyTimer = null; _hasPendingCoalescedNotify = false; _enabled = false; + _pendingGenerations.clear(); + _activeRealtimeSessions.clear(); _pendingTracker.dispose(); _memoryIndex.clear(); await _capturePipeline.dispose(); @@ -580,3 +621,27 @@ class InterceptlyEngine extends ChangeNotifier { super.dispose(); } } + +class _ActiveRealtimeSession { + const _ActiveRealtimeSession({ + required this.id, + required this.url, + required this.headers, + required this.timestamp, + required this.protocolType, + required this.isCaptured, + }); + + final String id; + final String url; + final Map headers; + final DateTime timestamp; + final RealtimeProtocolType protocolType; + final bool isCaptured; + + String get method => switch (protocolType) { + RealtimeProtocolType.webSocket => 'WS', + RealtimeProtocolType.socketIo => 'SIO', + RealtimeProtocolType.sse => 'SSE', + }; +} diff --git a/packages/interceptly/lib/src/engine/realtime_store.dart b/packages/interceptly/lib/src/engine/realtime_store.dart index 6cb4020..c7e576b 100644 --- a/packages/interceptly/lib/src/engine/realtime_store.dart +++ b/packages/interceptly/lib/src/engine/realtime_store.dart @@ -186,12 +186,19 @@ class RealtimeStore { final Map _buffersBySession = {}; final Map _viewsBySession = {}; - final Map> _notifiersBySession = - {}; + final Map _notifiersBySession = {}; final Map _liveCountsBySession = {}; final Set _dirtyNotifierSessions = {}; final Set _closedSessions = {}; + /// Number of session count entries currently retained in memory. + @visibleForTesting + int get trackedCountsSessionCount => _liveCountsBySession.length; + + /// Number of session count notifiers currently retained in memory. + @visibleForTesting + int get trackedNotifierSessionCount => _notifiersBySession.length; + int _totalFramesAcrossSessions = 0; String? _lastActiveSessionId; Timer? _throttleTimer; @@ -312,6 +319,14 @@ class RealtimeStore { _viewsBySession.remove(sessionId); _closedSessions.remove(sessionId); _dirtyNotifierSessions.remove(sessionId); + _liveCountsBySession.remove(sessionId); + final notifier = _notifiersBySession.remove(sessionId); + if (notifier != null) { + notifier.value = const RealtimeFrameCounts(); + if (!notifier.hasActiveListeners) { + notifier.dispose(); + } + } } /// Appends [frame] to the session buffer, enforces global budget, and optionally offloads large payloads to disk. @@ -519,12 +534,37 @@ class RealtimeStore { /// Returns the per-session [ValueListenable] for reactive frame count updates. ValueListenable getFrameCountsNotifier( String sessionId) { - return _notifiersBySession.putIfAbsent( - sessionId, - () => ValueNotifier( - _liveCountsBySession[sessionId] ?? const RealtimeFrameCounts(), - ), + final existing = _notifiersBySession[sessionId]; + if (existing != null) return existing; + + if (_notifiersBySession.length >= _maxSessions) { + final orphanKeys = _notifiersBySession.entries + .where((e) => + !_buffersBySession.containsKey(e.key) && + !e.value.hasActiveListeners) + .map((e) => e.key) + .toList(); + for (final key in orphanKeys) { + _notifiersBySession.remove(key)?.dispose(); + } + while (_notifiersBySession.length >= _maxSessions && + _notifiersBySession.isNotEmpty) { + final keyToRemove = _notifiersBySession.keys.firstWhere( + (k) => !_buffersBySession.containsKey(k), + orElse: () => _notifiersBySession.keys.first, + ); + final removed = _notifiersBySession.remove(keyToRemove); + if (removed != null && !removed.hasActiveListeners) { + removed.dispose(); + } + } + } + + final created = _SessionCountsNotifier( + _liveCountsBySession[sessionId] ?? const RealtimeFrameCounts(), ); + _notifiersBySession[sessionId] = created; + return created; } /// Returns the current total incoming and outgoing frame counts for [sessionId]. @@ -574,8 +614,16 @@ class RealtimeStore { } _buffersBySession.clear(); _viewsBySession.clear(); - for (final notifier in _notifiersBySession.values) { + final unusedNotifierIds = []; + _notifiersBySession.forEach((sessionId, notifier) { notifier.value = const RealtimeFrameCounts(); + if (!notifier.hasActiveListeners) { + notifier.dispose(); + unusedNotifierIds.add(sessionId); + } + }); + for (final sessionId in unusedNotifierIds) { + _notifiersBySession.remove(sessionId); } _closedSessions.clear(); _totalFramesAcrossSessions = 0; @@ -595,3 +643,9 @@ class RealtimeStore { await _bodyStore.dispose(); } } + +class _SessionCountsNotifier extends ValueNotifier { + _SessionCountsNotifier(super.value); + + bool get hasActiveListeners => hasListeners; +} diff --git a/packages/interceptly/lib/src/engine/search_service.dart b/packages/interceptly/lib/src/engine/search_service.dart index 06ba4f7..a171a4f 100644 --- a/packages/interceptly/lib/src/engine/search_service.dart +++ b/packages/interceptly/lib/src/engine/search_service.dart @@ -136,6 +136,7 @@ abstract final class SearchService { onPhaseChange?.call(SearchPhase.inMemoryBodies); var hasNewMatchInPhase2 = false; + var inlineBytesSinceYield = 0; for (var i = 0; i < candidates.length; i++) { if (isCancelled?.call() ?? false) return const []; if (limit != null && results.length >= limit) break; @@ -145,7 +146,12 @@ abstract final class SearchService { if (_isBinaryOrEmpty(e)) continue; - if (matchInlineBody(e, q)) { + final entryInlineBytes = (e.inlineRequestBody?.length ?? 0) + + (e.inlineResponseBody?.length ?? 0); + final matched = await matchInlineBodyAsync(e, q); + if (isCancelled?.call() ?? false) return const []; + + if (matched) { if (resultIndexMap.containsKey(e.id)) { final idx = resultIndexMap[e.id]!; results[idx] = SearchResult( @@ -164,7 +170,10 @@ abstract final class SearchService { } } - if (i > 0 && i % 50 == 0) { + inlineBytesSinceYield += entryInlineBytes; + if ((i > 0 && i % 50 == 0) || + inlineBytesSinceYield >= BodyDecodeService.computeThreshold) { + inlineBytesSinceYield = 0; await Future.delayed(Duration.zero); } } @@ -242,27 +251,16 @@ abstract final class SearchService { if (payload == null) return (false, null); final (e, raw) = payload; try { - final decoded = raw.length > BodyDecodeService.computeThreshold - ? await compute(BodyDecodeService.unpackToText, raw) - : BodyDecodeService.unpackToText(raw); - - final reqText = - (!BodyDecodeService.isBinary(e.requestContentType)) - ? decoded.$1 - : null; - final resText = - (!BodyDecodeService.isBinary(e.responseContentType)) - ? decoded.$2 - : null; - - final reqMatch = reqText != null && - !BodyDecodeService.isPlaceholder(reqText) && - reqText.toLowerCase().contains(q); - final resMatch = resText != null && - !BodyDecodeService.isPlaceholder(resText) && - resText.toLowerCase().contains(q); - - if (reqMatch || resMatch) { + final checkReq = + !BodyDecodeService.isBinary(e.requestContentType); + final checkRes = + !BodyDecodeService.isBinary(e.responseContentType); + final job = (raw, checkReq, checkRes, q); + final matched = raw.length > BodyDecodeService.computeThreshold + ? await compute(_matchPackedPayloadIsolate, job) + : _matchPackedPayloadIsolate(job); + + if (matched) { return (true, e); } } catch (err) { @@ -376,31 +374,93 @@ abstract final class SearchService { return scopes; } + /// Matches text inside inline in-memory bodies asynchronously, offloading + /// payloads larger than [BodyDecodeService.computeThreshold] to a background isolate. + static Future matchInlineBodyAsync( + IndexEntry entry, + String queryLower, + ) async { + final reqBytes = !BodyDecodeService.isBinary(entry.requestContentType) + ? entry.inlineRequestBody + : null; + final resBytes = !BodyDecodeService.isBinary(entry.responseContentType) + ? entry.inlineResponseBody + : null; + final totalBytes = (reqBytes?.length ?? 0) + (resBytes?.length ?? 0); + if (totalBytes == 0) return false; + + final job = ( + reqBytes, + entry.requestContentType, + resBytes, + entry.responseContentType, + queryLower, + ); + if (totalBytes > BodyDecodeService.computeThreshold) { + return compute(_matchInlinePayloadsIsolate, job); + } + return _matchInlinePayloadsIsolate(job); + } + /// Matches text inside inline in-memory bodies (both request and response). static bool matchInlineBody( IndexEntry entry, String queryLower, ) { - if (!BodyDecodeService.isBinary(entry.requestContentType)) { - final req = BodyDecodeService.decode( - entry.inlineRequestBody, - entry.requestContentType, - ); + final reqBytes = !BodyDecodeService.isBinary(entry.requestContentType) + ? entry.inlineRequestBody + : null; + final resBytes = !BodyDecodeService.isBinary(entry.responseContentType) + ? entry.inlineResponseBody + : null; + if ((reqBytes?.length ?? 0) + (resBytes?.length ?? 0) == 0) return false; + + return _matchInlinePayloadsIsolate(( + reqBytes, + entry.requestContentType, + resBytes, + entry.responseContentType, + queryLower, + )); + } + + /// Matches text inside a file-backed body chunk using an open [RandomAccessFile] handle. + static Future matchFileBody( + RandomAccessFile raf, + int offset, + int length, + String queryLower, + ) async { + try { + final raw = await BodyStore.readWithHandle(raf, offset, length); + final job = (raw, true, true, queryLower); + return raw.length > BodyDecodeService.computeThreshold + ? await compute(_matchPackedPayloadIsolate, job) + : _matchPackedPayloadIsolate(job); + } catch (_) { + return false; + } + } + + static bool _matchInlinePayloadsIsolate( + (Uint8List?, String?, Uint8List?, String?, String) job, + ) { + final (reqBytes, reqContentType, resBytes, resContentType, queryLower) = + job; + if (reqBytes != null && reqBytes.isNotEmpty) { + final req = BodyDecodeService.decode(reqBytes, reqContentType); if (req != null && !BodyDecodeService.isPlaceholder(req) && - req.toLowerCase().contains(queryLower)) { + _containsCaseInsensitive(req, queryLower)) { return true; } } - if (!BodyDecodeService.isBinary(entry.responseContentType)) { - final res = BodyDecodeService.decode( - entry.inlineResponseBody, - entry.responseContentType, - ); + if (resBytes != null && resBytes.isNotEmpty) { + final res = BodyDecodeService.decode(resBytes, resContentType); if (res != null && !BodyDecodeService.isPlaceholder(res) && - res.toLowerCase().contains(queryLower)) { + _containsCaseInsensitive(res, queryLower)) { return true; } } @@ -408,31 +468,63 @@ abstract final class SearchService { return false; } - /// Matches text inside a file-backed body chunk using an open [RandomAccessFile] handle. - static Future matchFileBody( - RandomAccessFile raf, - int offset, - int length, - String queryLower, - ) async { - try { - final raw = await BodyStore.readWithHandle(raf, offset, length); - final decoded = raw.length > BodyDecodeService.computeThreshold - ? await compute(BodyDecodeService.unpackToText, raw) - : BodyDecodeService.unpackToText(raw); - + static bool _matchPackedPayloadIsolate( + (Uint8List, bool, bool, String) job, + ) { + final (raw, checkReq, checkRes, queryLower) = job; + final decoded = BodyDecodeService.unpackToText(raw); + if (checkReq) { final reqText = decoded.$1; - final resText = decoded.$2; - final reqMatch = reqText != null && + if (reqText != null && !BodyDecodeService.isPlaceholder(reqText) && - reqText.toLowerCase().contains(queryLower); - final resMatch = resText != null && + _containsCaseInsensitive(reqText, queryLower)) { + return true; + } + } + if (checkRes) { + final resText = decoded.$2; + if (resText != null && !BodyDecodeService.isPlaceholder(resText) && - resText.toLowerCase().contains(queryLower); - return reqMatch || resMatch; - } catch (_) { - return false; + _containsCaseInsensitive(resText, queryLower)) { + return true; + } + } + return false; + } + + /// Case-insensitive substring check that avoids allocating a full lowercased + /// copy of [text] for ASCII queries. + static bool _containsCaseInsensitive(String text, String queryLower) { + final qLen = queryLower.length; + if (qLen == 0) return true; + final tLen = text.length; + if (tLen < qLen) return false; + + for (var i = 0; i < qLen; i++) { + if (queryLower.codeUnitAt(i) >= 0x80) { + return text.toLowerCase().contains(queryLower); + } } + + final firstQ = queryLower.codeUnitAt(0); + final maxStart = tLen - qLen; + for (var i = 0; i <= maxStart; i++) { + var c = text.codeUnitAt(i); + if (c >= 0x41 && c <= 0x5A) c |= 0x20; + if (c != firstQ) continue; + + var matched = true; + for (var j = 1; j < qLen; j++) { + var tc = text.codeUnitAt(i + j); + if (tc >= 0x41 && tc <= 0x5A) tc |= 0x20; + if (tc != queryLower.codeUnitAt(j)) { + matched = false; + break; + } + } + if (matched) return true; + } + return false; } /// Guard to check if all payloads in [entry] are binary or empty. diff --git a/packages/interceptly/lib/src/engine/writer_isolate.dart b/packages/interceptly/lib/src/engine/writer_isolate.dart index abda7ce..06f8119 100644 --- a/packages/interceptly/lib/src/engine/writer_isolate.dart +++ b/packages/interceptly/lib/src/engine/writer_isolate.dart @@ -23,6 +23,8 @@ class _InitMessage { required this.tempDirPath, this.fileName = BodyStore.kFileName, this.maxFileBytes = BodyStore.kMaxFileBytes, + this.preserveExisting = false, + this.initialRotationCount = 0, }); final SendPort replyPort; @@ -34,6 +36,8 @@ class _InitMessage { final String tempDirPath; final String fileName; final int maxFileBytes; + final bool preserveExisting; + final int initialRotationCount; } class _WriteMessage { @@ -96,6 +100,7 @@ class WriterIsolate { ReceivePort? _exitPort; Completer? _clearCompleter; Completer? _disposeCompleter; + int _lastRotationCount = 0; bool _isDisposed = false; final StreamController _resultController = @@ -120,8 +125,15 @@ class WriterIsolate { required String tempDirPath, String fileName = BodyStore.kFileName, int maxFileBytes = BodyStore.kMaxFileBytes, + bool preserveExisting = false, + int? initialRotationCount, }) async { _activeTempFilePath = '$tempDirPath/$fileName'; + final effectiveRotation = + initialRotationCount ?? (preserveExisting ? _lastRotationCount : 0); + if (!preserveExisting) { + _lastRotationCount = effectiveRotation; + } _receivePort = ReceivePort(); _errorPort = ReceivePort(); _exitPort = ReceivePort(); @@ -132,8 +144,13 @@ class WriterIsolate { if (message is SendPort && !completer.isCompleted) { completer.complete(message); } else if (message is IndexEntry) { + if (message.fileRotationCount != null && + message.fileRotationCount! > _lastRotationCount) { + _lastRotationCount = message.fileRotationCount!; + } _resultController.add(message); } else if (message is _ClearAck) { + _lastRotationCount++; _clearCompleter?.complete(); _clearCompleter = null; } else if (message is _DisposeAck) { @@ -172,6 +189,8 @@ class WriterIsolate { tempDirPath: tempDirPath, fileName: fileName, maxFileBytes: maxFileBytes, + preserveExisting: preserveExisting, + initialRotationCount: effectiveRotation, ), onError: _errorPort!.sendPort, onExit: _exitPort!.sendPort, @@ -210,6 +229,8 @@ class WriterIsolate { tempDirPath: tempDirPath, fileName: fileName, maxFileBytes: maxFileBytes, + preserveExisting: true, + initialRotationCount: _lastRotationCount, ); } @@ -342,7 +363,12 @@ Future _isolateEntry(_InitMessage init) async { final bodyStore = BodyStore(maxFileBytes: init.maxFileBytes); try { - await bodyStore.initForWrite(init.tempDirPath, fileName: init.fileName); + await bodyStore.initForWrite( + init.tempDirPath, + fileName: init.fileName, + preserveExisting: init.preserveExisting, + initialRotationCount: init.initialRotationCount, + ); } catch (_) {} init.replyPort.send(receivePort.sendPort); diff --git a/packages/interceptly/lib/src/model/raw_capture.dart b/packages/interceptly/lib/src/model/raw_capture.dart index 68b5948..021092d 100644 --- a/packages/interceptly/lib/src/model/raw_capture.dart +++ b/packages/interceptly/lib/src/model/raw_capture.dart @@ -25,6 +25,7 @@ class RawCapture { this.errorMessage, this.requestTruncatedAtStorage = false, this.responseTruncatedAtStorage = false, + this.captureGeneration, }); final String id; @@ -55,8 +56,37 @@ class RawCapture { final String? errorType; final String? errorMessage; + /// Generation fence captured when the request lifecycle started. + final int? captureGeneration; + bool get hasError => errorType != null; + /// Returns a copy of this capture stamped with [generation]. + RawCapture withGeneration(int generation) { + if (captureGeneration == generation) return this; + return RawCapture( + id: id, + method: method, + url: url, + requestHeaders: requestHeaders, + responseHeaders: responseHeaders, + statusCode: statusCode, + durationMs: durationMs, + timestamp: timestamp, + requestBodyBytes: requestBodyBytes, + responseBodyBytes: responseBodyBytes, + requestSizeBytes: requestSizeBytes, + responseSizeBytes: responseSizeBytes, + requestContentType: requestContentType, + responseContentType: responseContentType, + errorType: errorType, + errorMessage: errorMessage, + requestTruncatedAtStorage: requestTruncatedAtStorage, + responseTruncatedAtStorage: responseTruncatedAtStorage, + captureGeneration: generation, + ); + } + /// Convenience: wrap [Uint8List] into [TransferableTypedData], optionally capping at [maxBytes]. static TransferableTypedData? wrapBytes(Uint8List? bytes, [int maxBytes = 0]) { diff --git a/packages/interceptly/lib/src/service_extension/http_extension_handlers.dart b/packages/interceptly/lib/src/service_extension/http_extension_handlers.dart index bb8332b..17e9453 100644 --- a/packages/interceptly/lib/src/service_extension/http_extension_handlers.dart +++ b/packages/interceptly/lib/src/service_extension/http_extension_handlers.dart @@ -1,7 +1,9 @@ import 'dart:developer' as developer; +import '../engine/body_decode_service.dart'; import '../engine/interceptly_engine.dart'; import '../export/curl_generator.dart'; +import '../model/body_truncation.dart'; import '../model/request_summary.dart'; import 'service_extension_exception.dart'; @@ -164,16 +166,43 @@ abstract final class HttpExtensionHandlers { final offset = int.tryParse(parameters['offset'] ?? '0')?.clamp(0, 10000000) ?? 0; - String? reqBody = record.requestBodyPreview; - String? resBody = record.responseBodyPreview; + String? fullReqBody = record.requestBodyPreview; + if ((part == 'request_body' || part == 'all') && + record.requestBodyBytes != null && + (record.requestTruncation.stage == TruncationStage.preview || + record.requestTruncation.stage == + TruncationStage.storageAndPreview)) { + fullReqBody = await BodyDecodeService.decodeAsync( + record.requestBodyBytes, + record.requestContentType, + ); + } + + String? fullResBody = record.responseBodyPreview; + if ((part == 'response_body' || part == 'all') && + record.responseBodyBytes != null && + (record.responseTruncation.stage == TruncationStage.preview || + record.responseTruncation.stage == + TruncationStage.storageAndPreview)) { + fullResBody = await BodyDecodeService.decodeAsync( + record.responseBodyBytes, + record.responseContentType, + ); + } + + String? reqBody = fullReqBody; + String? resBody = fullResBody; bool isTruncated = false; int totalChars = 0; int remainingChars = 0; if (part == 'response_body' || part == 'all') { - final raw = resBody ?? ''; + final raw = fullResBody ?? ''; if (part == 'response_body') { totalChars = raw.length; + if (record.responseTruncation.hasDataLoss) { + isTruncated = true; + } } if (offset < raw.length) { final end = (offset + maxChars).clamp(0, raw.length); @@ -188,9 +217,12 @@ abstract final class HttpExtensionHandlers { } if (part == 'request_body' || part == 'all') { - final raw = reqBody ?? ''; + final raw = fullReqBody ?? ''; if (part == 'request_body') { totalChars = raw.length; + if (record.requestTruncation.hasDataLoss) { + isTruncated = true; + } } if (offset < raw.length) { final end = (offset + maxChars).clamp(0, raw.length); @@ -205,12 +237,15 @@ abstract final class HttpExtensionHandlers { } if (part == 'all') { - final totalReq = (record.requestBodyPreview ?? '').length; - final totalRes = (record.responseBodyPreview ?? '').length; + final totalReq = (fullReqBody ?? '').length; + final totalRes = (fullResBody ?? '').length; totalChars = totalReq + totalRes; final remReq = (totalReq - offset - maxChars).clamp(0, totalReq); final remRes = (totalRes - offset - maxChars).clamp(0, totalRes); remainingChars = remReq + remRes; + if (record.hasDataLoss) { + isTruncated = true; + } } return { diff --git a/packages/interceptly/lib/src/ui/search/master_search_controller.dart b/packages/interceptly/lib/src/ui/search/master_search_controller.dart index a27aea9..ce175dd 100644 --- a/packages/interceptly/lib/src/ui/search/master_search_controller.dart +++ b/packages/interceptly/lib/src/ui/search/master_search_controller.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:flutter/foundation.dart'; +import '../../engine/body_decode_service.dart'; import '../../engine/search_service.dart'; import '../../model/body_location.dart'; import '../../model/index_entry.dart'; @@ -179,7 +180,16 @@ class MasterSearchController extends ChangeNotifier { return; } - final matchResult = SearchService.matchEntry(entry, query); + final inlineBytes = (entry.inlineRequestBody?.length ?? 0) + + (entry.inlineResponseBody?.length ?? 0); + final offloadInline = entry.bodyLocation == BodyLocation.memory && + inlineBytes > BodyDecodeService.computeThreshold; + + final matchResult = SearchService.matchEntry( + entry, + query, + searchBody: !offloadInline, + ); final scopes = matchResult != null ? Set.from(matchResult.matchScopes) : {}; @@ -207,7 +217,7 @@ class MasterSearchController extends ChangeNotifier { } _matchScopes[entry.id] = scopes; _notifyThrottled(); - } else { + } else if (!offloadInline) { if (isExisting) { _results!.removeWhere((e) => e.id == entry.id); _matchedIds.remove(entry.id); @@ -215,6 +225,38 @@ class MasterSearchController extends ChangeNotifier { _notifyThrottled(); } } + + if (offloadInline) { + final gen = _generation; + final queryLower = query.trim().toLowerCase(); + unawaited( + SearchService.matchInlineBodyAsync(entry, queryLower).then((matched) { + if (_isDisposed || gen != _generation || _results == null) return; + final currentlyExisting = _matchedIds.contains(entry.id); + if (matched) { + if (currentlyExisting) { + final idx = _results!.indexWhere((e) => e.id == entry.id); + if (idx >= 0) { + _results![idx] = entry; + } else { + _results!.insert(0, entry); + } + } else { + _results!.insert(0, entry); + _matchedIds.add(entry.id); + } + (_matchScopes[entry.id] ??= {}) + .add(SearchMatchScope.body); + _notifyThrottled(); + } else if (scopes.isEmpty && currentlyExisting) { + _results!.removeWhere((e) => e.id == entry.id); + _matchedIds.remove(entry.id); + _matchScopes.remove(entry.id); + _notifyThrottled(); + } + }), + ); + } } /// Records a file-backed body match for testing purposes. diff --git a/packages/interceptly/test/session/core_engine_regressions_test.dart b/packages/interceptly/test/session/core_engine_regressions_test.dart new file mode 100644 index 0000000..386037a --- /dev/null +++ b/packages/interceptly/test/session/core_engine_regressions_test.dart @@ -0,0 +1,309 @@ +// ignore_for_file: invalid_use_of_internal_member +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:interceptly/adapter.dart'; +import 'package:interceptly/interceptly.dart'; +import 'package:interceptly/src/engine/body_decode_service.dart'; +import 'package:interceptly/src/engine/body_store.dart'; +import 'package:interceptly/src/engine/interceptly_engine.dart'; +import 'package:interceptly/src/engine/realtime_store.dart'; +import 'package:interceptly/src/engine/search_service.dart'; +import 'package:interceptly/src/engine/writer_isolate.dart'; +import 'package:interceptly/src/model/index_entry.dart'; +import 'package:interceptly/src/service_extension/http_extension_handlers.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + late Directory dir; + const channel = MethodChannel('plugins.flutter.io/path_provider'); + + setUp(() async { + await Interceptly.resetForTesting(); + dir = await Directory.systemTemp.createTemp('interceptly_regressions_'); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (_) async => dir.path); + }); + + tearDown(() async { + await Interceptly.resetForTesting(); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); + if (await dir.exists()) { + await dir.delete(recursive: true); + } + }); + + test('stream preserves data after a recoverable source error', () async { + final source = StreamController>(); + final output = []; + final errors = []; + final done = Completer(); + + teeStreamCapture( + source.stream, + maxBytes: 100, + onDone: (_, __) {}, + onError: (_, __, ___, ____) {}, + onCancel: (_, __) {}, + ).listen( + (chunk) => output.addAll(chunk), + onError: (Object e) => errors.add(e), + onDone: done.complete, + ); + + source.add([1]); + source.addError(StateError('recoverable')); + source.add([2]); + await source.close(); + await done.future; + + expect(output, [1, 2]); + expect(errors, hasLength(1)); + }); + + test('restart never aliases the body of an older request', () async { + final writer = WriterIsolate( + const InterceptlySettings(bodyOffloadThreshold: 1), + ); + addTearDown(writer.dispose); + await writer.start(tempDirPath: dir.path, fileName: 'restart.tmp'); + + RawCapture capture(String id, String body) => RawCapture( + id: id, + method: 'GET', + url: 'https://example.com/$id', + statusCode: 200, + durationMs: 1, + timestamp: DateTime.now(), + requestHeaders: const {}, + responseHeaders: const {}, + responseBodyBytes: RawCapture.wrapBytes( + Uint8List.fromList(utf8.encode(body)), + ), + ); + + final beforeFuture = writer.results.first; + writer.send(capture('A', 'AAAA')); + final before = await beforeFuture; + + await writer.restart(tempDirPath: dir.path, fileName: 'restart.tmp'); + + final afterFuture = writer.results.first; + writer.send(capture('B', 'BBBB')); + final after = await afterFuture; + + expect(before.fileRotationCount, after.fileRotationCount); + final rawA = await BodyStore.readBytes( + '${dir.path}/restart.tmp', + before.fileOffset!, + before.fileLength!, + ); + final rawB = await BodyStore.readBytes( + '${dir.path}/restart.tmp', + after.fileOffset!, + after.fileLength!, + ); + expect(BodyDecodeService.unpackToText(rawA).$2, 'AAAA'); + expect(BodyDecodeService.unpackToText(rawB).$2, 'BBBB'); + }); + + test('MCP can read beyond UI preview budget', () async { + final engine = InterceptlyEngine( + settings: const InterceptlySettings(maxUiPreviewBytes: 8), + ); + addTearDown(engine.dispose); + engine.recordEntryForTesting( + IndexEntry( + id: 'body', + method: 'GET', + url: 'https://example.com', + statusCode: 200, + durationMs: 1, + timestamp: DateTime.now(), + hasError: false, + requestSizeBytes: 16, + responseSizeBytes: 16, + bodyLocation: BodyLocation.memory, + inlineRequestBody: Uint8List.fromList(utf8.encode('0123456789abcdef')), + inlineResponseBody: Uint8List.fromList(utf8.encode('abcdefghijklmnop')), + requestTruncation: const BodyTruncation.complete(16), + responseTruncation: const BodyTruncation.complete(16), + ), + ); + + final result = await HttpExtensionHandlers.handleGetRequestDetail( + engine: engine, + parameters: { + 'id': 'body', + 'part': 'response_body', + 'offset': '8', + 'max_chars': '8', + }, + ); + expect(result['responseBody'], 'ijklmnop'); + expect(result['totalChars'], 16); + expect(result['remainingChars'], 0); + expect(result['isTruncated'], isFalse); + + final reqResult = await HttpExtensionHandlers.handleGetRequestDetail( + engine: engine, + parameters: { + 'id': 'body', + 'part': 'request_body', + 'offset': '8', + 'max_chars': '8', + }, + ); + expect(reqResult['requestBody'], '89abcdef'); + expect(reqResult['totalChars'], 16); + }); + + test('active realtime session keeps recording after clear and eviction', + () async { + final engine = InterceptlyEngine( + settings: InterceptlySettings( + persistToDisk: false, + maxEntries: 2, + shouldCapture: (_) => true, + ), + ); + addTearDown(engine.dispose); + final collector = DefaultRealtimeTrafficCollector(engine: engine); + final scope = collector.openSession(uri: Uri.parse('wss://example.com')); + addTearDown(scope.close); + + scope.receive('before'); + await engine.clearRealtime(); + scope.receive('after'); + + expect( + engine.getRealtimeFrames(scope.sessionId).map((f) => f.textPayload), + ['after'], + ); + expect(engine.byId(scope.sessionId), isNotNull); + + // Evict the realtime session entry from MemoryIndex by inserting 3 HTTP entries + for (var i = 0; i < 3; i++) { + engine.recordPending( + id: 'http_$i', + method: 'GET', + url: 'https://example.com/$i', + timestamp: DateTime.now(), + ); + } + expect(engine.byId(scope.sessionId), isNull); + + scope.receive('after_eviction'); + expect( + engine.getRealtimeFrames(scope.sessionId).map((f) => f.textPayload), + ['after', 'after_eviction'], + ); + expect(engine.byId(scope.sessionId), isNotNull); + }); + + test('HTTP completing or failing after clear does not resurrect a request', + () async { + final engine = InterceptlyEngine( + settings: const InterceptlySettings(persistToDisk: false), + ); + addTearDown(engine.dispose); + await engine.initialize(); + + final collector = DefaultHttpTrafficCollector(engine: engine); + final scope1 = await collector.begin( + method: 'GET', + uri: Uri.parse('https://example.com/1'), + ); + final scope2 = await collector.begin( + method: 'POST', + uri: Uri.parse('https://example.com/2'), + ); + + await engine.clear(); + + await scope1.complete(statusCode: 200, bodyBytes: Uint8List.fromList([65])); + scope2.fail(StateError('aborted')); + await Future.delayed(const Duration(milliseconds: 100)); + + expect(engine.entries, isEmpty); + }); + + test('RealtimeStore evicts session counts and notifiers within maxSessions', + () { + final store = RealtimeStore( + maxFramesPerSession: 10, + maxTotalRealtimeFrames: 100, + maxSessions: 3, + onThrottledBatch: () {}, + ); + addTearDown(store.dispose); + + // Keep one active listener on session 0 to verify safe disposal handling + final session0Notifier = store.getFrameCountsNotifier('s_0'); + void dummyListener() {} + session0Notifier.addListener(dummyListener); + + for (var i = 0; i < 10; i++) { + final sessionId = 's_$i'; + store.getFrameCountsNotifier(sessionId); + store.recordFrameSync( + RealtimeFrame( + id: 'f_$i', + sessionId: sessionId, + direction: FrameDirection.incoming, + type: FrameType.text, + textPayload: 'msg_$i', + ), + ); + store.markSessionClosed(sessionId); + } + + expect(store.trackedCountsSessionCount, lessThanOrEqualTo(3)); + expect(store.trackedNotifierSessionCount, lessThanOrEqualTo(3)); + + // Removing the listener after session_0 was evicted must not throw + expect( + () => session0Notifier.removeListener(dummyListener), returnsNormally); + }); + + test('SearchService offloads large in-memory bodies and matches accurately', + () async { + final largePrefix = 'x' * (BodyDecodeService.computeThreshold + 1024); + final largeBody = '$largePrefix{"token":"NeedlesInMemory_42"}'; + final entry = IndexEntry( + id: 'large_mem', + method: 'POST', + url: 'https://example.com/memory', + statusCode: 200, + durationMs: 12, + timestamp: DateTime.now(), + hasError: false, + requestSizeBytes: 0, + responseSizeBytes: largeBody.length, + bodyLocation: BodyLocation.memory, + inlineResponseBody: Uint8List.fromList(utf8.encode(largeBody)), + responseContentType: 'application/json', + requestTruncation: const BodyTruncation.complete(0), + responseTruncation: BodyTruncation.complete(largeBody.length), + ); + + final matched = await SearchService.matchInlineBodyAsync( + entry, + 'needlesinmemory_42', + ); + expect(matched, isTrue); + + final results = await SearchService.search( + entries: [entry], + query: 'NEEDLESINMEMORY_42', + searchBody: true, + ); + expect(results, hasLength(1)); + expect(results.first.summary.id, 'large_mem'); + expect(results.first.matchScopes, contains(SearchMatchScope.body)); + }); +} From edf19cd1571bf6e47987802088ec003cc3bcc956 Mon Sep 17 00:00:00 2001 From: "tuan.dn" Date: Sun, 25 Oct 2026 19:58:07 +0700 Subject: [PATCH 02/14] fix(interceptly): resolve rotation sync, active notifier retention, search races, Unicode folding, and public API surface --- .../lib/src/adapter/http_request_context.dart | 6 - .../src/adapter/http_traffic_collector.dart | 7 +- .../lib/src/engine/body_store.dart | 30 ++- .../lib/src/engine/capture_pipeline.dart | 44 ++-- .../lib/src/engine/interceptly_engine.dart | 9 +- .../lib/src/engine/realtime_store.dart | 68 ++++-- .../lib/src/engine/search_service.dart | 35 +-- .../lib/src/engine/writer_isolate.dart | 130 +++++++++-- .../lib/src/model/raw_capture.dart | 30 --- .../ui/search/master_search_controller.dart | 83 ++++++- .../session/core_engine_regressions_test.dart | 218 +++++++++++++++++- 11 files changed, 514 insertions(+), 146 deletions(-) diff --git a/packages/interceptly/lib/src/adapter/http_request_context.dart b/packages/interceptly/lib/src/adapter/http_request_context.dart index 449a875..6b3c644 100644 --- a/packages/interceptly/lib/src/adapter/http_request_context.dart +++ b/packages/interceptly/lib/src/adapter/http_request_context.dart @@ -11,7 +11,6 @@ class HttpRequestContext { this.requestHeaders = const {}, this.requestBodyBytes, this.requestContentType, - this.captureGeneration, }); /// Unique identifier for this request lifecycle. @@ -35,9 +34,6 @@ class HttpRequestContext { /// Content-Type header of the request body, if available. final String? requestContentType; - /// Generation fence captured when this request was initiated. - final int? captureGeneration; - /// String representation of [uri]. String get url => uri.toString(); @@ -48,7 +44,6 @@ class HttpRequestContext { Map? requestHeaders, Uint8List? requestBodyBytes, String? requestContentType, - int? captureGeneration, }) { return HttpRequestContext( id: id, @@ -58,7 +53,6 @@ class HttpRequestContext { requestHeaders: requestHeaders ?? this.requestHeaders, requestBodyBytes: requestBodyBytes ?? this.requestBodyBytes, requestContentType: requestContentType ?? this.requestContentType, - captureGeneration: captureGeneration ?? this.captureGeneration, ); } } diff --git a/packages/interceptly/lib/src/adapter/http_traffic_collector.dart b/packages/interceptly/lib/src/adapter/http_traffic_collector.dart index e91d85e..dd74f2d 100644 --- a/packages/interceptly/lib/src/adapter/http_traffic_collector.dart +++ b/packages/interceptly/lib/src/adapter/http_traffic_collector.dart @@ -1,6 +1,7 @@ import 'package:flutter/foundation.dart'; import '../capture/capture_guard.dart'; +import '../engine/capture_pipeline.dart'; import '../model/network_simulation.dart'; import '../model/raw_capture.dart'; import '../engine/interceptly_engine.dart'; @@ -166,8 +167,8 @@ class DefaultHttpTrafficCollector implements HttpTrafficCollector { requestHeaders: headers ?? const {}, requestBodyBytes: bodyBytes, requestContentType: resolvedContentType, - captureGeneration: engine.httpGeneration, ); + attachCaptureGeneration(context, engine.httpGeneration); guardCapture(() { engine.recordPending( @@ -270,8 +271,8 @@ class DefaultHttpTrafficCollector implements HttpTrafficCollector { responseContentType: resolvedContentType, requestTruncatedAtStorage: isRequestTruncated, responseTruncatedAtStorage: isResponseTruncated, - captureGeneration: context.captureGeneration, ); + attachCaptureGeneration(capture, readCaptureGeneration(context)); engine.record(capture); }); @@ -337,8 +338,8 @@ class DefaultHttpTrafficCollector implements HttpTrafficCollector { errorMessage: errorMessage ?? error.toString(), requestTruncatedAtStorage: isRequestTruncated, responseTruncatedAtStorage: isResponseTruncated, - captureGeneration: context.captureGeneration, ); + attachCaptureGeneration(capture, readCaptureGeneration(context)); engine.record(capture); }); diff --git a/packages/interceptly/lib/src/engine/body_store.dart b/packages/interceptly/lib/src/engine/body_store.dart index 3f94ca1..ec86a75 100644 --- a/packages/interceptly/lib/src/engine/body_store.dart +++ b/packages/interceptly/lib/src/engine/body_store.dart @@ -16,9 +16,15 @@ class BodyStore { /// Defaults to 100 MB. static const int kMaxFileBytes = 100 * 1024 * 1024; - BodyStore({int maxFileBytes = kMaxFileBytes}) : _maxFileBytes = maxFileBytes; + BodyStore({ + int maxFileBytes = kMaxFileBytes, + this.onRotate, + }) : _maxFileBytes = maxFileBytes; final int _maxFileBytes; + + /// Optional callback invoked synchronously whenever [_rotationCount] increments. + final void Function(int rotationCount)? onRotate; late File _file; RandomAccessFile? _raf; int _currentOffset = 0; @@ -61,6 +67,9 @@ class BodyStore { _currentOffset = 0; _rotationCount = preserveExisting ? initialRotationCount + 1 : initialRotationCount; + if (preserveExisting) { + onRotate?.call(_rotationCount); + } } /// Appends [bytes] to the file. @@ -82,13 +91,14 @@ class BodyStore { /// Closes the current file and recreates it empty at offset 0. Future rotate() async { + _currentOffset = 0; + _rotationCount++; + onRotate?.call(_rotationCount); try { await _raf?.flush(); } catch (_) {} await _raf?.close(); _raf = null; - _currentOffset = 0; - _rotationCount++; try { if (await _file.exists()) { await _file.delete(); @@ -100,19 +110,29 @@ class BodyStore { /// Resets the file: deletes and recreates it. Future resetFile(String dirPath, {String fileName = kFileName}) async { + _currentOffset = 0; + _rotationCount++; + onRotate?.call(_rotationCount); try { await _raf?.flush(); } catch (_) {} await _raf?.close(); _raf = null; - _currentOffset = 0; - _rotationCount++; _file = File('$dirPath/$fileName'); if (await _file.exists()) await _file.delete(); await _file.create(recursive: true); _raf = await _file.open(mode: FileMode.writeOnlyAppend); } + /// Flushes and closes the open file handle without deleting the backing file. + Future close() async { + try { + await _raf?.flush(); + } catch (_) {} + await _raf?.close(); + _raf = null; + } + Future dispose() async { await _raf?.close(); _raf = null; diff --git a/packages/interceptly/lib/src/engine/capture_pipeline.dart b/packages/interceptly/lib/src/engine/capture_pipeline.dart index 03a343a..08b8612 100644 --- a/packages/interceptly/lib/src/engine/capture_pipeline.dart +++ b/packages/interceptly/lib/src/engine/capture_pipeline.dart @@ -11,6 +11,21 @@ import 'body_store.dart'; import 'bounded_event_queue.dart'; import 'writer_isolate.dart'; +final Expando _captureGenerationExpando = + Expando('captureGeneration'); + +/// Associates a capture lifecycle object with the active pipeline [generation]. +@internal +void attachCaptureGeneration(Object target, int? generation) { + if (generation != null) { + _captureGenerationExpando[target] = generation; + } +} + +/// Reads the pipeline generation previously attached to [target], if any. +@internal +int? readCaptureGeneration(Object target) => _captureGenerationExpando[target]; + /// Pipeline managing pre-initialization queueing, asynchronous isolate writing, /// and generation-fenced entry ingestion. @internal @@ -94,7 +109,10 @@ class CapturePipeline { int get generation => _generation; /// Latest file rotation count observed from the writer isolate. - int get fileRotationCount => _fileRotationCount; + int get fileRotationCount => + _writerIsolate.lastRotationCount > _fileRotationCount + ? _writerIsolate.lastRotationCount + : _fileRotationCount; /// Updates settings propagated from session reconfiguration. void updateSettings(InterceptlySettings newSettings) { @@ -174,8 +192,8 @@ class CapturePipeline { } void _processInMemory(RawCapture capture) { - if (capture.captureGeneration != null && - capture.captureGeneration != _generation) { + final captureGen = readCaptureGeneration(capture); + if (captureGen != null && captureGen != _generation) { return; } _onEntryReceived?.call(capture.id); @@ -250,36 +268,36 @@ class CapturePipeline { /// Enqueues a capture directly to isolate or pre-init queue (zero allocation). void record(RawCapture capture) { if (_isDisposed) return; - if (capture.captureGeneration != null && - capture.captureGeneration != _generation) { + final existingGen = readCaptureGeneration(capture); + if (existingGen != null && existingGen != _generation) { return; } - final stamped = capture.captureGeneration != null - ? capture - : capture.withGeneration(_generation); + if (existingGen == null) { + attachCaptureGeneration(capture, _generation); + } if (_initFailed || _fallbackMode) { - _processInMemory(stamped); + _processInMemory(capture); return; } if (!_initialized) { - _preInitQueue.add(stamped); + _preInitQueue.add(capture); initialize(); return; } if (_isRespawning || !_writerIsolate.isReady) { - _preInitQueue.add(stamped); + _preInitQueue.add(capture); return; } - _sendCapture(stamped); + _sendCapture(capture); } void _sendCapture(RawCapture capture) { - final captureGen = capture.captureGeneration ?? _generation; + final captureGen = readCaptureGeneration(capture) ?? _generation; if (captureGen != _generation) { return; } diff --git a/packages/interceptly/lib/src/engine/interceptly_engine.dart b/packages/interceptly/lib/src/engine/interceptly_engine.dart index cf5f881..69efc5a 100644 --- a/packages/interceptly/lib/src/engine/interceptly_engine.dart +++ b/packages/interceptly/lib/src/engine/interceptly_engine.dart @@ -275,7 +275,7 @@ class InterceptlyEngine extends ChangeNotifier { /// Enqueues [capture] for asynchronous background processing. void record(RawCapture capture) { final pendingGen = _pendingGenerations.remove(capture.id); - final effectiveGen = capture.captureGeneration ?? pendingGen; + final effectiveGen = readCaptureGeneration(capture) ?? pendingGen; if (effectiveGen != null && effectiveGen != _capturePipeline.generation) { return; } @@ -288,9 +288,10 @@ class InterceptlyEngine extends ChangeNotifier { return; } } - final stamped = - effectiveGen != null ? capture.withGeneration(effectiveGen) : capture; - _capturePipeline.record(stamped); + if (effectiveGen != null) { + attachCaptureGeneration(capture, effectiveGen); + } + _capturePipeline.record(capture); } /// Inserts an immediate pending request entry before response completion. diff --git a/packages/interceptly/lib/src/engine/realtime_store.dart b/packages/interceptly/lib/src/engine/realtime_store.dart index c7e576b..6da2361 100644 --- a/packages/interceptly/lib/src/engine/realtime_store.dart +++ b/packages/interceptly/lib/src/engine/realtime_store.dart @@ -300,8 +300,13 @@ class RealtimeStore { while (_buffersBySession.length > _maxSessions) { final keyToEvict = _buffersBySession.keys.firstWhere( - (k) => k != incomingSessionId, - orElse: () => _buffersBySession.keys.first, + (k) => + k != incomingSessionId && + !(_notifiersBySession[k]?.hasActiveListeners ?? false), + orElse: () => _buffersBySession.keys.firstWhere( + (k) => k != incomingSessionId, + orElse: () => _buffersBySession.keys.first, + ), ); _evictSession(keyToEvict); } @@ -318,14 +323,16 @@ class RealtimeStore { } _viewsBySession.remove(sessionId); _closedSessions.remove(sessionId); + final notifier = _notifiersBySession[sessionId]; + if (notifier != null && notifier.hasActiveListeners) { + return; + } _dirtyNotifierSessions.remove(sessionId); _liveCountsBySession.remove(sessionId); - final notifier = _notifiersBySession.remove(sessionId); if (notifier != null) { + _notifiersBySession.remove(sessionId); notifier.value = const RealtimeFrameCounts(); - if (!notifier.hasActiveListeners) { - notifier.dispose(); - } + notifier.dispose(); } } @@ -545,28 +552,42 @@ class RealtimeStore { .map((e) => e.key) .toList(); for (final key in orphanKeys) { + _liveCountsBySession.remove(key); _notifiersBySession.remove(key)?.dispose(); } - while (_notifiersBySession.length >= _maxSessions && - _notifiersBySession.isNotEmpty) { - final keyToRemove = _notifiersBySession.keys.firstWhere( - (k) => !_buffersBySession.containsKey(k), - orElse: () => _notifiersBySession.keys.first, - ); - final removed = _notifiersBySession.remove(keyToRemove); - if (removed != null && !removed.hasActiveListeners) { - removed.dispose(); - } + final inactiveKeys = _notifiersBySession.entries + .where((e) => !e.value.hasActiveListeners) + .map((e) => e.key) + .toList(); + for (final key in inactiveKeys) { + if (_notifiersBySession.length < _maxSessions) break; + _notifiersBySession.remove(key)?.dispose(); } } final created = _SessionCountsNotifier( _liveCountsBySession[sessionId] ?? const RealtimeFrameCounts(), + onNoListeners: () => _onNotifierAllListenersRemoved(sessionId), ); _notifiersBySession[sessionId] = created; return created; } + void _onNotifierAllListenersRemoved(String sessionId) { + if (_isDisposed) return; + final notifier = _notifiersBySession[sessionId]; + if (notifier == null || notifier.hasActiveListeners) return; + if (!_buffersBySession.containsKey(sessionId)) { + _dirtyNotifierSessions.remove(sessionId); + _liveCountsBySession.remove(sessionId); + _notifiersBySession.remove(sessionId); + notifier.dispose(); + } else if (_notifiersBySession.length > _maxSessions) { + _notifiersBySession.remove(sessionId); + notifier.dispose(); + } + } + /// Returns the current total incoming and outgoing frame counts for [sessionId]. RealtimeFrameCounts getFrameCounts(String sessionId) { if (_isDisposed) return const RealtimeFrameCounts(); @@ -645,7 +666,20 @@ class RealtimeStore { } class _SessionCountsNotifier extends ValueNotifier { - _SessionCountsNotifier(super.value); + _SessionCountsNotifier( + super.value, { + this.onNoListeners, + }); + + final VoidCallback? onNoListeners; bool get hasActiveListeners => hasListeners; + + @override + void removeListener(VoidCallback listener) { + super.removeListener(listener); + if (!hasListeners) { + onNoListeners?.call(); + } + } } diff --git a/packages/interceptly/lib/src/engine/search_service.dart b/packages/interceptly/lib/src/engine/search_service.dart index a171a4f..0d5ea07 100644 --- a/packages/interceptly/lib/src/engine/search_service.dart +++ b/packages/interceptly/lib/src/engine/search_service.dart @@ -492,39 +492,10 @@ abstract final class SearchService { return false; } - /// Case-insensitive substring check that avoids allocating a full lowercased - /// copy of [text] for ASCII queries. + /// Case-insensitive substring check preserving full Unicode case-folding semantics. static bool _containsCaseInsensitive(String text, String queryLower) { - final qLen = queryLower.length; - if (qLen == 0) return true; - final tLen = text.length; - if (tLen < qLen) return false; - - for (var i = 0; i < qLen; i++) { - if (queryLower.codeUnitAt(i) >= 0x80) { - return text.toLowerCase().contains(queryLower); - } - } - - final firstQ = queryLower.codeUnitAt(0); - final maxStart = tLen - qLen; - for (var i = 0; i <= maxStart; i++) { - var c = text.codeUnitAt(i); - if (c >= 0x41 && c <= 0x5A) c |= 0x20; - if (c != firstQ) continue; - - var matched = true; - for (var j = 1; j < qLen; j++) { - var tc = text.codeUnitAt(i + j); - if (tc >= 0x41 && tc <= 0x5A) tc |= 0x20; - if (tc != queryLower.codeUnitAt(j)) { - matched = false; - break; - } - } - if (matched) return true; - } - return false; + if (queryLower.isEmpty) return true; + return text.toLowerCase().contains(queryLower); } /// Guard to check if all payloads in [entry] are binary or empty. diff --git a/packages/interceptly/lib/src/engine/writer_isolate.dart b/packages/interceptly/lib/src/engine/writer_isolate.dart index 06f8119..d53f7d4 100644 --- a/packages/interceptly/lib/src/engine/writer_isolate.dart +++ b/packages/interceptly/lib/src/engine/writer_isolate.dart @@ -62,7 +62,25 @@ class _ClearMessage { } class _ClearAck { - const _ClearAck(); + const _ClearAck(this.rotationCount); + + final int rotationCount; +} + +class _StopMessage { + const _StopMessage(); +} + +class _StopAck { + const _StopAck(this.rotationCount); + + final int rotationCount; +} + +class _RotationNotice { + const _RotationNotice(this.rotationCount); + + final int rotationCount; } class _DisposeMessage { @@ -99,10 +117,16 @@ class WriterIsolate { ReceivePort? _errorPort; ReceivePort? _exitPort; Completer? _clearCompleter; + Completer? _stopCompleter; Completer? _disposeCompleter; + final List<_WriteMessage> _queuedDuringRestart = []; int _lastRotationCount = 0; + bool _isRestarting = false; bool _isDisposed = false; + /// Latest file rotation count observed from the writer isolate. + int get lastRotationCount => _lastRotationCount; + final StreamController _resultController = StreamController.broadcast(); @@ -143,6 +167,10 @@ class WriterIsolate { _receivePort!.listen((message) { if (message is SendPort && !completer.isCompleted) { completer.complete(message); + } else if (message is _RotationNotice) { + if (message.rotationCount > _lastRotationCount) { + _lastRotationCount = message.rotationCount; + } } else if (message is IndexEntry) { if (message.fileRotationCount != null && message.fileRotationCount! > _lastRotationCount) { @@ -150,9 +178,19 @@ class WriterIsolate { } _resultController.add(message); } else if (message is _ClearAck) { - _lastRotationCount++; + if (message.rotationCount > _lastRotationCount) { + _lastRotationCount = message.rotationCount; + } _clearCompleter?.complete(); _clearCompleter = null; + } else if (message is _StopAck) { + if (message.rotationCount > _lastRotationCount) { + _lastRotationCount = message.rotationCount; + } + if (_stopCompleter != null && !_stopCompleter!.isCompleted) { + _stopCompleter!.complete(); + } + _stopCompleter = null; } else if (message is _DisposeAck) { _disposeCompleter?.complete(); _disposeCompleter = null; @@ -165,9 +203,13 @@ class WriterIsolate { } _clearCompleter?.complete(); _clearCompleter = null; + if (_stopCompleter != null && !_stopCompleter!.isCompleted) { + _stopCompleter!.complete(); + } + _stopCompleter = null; _disposeCompleter?.complete(); _disposeCompleter = null; - if (!_isDisposed && !_eventController.isClosed) { + if (!_isDisposed && !_isRestarting && !_eventController.isClosed) { _eventController.add(WriterIsolateEvent.error); } }); @@ -175,9 +217,13 @@ class WriterIsolate { _exitPort!.listen((_) { _clearCompleter?.complete(); _clearCompleter = null; + if (_stopCompleter != null && !_stopCompleter!.isCompleted) { + _stopCompleter!.complete(); + } + _stopCompleter = null; _disposeCompleter?.complete(); _disposeCompleter = null; - if (!_isDisposed && !_eventController.isClosed) { + if (!_isDisposed && !_isRestarting && !_eventController.isClosed) { _eventController.add(WriterIsolateEvent.exit); } }); @@ -207,6 +253,26 @@ class WriterIsolate { }) async { if (_isDisposed) return; + _isRestarting = true; + final oldPort = _sendPort; + _sendPort = null; + + if (oldPort != null && _isolate != null) { + final completer = Completer(); + _stopCompleter = completer; + try { + oldPort.send(const _StopMessage()); + await completer.future.timeout(const Duration(milliseconds: 500)); + } catch (_) {} + _stopCompleter = null; + } + + await Future.delayed(Duration.zero); + if (_isDisposed) { + _isRestarting = false; + return; + } + _clearCompleter?.complete(); _clearCompleter = null; _disposeCompleter?.complete(); @@ -223,31 +289,48 @@ class WriterIsolate { _errorPort = null; _exitPort?.close(); _exitPort = null; - _sendPort = null; - await start( - tempDirPath: tempDirPath, - fileName: fileName, - maxFileBytes: maxFileBytes, - preserveExisting: true, - initialRotationCount: _lastRotationCount, - ); + try { + await start( + tempDirPath: tempDirPath, + fileName: fileName, + maxFileBytes: maxFileBytes, + preserveExisting: true, + initialRotationCount: _lastRotationCount, + ); + if (_sendPort != null && _queuedDuringRestart.isNotEmpty) { + for (final msg in _queuedDuringRestart) { + _sendPort!.send(msg); + } + _queuedDuringRestart.clear(); + } + } finally { + _isRestarting = false; + } } /// Fire-and-forget: enqueue a capture for background processing. /// /// Returns `true` if dispatched to the isolate, or `false` if the isolate is not ready. bool send(RawCapture capture) { - final port = _sendPort; - if (port == null || _isDisposed) { + if (_isDisposed) { return false; } - port.send(_WriteMessage( + final message = _WriteMessage( capture: capture, bodyOffloadThreshold: _settings.bodyOffloadThreshold, maxBodyBytes: _settings.maxBodyBytes, persistToDisk: _settings.persistToDisk, - )); + ); + final port = _sendPort; + if (port == null) { + if (_isRestarting) { + _queuedDuringRestart.add(message); + return true; + } + return false; + } + port.send(message); return true; } @@ -360,7 +443,12 @@ class WriterIsolate { Future _isolateEntry(_InitMessage init) async { final receivePort = ReceivePort(); - final bodyStore = BodyStore(maxFileBytes: init.maxFileBytes); + final bodyStore = BodyStore( + maxFileBytes: init.maxFileBytes, + onRotate: (rotationCount) { + init.replyPort.send(_RotationNotice(rotationCount)); + }, + ); try { await bodyStore.initForWrite( @@ -405,7 +493,13 @@ Future _isolateEntry(_InitMessage init) async { try { await bodyStore.resetFile(init.tempDirPath, fileName: init.fileName); } catch (_) {} - init.replyPort.send(const _ClearAck()); + init.replyPort.send(_ClearAck(bodyStore.rotationCount)); + } else if (message is _StopMessage) { + try { + await bodyStore.close(); + } catch (_) {} + init.replyPort.send(_StopAck(bodyStore.rotationCount)); + break; } else if (message is _DisposeMessage) { try { await bodyStore.dispose(); diff --git a/packages/interceptly/lib/src/model/raw_capture.dart b/packages/interceptly/lib/src/model/raw_capture.dart index 021092d..68b5948 100644 --- a/packages/interceptly/lib/src/model/raw_capture.dart +++ b/packages/interceptly/lib/src/model/raw_capture.dart @@ -25,7 +25,6 @@ class RawCapture { this.errorMessage, this.requestTruncatedAtStorage = false, this.responseTruncatedAtStorage = false, - this.captureGeneration, }); final String id; @@ -56,37 +55,8 @@ class RawCapture { final String? errorType; final String? errorMessage; - /// Generation fence captured when the request lifecycle started. - final int? captureGeneration; - bool get hasError => errorType != null; - /// Returns a copy of this capture stamped with [generation]. - RawCapture withGeneration(int generation) { - if (captureGeneration == generation) return this; - return RawCapture( - id: id, - method: method, - url: url, - requestHeaders: requestHeaders, - responseHeaders: responseHeaders, - statusCode: statusCode, - durationMs: durationMs, - timestamp: timestamp, - requestBodyBytes: requestBodyBytes, - responseBodyBytes: responseBodyBytes, - requestSizeBytes: requestSizeBytes, - responseSizeBytes: responseSizeBytes, - requestContentType: requestContentType, - responseContentType: responseContentType, - errorType: errorType, - errorMessage: errorMessage, - requestTruncatedAtStorage: requestTruncatedAtStorage, - responseTruncatedAtStorage: responseTruncatedAtStorage, - captureGeneration: generation, - ); - } - /// Convenience: wrap [Uint8List] into [TransferableTypedData], optionally capping at [maxBytes]. static TransferableTypedData? wrapBytes(Uint8List? bytes, [int maxBytes = 0]) { diff --git a/packages/interceptly/lib/src/ui/search/master_search_controller.dart b/packages/interceptly/lib/src/ui/search/master_search_controller.dart index ce175dd..bdf14c8 100644 --- a/packages/interceptly/lib/src/ui/search/master_search_controller.dart +++ b/packages/interceptly/lib/src/ui/search/master_search_controller.dart @@ -17,6 +17,9 @@ class MasterSearchController extends ChangeNotifier { List? _results; final Set _matchedIds = {}; final Map> _matchScopes = {}; + final Map _entryVersions = {}; + final Map _latestEntries = {}; + final Set _prunedIds = {}; bool _isScanningBodies = false; bool _isScanningFiles = false; int _generation = 0; @@ -68,6 +71,9 @@ class MasterSearchController extends ChangeNotifier { _results = null; _matchedIds.clear(); _matchScopes.clear(); + _entryVersions.clear(); + _latestEntries.clear(); + _prunedIds.clear(); _isScanningBodies = false; _isScanningFiles = false; if (!_isDisposed) { @@ -84,6 +90,9 @@ class MasterSearchController extends ChangeNotifier { _results = null; _matchedIds.clear(); _matchScopes.clear(); + _entryVersions.clear(); + _latestEntries.clear(); + _prunedIds.clear(); _isScanningBodies = false; _isScanningFiles = false; } @@ -114,6 +123,13 @@ class MasterSearchController extends ChangeNotifier { _results = []; _matchedIds.clear(); _matchScopes.clear(); + _entryVersions.clear(); + _latestEntries.clear(); + _prunedIds.clear(); + for (final e in entriesSnapshot) { + _latestEntries[e.id] = e; + _entryVersions[e.id] = 0; + } _isScanningBodies = false; _isScanningFiles = false; _notifyThrottled(force: true); @@ -150,16 +166,38 @@ class MasterSearchController extends ChangeNotifier { onProgress: (progressiveResults, {required bool isDone}) { if (gen != _generation || _isDisposed) return; final updatedList = []; - _matchedIds.clear(); - _matchScopes.clear(); + final seenIds = {}; + + // Preserve dynamically added/updated entries that occurred after start() snapshot + if (_results != null) { + for (final existing in _results!) { + final id = existing.id; + if (_prunedIds.contains(id)) continue; + if ((_entryVersions[id] ?? 0) > 0 && _matchedIds.contains(id)) { + updatedList.add(_latestEntries[id] ?? existing); + seenIds.add(id); + } + } + } + for (final res in progressiveResults) { if (res.summary case final IndexEntry entry) { + final id = entry.id; + if (_prunedIds.contains(id) || !_latestEntries.containsKey(id)) { + continue; + } + if ((_entryVersions[id] ?? 0) > 0) { + continue; + } updatedList.add(entry); - _matchedIds.add(entry.id); - _matchScopes[entry.id] = - Set.from(res.matchScopes); + seenIds.add(id); + _matchedIds.add(id); + _matchScopes[id] = Set.from(res.matchScopes); } } + + _matchedIds.removeWhere((id) => !seenIds.contains(id)); + _matchScopes.removeWhere((id, _) => !seenIds.contains(id)); _results = updatedList; _notifyThrottled(force: isDone); }, @@ -180,6 +218,11 @@ class MasterSearchController extends ChangeNotifier { return; } + final entryVer = (_entryVersions[entry.id] ?? 0) + 1; + _entryVersions[entry.id] = entryVer; + _latestEntries[entry.id] = entry; + _prunedIds.remove(entry.id); + final inlineBytes = (entry.inlineRequestBody?.length ?? 0) + (entry.inlineResponseBody?.length ?? 0); final offloadInline = entry.bodyLocation == BodyLocation.memory && @@ -224,6 +267,11 @@ class MasterSearchController extends ChangeNotifier { _matchScopes.remove(entry.id); _notifyThrottled(); } + } else if (isExisting) { + final existingIndex = _results!.indexWhere((e) => e.id == entry.id); + if (existingIndex >= 0) { + _results![existingIndex] = entry; + } } if (offloadInline) { @@ -231,7 +279,14 @@ class MasterSearchController extends ChangeNotifier { final queryLower = query.trim().toLowerCase(); unawaited( SearchService.matchInlineBodyAsync(entry, queryLower).then((matched) { - if (_isDisposed || gen != _generation || _results == null) return; + if (_isDisposed || + gen != _generation || + _results == null || + _prunedIds.contains(entry.id) || + _entryVersions[entry.id] != entryVer || + !identical(_latestEntries[entry.id], entry)) { + return; + } final currentlyExisting = _matchedIds.contains(entry.id); if (matched) { if (currentlyExisting) { @@ -263,6 +318,8 @@ class MasterSearchController extends ChangeNotifier { @visibleForTesting void recordFileMatchForTesting(IndexEntry entry) { if (_results == null) return; + _latestEntries[entry.id] = entry; + _prunedIds.remove(entry.id); if (!_matchedIds.contains(entry.id)) { _matchedIds.add(entry.id); _results!.insert(0, entry); @@ -273,7 +330,17 @@ class MasterSearchController extends ChangeNotifier { /// Prunes any search results whose IDs are not present in [validIds]. void pruneMissing(Set validIds) { - if (_isDisposed || _results == null || _matchedIds.isEmpty) return; + if (_isDisposed || _results == null) return; + + for (final id in _latestEntries.keys.toList()) { + if (!validIds.contains(id)) { + _latestEntries.remove(id); + _prunedIds.add(id); + _entryVersions[id] = (_entryVersions[id] ?? 0) + 1; + } + } + + if (_matchedIds.isEmpty) return; final hasMissing = _matchedIds.any((id) => !validIds.contains(id)); if (!hasMissing) return; @@ -284,6 +351,8 @@ class MasterSearchController extends ChangeNotifier { _results!.removeAt(i); _matchedIds.remove(id); _matchScopes.remove(id); + _prunedIds.add(id); + _entryVersions[id] = (_entryVersions[id] ?? 0) + 1; removed = true; } } diff --git a/packages/interceptly/test/session/core_engine_regressions_test.dart b/packages/interceptly/test/session/core_engine_regressions_test.dart index 386037a..b7e3736 100644 --- a/packages/interceptly/test/session/core_engine_regressions_test.dart +++ b/packages/interceptly/test/session/core_engine_regressions_test.dart @@ -15,6 +15,7 @@ import 'package:interceptly/src/engine/search_service.dart'; import 'package:interceptly/src/engine/writer_isolate.dart'; import 'package:interceptly/src/model/index_entry.dart'; import 'package:interceptly/src/service_extension/http_extension_handlers.dart'; +import 'package:interceptly/src/ui/search/master_search_controller.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); @@ -111,6 +112,93 @@ void main() { expect(BodyDecodeService.unpackToText(rawB).$2, 'BBBB'); }); + test( + 'restart before IndexEntry is confirmed on main isolate preserves rotation and never aliases bodies', + () async { + final writer = WriterIsolate( + const InterceptlySettings(bodyOffloadThreshold: 1), + ); + addTearDown(writer.dispose); + // 12 bytes = exact packed length of a 4-byte response body ('0000' or 'AAAA'). + await writer.start( + tempDirPath: dir.path, + fileName: 'restart_rot.tmp', + maxFileBytes: 12, + ); + + RawCapture capture(String id, String body) => RawCapture( + id: id, + method: 'GET', + url: 'https://example.com/$id', + statusCode: 200, + durationMs: 1, + timestamp: DateTime.now(), + requestHeaders: const {}, + responseHeaders: const {}, + responseBodyBytes: RawCapture.wrapBytes( + Uint8List.fromList(utf8.encode(body)), + ), + ); + + // Fill initial file at rotation 0. + final warmupFuture = writer.results.first; + writer.send(capture('0', '0000')); + final warmup = await warmupFuture; + expect(warmup.fileRotationCount, 0); + + // Send A ('AAAA') which triggers file rotation to 1 in the writer isolate, + // and immediately call restart BEFORE awaiting A's IndexEntry! + final entries = []; + final sub = writer.results.listen(entries.add); + addTearDown(sub.cancel); + + writer.send(capture('A', 'AAAA')); + // Spin synchronously until the background isolate has rotated the file on + // disk and written 'AAAA', before the main isolate event loop can deliver + // A's IndexEntry over ReceivePort. + final tempFile = File('${dir.path}/restart_rot.tmp'); + final deadline = DateTime.now().add(const Duration(seconds: 5)); + while (DateTime.now().isBefore(deadline)) { + try { + if (tempFile.existsSync()) { + final bytes = tempFile.readAsBytesSync(); + if (utf8.decode(bytes, allowMalformed: true).contains('AAAA')) { + break; + } + } + } catch (_) {} + } + + await writer.restart( + tempDirPath: dir.path, + fileName: 'restart_rot.tmp', + maxFileBytes: 24, + ); + + writer.send(capture('B', 'BBBB')); + while (entries.length < 2) { + await Future.delayed(const Duration(milliseconds: 10)); + } + + final before = entries.firstWhere((e) => e.id == 'A'); + final after = entries.firstWhere((e) => e.id == 'B'); + + expect(before.fileRotationCount, 1); + expect(after.fileRotationCount, 1); + final rawA = await BodyStore.readBytes( + '${dir.path}/restart_rot.tmp', + before.fileOffset!, + before.fileLength!, + ); + final rawB = await BodyStore.readBytes( + '${dir.path}/restart_rot.tmp', + after.fileOffset!, + after.fileLength!, + ); + expect(BodyDecodeService.unpackToText(rawA).$2, 'AAAA'); + expect(BodyDecodeService.unpackToText(rawB).$2, 'BBBB'); + }); + test('MCP can read beyond UI preview budget', () async { final engine = InterceptlyEngine( settings: const InterceptlySettings(maxUiPreviewBytes: 8), @@ -232,22 +320,40 @@ void main() { expect(engine.entries, isEmpty); }); - test('RealtimeStore evicts session counts and notifiers within maxSessions', - () { + test( + 'RealtimeStore evicts session counts and notifiers within maxSessions while keeping active listeners live', + () async { final store = RealtimeStore( maxFramesPerSession: 10, maxTotalRealtimeFrames: 100, - maxSessions: 3, + maxSessions: 2, onThrottledBatch: () {}, ); addTearDown(store.dispose); - // Keep one active listener on session 0 to verify safe disposal handling + // Attach an active listener to s_0 final session0Notifier = store.getFrameCountsNotifier('s_0'); - void dummyListener() {} - session0Notifier.addListener(dummyListener); + var listenerUpdates = 0; + void activeListener() { + listenerUpdates++; + } + + session0Notifier.addListener(activeListener); - for (var i = 0; i < 10; i++) { + store.recordFrameSync( + RealtimeFrame( + id: 'f_0_1', + sessionId: 's_0', + direction: FrameDirection.incoming, + type: FrameType.text, + textPayload: 'first', + ), + ); + await Future.delayed(const Duration(milliseconds: 150)); + expect(session0Notifier.value.total, 1); + + // Exceed maxSessions with other sessions and notifiers + for (var i = 1; i < 6; i++) { final sessionId = 's_$i'; store.getFrameCountsNotifier(sessionId); store.recordFrameSync( @@ -262,12 +368,102 @@ void main() { store.markSessionClosed(sessionId); } - expect(store.trackedCountsSessionCount, lessThanOrEqualTo(3)); - expect(store.trackedNotifierSessionCount, lessThanOrEqualTo(3)); + // Recording a second frame on s_0 must still update the existing notifier to 2 + store.recordFrameSync( + RealtimeFrame( + id: 'f_0_2', + sessionId: 's_0', + direction: FrameDirection.incoming, + type: FrameType.text, + textPayload: 'second', + ), + ); + await Future.delayed(const Duration(milliseconds: 150)); + expect(session0Notifier.value.total, 2); + expect(listenerUpdates, greaterThanOrEqualTo(2)); - // Removing the listener after session_0 was evicted must not throw + // Once the listener is removed and s_0 is evicted, counts/notifiers stay bounded expect( - () => session0Notifier.removeListener(dummyListener), returnsNormally); + () => session0Notifier.removeListener(activeListener), returnsNormally); + expect(store.trackedCountsSessionCount, lessThanOrEqualTo(2)); + expect(store.trackedNotifierSessionCount, lessThanOrEqualTo(2)); + }); + + test( + 'MasterSearchController async inline body search does not resurrect pruned entries or overwrite newer versions', + () async { + final search = MasterSearchController(); + addTearDown(search.dispose); + + await search.start( + query: 'needle_token', + allEntries: const [], + filePath: null, + ); + + final largePrefix = 'x' * (BodyDecodeService.computeThreshold + 1024); + final matchingBody = + Uint8List.fromList(utf8.encode('$largePrefix needle_token')); + final nonMatchingBody = + Uint8List.fromList(utf8.encode('$largePrefix nothing_here')); + + IndexEntry buildEntry(String id, Uint8List body, {int statusCode = 200}) => + IndexEntry( + id: id, + method: 'POST', + url: 'https://example.com/$id', + statusCode: statusCode, + durationMs: 10, + timestamp: DateTime.now(), + hasError: false, + requestSizeBytes: 0, + responseSizeBytes: body.length, + bodyLocation: BodyLocation.memory, + inlineResponseBody: body, + responseContentType: 'text/plain', + requestTruncation: const BodyTruncation.complete(0), + responseTruncation: BodyTruncation.complete(body.length), + ); + + // 1. Pruned entry while async body check is in flight must not resurrect + search.updateOrAddEntry(buildEntry('pruned_req', matchingBody)); + search.pruneMissing(const {}); + + // 2. Newer version of an entry must not be overwritten by stale async body check + search.updateOrAddEntry( + buildEntry('updated_req', matchingBody, statusCode: 200), + ); + search.updateOrAddEntry( + buildEntry('updated_req', nonMatchingBody, statusCode: 201), + ); + + await Future.delayed(const Duration(milliseconds: 200)); + + expect(search.results, isEmpty); + }); + + test( + 'SearchService preserves Unicode case-folding semantics (e.g. Kelvin sign K matches k)', + () async { + final entry = IndexEntry( + id: 'unicode_kelvin', + method: 'GET', + url: 'https://example.com/temp', + statusCode: 200, + durationMs: 5, + timestamp: DateTime.now(), + hasError: false, + requestSizeBytes: 0, + responseSizeBytes: 10, + bodyLocation: BodyLocation.memory, + inlineResponseBody: Uint8List.fromList(utf8.encode('300\u212A')), + responseContentType: 'text/plain', + requestTruncation: const BodyTruncation.complete(0), + responseTruncation: const BodyTruncation.complete(6), + ); + + expect(SearchService.matchInlineBody(entry, 'k'), isTrue); + expect(await SearchService.matchInlineBodyAsync(entry, 'k'), isTrue); }); test('SearchService offloads large in-memory bodies and matches accurately', From 30505d1f06dc212e599dff4209e194ddd9c2db19 Mon Sep 17 00:00:00 2001 From: "tuan.dn" Date: Sun, 25 Oct 2026 20:08:09 +0700 Subject: [PATCH 03/14] fix(interceptly): preserve capture generation across copyWith and bound search async state --- .../lib/src/adapter/http_request_context.dart | 6 +- .../ui/search/master_search_controller.dart | 71 ++++++++----------- .../session/core_engine_regressions_test.dart | 19 ++++- 3 files changed, 52 insertions(+), 44 deletions(-) diff --git a/packages/interceptly/lib/src/adapter/http_request_context.dart b/packages/interceptly/lib/src/adapter/http_request_context.dart index 6b3c644..bf78596 100644 --- a/packages/interceptly/lib/src/adapter/http_request_context.dart +++ b/packages/interceptly/lib/src/adapter/http_request_context.dart @@ -1,5 +1,7 @@ import 'dart:typed_data'; +import '../engine/capture_pipeline.dart'; + /// Immutable context tracking an ongoing HTTP request capture lifecycle. class HttpRequestContext { /// Creates an immutable [HttpRequestContext]. @@ -45,7 +47,7 @@ class HttpRequestContext { Uint8List? requestBodyBytes, String? requestContentType, }) { - return HttpRequestContext( + final copy = HttpRequestContext( id: id, method: method ?? this.method, uri: uri ?? this.uri, @@ -54,5 +56,7 @@ class HttpRequestContext { requestBodyBytes: requestBodyBytes ?? this.requestBodyBytes, requestContentType: requestContentType ?? this.requestContentType, ); + attachCaptureGeneration(copy, readCaptureGeneration(this)); + return copy; } } diff --git a/packages/interceptly/lib/src/ui/search/master_search_controller.dart b/packages/interceptly/lib/src/ui/search/master_search_controller.dart index bdf14c8..bf95895 100644 --- a/packages/interceptly/lib/src/ui/search/master_search_controller.dart +++ b/packages/interceptly/lib/src/ui/search/master_search_controller.dart @@ -17,15 +17,18 @@ class MasterSearchController extends ChangeNotifier { List? _results; final Set _matchedIds = {}; final Map> _matchScopes = {}; - final Map _entryVersions = {}; - final Map _latestEntries = {}; - final Set _prunedIds = {}; + final Map _pendingAsyncEntries = {}; + Set? _activeSnapshotIds; bool _isScanningBodies = false; bool _isScanningFiles = false; int _generation = 0; Timer? _throttleTimer; bool _isDisposed = false; + /// Number of entries currently awaiting asynchronous inline body matching. + @visibleForTesting + int get pendingAsyncEntryCount => _pendingAsyncEntries.length; + /// The active search query, or `null` if search is inactive. String? get query => _query; @@ -71,9 +74,8 @@ class MasterSearchController extends ChangeNotifier { _results = null; _matchedIds.clear(); _matchScopes.clear(); - _entryVersions.clear(); - _latestEntries.clear(); - _prunedIds.clear(); + _pendingAsyncEntries.clear(); + _activeSnapshotIds = null; _isScanningBodies = false; _isScanningFiles = false; if (!_isDisposed) { @@ -90,9 +92,8 @@ class MasterSearchController extends ChangeNotifier { _results = null; _matchedIds.clear(); _matchScopes.clear(); - _entryVersions.clear(); - _latestEntries.clear(); - _prunedIds.clear(); + _pendingAsyncEntries.clear(); + _activeSnapshotIds = null; _isScanningBodies = false; _isScanningFiles = false; } @@ -123,13 +124,8 @@ class MasterSearchController extends ChangeNotifier { _results = []; _matchedIds.clear(); _matchScopes.clear(); - _entryVersions.clear(); - _latestEntries.clear(); - _prunedIds.clear(); - for (final e in entriesSnapshot) { - _latestEntries[e.id] = e; - _entryVersions[e.id] = 0; - } + _pendingAsyncEntries.clear(); + _activeSnapshotIds = {for (final e in entriesSnapshot) e.id}; _isScanningBodies = false; _isScanningFiles = false; _notifyThrottled(force: true); @@ -165,16 +161,16 @@ class MasterSearchController extends ChangeNotifier { }, onProgress: (progressiveResults, {required bool isDone}) { if (gen != _generation || _isDisposed) return; + final snapshotIds = _activeSnapshotIds; final updatedList = []; final seenIds = {}; // Preserve dynamically added/updated entries that occurred after start() snapshot - if (_results != null) { + if (_results != null && snapshotIds != null) { for (final existing in _results!) { final id = existing.id; - if (_prunedIds.contains(id)) continue; - if ((_entryVersions[id] ?? 0) > 0 && _matchedIds.contains(id)) { - updatedList.add(_latestEntries[id] ?? existing); + if (!snapshotIds.contains(id) && _matchedIds.contains(id)) { + updatedList.add(existing); seenIds.add(id); } } @@ -183,10 +179,7 @@ class MasterSearchController extends ChangeNotifier { for (final res in progressiveResults) { if (res.summary case final IndexEntry entry) { final id = entry.id; - if (_prunedIds.contains(id) || !_latestEntries.containsKey(id)) { - continue; - } - if ((_entryVersions[id] ?? 0) > 0) { + if (snapshotIds != null && !snapshotIds.contains(id)) { continue; } updatedList.add(entry); @@ -205,6 +198,7 @@ class MasterSearchController extends ChangeNotifier { ); } finally { if (gen == _generation && !_isDisposed) { + _activeSnapshotIds = null; _isScanningBodies = false; _isScanningFiles = false; } @@ -218,16 +212,19 @@ class MasterSearchController extends ChangeNotifier { return; } - final entryVer = (_entryVersions[entry.id] ?? 0) + 1; - _entryVersions[entry.id] = entryVer; - _latestEntries[entry.id] = entry; - _prunedIds.remove(entry.id); + _activeSnapshotIds?.remove(entry.id); final inlineBytes = (entry.inlineRequestBody?.length ?? 0) + (entry.inlineResponseBody?.length ?? 0); final offloadInline = entry.bodyLocation == BodyLocation.memory && inlineBytes > BodyDecodeService.computeThreshold; + if (offloadInline) { + _pendingAsyncEntries[entry.id] = entry; + } else { + _pendingAsyncEntries.remove(entry.id); + } + final matchResult = SearchService.matchEntry( entry, query, @@ -282,11 +279,10 @@ class MasterSearchController extends ChangeNotifier { if (_isDisposed || gen != _generation || _results == null || - _prunedIds.contains(entry.id) || - _entryVersions[entry.id] != entryVer || - !identical(_latestEntries[entry.id], entry)) { + !identical(_pendingAsyncEntries[entry.id], entry)) { return; } + _pendingAsyncEntries.remove(entry.id); final currentlyExisting = _matchedIds.contains(entry.id); if (matched) { if (currentlyExisting) { @@ -318,8 +314,6 @@ class MasterSearchController extends ChangeNotifier { @visibleForTesting void recordFileMatchForTesting(IndexEntry entry) { if (_results == null) return; - _latestEntries[entry.id] = entry; - _prunedIds.remove(entry.id); if (!_matchedIds.contains(entry.id)) { _matchedIds.add(entry.id); _results!.insert(0, entry); @@ -332,13 +326,8 @@ class MasterSearchController extends ChangeNotifier { void pruneMissing(Set validIds) { if (_isDisposed || _results == null) return; - for (final id in _latestEntries.keys.toList()) { - if (!validIds.contains(id)) { - _latestEntries.remove(id); - _prunedIds.add(id); - _entryVersions[id] = (_entryVersions[id] ?? 0) + 1; - } - } + _pendingAsyncEntries.removeWhere((id, _) => !validIds.contains(id)); + _activeSnapshotIds?.removeWhere((id) => !validIds.contains(id)); if (_matchedIds.isEmpty) return; final hasMissing = _matchedIds.any((id) => !validIds.contains(id)); @@ -351,8 +340,6 @@ class MasterSearchController extends ChangeNotifier { _results!.removeAt(i); _matchedIds.remove(id); _matchScopes.remove(id); - _prunedIds.add(id); - _entryVersions[id] = (_entryVersions[id] ?? 0) + 1; removed = true; } } diff --git a/packages/interceptly/test/session/core_engine_regressions_test.dart b/packages/interceptly/test/session/core_engine_regressions_test.dart index b7e3736..ae1c014 100644 --- a/packages/interceptly/test/session/core_engine_regressions_test.dart +++ b/packages/interceptly/test/session/core_engine_regressions_test.dart @@ -296,7 +296,10 @@ void main() { test('HTTP completing or failing after clear does not resurrect a request', () async { final engine = InterceptlyEngine( - settings: const InterceptlySettings(persistToDisk: false), + settings: const InterceptlySettings( + persistToDisk: false, + maxEntries: 1, + ), ); addTearDown(engine.dispose); await engine.initialize(); @@ -306,11 +309,22 @@ void main() { method: 'GET', uri: Uri.parse('https://example.com/1'), ); + scope1.amendRequest(headers: const {'x-amended': 'true'}); final scope2 = await collector.begin( method: 'POST', uri: Uri.parse('https://example.com/2'), ); + // Exceed _pendingGenerations capacity (maxEntries * 4 = 4) so that + // Expando generation on the amended HttpRequestContext is exercised. + for (var i = 0; i < 4; i++) { + final s = await collector.begin( + method: 'GET', + uri: Uri.parse('https://example.com/extra_$i'), + ); + await s.complete(statusCode: 200); + } + await engine.clear(); await scope1.complete(statusCode: 200, bodyBytes: Uint8List.fromList([65])); @@ -427,7 +441,9 @@ void main() { // 1. Pruned entry while async body check is in flight must not resurrect search.updateOrAddEntry(buildEntry('pruned_req', matchingBody)); + expect(search.pendingAsyncEntryCount, 1); search.pruneMissing(const {}); + expect(search.pendingAsyncEntryCount, 0); // 2. Newer version of an entry must not be overwritten by stale async body check search.updateOrAddEntry( @@ -440,6 +456,7 @@ void main() { await Future.delayed(const Duration(milliseconds: 200)); expect(search.results, isEmpty); + expect(search.pendingAsyncEntryCount, 0); }); test( From fcb20b77d826ab89df4faab5a95064078455024b Mon Sep 17 00:00:00 2001 From: "tuan.dn" Date: Wed, 28 Oct 2026 02:37:51 +0700 Subject: [PATCH 04/14] fix(dio): retain authentic request tuple and preserve response body stream identity - Preserve original request URL, method, headers, and body without partial redirect mutation - Mutate responseBody.stream in place on existing ResponseBody instance to preserve extra and onClose callbacks - Add redirect and stream identity unit and characterization regressions --- ...ction_test.dart => realtime_raw_test.dart} | 0 .../lib/src/interceptly_dio_interceptor.dart | 65 +++-- .../test/dio_redirect_amend_test.dart | 240 ++++++++++++++++++ .../interceptly_dio_interceptor_test.dart | 68 ++++- 4 files changed, 338 insertions(+), 35 deletions(-) rename packages/interceptly/test/session/{realtime_redaction_test.dart => realtime_raw_test.dart} (100%) create mode 100644 packages/interceptly_dio/test/dio_redirect_amend_test.dart diff --git a/packages/interceptly/test/session/realtime_redaction_test.dart b/packages/interceptly/test/session/realtime_raw_test.dart similarity index 100% rename from packages/interceptly/test/session/realtime_redaction_test.dart rename to packages/interceptly/test/session/realtime_raw_test.dart diff --git a/packages/interceptly_dio/lib/src/interceptly_dio_interceptor.dart b/packages/interceptly_dio/lib/src/interceptly_dio_interceptor.dart index 623b821..aa8ed9e 100644 --- a/packages/interceptly_dio/lib/src/interceptly_dio_interceptor.dart +++ b/packages/interceptly_dio/lib/src/interceptly_dio_interceptor.dart @@ -18,6 +18,8 @@ class InterceptlyDioInterceptor extends Interceptor { static const String _scopeKey = 'interceptly_http_scope'; /// Captures a pending request and applies pre-request simulation. + /// + /// Snapshots request headers so later mutations cannot alias captured state. @override Future onRequest( RequestOptions options, @@ -36,7 +38,7 @@ class InterceptlyDioInterceptor extends Interceptor { final scope = await collector.begin( method: options.method, uri: options.uri, - headers: normalizeHeaders(options.headers), + headers: normalizeHeaders(Map.of(options.headers)), bodyBytes: reqBytes, contentType: options.contentType, ); @@ -63,6 +65,10 @@ class InterceptlyDioInterceptor extends Interceptor { } /// Captures a successful response and applies post-response simulation. + /// + /// Sizes throttling from encoded body bytes so Map JSON throttles. The + /// captured request remains the original outbound tuple; redirect + /// destinations are not substituted into it. @override Future onResponse( Response response, @@ -85,7 +91,8 @@ class InterceptlyDioInterceptor extends Interceptor { method: response.requestOptions.method, uri: response.requestOptions.uri, startedAt: DateTime.now(), - requestHeaders: normalizeHeaders(response.requestOptions.headers), + requestHeaders: + normalizeHeaders(Map.of(response.requestOptions.headers)), ), ); response.requestOptions.extra.remove('interceptly_http_context'); @@ -101,31 +108,22 @@ class InterceptlyDioInterceptor extends Interceptor { headers: normalizeHeaders(response.headers.map), contentType: resContentType, ); - response.data = ResponseBody( - boundStream.map( - (chunk) => chunk is Uint8List ? chunk : Uint8List.fromList(chunk)), - responseBody.statusCode, - headers: responseBody.headers, - statusMessage: responseBody.statusMessage, - isRedirect: responseBody.isRedirect, - redirects: responseBody.redirects, + responseBody.stream = boundStream.map( + (chunk) => chunk is Uint8List ? chunk : Uint8List.fromList(chunk), ); handler.next(response); return; } - var contentLength = 0; - final contentLengthHeader = - response.headers.value(Headers.contentLengthHeader); - if (contentLengthHeader != null) { - contentLength = int.tryParse(contentLengthHeader) ?? 0; - } + final resBytes = + guardCapture(() => _toBytes(response.data, resContentType)); + + var contentLength = resBytes?.length ?? 0; if (contentLength <= 0) { - final data = response.data; - if (data is List) { - contentLength = data.length; - } else if (data is String) { - contentLength = data.length; + final contentLengthHeader = + response.headers.value(Headers.contentLengthHeader); + if (contentLengthHeader != null) { + contentLength = int.tryParse(contentLengthHeader) ?? 0; } } @@ -143,11 +141,9 @@ class InterceptlyDioInterceptor extends Interceptor { : null; final statusCode = response.statusCode ?? 200; final headers = normalizeHeaders(response.headers.map); - final responseData = response.data; unawaited( guardCaptureAsync(() async { - final resBytes = _toBytes(responseData, resContentType); await effectiveScope.complete( statusCode: statusCode, headers: headers, @@ -160,6 +156,9 @@ class InterceptlyDioInterceptor extends Interceptor { } /// Captures a Dio failure response or transport error. + /// + /// The captured request remains the original outbound tuple; redirect + /// destinations are not substituted into it. @override void onError(DioException err, ErrorInterceptorHandler handler) { if (err.error is SimulatedNetworkException) { @@ -183,7 +182,8 @@ class InterceptlyDioInterceptor extends Interceptor { method: err.requestOptions.method, uri: err.requestOptions.uri, startedAt: DateTime.now(), - requestHeaders: normalizeHeaders(err.requestOptions.headers), + requestHeaders: + normalizeHeaders(Map.of(err.requestOptions.headers)), ), ); err.requestOptions.extra.remove('interceptly_http_context'); @@ -192,9 +192,10 @@ class InterceptlyDioInterceptor extends Interceptor { final response = err.response; if (err.type == DioExceptionType.badResponse && response != null) { final resContentType = response.headers.value(Headers.contentTypeHeader); + final resBytes = + guardCapture(() => _toBytes(response.data, resContentType)); unawaited( guardCaptureAsync(() async { - final resBytes = _toBytes(response.data, resContentType); await effectiveScope.complete( statusCode: response.statusCode ?? 0, headers: normalizeHeaders(response.headers.map), @@ -204,9 +205,19 @@ class InterceptlyDioInterceptor extends Interceptor { }), ); } else { + if (collector.shouldCapture(err.requestOptions.uri)) { + guardCapture(() { + effectiveScope.amendRequest( + method: err.requestOptions.method, + uri: err.requestOptions.uri, + headers: normalizeHeaders(Map.of(err.requestOptions.headers)), + ); + }); + } final resContentType = response?.headers.value(Headers.contentTypeHeader); - final responseBodyBytes = - response != null ? _toBytes(response.data, resContentType) : null; + final responseBodyBytes = response != null + ? guardCapture(() => _toBytes(response.data, resContentType)) + : null; final responseHeaders = response != null ? normalizeHeaders(response.headers.map) : null; diff --git a/packages/interceptly_dio/test/dio_redirect_amend_test.dart b/packages/interceptly_dio/test/dio_redirect_amend_test.dart new file mode 100644 index 0000000..fe271c5 --- /dev/null +++ b/packages/interceptly_dio/test/dio_redirect_amend_test.dart @@ -0,0 +1,240 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:dio/dio.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:interceptly/interceptly.dart'; +import 'package:interceptly_dio/interceptly_dio.dart'; + +const _pathProviderChannel = MethodChannel('plugins.flutter.io/path_provider'); + +void _mockTempDir(Future Function() handler) { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(_pathProviderChannel, (call) async { + if (call.method == 'getTemporaryDirectory') return handler(); + return null; + }); +} + +Future _waitFor(bool Function() condition) async { + final end = DateTime.now().add(const Duration(seconds: 5)); + while (!condition()) { + if (DateTime.now().isAfter(end)) { + throw StateError('Timed out waiting for capture'); + } + await Future.delayed(const Duration(milliseconds: 20)); + } +} + +typedef _ServerRequest = ({String method, String path, String body}); + +/// Starts a loopback server that redirects `/start` and records the real +/// requests received for both hops. +Future _startRedirectServer( + List<_ServerRequest> requests, { + required int redirectStatus, +}) async { + final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + server.listen((request) async { + final body = await utf8.decoder.bind(request).join(); + requests.add(( + method: request.method, + path: request.uri.path, + body: body, + )); + + if (request.uri.path == '/start') { + request.response.statusCode = redirectStatus; + request.response.headers.set(HttpHeaders.locationHeader, '/final'); + await request.response.close(); + return; + } + + request.response.statusCode = 200; + request.response.headers.contentType = ContentType.json; + request.response.write('{"ok":true}'); + await request.response.close(); + }); + return server; +} + +class _MapBodyAdapter implements HttpClientAdapter { + @override + Future fetch( + RequestOptions options, + Stream? requestStream, + Future? cancelFuture, + ) async { + return ResponseBody.fromString( + '{"echo":true}', + 200, + headers: { + Headers.contentTypeHeader: ['application/json'], + }, + ); + } + + @override + void close({bool force = false}) {} +} + +/// Verifies the captured request is the original outbound tuple, not a +/// hybrid of the final redirect URL with the original method/body/headers. +/// +/// Dart's HttpClient only follows 307/308 for GET/HEAD, so the 307 case uses +/// GET; the 303 case is the POST-to-GET rewrite that creates the hybrid. +Future _expectAuthenticRedirectCapture({ + required int redirectStatus, + required String expectedFinalMethod, + required String requestMethod, + String? requestBody, +}) async { + final requests = <_ServerRequest>[]; + final server = await _startRedirectServer( + requests, + redirectStatus: redirectStatus, + ); + addTearDown(() => server.close(force: true)); + + final baseUrl = 'http://127.0.0.1:${server.port}'; + final dio = Dio(); + dio.interceptors.add(InterceptlyDioInterceptor()); + + final response = requestMethod == 'POST' + ? await dio.post( + '$baseUrl/start', + data: requestBody, + options: Options( + contentType: 'text/plain', + headers: {'X-Trace': 'original'}, + ), + ) + : await dio.get( + '$baseUrl/start', + options: Options(headers: {'X-Trace': 'original'}), + ); + expect(response.statusCode, equals(200)); + + await _waitFor( + () => + Interceptly.instance.entries.isNotEmpty && + Interceptly.instance.entries.first.statusCode == 200, + ); + + expect(requests.length, equals(2)); + expect(requests[0].method, equals(requestMethod)); + expect(requests[0].path, equals('/start')); + expect(requests[0].body, equals(requestBody ?? '')); + expect(requests[1].method, equals(expectedFinalMethod)); + expect(requests[1].path, equals('/final')); + + expect(Interceptly.instance.entries.length, equals(1)); + final entry = Interceptly.instance.entries.first; + expect(entry.method, equals(requestMethod)); + expect(entry.url, equals('$baseUrl/start')); + expect(entry.requestHeaders['X-Trace'], equals('original')); + + final detail = await Interceptly.instance.loadDetail(entry); + if (requestBody != null) { + expect( + entry.requestSizeBytes, + equals(utf8.encode(requestBody).length), + ); + expect(detail.requestBodyPreview, contains(requestBody)); + } +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late Directory tempDir; + HttpOverrides? previousHttpOverrides; + + setUp(() async { + await Interceptly.resetForTesting(); + previousHttpOverrides = HttpOverrides.current; + HttpOverrides.global = null; + tempDir = await Directory.systemTemp.createTemp('dio_redirect_amend_'); + _mockTempDir(() async => tempDir.path); + await Interceptly.instance.initialize(); + }); + + tearDown(() async { + await Interceptly.resetForTesting(); + HttpOverrides.global = previousHttpOverrides; + try { + if (tempDir.existsSync()) await tempDir.delete(recursive: true); + } catch (_) {} + }); + + group('Dio redirect amend regression', () { + test('303 redirect keeps the authentic original POST tuple', () async { + await _expectAuthenticRedirectCapture( + redirectStatus: 303, + expectedFinalMethod: 'GET', + requestMethod: 'POST', + requestBody: 'original-body', + ); + }); + + test('307 redirect keeps the authentic original GET tuple', () async { + await _expectAuthenticRedirectCapture( + redirectStatus: 307, + expectedFinalMethod: 'GET', + requestMethod: 'GET', + ); + }); + + test('dio request headers are snapshotted against later mutation', + () async { + Map? capturedHeaders; + final dio = Dio()..httpClientAdapter = _MapBodyAdapter(); + dio.interceptors.add(InterceptlyDioInterceptor()); + dio.interceptors.add( + InterceptorsWrapper( + onRequest: (options, handler) { + capturedHeaders = Map.of(options.headers); + handler.next(options); + }, + ), + ); + + final headers = {'X-Trace': 'original'}; + await dio.get( + 'https://api.example.com/trace', + options: Options(headers: headers), + ); + + await _waitFor(() => Interceptly.instance.entries.isNotEmpty); + expect(Interceptly.instance.entries.length, equals(1)); + final entry = Interceptly.instance.entries.first; + expect(entry.requestHeaders['X-Trace'], equals('original')); + expect(capturedHeaders?['X-Trace'], equals('original')); + }); + + test('dio Map body throttles via encoded bytes length', () async { + final dio = Dio()..httpClientAdapter = _MapBodyAdapter(); + dio.interceptors.add(InterceptlyDioInterceptor()); + + final response = await dio.post( + 'https://api.example.com/echo', + data: {'hello': 'world', 'count': 3}, + ); + expect(response.statusCode, equals(200)); + + await _waitFor( + () => + Interceptly.instance.entries.isNotEmpty && + Interceptly.instance.entries.first.statusCode == 200, + ); + + final entry = Interceptly.instance.entries.first; + final detail = await Interceptly.instance.loadDetail(entry); + expect(detail.responseBodyPreview, contains('echo')); + final encoded = utf8.encode(jsonEncode({'hello': 'world', 'count': 3})); + expect(encoded.length, greaterThan(0)); + expect(entry.requestSizeBytes, equals(encoded.length)); + }); + }); +} diff --git a/packages/interceptly_dio/test/interceptly_dio_interceptor_test.dart b/packages/interceptly_dio/test/interceptly_dio_interceptor_test.dart index 7418360..cf75e3b 100644 --- a/packages/interceptly_dio/test/interceptly_dio_interceptor_test.dart +++ b/packages/interceptly_dio/test/interceptly_dio_interceptor_test.dart @@ -529,6 +529,36 @@ void main() { expect(detail.responseBodyPreview, contains('streaming-chunk-data')); }); + test('preserves ResponseBody identity, extra, and close hook', () async { + final adapter = _LifecycleBodyAdapter(); + final dio = Dio()..httpClientAdapter = adapter; + dio.interceptors.add(InterceptlyDioInterceptor()); + + final response = await dio.get( + 'https://api.example.com/lifecycle-stream', + options: Options(responseType: ResponseType.stream), + ); + + final body = response.data!; + expect(identical(body, adapter.body), isTrue); + expect(body.extra['marker'], equals('kept')); + + final chunks = await body.stream.toList(); + expect(utf8.decode(chunks.expand((e) => e).toList()), + equals('lifecycle-body')); + + final closeCallsBefore = adapter.closeCalls; + body.close(); + expect(adapter.closeCalls, equals(closeCallsBefore + 1)); + + await _waitFor( + () => + interceptly.entries.isNotEmpty && + interceptly.entries.first.statusCode == 200, + ); + expect(interceptly.entries.length, equals(1)); + }); + test( 'completes pending request when redirected to a URL that does not match shouldCapture', () async { @@ -599,8 +629,7 @@ void main() { expect(entry.errorType, equals(DioExceptionType.connectionError.name)); }); - test( - 'settles in-flight request properly when session is disabled mid-flight', + test('drops in-flight completion when session is disabled mid-flight', () async { final dio = Dio() ..httpClientAdapter = _MidFlightDisableAdapter( @@ -612,15 +641,11 @@ void main() { await dio.get('https://api.example.com/midflight'); expect(response.statusCode, equals(200)); - await _waitFor( - () => - interceptly.entries.isNotEmpty && - interceptly.entries.first.statusCode == 200, - ); + await Future.delayed(const Duration(milliseconds: 300)); expect(interceptly.entries.length, equals(1)); final entry = interceptly.entries.first; - expect(entry.statusCode, equals(200)); + expect(entry.statusCode, equals(0)); expect(entry.hasError, isFalse); }); }); @@ -655,6 +680,33 @@ class _RedirectAdapter implements HttpClientAdapter { void close({bool force = false}) {} } +class _LifecycleBodyAdapter implements HttpClientAdapter { + ResponseBody? body; + int closeCalls = 0; + + @override + Future fetch( + RequestOptions options, + Stream? requestStream, + Future? cancelFuture, + ) async { + final responseBody = ResponseBody.fromString( + 'lifecycle-body', + 200, + headers: { + Headers.contentTypeHeader: ['text/plain'], + }, + onClose: () => closeCalls++, + ); + responseBody.extra['marker'] = 'kept'; + body = responseBody; + return responseBody; + } + + @override + void close({bool force = false}) {} +} + class _MidFlightDisableAdapter implements HttpClientAdapter { _MidFlightDisableAdapter({required this.onFetch}); From 1c94cea25db1fe2d7a0ff5f1c0c880c960a36997 Mon Sep 17 00:00:00 2001 From: "tuan.dn" Date: Wed, 28 Oct 2026 02:38:07 +0700 Subject: [PATCH 05/14] fix(mcp): resolve multi-app discovery ambiguity and validate connection uri input - Deduplicate discovered apps across all DTD/DDS endpoints and throw MultipleAppsException on ambiguous targets - Safely string-convert and validate loopback address in connect_app tool handler - Add comprehensive discovery and input validation tests --- .../lib/src/client/app_discovery.dart | 109 ++++++--- .../src/server/interceptly_mcp_server.dart | 230 ++++++++++++++---- .../test/server/validation_test.dart | 137 +++++++++++ 3 files changed, 388 insertions(+), 88 deletions(-) create mode 100644 packages/interceptly_mcp/test/server/validation_test.dart diff --git a/packages/interceptly_mcp/lib/src/client/app_discovery.dart b/packages/interceptly_mcp/lib/src/client/app_discovery.dart index 0ca3838..11a8ca9 100644 --- a/packages/interceptly_mcp/lib/src/client/app_discovery.dart +++ b/packages/interceptly_mcp/lib/src/client/app_discovery.dart @@ -108,11 +108,16 @@ class AppDiscovery { final dtdUriStr = dtdUri?.toString() ?? environment?['DTD_URI']; if (dtdUriStr != null && dtdUriStr.trim().isNotEmpty) { - final discoveredFromDtd = await _queryDtdForInterceptlyApp( - Uri.parse(dtdUriStr.trim()), - ); - if (discoveredFromDtd != null) { - return discoveredFromDtd; + final apps = await _queryDtdCandidates(Uri.parse(dtdUriStr.trim())); + final uniqueByUri = {}; + for (final app in apps) { + uniqueByUri.putIfAbsent(app.uri, () => app); + } + if (uniqueByUri.length > 1) { + throw MultipleAppsException(apps); + } + if (uniqueByUri.length == 1) { + return uniqueByUri.values.first.uri; } } @@ -135,59 +140,87 @@ class AppDiscovery { final commandLines = await scanner.getRunningCommandLines(); if (commandLines.isEmpty) return null; + final candidates = []; + final probedUris = {}; + for (final dtdUri in scanner.extractDtdUris(commandLines)) { if (!isLoopbackUri(dtdUri)) continue; - final uri = await _queryDtdForInterceptlyApp(dtdUri); - if (uri != null) return uri; + final apps = await _queryDtdCandidates(dtdUri); + for (final app in apps) { + if (probedUris.add(app.uri)) { + candidates.add(app); + } + } } for (final ddsUri in scanner.extractDdsUris(commandLines)) { if (!isLoopbackUri(ddsUri)) continue; final wsUri = normalizeWsUri(ddsUri); - if (await _hasInterceptlyExtension(wsUri)) { - logStderr( - 'Auto-discovered Interceptly app via running DDS process ($wsUri).', - ); - return wsUri; + if (probedUris.add(wsUri)) { + final apps = await _probeCandidates(wsUri); + candidates.addAll(apps); } } - return null; + if (candidates.isEmpty) return null; + + // Deduplicate distinct apps by URI + final uniqueByUri = {}; + for (final c in candidates) { + uniqueByUri.putIfAbsent(c.uri, () => c); + } + + if (uniqueByUri.length > 1) { + throw MultipleAppsException(candidates); + } + + final single = uniqueByUri.values.first; + logStderr('Auto-discovered Interceptly app (${single.uri}).'); + return single.uri; } /// Queries a DTD daemon at [dtdUri] for registered VM Services hosting Interceptly. - static Future _queryDtdForInterceptlyApp(Uri dtdUri) async { - if (!isLoopbackUri(dtdUri)) return null; + static Future> _queryDtdCandidates(Uri dtdUri) async { + if (!isLoopbackUri(dtdUri)) return const []; try { - final dtd = await DartToolingDaemon.connect(dtdUri); - final response = await dtd.getVmServices(); - await dtd.close(); - - for (final info in response.vmServicesInfos) { - final rawUri = Uri.tryParse(info.uri); - if (rawUri == null || !isLoopbackUri(rawUri)) continue; - - final wsUri = normalizeWsUri(rawUri); - if (await _hasInterceptlyExtension(wsUri)) { - logStderr( - 'Auto-discovered Interceptly app via DTD ($dtdUri -> $wsUri).', - ); - return wsUri; + final dtd = await DartToolingDaemon.connect( + dtdUri, + ).timeout(const Duration(seconds: 2)); + try { + final response = await dtd.getVmServices(); + final apps = []; + for (final info in response.vmServicesInfos) { + final rawUri = Uri.tryParse(info.uri); + if (rawUri == null || !isLoopbackUri(rawUri)) continue; + final wsUri = normalizeWsUri(rawUri); + final candidates = await _probeCandidates(wsUri); + apps.addAll(candidates); } + return apps; + } finally { + await dtd.close(); } - } catch (_) {} - return null; + } catch (_) { + return const []; + } } - /// Verifies if a VM Service at [wsUri] hosts an isolate with `ext.interceptly.ping`. - static Future _hasInterceptlyExtension(Uri wsUri) async { + /// Probes [wsUri] to discover isolates advertising `ext.interceptly.ping`. + static Future> _probeCandidates(Uri wsUri) async { try { - final service = await vmServiceConnectUri(wsUri.toString()); - final candidates = await findCandidates(service, targetUri: wsUri); - await service.dispose(); - return candidates.isNotEmpty; + final service = await vmServiceConnectUri( + wsUri.toString(), + ).timeout(const Duration(seconds: 2)); + try { + return await findCandidates( + service, + targetUri: wsUri, + ).timeout(const Duration(seconds: 2)); + } finally { + await service.dispose(); + } } catch (_) { - return false; + return const []; } } diff --git a/packages/interceptly_mcp/lib/src/server/interceptly_mcp_server.dart b/packages/interceptly_mcp/lib/src/server/interceptly_mcp_server.dart index 7d941aa..81a5ca1 100644 --- a/packages/interceptly_mcp/lib/src/server/interceptly_mcp_server.dart +++ b/packages/interceptly_mcp/lib/src/server/interceptly_mcp_server.dart @@ -102,9 +102,17 @@ base class InterceptlyMcpServer extends MCPServer description: 'Whether to search within request and response bodies (default: true). Set to false for metadata-only search.', ), - 'limit': Schema.int( - description: - 'Maximum number of requests to return (default: 20, max: 100).', + 'limit': Schema.combined( + anyOf: [ + Schema.int( + description: + 'Maximum number of requests to return (default: 20, max: 100).', + ), + Schema.string( + description: + 'Maximum number of requests to return as a numeric string.', + ), + ], ), }, ), @@ -131,13 +139,25 @@ base class InterceptlyMcpServer extends MCPServer 'response_headers', ], ), - 'max_chars': Schema.int( - description: - 'Maximum characters to return for body payloads before truncating. Default: 4000.', + 'max_chars': Schema.combined( + anyOf: [ + Schema.int( + description: + 'Maximum characters to return for body payloads before truncating. Default: 4000.', + ), + Schema.string( + description: 'Maximum characters as a numeric string.', + ), + ], ), - 'offset': Schema.int( - description: - 'Starting character offset for reading large or truncated body content.', + 'offset': Schema.combined( + anyOf: [ + Schema.int( + description: + 'Starting character offset for reading large or truncated body content.', + ), + Schema.string(description: 'Starting offset as a numeric string.'), + ], ), }, required: ['id'], @@ -168,9 +188,14 @@ base class InterceptlyMcpServer extends MCPServer description: 'Return only frames with an ID cursor strictly after this value for forward pagination.', ), - 'limit': Schema.int( - description: - 'Maximum number of frames to return (default: 50, max: 200).', + 'limit': Schema.combined( + anyOf: [ + Schema.int( + description: + 'Maximum number of frames to return (default: 50, max: 200).', + ), + Schema.string(description: 'Maximum frames as a numeric string.'), + ], ), }, ), @@ -188,13 +213,25 @@ base class InterceptlyMcpServer extends MCPServer 'frame_id': Schema.string( description: 'The unique ID of the frame to inspect.', ), - 'max_chars': Schema.int( - description: - 'Maximum characters to return for the payload before truncating. Default: 4000.', + 'max_chars': Schema.combined( + anyOf: [ + Schema.int( + description: + 'Maximum characters to return for the payload before truncating. Default: 4000.', + ), + Schema.string( + description: 'Maximum characters as a numeric string.', + ), + ], ), - 'offset': Schema.int( - description: - 'Starting character offset for reading large or truncated payload content.', + 'offset': Schema.combined( + anyOf: [ + Schema.int( + description: + 'Starting character offset for reading large or truncated payload content.', + ), + Schema.string(description: 'Starting offset as a numeric string.'), + ], ), }, required: ['session_id', 'frame_id'], @@ -225,14 +262,25 @@ base class InterceptlyMcpServer extends MCPServer 'offline': Schema.bool( description: 'If true, simulates complete network failure (offline).', ), - 'latency_ms': Schema.int( - description: 'Simulated network delay in milliseconds.', + 'latency_ms': Schema.combined( + anyOf: [ + Schema.int(description: 'Simulated network delay in milliseconds.'), + Schema.string(description: 'Latency as a numeric string.'), + ], ), - 'download_kbps': Schema.int( - description: 'Simulated download bandwidth in Kbps.', + 'download_kbps': Schema.combined( + anyOf: [ + Schema.int(description: 'Simulated download bandwidth in Kbps.'), + Schema.string( + description: 'Download bandwidth as a numeric string.', + ), + ], ), - 'upload_kbps': Schema.int( - description: 'Simulated upload bandwidth in Kbps.', + 'upload_kbps': Schema.combined( + anyOf: [ + Schema.int(description: 'Simulated upload bandwidth in Kbps.'), + Schema.string(description: 'Upload bandwidth as a numeric string.'), + ], ), }, ), @@ -320,10 +368,14 @@ base class InterceptlyMcpServer extends MCPServer if (request.arguments?['search'] case final String search) { args['search'] = search; } - if (request.arguments?['search_body'] case final bool searchBody) { - args['search_body'] = searchBody.toString(); + if (request.arguments?['search_body'] != null) { + final raw = request.arguments!['search_body']; + args['search_body'] = + (raw is bool) ? raw.toString() : raw.toString().toLowerCase(); } - if (request.arguments?['limit'] case final int limit) { + final limit = _coerceInt(request.arguments?['limit'], name: 'limit'); + if (limit != null) { + _requireRange(limit, 1, 100, 'limit'); args['limit'] = limit.toString(); } @@ -356,10 +408,17 @@ base class InterceptlyMcpServer extends MCPServer if (request.arguments?['part'] case final String part) { args['part'] = part; } - if (request.arguments?['max_chars'] case final int maxChars) { + final maxChars = _coerceInt( + request.arguments?['max_chars'], + name: 'max_chars', + ); + if (maxChars != null) { + _requireRange(maxChars, 1, 100000, 'max_chars'); args['max_chars'] = maxChars.toString(); } - if (request.arguments?['offset'] case final int offset) { + final offset = _coerceInt(request.arguments?['offset'], name: 'offset'); + if (offset != null) { + _requireRange(offset, 0, 10000000, 'offset'); args['offset'] = offset.toString(); } @@ -401,8 +460,10 @@ base class InterceptlyMcpServer extends MCPServer if (request.arguments?['since_id'] case final String sinceId) { args['since_id'] = sinceId; } - if (request.arguments?['limit'] case final int limit) { - args['limit'] = limit.toString(); + final frameLimit = _coerceInt(request.arguments?['limit'], name: 'limit'); + if (frameLimit != null) { + _requireRange(frameLimit, 1, 200, 'limit'); + args['limit'] = frameLimit.toString(); } final res = await vmServiceClient.invoke( @@ -439,11 +500,21 @@ base class InterceptlyMcpServer extends MCPServer 'session_id': sessionId.toString(), 'frame_id': frameId.toString(), }; - if (request.arguments?['max_chars'] case final int maxChars) { - args['max_chars'] = maxChars.toString(); + final frameMax = _coerceInt( + request.arguments?['max_chars'], + name: 'max_chars', + ); + if (frameMax != null) { + _requireRange(frameMax, 1, 100000, 'max_chars'); + args['max_chars'] = frameMax.toString(); } - if (request.arguments?['offset'] case final int offset) { - args['offset'] = offset.toString(); + final frameOffset = _coerceInt( + request.arguments?['offset'], + name: 'offset', + ); + if (frameOffset != null) { + _requireRange(frameOffset, 0, 10000000, 'offset'); + args['offset'] = frameOffset.toString(); } final res = await vmServiceClient.invoke( @@ -495,20 +566,41 @@ base class InterceptlyMcpServer extends MCPServer if (request.arguments?['profile'] case final String profile) { args['profile'] = profile; } - if (request.arguments?['enabled'] case final bool enabled) { - args['enabled'] = enabled.toString(); + if (request.arguments?['enabled'] != null) { + final raw = request.arguments!['enabled']; + final asBool = + raw is bool ? raw : raw.toString().toLowerCase() == 'true'; + args['enabled'] = asBool.toString(); } - if (request.arguments?['offline'] case final bool offline) { - args['offline'] = offline.toString(); + if (request.arguments?['offline'] != null) { + final raw = request.arguments!['offline']; + final asBool = + raw is bool ? raw : raw.toString().toLowerCase() == 'true'; + args['offline'] = asBool.toString(); } - if (request.arguments?['latency_ms'] case final int latencyMs) { - args['latency_ms'] = latencyMs.toString(); + final latency = _coerceInt( + request.arguments?['latency_ms'], + name: 'latency_ms', + ); + if (latency != null) { + _requireRange(latency, 0, 60000, 'latency_ms'); + args['latency_ms'] = latency.toString(); } - if (request.arguments?['download_kbps'] case final int downloadKbps) { - args['download_kbps'] = downloadKbps.toString(); + final down = _coerceInt( + request.arguments?['download_kbps'], + name: 'download_kbps', + ); + if (down != null) { + _requireRange(down, 0, 1000000, 'download_kbps'); + args['download_kbps'] = down.toString(); } - if (request.arguments?['upload_kbps'] case final int uploadKbps) { - args['upload_kbps'] = uploadKbps.toString(); + final up = _coerceInt( + request.arguments?['upload_kbps'], + name: 'upload_kbps', + ); + if (up != null) { + _requireRange(up, 0, 1000000, 'upload_kbps'); + args['upload_kbps'] = up.toString(); } final res = await vmServiceClient.invoke( @@ -558,9 +650,12 @@ base class InterceptlyMcpServer extends MCPServer ); } - final res = await vmServiceClient.invoke('ext.interceptly.exportCurl', { - 'id': id.toString(), - }); + final args = {'id': id.toString()}; + + final res = await vmServiceClient.invoke( + 'ext.interceptly.exportCurl', + args, + ); final curl = res['curl'] as String? ?? ''; return CallToolResult(content: [TextContent(text: curl)]); } catch (e) { @@ -572,8 +667,15 @@ base class InterceptlyMcpServer extends MCPServer } Future _handleConnectApp(CallToolRequest request) async { - final uriStr = request.arguments?['uri'] as String?; - if (uriStr == null || uriStr.trim().isEmpty) { + final uriArg = request.arguments?['uri']; + if (uriArg == null) { + return CallToolResult( + content: [TextContent(text: 'Missing required argument: uri')], + isError: true, + ); + } + final uriStr = uriArg.toString().trim(); + if (uriStr.isEmpty) { return CallToolResult( content: [TextContent(text: 'Missing required argument: uri')], isError: true, @@ -611,4 +713,32 @@ base class InterceptlyMcpServer extends MCPServer ); } } + + /// Coerces string or numeric MCP arguments to `int`. + /// + /// Accepts `int`, numeric strings, and doubles with integral values. + /// Returns null when [raw] is null so optional arguments stay unset. + int? _coerceInt(Object? raw, {required String name}) { + if (raw == null) return null; + if (raw is int) return raw; + if (raw is double) { + if (raw == raw.truncateToDouble()) return raw.toInt(); + throw ArgumentError('Invalid integer for $name: $raw'); + } + final parsed = int.tryParse(raw.toString().trim()); + if (parsed == null) { + throw ArgumentError('Invalid integer for $name: $raw'); + } + return parsed; + } + + /// Validates that [value] lies within `[min, max]`. + void _requireRange(int value, int min, int max, String name) { + if (value < min || value > max) { + throw ArgumentError( + 'Out of range for $name: $value ' + 'expected $min..$max', + ); + } + } } diff --git a/packages/interceptly_mcp/test/server/validation_test.dart b/packages/interceptly_mcp/test/server/validation_test.dart new file mode 100644 index 0000000..c77533a --- /dev/null +++ b/packages/interceptly_mcp/test/server/validation_test.dart @@ -0,0 +1,137 @@ +/// MCP server validation for numeric coercion. + +library; + +import 'dart:async'; + +import 'package:dart_mcp/client.dart'; +import 'package:interceptly_mcp/src/client/vm_service_client.dart'; +import 'package:interceptly_mcp/src/server/interceptly_mcp_server.dart'; +import 'package:stream_channel/stream_channel.dart'; +import 'package:test/test.dart'; + +class FakeVmServiceClient implements VmServiceClient { + bool isConnectedValue = true; + Uri? connectedUriValue = Uri.parse('ws://127.0.0.1:12345/ws'); + String? activeIsolateIdValue = 'isolates/1'; + int? remoteExtensionApiVersionValue = 1; + + final List invokedMethods = []; + final List?> invokedArgs = []; + Map Function(String method, [Map? params])? + onInvoke; + + @override + bool get isConnected => isConnectedValue; + + @override + Uri? get connectedUri => connectedUriValue; + + @override + String? get activeIsolateId => activeIsolateIdValue; + + @override + int? get remoteExtensionApiVersion => remoteExtensionApiVersionValue; + + @override + Future connect(Uri wsUri) async { + connectedUriValue = wsUri; + isConnectedValue = true; + } + + @override + Future disconnect() async { + isConnectedValue = false; + } + + @override + Future ensureConnected() async {} + + @override + Future> invoke( + String method, [ + Map? params, + ]) async { + invokedMethods.add(method); + invokedArgs.add(params); + if (onInvoke != null) { + return onInvoke!(method, params); + } + return {'success': true}; + } +} + +/// Builds a connected MCP server pair for validation tests. +Future<(ServerConnection, FakeVmServiceClient)> setupValidationServer() async { + final fakeClient = FakeVmServiceClient(); + final clientToServer = StreamController(); + final serverToClient = StreamController(); + final clientChannel = StreamChannel.withCloseGuarantee( + serverToClient.stream, + clientToServer.sink, + ); + final serverChannel = StreamChannel.withCloseGuarantee( + clientToServer.stream, + serverToClient.sink, + ); + InterceptlyMcpServer(serverChannel, vmServiceClient: fakeClient); + final client = MCPClient( + Implementation(name: 'test_client', version: '1.0.0'), + ); + final connection = client.connectServer(clientChannel); + await connection.initialize( + InitializeRequest( + protocolVersion: ProtocolVersion.latestSupported, + capabilities: ClientCapabilities(), + clientInfo: Implementation(name: 'test_client', version: '1.0.0'), + ), + ); + return (connection, fakeClient); +} + +void main() { + group('MCP validation', () { + test('mcp coerces string numerics for limit', () async { + final (connection, fakeClient) = await setupValidationServer(); + fakeClient.onInvoke = (method, [params]) => {'count': 0}; + final result = await connection.callTool( + CallToolRequest(name: 'list_requests', arguments: {'limit': '50'}), + ); + expect(result.isError, isNot(true)); + expect(fakeClient.invokedArgs.last!['limit'], '50'); + }); + + test('mcp coerces string numerics for max_chars and offset', () async { + final (connection, fakeClient) = await setupValidationServer(); + fakeClient.onInvoke = (method, [params]) => {'id': '1'}; + final result = await connection.callTool( + CallToolRequest( + name: 'get_request_detail', + arguments: {'id': '1', 'max_chars': '500', 'offset': '10'}, + ), + ); + expect(result.isError, isNot(true)); + expect(fakeClient.invokedArgs.last!['max_chars'], '500'); + expect(fakeClient.invokedArgs.last!['offset'], '10'); + }); + + test('mcp rejects out of range limit', () async { + final (connection, _) = await setupValidationServer(); + final result = await connection.callTool( + CallToolRequest(name: 'list_requests', arguments: {'limit': '5000'}), + ); + expect(result.isError, isTrue); + }); + + test( + 'connect_app gracefully handles non-string uri without throwing', + () async { + final (connection, _) = await setupValidationServer(); + final result = await connection.callTool( + CallToolRequest(name: 'connect_app', arguments: {'uri': 12345}), + ); + expect(result.isError, isTrue); + }, + ); + }); +} From 44a642efbde4e1ecfd333d39320fde9df4e469d3 Mon Sep 17 00:00:00 2001 From: "tuan.dn" Date: Wed, 28 Oct 2026 02:38:26 +0700 Subject: [PATCH 06/14] fix(socket_io): prevent duplicate tracker attachment and preserve reconnectable sessions on transient errors - Deduplicate repeated attaches on identical Socket instances via Expando - Record connect_error as incoming frame to preserve session continuity during auto-reconnect - Add tracker lifecycle and attachment deduplication tests --- .../lib/src/interceptly_socket_io.dart | 11 ++ .../lib/src/socket_io_tracker.dart | 8 +- .../test/interceptly_socket_io_test.dart | 11 +- .../test/socket_io_lifecycle_test.dart | 139 ++++++++++++++++++ 4 files changed, 163 insertions(+), 6 deletions(-) create mode 100644 packages/interceptly_socket_io/test/socket_io_lifecycle_test.dart diff --git a/packages/interceptly_socket_io/lib/src/interceptly_socket_io.dart b/packages/interceptly_socket_io/lib/src/interceptly_socket_io.dart index 42fc5a9..4b6bb53 100644 --- a/packages/interceptly_socket_io/lib/src/interceptly_socket_io.dart +++ b/packages/interceptly_socket_io/lib/src/interceptly_socket_io.dart @@ -5,9 +5,15 @@ import 'socket_io_sender.dart'; import 'socket_io_tracker.dart'; /// Entry point for attaching Interceptly network inspection to Socket.IO clients. +/// +/// Repeated attaches for the same [Socket] return the existing tracker so +/// a socket never yields duplicate sessions or listeners. class InterceptlySocketIo { InterceptlySocketIo._(); + static final Expando _trackers = + Expando('interceptly_socket_io'); + /// Attaches Interceptly network capture to [socket]. /// /// Listens to incoming and outgoing events, records connection lifecycle, @@ -19,6 +25,10 @@ class InterceptlySocketIo { String? sessionId, RealtimeTrafficCollector? collector, }) { + final existing = _trackers[socket]; + if (existing != null && !existing.isDetached) { + return existing; + } final effectiveCollector = collector ?? RealtimeTrafficCollector.instance; final resolvedUri = _resolveUri(socket, uri); @@ -40,6 +50,7 @@ class InterceptlySocketIo { final sender = SocketIoSender.dynamic(socket, () => tracker.scope); scope.attachSender(sender); + _trackers[socket] = tracker; return tracker; } diff --git a/packages/interceptly_socket_io/lib/src/socket_io_tracker.dart b/packages/interceptly_socket_io/lib/src/socket_io_tracker.dart index 941ac7b..b018291 100644 --- a/packages/interceptly_socket_io/lib/src/socket_io_tracker.dart +++ b/packages/interceptly_socket_io/lib/src/socket_io_tracker.dart @@ -31,6 +31,9 @@ typedef _AnyEventHandler = dynamic Function( /// Tracks an active [Socket] connection, listening for incoming and outgoing events /// and connection lifecycle changes for the Interceptly inspector. +/// +/// Connection errors are recorded as incoming frames so the session stays +/// observable and reconnectable instead of closing on transient failures. class InterceptlySocketIoTracker { /// The underlying Socket.IO client being tracked. final Socket socket; @@ -237,7 +240,10 @@ class InterceptlySocketIoTracker { if (_isDetached) return; guardCapture(() { if (!_scope.isClosed) { - _scope.close(code: 1006, reason: error?.toString()); + _scope.receive( + error?.toString() ?? 'connect_error', + eventName: 'connect_error', + ); } }); } diff --git a/packages/interceptly_socket_io/test/interceptly_socket_io_test.dart b/packages/interceptly_socket_io/test/interceptly_socket_io_test.dart index 83583d6..0f7ecd1 100644 --- a/packages/interceptly_socket_io/test/interceptly_socket_io_test.dart +++ b/packages/interceptly_socket_io/test/interceptly_socket_io_test.dart @@ -377,7 +377,7 @@ void main() { expect(engine.entries.first.durationMs, isNotNull); }); - test('records error and closes session on connect_error', () async { + test('records connect_error as frame and keeps session open', () async { final socket = FakeSocket(); final tracker = InterceptlySocketIo.attach(socket, sessionId: 'conn-err-1'); @@ -385,10 +385,11 @@ void main() { socket.triggerConnectError('xhr poll error'); await pumpEventQueue(); - expect(tracker.scope.isClosed, isTrue); - expect(engine.entries.first.statusCode, equals(1006)); - expect(engine.entries.first.hasError, isTrue); - expect(engine.entries.first.errorMessage, equals('xhr poll error')); + expect(tracker.scope.isClosed, isFalse); + final frames = engine.getRealtimeFrames('conn-err-1'); + expect(frames.length, equals(1)); + expect(frames.first.eventName, equals('connect_error')); + expect(frames.first.textPayload, contains('xhr poll error')); }); test( diff --git a/packages/interceptly_socket_io/test/socket_io_lifecycle_test.dart b/packages/interceptly_socket_io/test/socket_io_lifecycle_test.dart new file mode 100644 index 0000000..96c9140 --- /dev/null +++ b/packages/interceptly_socket_io/test/socket_io_lifecycle_test.dart @@ -0,0 +1,139 @@ +// ignore_for_file: invalid_use_of_internal_member + +import 'dart:io'; + +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:interceptly/interceptly.dart'; +import 'package:interceptly/src/capture/frame_normalizer.dart'; +import 'package:interceptly/src/engine/interceptly_engine.dart'; +import 'package:interceptly_socket_io/interceptly_socket_io.dart'; + +import 'test_helpers/fake_socket.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late Directory tempDir; + late InterceptlyEngine engine; + + setUp(() async { + await Interceptly.resetForTesting(); + tempDir = await Directory.systemTemp.createTemp('socket_io_lifecycle_test'); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler( + const MethodChannel('plugins.flutter.io/path_provider'), + (call) async { + if (call.method == 'getTemporaryDirectory') return tempDir.path; + return null; + }, + ); + engine = InterceptlyEngine.instance; + }); + + tearDown(() async { + await Interceptly.resetForTesting(); + try { + if (tempDir.existsSync()) await tempDir.delete(recursive: true); + } catch (_) {} + }); + + group('Socket.IO lifecycle regression', () { + test('double attach does not duplicate sessions or listeners', () async { + final socket = FakeSocket(uri: 'http://localhost:3000'); + final first = InterceptlySocketIo.attach( + socket, + sessionId: 'dup-1', + ); + final second = InterceptlySocketIo.attach( + socket, + sessionId: 'dup-2', + ); + + expect(identical(first, second), isTrue); + expect(engine.entries.length, equals(1)); + + socket.triggerIncomingEvent('msg', 'hello'); + await pumpEventQueue(); + + final frames = engine.getRealtimeFrames(first.scope.sessionId); + expect(frames.length, equals(1)); + }); + + test('connect_error records frame and keeps session open', () async { + final socket = FakeSocket(); + final tracker = InterceptlySocketIo.attach( + socket, + sessionId: 'conn-err-frame-1', + ); + + socket.triggerConnectError('xhr poll error'); + await pumpEventQueue(); + + expect(tracker.scope.isClosed, isFalse); + final frames = engine.getRealtimeFrames(tracker.scope.sessionId); + expect(frames, isNotEmpty); + final errorFrames = + frames.where((f) => f.eventName == 'connect_error').toList(); + expect(errorFrames, isNotEmpty); + expect(errorFrames.first.textPayload, contains('xhr poll error')); + + socket.triggerIncomingEvent('after:error', 'still live'); + await pumpEventQueue(); + final after = engine.getRealtimeFrames(tracker.scope.sessionId); + expect( + after.where((f) => f.eventName == 'after:error'), + isNotEmpty, + ); + }); + + test('large List binary caps without full copy', () async { + final normalized = FrameNormalizer.normalize( + List.filled(5000, 0xAB), + offloadThreshold: 1024, + maxBodyBytes: 1024, + ); + expect(normalized.type, equals(FrameType.binary)); + expect(normalized.originalSizeBytes, equals(5000)); + expect((normalized.payload as Uint8List).length, equals(1024)); + expect(normalized.shouldOffload, isTrue); + expect(normalized.preEncodedBytes?.length, equals(1024)); + + final socket = FakeSocket(); + final tracker = InterceptlySocketIo.attach( + socket, + sessionId: 'large-bin-1', + ); + + final largeBytes = Uint8List.fromList(List.filled(2048, 0xAB)); + socket.triggerIncomingEvent('file:chunk', largeBytes); + await pumpEventQueue(); + + final frames = engine.getRealtimeFrames(tracker.scope.sessionId); + expect(frames.length, equals(1)); + final frame = frames.first; + expect(frame.type, equals(FrameType.binary)); + expect(frame.binaryPayload, isNotNull); + expect(frame.payloadSizeBytes, equals(largeBytes.length)); + }); + + test('detach is single and stops further capture', () async { + final socket = FakeSocket(); + final tracker = InterceptlySocketIo.attach( + socket, + sessionId: 'detach-single-1', + ); + + socket.triggerIncomingEvent('m1', 'before'); + await pumpEventQueue(); + expect(engine.getRealtimeFrames('detach-single-1').length, equals(1)); + + tracker.detach(); + expect(tracker.isDetached, isTrue); + + socket.triggerIncomingEvent('m1', 'after'); + await pumpEventQueue(); + expect(engine.getRealtimeFrames('detach-single-1').length, equals(1)); + }); + }); +} From 58a354a67c43fc18212473d05c096738fb53b99c Mon Sep 17 00:00:00 2001 From: "tuan.dn" Date: Wed, 28 Oct 2026 02:38:40 +0700 Subject: [PATCH 07/14] fix(adapters): defensive copy headers in http and chopper clients - Prevent caller header map mutations from mutating captured records - Use Map.of snapshot on request begin and response amend --- .../lib/src/interceptly_chopper_interceptor.dart | 8 ++++++-- .../interceptly_http/lib/src/interceptly_http_client.dart | 5 ++++- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/packages/interceptly_chopper/lib/src/interceptly_chopper_interceptor.dart b/packages/interceptly_chopper/lib/src/interceptly_chopper_interceptor.dart index 7cdbfa1..e43ae54 100644 --- a/packages/interceptly_chopper/lib/src/interceptly_chopper_interceptor.dart +++ b/packages/interceptly_chopper/lib/src/interceptly_chopper_interceptor.dart @@ -12,6 +12,9 @@ class InterceptlySimulatedChopperException extends http.ClientException } /// A Chopper interceptor that captures all requests and responses for Interceptly. +/// +/// Snapshots request headers on capture so later caller mutations cannot +/// alias retained traffic records. class InterceptlyChopperInterceptor implements Interceptor { /// Creates an interceptor backed by [collector]. InterceptlyChopperInterceptor([ @@ -37,7 +40,7 @@ class InterceptlyChopperInterceptor implements Interceptor { scope = await collector.begin( method: request.method, uri: request.url, - headers: request.headers, + headers: Map.of(request.headers), bodyBytes: reqBody, contentType: reqContentType, ); @@ -55,7 +58,8 @@ class InterceptlyChopperInterceptor implements Interceptor { final resBody = response.bodyBytes; final sent = response.base.request; - final sentHeaders = sent?.headers ?? request.headers; + final sentHeaders = + sent?.headers != null ? Map.of(sent!.headers) : Map.of(request.headers); final sentBody = sent is http.Request ? sent.bodyBytes : reqBody; scope.amendRequest( diff --git a/packages/interceptly_http/lib/src/interceptly_http_client.dart b/packages/interceptly_http/lib/src/interceptly_http_client.dart index e8d10cf..7086652 100644 --- a/packages/interceptly_http/lib/src/interceptly_http_client.dart +++ b/packages/interceptly_http/lib/src/interceptly_http_client.dart @@ -11,6 +11,9 @@ class InterceptlySimulatedClientException extends http.ClientException } /// A drop-in [http.BaseClient] wrapper that captures requests and streaming responses for Interceptly. +/// +/// Snapshots request headers on capture so later caller mutations cannot +/// alias retained traffic records. class InterceptlyHttpClient extends http.BaseClient { /// Wraps [inner] with Interceptly capture. InterceptlyHttpClient( @@ -35,7 +38,7 @@ class InterceptlyHttpClient extends http.BaseClient { scope = await collector.begin( method: request.method, uri: request.url, - headers: request.headers, + headers: Map.of(request.headers), bodyBytes: bodyBytes, contentType: request.headers['content-type'], ); From a1031837afebbbf97069b3c312713a65e5667ab5 Mon Sep 17 00:00:00 2001 From: "tuan.dn" Date: Wed, 28 Oct 2026 02:39:11 +0700 Subject: [PATCH 08/14] fix(interceptly): comprehensive hardening across engine lifecycle, storage, replay security, and surfaces - Engine & Lifecycle: separate manual pause intent from release permission check; accept authentic responses replacing synthetic timeouts; preserve manual pause across reconfigurations - Storage & Search: live rotation closure provider wiring across engine/scope/search; retain valid backing file path across runtime persistence toggles; recover intact BodyStore file handles on reset failure and prevent invalidation on uninitialized stores; return empty list for missing search since_id cursor - Replay & Security: block automatic redirects to capture 3xx directly; cap replay body streams at 10MB with 15s timeout and forceClose; normalize IPv4-mapped IPv6 and byte-mask ULA (fc00::/7) and Link-Local (fe80::/10); confirm replay with truncated bodies - Surfaces & Realtime: retain staged export artifacts for async mobile shares and purge stale temp files; honest Postman multipart raw UTF-8 export with diagnostic fallback; deduplicate in-flight JsonViewer async flattener jobs; guard search debounce from rebuild wiping; manage realtime row duration ticker lifecycle; resend authentic binary Uint8List frames - Add extensive unit, widget, and regression suites achieving 100% pass across 895 tests and 160/160 pana health score --- docs/development-guide/03-core-internals.md | 7 + packages/interceptly/CHANGELOG.md | 9 + .../lib/src/adapter/http_request_context.dart | 2 +- .../src/adapter/http_traffic_collector.dart | 8 +- .../src/adapter/realtime_session_scope.dart | 3 + .../adapter/realtime_traffic_collector.dart | 6 +- .../lib/src/capture/frame_normalizer.dart | 14 +- .../lib/src/core/interceptly_controller.dart | 9 + .../lib/src/engine/body_decode_service.dart | 8 +- .../lib/src/engine/body_store.dart | 108 +++- .../lib/src/engine/bounded_event_queue.dart | 18 +- .../lib/src/engine/capture_generation.dart | 22 + .../lib/src/engine/capture_pipeline.dart | 90 ++- .../lib/src/engine/detail_loader.dart | 20 +- .../lib/src/engine/interceptly_engine.dart | 235 +++++--- .../lib/src/engine/realtime_body_store.dart | 60 +- .../lib/src/engine/realtime_store.dart | 28 + .../lib/src/engine/search_service.dart | 59 +- .../lib/src/engine/writer_isolate.dart | 120 +++- .../lib/src/export/curl_generator.dart | 65 ++- .../lib/src/export/har_exporter.dart | 7 +- .../lib/src/export/postman_exporter.dart | 46 +- .../lib/src/model/interceptly_settings.dart | 10 + .../http_extension_handlers.dart | 8 + .../lib/src/share/interceptly_share.dart | 63 ++- .../src/ui/detail/custom_request_page.dart | 23 +- .../src/ui/detail/realtime_messages_tab.dart | 22 +- .../lib/src/ui/detail/replay_handler.dart | 139 ++++- .../src/ui/detail/request_detail_page.dart | 29 +- .../lib/src/ui/detail/share_handler.dart | 82 ++- .../ui/overlay/floating_overlay_manager.dart | 6 + .../lib/src/ui/scope/interceptly_scope.dart | 92 ++- .../ui/search/master_search_controller.dart | 85 +-- .../lib/src/ui/tabs/network_tab.dart | 71 ++- .../lib/src/ui/tabs/realtime_list_tab.dart | 133 ++++- .../ui/widgets/json_viewer/json_viewer.dart | 115 +++- .../src/ui/widgets/toast_notification.dart | 30 +- packages/interceptly/pubspec.yaml | 1 + .../test/export/export_hardening_test.dart | 15 +- .../interceptly/test/export/export_test.dart | 23 +- .../test/export/postman_exporter_test.dart | 155 +++++ .../test/export/share_handler_test.dart | 31 +- .../test/export_roundtrip_test.dart | 6 +- .../test/helpers/mock_http_client.dart | 24 +- .../session/body_decode_service_test.dart | 5 +- .../test/session/body_store_test.dart | 176 ++++++ .../test/session/bulk_export_replay_test.dart | 112 ++++ .../session/core_engine_regressions_test.dart | 533 +++++++++++++++++- .../test/session/engine_search_test.dart | 21 + .../test/session/pending_fencing_test.dart | 243 ++++++++ .../realtime_epoch_regression_test.dart | 138 +++++ .../test/session/release_guard_test.dart | 148 +++++ .../test/session/search_service_test.dart | 7 + .../session/settings_propagation_test.dart | 51 ++ .../should_capture_robustness_test.dart | 116 ++++ .../test/session/ui_perf_regression_test.dart | 99 ++++ .../writer_lifecycle_regression_test.dart | 201 +++++++ ...ession_eviction_lifecycle_stress_test.dart | 32 +- .../detail/detail_page_large_body_test.dart | 12 +- .../ui/detail/realtime_messages_tab_test.dart | 66 +++ .../test/ui/detail/replay_handler_test.dart | 314 ++++++++++- .../network_tab_filter_integration_test.dart | 46 ++ .../test/ui/tabs/realtime_list_tab_test.dart | 31 + .../widgets/json_viewer/json_viewer_test.dart | 42 ++ 64 files changed, 4111 insertions(+), 389 deletions(-) create mode 100644 packages/interceptly/lib/src/engine/capture_generation.dart create mode 100644 packages/interceptly/test/export/postman_exporter_test.dart create mode 100644 packages/interceptly/test/session/body_store_test.dart create mode 100644 packages/interceptly/test/session/bulk_export_replay_test.dart create mode 100644 packages/interceptly/test/session/pending_fencing_test.dart create mode 100644 packages/interceptly/test/session/realtime_epoch_regression_test.dart create mode 100644 packages/interceptly/test/session/release_guard_test.dart create mode 100644 packages/interceptly/test/session/should_capture_robustness_test.dart create mode 100644 packages/interceptly/test/session/ui_perf_regression_test.dart create mode 100644 packages/interceptly/test/session/writer_lifecycle_regression_test.dart diff --git a/docs/development-guide/03-core-internals.md b/docs/development-guide/03-core-internals.md index 2d71290..6f736ae 100644 --- a/docs/development-guide/03-core-internals.md +++ b/docs/development-guide/03-core-internals.md @@ -67,6 +67,13 @@ Future startMasterSearch(String query); // Prevents race: clear() while records being written ``` +Fencing lifecycle: `_pendingGenerations` + `Expando` propagation via +`copyWith`, `clearSync`/`clear` generation bump, `_sentGenerations` drop in +`_handleEntryReady` (null means fenced and must drop). Policy is +`clear-wins`: writes racing clear are dropped. Double bump is intentional — +second fence drops entries recorded during `await writer.clear()` so +`engine.entries` is empty after `await clear()`. + --- ## 3.2 `CapturePipeline` & `WriterIsolate` — Off-Main-Thread Processing diff --git a/packages/interceptly/CHANGELOG.md b/packages/interceptly/CHANGELOG.md index 5e297df..1f992f4 100644 --- a/packages/interceptly/CHANGELOG.md +++ b/packages/interceptly/CHANGELOG.md @@ -1,3 +1,12 @@ +## Unreleased + +### Fixed +- Preserve capture generation across `HttpRequestContext.copyWith` so amended scopes stay fenced after `clear()`. +- Generation-fenced `clear`/`clearRest`/`clearRealtime` with per-scope futures; `clear-wins` linearization documented. +- Writer restart aliasing via `_StopAck` handshake with `preserveExisting` resume and rotation-count propagation. +- `getRequestDetail` reads beyond UI preview via full decode and propagates `hasDataLoss` to `isTruncated`; `totalChars` now measures full bodies (previously measured UI-preview truncation). +- Rotation sync for detail loader and search; active realtime notifier retention; bound restart queue. + ## 2.3.0 ### Added diff --git a/packages/interceptly/lib/src/adapter/http_request_context.dart b/packages/interceptly/lib/src/adapter/http_request_context.dart index bf78596..fb20e59 100644 --- a/packages/interceptly/lib/src/adapter/http_request_context.dart +++ b/packages/interceptly/lib/src/adapter/http_request_context.dart @@ -1,6 +1,6 @@ import 'dart:typed_data'; -import '../engine/capture_pipeline.dart'; +import '../engine/capture_generation.dart'; /// Immutable context tracking an ongoing HTTP request capture lifecycle. class HttpRequestContext { diff --git a/packages/interceptly/lib/src/adapter/http_traffic_collector.dart b/packages/interceptly/lib/src/adapter/http_traffic_collector.dart index dd74f2d..eb6a390 100644 --- a/packages/interceptly/lib/src/adapter/http_traffic_collector.dart +++ b/packages/interceptly/lib/src/adapter/http_traffic_collector.dart @@ -1,7 +1,7 @@ import 'package:flutter/foundation.dart'; import '../capture/capture_guard.dart'; -import '../engine/capture_pipeline.dart'; +import '../engine/capture_generation.dart'; import '../model/network_simulation.dart'; import '../model/raw_capture.dart'; import '../engine/interceptly_engine.dart'; @@ -117,7 +117,11 @@ class DefaultHttpTrafficCollector implements HttpTrafficCollector { if (uri == null) return true; final predicate = engine.settings.shouldCapture; if (predicate == null) return true; - return predicate(uri.toString()); + try { + return predicate(uri.toString()); + } catch (_) { + return true; + } } @override diff --git a/packages/interceptly/lib/src/adapter/realtime_session_scope.dart b/packages/interceptly/lib/src/adapter/realtime_session_scope.dart index 28fc7dc..13d01fc 100644 --- a/packages/interceptly/lib/src/adapter/realtime_session_scope.dart +++ b/packages/interceptly/lib/src/adapter/realtime_session_scope.dart @@ -91,6 +91,9 @@ class RealtimeSessionScope { }); } + /// Normalizes [data] once and forwards the computed preview and size + /// so [RealtimeTrafficCollector.recordFrame] hits its fast path without + /// renormalizing the payload. void _record(dynamic data, FrameDirection direction, {String? eventName}) { if (data == null) return; final normalized = FrameNormalizer.normalize( diff --git a/packages/interceptly/lib/src/adapter/realtime_traffic_collector.dart b/packages/interceptly/lib/src/adapter/realtime_traffic_collector.dart index 9a0bceb..3ed9848 100644 --- a/packages/interceptly/lib/src/adapter/realtime_traffic_collector.dart +++ b/packages/interceptly/lib/src/adapter/realtime_traffic_collector.dart @@ -108,7 +108,11 @@ class DefaultRealtimeTrafficCollector implements RealtimeTrafficCollector { if (uri == null) return true; final predicate = engine.settings.shouldCapture; if (predicate == null) return true; - return predicate(uri.toString()); + try { + return predicate(uri.toString()); + } catch (_) { + return true; + } } @override diff --git a/packages/interceptly/lib/src/capture/frame_normalizer.dart b/packages/interceptly/lib/src/capture/frame_normalizer.dart index a6c3610..9389de2 100644 --- a/packages/interceptly/lib/src/capture/frame_normalizer.dart +++ b/packages/interceptly/lib/src/capture/frame_normalizer.dart @@ -43,6 +43,7 @@ class FrameNormalizer { /// /// - Computes [originalSizeBytes] with 0 heap byte allocations for UTF-8 text via [utf8ByteLength]. /// - Avoids cloning [Uint8List] / [ByteData] buffers (uses [Uint8List.sublistView] when truncating). + /// - Caps generic [List] windows with take before copying to avoid full allocation. /// - Generates a safe Unicode [shortPreview] (<= 128 chars). /// - Flags [shouldOffload] when [originalSizeBytes] >= [offloadThreshold] and [offloadThreshold] > 0. /// - Pre-encodes [preEncodedBytes] once when [shouldOffload] is true, eliminating downstream re-encoding. @@ -85,9 +86,18 @@ class FrameNormalizer { } else if (raw is List && explicitType != FrameType.text) { type = explicitType ?? FrameType.binary; originalSize = raw.length; - final bytes = raw is Uint8List ? raw : Uint8List.fromList(raw); + final Uint8List bytes; + if (raw is Uint8List) { + bytes = raw; + } else if (maxBodyBytes > 0 && originalSize > maxBodyBytes) { + bytes = Uint8List.fromList(raw.take(maxBodyBytes).toList()); + } else { + bytes = Uint8List.fromList(raw); + } shouldOffload = offloadThreshold > 0 && originalSize >= offloadThreshold; - final capped = (maxBodyBytes > 0 && originalSize > maxBodyBytes) + final capped = (maxBodyBytes > 0 && + originalSize > maxBodyBytes && + bytes.length > maxBodyBytes) ? bytes.sublist(0, maxBodyBytes) : bytes; payload = capped; diff --git a/packages/interceptly/lib/src/core/interceptly_controller.dart b/packages/interceptly/lib/src/core/interceptly_controller.dart index 3a0c983..99f1b30 100644 --- a/packages/interceptly/lib/src/core/interceptly_controller.dart +++ b/packages/interceptly/lib/src/core/interceptly_controller.dart @@ -43,6 +43,15 @@ class Interceptly { Stream? customTrigger, InterceptlySettings? settings, }) { + /// Release guard evaluated before mutating the singleton, so a release + /// call without opt-in leaves existing engine settings untouched. + if (kReleaseMode) { + final allowInRelease = + settings?.allowInRelease ?? engine.settings.allowInRelease; + if (!allowInRelease) { + return Future.value(); + } + } if (settings != null) engine.configure(settings); return _init( diff --git a/packages/interceptly/lib/src/engine/body_decode_service.dart b/packages/interceptly/lib/src/engine/body_decode_service.dart index b54f6fe..c64ed0e 100644 --- a/packages/interceptly/lib/src/engine/body_decode_service.dart +++ b/packages/interceptly/lib/src/engine/body_decode_service.dart @@ -116,12 +116,14 @@ class BodyDecodeService { /// Returns a preview of [bytes] truncated to [previewLen] bytes, /// or `null` when [bytes] is null or empty. /// - /// If [maxBody] <= 0 (unlimited) or [bytes.length] <= [maxBody], - /// the full buffer is returned unchanged. + /// The retained window is capped by the smallest of [previewLen], + /// [maxBody] (when positive), and the buffer length, so a large preview + /// budget can never exceed the storage cap. static Uint8List? truncate(Uint8List? bytes, int maxBody, int previewLen) { if (bytes == null || bytes.isEmpty) return null; if (maxBody <= 0 || bytes.length <= maxBody) return bytes; - final end = previewLen < bytes.length ? previewLen : bytes.length; + var end = previewLen < bytes.length ? previewLen : bytes.length; + if (end > maxBody) end = maxBody; return bytes.sublist(0, end); } diff --git a/packages/interceptly/lib/src/engine/body_store.dart b/packages/interceptly/lib/src/engine/body_store.dart index ec86a75..af555c2 100644 --- a/packages/interceptly/lib/src/engine/body_store.dart +++ b/packages/interceptly/lib/src/engine/body_store.dart @@ -33,6 +33,9 @@ class BodyStore { /// Current byte offset in the active file. int get currentOffset => _currentOffset; + /// Whether the store currently holds an open, writable file handle. + bool get isWritable => _raf != null; + /// Maximum byte capacity before rotation occurs. int get maxFileBytes => _maxFileBytes; @@ -78,22 +81,35 @@ class BodyStore { /// the file is automatically rotated (truncated to 0 bytes) and the offset resets to 0. /// Returns the (offset, length) needed to read the data back later. Future<(int, int)> append(Uint8List bytes) async { - assert(_raf != null, 'initForWrite() must be called first'); + if (_raf == null) { + throw const FileSystemException('BodyStore has no open file handle'); + } if (_currentOffset + bytes.length > _maxFileBytes) { - await rotate(); + final ok = await rotate(); + if (!ok) { + throw const FileSystemException('Failed to rotate BodyStore'); + } + } + final raf = _raf; + if (raf == null) { + throw const FileSystemException('BodyStore has no open file handle'); } final offset = _currentOffset; final length = bytes.length; - await _raf!.writeFrom(bytes); + await raf.writeFrom(bytes); _currentOffset += length; return (offset, length); } /// Closes the current file and recreates it empty at offset 0. - Future rotate() async { - _currentOffset = 0; - _rotationCount++; - onRotate?.call(_rotationCount); + /// + /// Returns `true` when the file was recreated and the rotation counter + /// advanced, `false` when recreation failed and the previous dead-handle + /// state was left untouched. + /// + /// When this returns `false` the store is left unusable (no open handle) + /// until [resetFile] or [initForWrite] reopens it. + Future rotate() async { try { await _raf?.flush(); } catch (_) {} @@ -105,23 +121,83 @@ class BodyStore { } await _file.create(recursive: true); _raf = await _file.open(mode: FileMode.writeOnlyAppend); - } catch (_) {} - } - - /// Resets the file: deletes and recreates it. - Future resetFile(String dirPath, {String fileName = kFileName}) async { + } catch (_) { + return false; + } _currentOffset = 0; _rotationCount++; onRotate?.call(_rotationCount); + return true; + } + + /// Resets the file: deletes and recreates it at [dirPath]/[fileName]. + /// + /// Returns `true` when the file was recreated and the rotation counter + /// advanced, `false` when recreation failed. + /// + /// When recreation fails, if the failure was non-destructive (e.g. attempting + /// to switch to an invalid directory while the old file remains intact at its + /// current length), the previous handle is restored and the store remains writable. + /// + /// If the failure was destructive (e.g. same-path deletion succeeded or old + /// file was modified/missing), the store enters a degraded state with rotation + /// advanced to invalidate stale offsets. + Future resetFile(String dirPath, {String fileName = kFileName}) async { + File? oldFile; + final oldOffset = _currentOffset; + try { + oldFile = _file; + } catch (_) { + oldFile = null; + } + final targetPath = '$dirPath/$fileName'; + final isSamePath = oldFile != null && oldFile.path == targetPath; + try { await _raf?.flush(); } catch (_) {} await _raf?.close(); _raf = null; - _file = File('$dirPath/$fileName'); - if (await _file.exists()) await _file.delete(); - await _file.create(recursive: true); - _raf = await _file.open(mode: FileMode.writeOnlyAppend); + + final targetFile = File(targetPath); + try { + if (await targetFile.exists()) { + await targetFile.delete(); + } + await targetFile.create(recursive: true); + _raf = await targetFile.open(mode: FileMode.writeOnlyAppend); + _file = targetFile; + _currentOffset = 0; + _rotationCount++; + onRotate?.call(_rotationCount); + return true; + } catch (_) { + if (oldFile == null) { + return false; + } + + // Attempt rollback to intact previous file if path was different + if (!isSamePath) { + try { + if (await oldFile.exists()) { + final oldLen = await oldFile.length(); + if (oldLen == oldOffset) { + _raf = await oldFile.open(mode: FileMode.writeOnlyAppend); + _file = oldFile; + _currentOffset = oldOffset; + return false; + } + } + } catch (_) {} + } + + // Destructive or unrecoverable failure: advance rotation to invalidate + // stale offsets and remain in degraded state (no open RAF). + _currentOffset = 0; + _rotationCount++; + onRotate?.call(_rotationCount); + return false; + } } /// Flushes and closes the open file handle without deleting the backing file. diff --git a/packages/interceptly/lib/src/engine/bounded_event_queue.dart b/packages/interceptly/lib/src/engine/bounded_event_queue.dart index e3054cc..fa1bb40 100644 --- a/packages/interceptly/lib/src/engine/bounded_event_queue.dart +++ b/packages/interceptly/lib/src/engine/bounded_event_queue.dart @@ -27,19 +27,33 @@ class BoundedEventQueue { int get droppedCount => _droppedCount; bool get isEmpty => _items.isEmpty; + /// Resets the overflow drop counter without discarding queued items. + /// + /// Used after folding [droppedCount] into an owner counter so subsequent + /// reads do not double-count previously reported drops. + void resetDroppedCount() { + _droppedCount = 0; + } + /// Enqueues [value], dropping the oldest entry when full. /// + /// Returns the evicted element when an overflow drop occurred, or `null` + /// when the queue had capacity. Callers that ignore the return value + /// remain source-compatible. + /// /// A non-positive [maxSize] (only reachable via direct field mutation) /// is clamped back to 1 instead of throwing. - void add(T value) { + T? add(T value) { if (maxSize < 1) { maxSize = 1; } + T? evicted; if (_items.length >= maxSize) { - _items.removeFirst(); + evicted = _items.removeFirst(); _droppedCount++; } _items.addLast(value); + return evicted; } T? removeFirstOrNull() { diff --git a/packages/interceptly/lib/src/engine/capture_generation.dart b/packages/interceptly/lib/src/engine/capture_generation.dart new file mode 100644 index 0000000..35f7c29 --- /dev/null +++ b/packages/interceptly/lib/src/engine/capture_generation.dart @@ -0,0 +1,22 @@ +import 'package:flutter/foundation.dart'; + +/// Pipeline generation fencing helpers. +/// +/// Extracted to an io-free file so adapters importing +/// `HttpRequestContext` do not pull `dart:io` or `path_provider` +/// transitively. Must never be barrel-exported. +final Expando _captureGenerationExpando = Expando( + 'captureGeneration', +); + +/// Associates a capture lifecycle object with the active pipeline [generation]. +@internal +void attachCaptureGeneration(Object target, int? generation) { + if (generation != null) { + _captureGenerationExpando[target] = generation; + } +} + +/// Reads the pipeline generation previously attached to [target], if any. +@internal +int? readCaptureGeneration(Object target) => _captureGenerationExpando[target]; diff --git a/packages/interceptly/lib/src/engine/capture_pipeline.dart b/packages/interceptly/lib/src/engine/capture_pipeline.dart index 08b8612..111abdd 100644 --- a/packages/interceptly/lib/src/engine/capture_pipeline.dart +++ b/packages/interceptly/lib/src/engine/capture_pipeline.dart @@ -9,23 +9,9 @@ import '../model/interceptly_settings.dart'; import '../model/raw_capture.dart'; import 'body_store.dart'; import 'bounded_event_queue.dart'; +import 'capture_generation.dart'; import 'writer_isolate.dart'; -final Expando _captureGenerationExpando = - Expando('captureGeneration'); - -/// Associates a capture lifecycle object with the active pipeline [generation]. -@internal -void attachCaptureGeneration(Object target, int? generation) { - if (generation != null) { - _captureGenerationExpando[target] = generation; - } -} - -/// Reads the pipeline generation previously attached to [target], if any. -@internal -int? readCaptureGeneration(Object target) => _captureGenerationExpando[target]; - /// Pipeline managing pre-initialization queueing, asynchronous isolate writing, /// and generation-fenced entry ingestion. @internal @@ -92,9 +78,13 @@ class CapturePipeline { String? get tempFilePath => _tempFilePath; /// Number of captures dropped due to queue saturation or isolate failure. + /// + /// Folds the pre-init queue overflow counter and the writer restart + /// overflow counter so saturation is visible in one place. int get droppedCount => _droppedCount + - (_initialized || isDegraded ? 0 : _preInitQueue.droppedCount); + _preInitQueue.droppedCount + + _writerIsolate.restartDroppedCount; /// Number of captures currently dispatched to the writer isolate. int get inFlight => _inFlight; @@ -138,7 +128,6 @@ class CapturePipeline { final dir = await getTemporaryDirectory(); if (_isDisposed) return; dirPath = dir.path; - _tempFilePath = '$dirPath/$fileName'; await BodyStore.purgeOrphanedTempFiles( dirPath, @@ -150,6 +139,8 @@ class CapturePipeline { dirPath = Directory.systemTemp.path; } + _tempFilePath = '$dirPath/$fileName'; + await _writerIsolate.start(tempDirPath: dirPath, fileName: fileName); if (_isDisposed) { await _writerIsolate.dispose(); @@ -161,8 +152,10 @@ class CapturePipeline { _resultSub = _writerIsolate.results.listen(_handleEntryReady); _eventSub = _writerIsolate.events.listen(_handleIsolateEvent); _initialized = true; + _initFailed = false; _droppedCount += _preInitQueue.droppedCount; + _preInitQueue.resetDroppedCount(); while (true) { final pending = _preInitQueue.removeFirstOrNull(); if (pending == null) break; @@ -171,6 +164,7 @@ class CapturePipeline { } catch (error, stackTrace) { if (_isDisposed) return; _initFailed = true; + _initFuture = null; _drainPreInitQueueToFallback(); if (kDebugMode) { debugPrint( @@ -183,6 +177,7 @@ class CapturePipeline { void _drainPreInitQueueToFallback() { if (!_initialized) { _droppedCount += _preInitQueue.droppedCount; + _preInitQueue.resetDroppedCount(); } while (true) { final pending = _preInitQueue.removeFirstOrNull(); @@ -212,6 +207,10 @@ class CapturePipeline { void _handleIsolateEvent(WriterIsolateEvent event) { if (_isDisposed || _isRespawning || _fallbackMode) return; + _droppedCount += _inFlight; + for (final id in _sentGenerations.keys) { + _onEntryReceived?.call(id); + } _inFlight = 0; _inFlightBytes = 0; _sentBytes.clear(); @@ -237,8 +236,9 @@ class CapturePipeline { fileName: fileName, ); if (_isDisposed) return; - _isRespawning = false; + _droppedCount += _preInitQueue.droppedCount; + _preInitQueue.resetDroppedCount(); while (true) { final pending = _preInitQueue.removeFirstOrNull(); if (pending == null) break; @@ -283,13 +283,19 @@ class CapturePipeline { } if (!_initialized) { - _preInitQueue.add(capture); + final evicted = _preInitQueue.add(capture); + if (evicted != null) { + _onEntryReceived?.call(evicted.id); + } initialize(); return; } if (_isRespawning || !_writerIsolate.isReady) { - _preInitQueue.add(capture); + final evicted = _preInitQueue.add(capture); + if (evicted != null) { + _onEntryReceived?.call(evicted.id); + } return; } @@ -304,6 +310,7 @@ class CapturePipeline { if (_inFlight >= _settings.maxQueuedEvents || _inFlightBytes >= maxInFlightBytes) { _droppedCount++; + _onEntryReceived?.call(capture.id); return; } final captureBytes = _computeCaptureBytes(capture); @@ -319,10 +326,18 @@ class CapturePipeline { _inFlightBytes = rem > 0 ? rem : 0; _sentBytes.remove(capture.id); _sentGenerations.remove(capture.id); - _preInitQueue.add(capture); + final evicted = _preInitQueue.add(capture); + if (evicted != null) { + _onEntryReceived?.call(evicted.id); + } } } + /// Handles a decoded entry from the writer isolate. + /// + /// Strict fence: a missing generation means the entry was fenced by + /// clear/respawn and must drop. Do NOT relax to a nullable check — + /// that resurrects pre-clear entries. void _handleEntryReady(IndexEntry entry) { if (_inFlight > 0) { _inFlight--; @@ -344,10 +359,23 @@ class CapturePipeline { _onEntryReady(entry); } - /// Clears queue and counters synchronously when isolate is not initialized. + /// Clears queued and in-flight items, fencing stale entries by generation. + /// + /// Queued overflow drops and restart overflow drops are folded into + /// [_droppedCount] before the queues are discarded, and every discarded + /// entry notifies [_onEntryReceived] so pending timers never fire as + /// phantom timeouts. The cumulative drop counter is preserved across + /// clears as a lifetime diagnostic. void clearSync() { + _droppedCount += _preInitQueue.droppedCount; + _droppedCount += _writerIsolate.restartDroppedCount; + while (true) { + final pending = _preInitQueue.removeFirstOrNull(); + if (pending == null) break; + _onEntryReceived?.call(pending.id); + } _preInitQueue.clear(); - _droppedCount = 0; + _writerIsolate.resetRestartDroppedCount(); _generation++; _sentGenerations.clear(); _sentBytes.clear(); @@ -356,6 +384,10 @@ class CapturePipeline { } /// Clears in-flight and queued items, bumping generation to drop stale entries. + /// + /// `clear-wins` linearization: writes racing clear are fenced and dropped. + /// Double bump is intentional — second fence drops entries recorded during + /// `await writer.clear()` so `engine.entries` is empty after `await clear()`. Future clear() async { clearSync(); if (_initialized && !_fallbackMode) { @@ -365,8 +397,20 @@ class CapturePipeline { } /// Disposes background writer isolate and associated stream subscriptions. + /// + /// Fences a concurrent [initialize] with a generation bump and awaits the + /// in-flight init (if any) before teardown, so late init continuations + /// cannot resurrect subscriptions or mark the pipeline initialized. Future dispose() async { + if (_isDisposed) return; _isDisposed = true; + _generation++; + final pendingInit = _initFuture; + if (pendingInit != null) { + try { + await pendingInit; + } catch (_) {} + } _preInitQueue.clear(); await _eventSub?.cancel(); _eventSub = null; diff --git a/packages/interceptly/lib/src/engine/detail_loader.dart b/packages/interceptly/lib/src/engine/detail_loader.dart index e20ae67..314d58c 100644 --- a/packages/interceptly/lib/src/engine/detail_loader.dart +++ b/packages/interceptly/lib/src/engine/detail_loader.dart @@ -40,6 +40,10 @@ class DetailLoader { } /// Loads request details for the given index [entry]. + /// + /// Snapshots the file rotation before disk reads and revalidates after + /// each await; a rotation mid-read yields an expired placeholder instead + /// of new-session bytes aliased as the old entry. Future loadDetailByEntry(IndexEntry entry) async { String? reqPreview; String? resPreview; @@ -55,10 +59,10 @@ class DetailLoader { final offset = entry.fileOffset; final length = entry.fileLength; final filePath = _tempFilePathProvider(); - final currentRotation = _fileRotationCountProvider?.call(); + final rotBefore = _fileRotationCountProvider?.call(); final isExpiredByRotation = entry.fileRotationCount != null && - currentRotation != null && - entry.fileRotationCount! < currentRotation; + rotBefore != null && + entry.fileRotationCount! < rotBefore; if (!isExpiredByRotation && offset != null && @@ -66,6 +70,16 @@ class DetailLoader { filePath != null) { try { final raw = await BodyStore.readBytes(filePath, offset, length); + final rotAfter = _fileRotationCountProvider?.call(); + if (rotBefore != null && rotAfter != null && rotBefore != rotAfter) { + throw const FileSystemException('rotated mid-read'); + } + final isExpiredAfter = entry.fileRotationCount != null && + rotAfter != null && + entry.fileRotationCount! < rotAfter; + if (isExpiredAfter) { + throw const FileSystemException('rotated mid-read'); + } if (raw.length < length) { throw const FileSystemException('Truncated body read'); } diff --git a/packages/interceptly/lib/src/engine/interceptly_engine.dart b/packages/interceptly/lib/src/engine/interceptly_engine.dart index 69efc5a..2fa4724 100644 --- a/packages/interceptly/lib/src/engine/interceptly_engine.dart +++ b/packages/interceptly/lib/src/engine/interceptly_engine.dart @@ -13,6 +13,7 @@ import '../model/request_record.dart'; import '../model/request_summary.dart'; import '../model/search_result.dart'; import '../simulation/network_simulation_service.dart'; +import 'capture_generation.dart'; import 'capture_pipeline.dart'; import 'detail_loader.dart'; import 'memory_index.dart'; @@ -67,6 +68,16 @@ class InterceptlyEngine extends ChangeNotifier { return '${time}_$seq'; } + /// Release-guard decision shared by the engine gates and tests. + /// + /// Returns `false` only when [isReleaseMode] is true and the caller has + /// not opted in via [InterceptlySettings.allowInRelease]. + static bool shouldEnableCapture({ + required bool isReleaseMode, + required InterceptlySettings settings, + }) => + !(isReleaseMode && !settings.allowInRelease); + static InterceptlyEngine? _instance; /// Returns the shared default engine singleton. @@ -92,6 +103,8 @@ class InterceptlyEngine extends ChangeNotifier { NetworkSimulationProfile _networkSimulation = NetworkSimulationProfile.none; Future? _clearFuture; + Future? _clearRestFuture; + Future? _clearRealtimeFuture; bool _enabled = true; bool _isDisposed = false; int _entriesVersion = 0; @@ -115,7 +128,13 @@ class InterceptlyEngine extends ChangeNotifier { List get recentChangedEntries => List.unmodifiable(_recentChangedEntries); - bool get isEnabled => _enabled; + bool get isEnabled => + !_isDisposed && + _enabled && + shouldEnableCapture( + isReleaseMode: kReleaseMode, + settings: _settings, + ); int get droppedCount => _capturePipeline.droppedCount; @@ -157,6 +176,11 @@ class InterceptlyEngine extends ChangeNotifier { super.notifyListeners(); } + /// Applies [newSettings] without changing the user's capture intent. + /// + /// Release permission is evaluated dynamically by [isEnabled], so + /// `configure` never resumes an explicitly disabled engine. A caller that + /// wants to resume capture must call [enable] explicitly. void _applySettings(InterceptlySettings newSettings) { _settings = newSettings; _capturePipeline.updateSettings(newSettings); @@ -174,7 +198,19 @@ class InterceptlyEngine extends ChangeNotifier { _applySettings(newSettings); /// Initializes background workers and disk storage. + /// + /// Worker creation is gated by release permission, not by the user's pause + /// intent: a paused debug/profile engine may still be initialized + /// explicitly, while a denied release engine must not create files or + /// isolates. Repeating the call after opt-in retries initialization. Future initialize() async { + if (_isDisposed || + !shouldEnableCapture( + isReleaseMode: kReleaseMode, + settings: _settings, + )) { + return; + } await _capturePipeline.initialize(); if (_settings.persistToDisk) { await _realtimeStore.ensureDiskStorageInitialized( @@ -224,7 +260,11 @@ class InterceptlyEngine extends ChangeNotifier { _recentChangedEntries.clear(); } - /// Enables traffic capture. + /// Enables traffic capture intent. + /// + /// This never grants release permission: [isEnabled] still composes the + /// intent with [shouldEnableCapture]. In a denied release build the call + /// records the intent for a later opt-in but captures nothing. void enable() { if (_isDisposed || _enabled) return; _enabled = true; @@ -232,6 +272,8 @@ class InterceptlyEngine extends ChangeNotifier { } /// Disables traffic capture, dropping new captures silently. + /// + /// The explicit pause survives [configure] until [enable] is called. void disable() { if (!_enabled) return; _enabled = false; @@ -272,6 +314,20 @@ class InterceptlyEngine extends ChangeNotifier { chunkBytes, ); + /// Evaluates the user capture predicate fail-open. + /// + /// A throwing predicate never breaks the host request; the capture + /// proceeds as if allowed. + bool _predicateAllows(String url) { + final predicate = _settings.shouldCapture; + if (predicate == null) return true; + try { + return predicate(url); + } catch (_) { + return true; + } + } + /// Enqueues [capture] for asynchronous background processing. void record(RawCapture capture) { final pendingGen = _pendingGenerations.remove(capture.id); @@ -280,14 +336,8 @@ class InterceptlyEngine extends ChangeNotifier { return; } - final isPending = _memoryIndex.byId(capture.id)?.statusCode == 0; - if (!isPending) { - if (!_enabled) return; - if (_settings.shouldCapture != null && - !_settings.shouldCapture!(capture.url)) { - return; - } - } + if (!isEnabled) return; + if (!_predicateAllows(capture.url)) return; if (effectiveGen != null) { attachCaptureGeneration(capture, effectiveGen); } @@ -304,8 +354,8 @@ class InterceptlyEngine extends ChangeNotifier { Uint8List? requestBodyBytes, String? requestContentType, }) { - if (!_enabled) return; - if (_settings.shouldCapture != null && !_settings.shouldCapture!(url)) { + if (!isEnabled) return; + if (!_predicateAllows(url)) { return; } _recordPendingEntry( @@ -353,7 +403,7 @@ class InterceptlyEngine extends ChangeNotifier { /// Records a captured [RealtimeFrame] into the buffer. Future recordRealtimeFrame(RealtimeFrame frame) { - if (!_enabled || _isDisposed) return Future.value(); + if (!isEnabled) return Future.value(); final activeSession = _activeRealtimeSessions[frame.sessionId]; if (activeSession != null) { if (!activeSession.isCaptured) return Future.value(); @@ -401,6 +451,9 @@ class InterceptlyEngine extends ChangeNotifier { RealtimeTrafficCollector.instance.sendMessage(sessionId, draft); /// Records opening of a realtime connection. + /// + /// Defensive copy: retention outlives caller map lifetime; also bounds + /// active sessions to avoid unbounded growth when close is never called. void recordRealtimeConnection({ required String id, required String url, @@ -409,19 +462,21 @@ class InterceptlyEngine extends ChangeNotifier { bool isPreValidated = false, RealtimeProtocolType protocolType = RealtimeProtocolType.webSocket, }) { - if (!_enabled || _isDisposed) return; + if (!isEnabled) return; final effectiveTimestamp = timestamp ?? DateTime.now(); - final isCaptured = isPreValidated || - _settings.shouldCapture == null || - _settings.shouldCapture!(url); + final isCaptured = isPreValidated || _predicateAllows(url); final active = _ActiveRealtimeSession( id: id, url: url, - headers: headers, + headers: Map.unmodifiable(Map.of(headers)), timestamp: effectiveTimestamp, protocolType: protocolType, isCaptured: isCaptured, ); + if (_activeRealtimeSessions.length >= _settings.maxEntries * 2 && + !_activeRealtimeSessions.containsKey(id)) { + _activeRealtimeSessions.remove(_activeRealtimeSessions.keys.first); + } _activeRealtimeSessions[id] = active; if (!isCaptured) { return; @@ -432,7 +487,7 @@ class InterceptlyEngine extends ChangeNotifier { method: active.method, url: url, timestamp: effectiveTimestamp, - requestHeaders: headers, + requestHeaders: active.headers, ); } @@ -487,12 +542,15 @@ class InterceptlyEngine extends ChangeNotifier { String statusFilter = 'all', String? sinceId, int limit = 20, + int? Function()? fileRotationCountProvider, }) { return SearchService.search( entries: _memoryIndex.entries.toList(), query: query, filePath: _capturePipeline.tempFilePath, fileRotationCount: _capturePipeline.fileRotationCount, + fileRotationCountProvider: + fileRotationCountProvider ?? () => _capturePipeline.fileRotationCount, searchBody: searchBody, statusFilter: statusFilter, sinceId: sinceId, @@ -506,104 +564,101 @@ class InterceptlyEngine extends ChangeNotifier { _memoryIndex.add(entry); } - Future clear() async { + /// Clears all entries, coalescing concurrent calls to an identical future. + Future clear() { if (_clearFuture != null) return _clearFuture!; final future = _performClear(); _clearFuture = future; - try { - await future; - } finally { + future.whenComplete(() { if (identical(_clearFuture, future)) _clearFuture = null; - } + }).ignore(); + return future; } - Future clearRest() async { - if (_clearFuture != null) return _clearFuture!; + /// Clears REST entries only, coalescing concurrent calls to an identical future. + Future clearRest() { + if (_clearRestFuture != null) return _clearRestFuture!; final future = _performScopedClear(isRealtime: false); - _clearFuture = future; - try { - await future; - } finally { - if (identical(_clearFuture, future)) _clearFuture = null; - } + _clearRestFuture = future; + future.whenComplete(() { + if (identical(_clearRestFuture, future)) _clearRestFuture = null; + }).ignore(); + return future; } - Future clearRealtime() async { - if (_clearFuture != null) return _clearFuture!; + /// Clears realtime entries only, coalescing concurrent calls to an identical future. + Future clearRealtime() { + if (_clearRealtimeFuture != null) return _clearRealtimeFuture!; final future = _performScopedClear(isRealtime: true); - _clearFuture = future; - try { - await future; - } finally { - if (identical(_clearFuture, future)) _clearFuture = null; - } + _clearRealtimeFuture = future; + future.whenComplete(() { + if (identical(_clearRealtimeFuture, future)) _clearRealtimeFuture = null; + }).ignore(); + return future; } /// Removes entries of one scope while keeping the other intact. Future _performScopedClear({required bool isRealtime}) async { - try { - _coalesceNotifyTimer?.cancel(); - _coalesceNotifyTimer = null; - _hasPendingCoalescedNotify = false; - _entriesVersion++; - _recentChangedEntries.clear(); - final ids = _memoryIndex.entries - .where((e) => e.isRealtime == isRealtime) - .map((e) => e.id) - .toList(); - if (!isRealtime) { - if (_capturePipeline.isInitialized) { - await _capturePipeline.clear(); - } else { - _capturePipeline.clearSync(); - } - } - for (final id in ids) { - _pendingTracker.cancel(id); - } - _memoryIndex.removeWhere((e) => e.isRealtime == isRealtime); - if (isRealtime) { - _realtimeStore.clear(); - } - if (!_isDisposed) { - notifyListeners(); - } - if (isRealtime) { - await _realtimeStore.resetDiskStorage(); - } - } finally { - _clearFuture = null; - } - } - - Future _performClear() async { - try { - _coalesceNotifyTimer?.cancel(); - _coalesceNotifyTimer = null; - _hasPendingCoalescedNotify = false; - _entriesVersion++; - _recentChangedEntries.clear(); + _coalesceNotifyTimer?.cancel(); + _coalesceNotifyTimer = null; + _hasPendingCoalescedNotify = false; + _entriesVersion++; + _recentChangedEntries.clear(); + final ids = _memoryIndex.entries + .where((e) => e.isRealtime == isRealtime) + .map((e) => e.id) + .toList(); + if (!isRealtime) { if (_capturePipeline.isInitialized) { await _capturePipeline.clear(); } else { _capturePipeline.clearSync(); } - _memoryIndex.clear(); - _pendingTracker.clear(); + } + for (final id in ids) { + _pendingTracker.cancel(id); + } + _memoryIndex.removeWhere((e) => e.isRealtime == isRealtime); + if (isRealtime) { _realtimeStore.clear(); - if (!_isDisposed) { - notifyListeners(); - } + } + if (!_isDisposed) { + notifyListeners(); + } + if (isRealtime) { await _realtimeStore.resetDiskStorage(); - } finally { - _clearFuture = null; } } + Future _performClear() async { + _coalesceNotifyTimer?.cancel(); + _coalesceNotifyTimer = null; + _hasPendingCoalescedNotify = false; + _entriesVersion++; + _recentChangedEntries.clear(); + if (_capturePipeline.isInitialized) { + await _capturePipeline.clear(); + } else { + _capturePipeline.clearSync(); + } + _memoryIndex.clear(); + _pendingTracker.clear(); + _realtimeStore.clear(); + _pendingGenerations.clear(); + if (!_isDisposed) { + notifyListeners(); + } + await _realtimeStore.resetDiskStorage(); + } + + /// Disposes background workers and storage, idempotent on repeat calls. + /// + /// Mirrors [CapturePipeline.dispose] early-return semantics: a second call + /// is a no-op so test teardown and [resetForTesting] never throw. @override Future dispose() async { if (_isDisposed) { - throw StateError('InterceptlyEngine has already been disposed.'); + return; } _isDisposed = true; if (identical(_instance, this)) { diff --git a/packages/interceptly/lib/src/engine/realtime_body_store.dart b/packages/interceptly/lib/src/engine/realtime_body_store.dart index bc931e5..250ab95 100644 --- a/packages/interceptly/lib/src/engine/realtime_body_store.dart +++ b/packages/interceptly/lib/src/engine/realtime_body_store.dart @@ -143,17 +143,58 @@ class RealtimeBodyStore { } /// Reads [length] bytes starting at [offset] from the store file. - Future readBytes(int offset, int length) async { + /// + /// Enqueued on the FIFO [_writeChain] to avoid torn reads during + /// concurrent rotate or reset operations. Captures the rotation count + /// at call time and fails with a [FileSystemException] when the file + /// rotates before or during the read so callers can emit an expired + /// placeholder instead of new-session bytes. + Future readBytes(int offset, int length) { if (!_isInitialized || _isDisposed) { throw StateError('RealtimeBodyStore is not initialized.'); } - final readRaf = await _file.open(mode: FileMode.read); - try { - await readRaf.setPosition(offset); - return await readRaf.read(length); - } finally { - await readRaf.close(); + final rotBefore = _rotationCount; + final completer = Completer(); + Future readOp(_) async { + try { + if (_rotationCount != rotBefore) { + completer.completeError( + const FileSystemException('rotated mid-read'), + ); + return; + } + if (!_isInitialized || _isDisposed || _raf == null) { + completer.completeError( + StateError('RealtimeBodyStore is not initialized.'), + ); + return; + } + final readRaf = await _file.open(mode: FileMode.read); + try { + await readRaf.setPosition(offset); + final data = await readRaf.read(length); + if (_rotationCount != rotBefore) { + completer.completeError( + const FileSystemException('rotated mid-read'), + ); + return; + } + completer.complete(data); + } finally { + await readRaf.close(); + } + } catch (error, stackTrace) { + completer.completeError(error, stackTrace); + } } + + final prev = _writeChain; + if (prev == null) { + _writeChain = readOp(null); + } else { + _writeChain = prev.then(readOp); + } + return completer.future; } /// Explicitly rotates the store file, resetting offset to 0 and incrementing [rotationCount]. @@ -194,7 +235,8 @@ class RealtimeBodyStore { } catch (_) {} } - /// Resets the file on disk: deletes, recreates, and resets offset/rotation. + /// Resets the file on disk: deletes, recreates, and resets offset while + /// advancing the rotation generation so prior offsets never alias new bytes. Future resetFile(String dirPath, {String? fileName}) { final completer = Completer(); Future resetOp(_) async { @@ -205,7 +247,7 @@ class RealtimeBodyStore { await _raf?.close(); _raf = null; _currentOffset = 0; - _rotationCount = 0; + _rotationCount++; final effectiveName = fileName ?? _file.path.split(RegExp(r'[/\\]')).last; _file = File('$dirPath/$effectiveName'); diff --git a/packages/interceptly/lib/src/engine/realtime_store.dart b/packages/interceptly/lib/src/engine/realtime_store.dart index 6da2361..0afb99b 100644 --- a/packages/interceptly/lib/src/engine/realtime_store.dart +++ b/packages/interceptly/lib/src/engine/realtime_store.dart @@ -419,6 +419,10 @@ class RealtimeStore { } /// Loads full payload for [frame] asynchronously from disk or memory. + /// + /// Snapshots the body store generation before disk reads and revalidates + /// afterwards; a rotation or reset mid-read returns an expired placeholder + /// instead of new-session bytes aliased as the old frame. Future loadPayload(RealtimeFrame frame) async { if (frame.location == BodyLocation.memory) { if (frame.type == FrameType.text) { @@ -443,12 +447,26 @@ class RealtimeStore { } try { + final rotBefore = _bodyStore.rotationCount; final bytes = await _bodyStore.readBytes(offset, length); + final rotAfter = _bodyStore.rotationCount; + if (frame.rotationCount != null && frame.rotationCount! < rotAfter) { + return '[Payload expired due to file rotation]'; + } + if (rotBefore != rotAfter) { + return '[Payload expired due to file rotation]'; + } + if (bytes.length < length) { + return '[Payload unavailable: truncated body store]'; + } if (frame.type == FrameType.text) { return utf8.decode(bytes, allowMalformed: true); } return bytes; } catch (e) { + if (e is FileSystemException && e.message.contains('rotated')) { + return '[Payload expired due to file rotation]'; + } return '[Error loading payload: $e]'; } } @@ -539,6 +557,9 @@ class RealtimeStore { } /// Returns the per-session [ValueListenable] for reactive frame count updates. + /// + /// Keeps [_liveCountsBySession] when the buffer still exists so a future + /// notifier reseeds with correct counts; only drops the dirty flag. ValueListenable getFrameCountsNotifier( String sessionId) { final existing = _notifiersBySession[sessionId]; @@ -553,6 +574,7 @@ class RealtimeStore { .toList(); for (final key in orphanKeys) { _liveCountsBySession.remove(key); + _dirtyNotifierSessions.remove(key); _notifiersBySession.remove(key)?.dispose(); } final inactiveKeys = _notifiersBySession.entries @@ -561,6 +583,7 @@ class RealtimeStore { .toList(); for (final key in inactiveKeys) { if (_notifiersBySession.length < _maxSessions) break; + _dirtyNotifierSessions.remove(key); _notifiersBySession.remove(key)?.dispose(); } } @@ -573,6 +596,10 @@ class RealtimeStore { return created; } + /// Handles removal of the last listener on the [sessionId] notifier. + /// + /// When the buffer is still live, keeps counts for reseed and drops + /// the dirty flag only. void _onNotifierAllListenersRemoved(String sessionId) { if (_isDisposed) return; final notifier = _notifiersBySession[sessionId]; @@ -583,6 +610,7 @@ class RealtimeStore { _notifiersBySession.remove(sessionId); notifier.dispose(); } else if (_notifiersBySession.length > _maxSessions) { + _dirtyNotifierSessions.remove(sessionId); _notifiersBySession.remove(sessionId); notifier.dispose(); } diff --git a/packages/interceptly/lib/src/engine/search_service.dart b/packages/interceptly/lib/src/engine/search_service.dart index 0d5ea07..1dede1d 100644 --- a/packages/interceptly/lib/src/engine/search_service.dart +++ b/packages/interceptly/lib/src/engine/search_service.dart @@ -22,6 +22,15 @@ enum SearchPhase { /// Unified, self-contained search engine encapsulating multi-phase progressive search, /// structured metadata matching, binary guards, and batched disk I/O with isolate offloading. abstract final class SearchService { + /// Test-only override for [matchInlineBodyAsync]. + /// + /// When set, [matchInlineBodyAsync] delegates to this hook instead of + /// running isolate offloading, allowing tests to force per-entry + /// success or failure deterministically. + @visibleForTesting + static Future Function(IndexEntry entry, String queryLower)? + matchInlineBodyAsyncOverride; + /// Executes a multi-phase progressive search over [entries]. /// /// - Phase 1: Structured metadata matching (URL, method, headers, error details). @@ -31,11 +40,16 @@ abstract final class SearchService { /// Callers may supply [onProgress] to receive progressive result updates, /// [onPhaseChange] to track the active scanning phase, and [isCancelled] /// to abort promptly without leaking resources. + /// + /// [fileRotationCountProvider] optionally supplies the live file generation; + /// when present each batch revalidates entries after awaits so a clear or + /// rotation mid-search yields no new-session bytes aliased as old entries. static Future> search({ required List entries, required String query, String? filePath, int? fileRotationCount, + int? Function()? fileRotationCountProvider, bool searchBody = true, int? limit, String? sinceId, @@ -68,7 +82,7 @@ abstract final class SearchService { final index = candidates.indexWhere((e) => e.id == sinceId); if (index > 0) { candidates = candidates.sublist(0, index); - } else if (index == 0) { + } else { onPhaseChange?.call(SearchPhase.done); onProgress?.call(const [], isDone: true); return const []; @@ -194,12 +208,17 @@ abstract final class SearchService { if (filePath != null) { onPhaseChange?.call(SearchPhase.fileBodies); + int? currentRotation() => + fileRotationCountProvider?.call() ?? fileRotationCount; + + bool isExpired(IndexEntry e, int? rotation) => + rotation != null && + e.fileRotationCount != null && + e.fileRotationCount! < rotation; + final fileCandidates = []; for (final e in candidates) { - final isExpiredByRotation = fileRotationCount != null && - e.fileRotationCount != null && - e.fileRotationCount! < fileRotationCount; - if (!isExpiredByRotation && + if (!isExpired(e, currentRotation()) && e.bodyLocation == BodyLocation.file && !matchedIds.contains(e.id) && e.fileOffset != null && @@ -214,6 +233,7 @@ abstract final class SearchService { RandomAccessFile? raf; try { raf = await BodyStore.openReadHandle(filePath); + final rotAfterOpen = currentRotation(); final batchSize = fileCandidates.length > 100 ? 32 : 16; for (var start = 0; start < fileCandidates.length; @@ -229,12 +249,26 @@ abstract final class SearchService { final batchPayloads = <(IndexEntry, Uint8List)?>[]; for (final e in batch) { if (isCancelled?.call() ?? false) break; + if (isExpired(e, currentRotation()) || + isExpired(e, rotAfterOpen)) { + batchPayloads.add(null); + continue; + } try { final raw = await BodyStore.readWithHandle( raf, e.fileOffset!, e.fileLength!, ); + final rotAfterRead = currentRotation(); + if (isExpired(e, rotAfterRead)) { + batchPayloads.add(null); + continue; + } + if (raw.length < e.fileLength!) { + batchPayloads.add(null); + continue; + } batchPayloads.add((e, raw)); } catch (err) { if (kDebugMode) { @@ -250,6 +284,10 @@ abstract final class SearchService { final futures = batchPayloads.map((payload) async { if (payload == null) return (false, null); final (e, raw) = payload; + final rotBeforeMatch = currentRotation(); + if (isExpired(e, rotBeforeMatch)) { + return (false, null); + } try { final checkReq = !BodyDecodeService.isBinary(e.requestContentType); @@ -259,7 +297,10 @@ abstract final class SearchService { final matched = raw.length > BodyDecodeService.computeThreshold ? await compute(_matchPackedPayloadIsolate, job) : _matchPackedPayloadIsolate(job); - + final rotAfterMatch = currentRotation(); + if (isExpired(e, rotAfterMatch)) { + return (false, null); + } if (matched) { return (true, e); } @@ -380,6 +421,10 @@ abstract final class SearchService { IndexEntry entry, String queryLower, ) async { + final override = matchInlineBodyAsyncOverride; + if (override != null) { + return override(entry, queryLower); + } final reqBytes = !BodyDecodeService.isBinary(entry.requestContentType) ? entry.inlineRequestBody : null; @@ -492,7 +537,7 @@ abstract final class SearchService { return false; } - /// Case-insensitive substring check preserving full Unicode case-folding semantics. + /// Case-insensitive substring check via toLowerCase (not full casefold). static bool _containsCaseInsensitive(String text, String queryLower) { if (queryLower.isEmpty) return true; return text.toLowerCase().contains(queryLower); diff --git a/packages/interceptly/lib/src/engine/writer_isolate.dart b/packages/interceptly/lib/src/engine/writer_isolate.dart index d53f7d4..1094a65 100644 --- a/packages/interceptly/lib/src/engine/writer_isolate.dart +++ b/packages/interceptly/lib/src/engine/writer_isolate.dart @@ -120,6 +120,7 @@ class WriterIsolate { Completer? _stopCompleter; Completer? _disposeCompleter; final List<_WriteMessage> _queuedDuringRestart = []; + int _restartDroppedCount = 0; int _lastRotationCount = 0; bool _isRestarting = false; bool _isDisposed = false; @@ -127,6 +128,18 @@ class WriterIsolate { /// Latest file rotation count observed from the writer isolate. int get lastRotationCount => _lastRotationCount; + /// Captures dropped from the restart buffer on overflow. + /// + /// Folded into [CapturePipeline.droppedCount] so restart pressure is + /// visible instead of silently absorbed by the queue. + int get restartDroppedCount => _restartDroppedCount; + + /// Resets the restart overflow counter after its value has been folded + /// into an owner counter or after a user-initiated clear. + void resetRestartDroppedCount() { + _restartDroppedCount = 0; + } + final StreamController _resultController = StreamController.broadcast(); @@ -176,7 +189,9 @@ class WriterIsolate { message.fileRotationCount! > _lastRotationCount) { _lastRotationCount = message.fileRotationCount!; } - _resultController.add(message); + if (!_resultController.isClosed) { + _resultController.add(message); + } } else if (message is _ClearAck) { if (message.rotationCount > _lastRotationCount) { _lastRotationCount = message.rotationCount; @@ -228,21 +243,41 @@ class WriterIsolate { } }); - _isolate = await Isolate.spawn( - _isolateEntry, - _InitMessage( - replyPort: _receivePort!.sendPort, - tempDirPath: tempDirPath, - fileName: fileName, - maxFileBytes: maxFileBytes, - preserveExisting: preserveExisting, - initialRotationCount: effectiveRotation, - ), - onError: _errorPort!.sendPort, - onExit: _exitPort!.sendPort, - ); + _isolate = null; + _sendPort = null; + try { + _isolate = await Isolate.spawn( + _isolateEntry, + _InitMessage( + replyPort: _receivePort!.sendPort, + tempDirPath: tempDirPath, + fileName: fileName, + maxFileBytes: maxFileBytes, + preserveExisting: preserveExisting, + initialRotationCount: effectiveRotation, + ), + onError: _errorPort!.sendPort, + onExit: _exitPort!.sendPort, + ); - _sendPort = await completer.future; + _sendPort = await completer.future; + } catch (error) { + _sendPort = null; + try { + _isolate?.kill(priority: Isolate.immediate); + } catch (_) {} + _isolate = null; + _receivePort?.close(); + _receivePort = null; + _errorPort?.close(); + _errorPort = null; + _exitPort?.close(); + _exitPort = null; + if (!completer.isCompleted) { + completer.completeError(error); + } + rethrow; + } } /// Restarts the background isolate after an unexpected error or termination. @@ -298,8 +333,24 @@ class WriterIsolate { preserveExisting: true, initialRotationCount: _lastRotationCount, ); + if (_isDisposed) { + try { + _isolate?.kill(priority: Isolate.immediate); + } catch (_) {} + _isolate = null; + _receivePort?.close(); + _receivePort = null; + _errorPort?.close(); + _errorPort = null; + _exitPort?.close(); + _exitPort = null; + _sendPort = null; + _queuedDuringRestart.clear(); + return; + } if (_sendPort != null && _queuedDuringRestart.isNotEmpty) { for (final msg in _queuedDuringRestart) { + if (_isDisposed) break; _sendPort!.send(msg); } _queuedDuringRestart.clear(); @@ -325,6 +376,10 @@ class WriterIsolate { final port = _sendPort; if (port == null) { if (_isRestarting) { + if (_queuedDuringRestart.length >= _settings.maxQueuedEvents) { + _queuedDuringRestart.removeAt(0); + _restartDroppedCount++; + } _queuedDuringRestart.add(message); return true; } @@ -336,20 +391,36 @@ class WriterIsolate { /// Sends a clear request and waits for the isolate to confirm the file has /// been reset. Completes only after all previously queued writes are done. + /// + /// Concurrent callers coalesce onto the in-flight clear instead of + /// overwriting each other's waiter, so no caller hangs. Future clear() async { + if (_isDisposed) return; if (_sendPort == null) return; + final pending = _clearCompleter; + if (pending != null) return pending.future; final completer = Completer(); _clearCompleter = completer; _sendPort!.send(const _ClearMessage()); await completer.future; } + /// Releases isolate resources and deletes the session temp file. + /// + /// Idempotent: concurrent or repeated calls after the first return + /// immediately instead of throwing on already-closed controllers. Future dispose() async { + if (_isDisposed && _resultController.isClosed) return; _isDisposed = true; if (_clearCompleter != null && !_clearCompleter!.isCompleted) { _clearCompleter!.complete(); } _clearCompleter = null; + if (_stopCompleter != null && !_stopCompleter!.isCompleted) { + _stopCompleter!.complete(); + } + _stopCompleter = null; + _queuedDuringRestart.clear(); if (_sendPort != null) { final completer = Completer(); _disposeCompleter = completer; @@ -366,8 +437,12 @@ class WriterIsolate { _errorPort = null; _exitPort?.close(); _exitPort = null; - await _resultController.close(); - await _eventController.close(); + if (!_resultController.isClosed) { + await _resultController.close(); + } + if (!_eventController.isClosed) { + await _eventController.close(); + } final filePath = _activeTempFilePath; _activeTempFilePath = null; @@ -491,7 +566,16 @@ Future _isolateEntry(_InitMessage init) async { } } else if (message is _ClearMessage) { try { - await bodyStore.resetFile(init.tempDirPath, fileName: init.fileName); + final ok = await bodyStore.resetFile( + init.tempDirPath, + fileName: init.fileName, + ); + if (!ok && kDebugMode) { + debugPrint( + '[Interceptly] BodyStore reset failed during clear; ' + '${bodyStore.isWritable ? "restored prior handle" : "entered degraded state"}', + ); + } } catch (_) {} init.replyPort.send(_ClearAck(bodyStore.rotationCount)); } else if (message is _StopMessage) { diff --git a/packages/interceptly/lib/src/export/curl_generator.dart b/packages/interceptly/lib/src/export/curl_generator.dart index 0b38f27..3ffc419 100644 --- a/packages/interceptly/lib/src/export/curl_generator.dart +++ b/packages/interceptly/lib/src/export/curl_generator.dart @@ -4,6 +4,9 @@ import '../model/request_record.dart'; import '../engine/body_decode_service.dart'; /// Builds cURL commands from captured request records. +/// +/// Bodies containing control characters use ANSI-C `$'...'` quoting +/// to remain executable. class CurlGenerator { const CurlGenerator._(); @@ -49,9 +52,9 @@ class CurlGenerator { for (final entry in record.requestHeaders.entries) { final key = entry.key.replaceAll(RegExp(r'[\r\n]'), '').trim(); - final value = entry.value.replaceAll(RegExp(r'[\r\n]'), ''); + final rawValue = entry.value.replaceAll(RegExp(r'[\r\n]'), ''); if (key.isEmpty) continue; - buffer.write(" -H '${_escapeShell('$key: $value')}'"); + buffer.write(' -H ${_shellQuote('$key: $rawValue')}'); } String? body; @@ -69,7 +72,7 @@ class CurlGenerator { if (body != null && body.isNotEmpty && !BodyDecodeService.isPlaceholder(body)) { - buffer.write(" --data-raw '${_escapeShell(body)}'"); + buffer.write(' --data-raw ${_shellQuote(body)}'); } final acceptEncoding = record.requestHeaders.entries @@ -82,9 +85,63 @@ class CurlGenerator { buffer.write(' --compressed'); } - buffer.write(" '${_escapeShell(sanitizedUrl)}'"); + buffer.write(' ${_shellQuote(sanitizedUrl)}'); return buffer.toString(); } + /// Wraps [value] in single quotes or ANSI-C `$'...'` when control + /// characters are present so multi-line bodies remain shell-safe. + static String _shellQuote(String value) { + if (_needsAnsiC(value)) { + return "\$'${_escapeAnsiC(value)}'"; + } + return "'${_escapeShell(value)}'"; + } + + /// Reports whether [value] requires ANSI-C quoting for safe shell export. + static bool _needsAnsiC(String value) { + for (var i = 0; i < value.length; i++) { + final c = value.codeUnitAt(i); + if (c == 10 || c == 13 || c == 0) return true; + if (c < 32 || c == 127) return true; + } + return false; + } + + /// Escapes [value] for inclusion inside ANSI-C `$'...'` quoting. + static String _escapeAnsiC(String value) { + final out = StringBuffer(); + for (var i = 0; i < value.length; i++) { + final c = value.codeUnitAt(i); + switch (c) { + case 92: + out.write(r'\\'); + break; + case 39: + out.write(r"\'"); + break; + case 10: + out.write(r'\n'); + break; + case 13: + out.write(r'\r'); + break; + case 9: + out.write(r'\t'); + break; + case 0: + out.write(r'\0'); + break; + default: + if (c < 32 || c == 127) { + out.write('\\x${c.toRadixString(16).padLeft(2, '0')}'); + } else { + out.writeCharCode(c); + } + } + } + return out.toString(); + } + static String _escapeShell(String value) => value.replaceAll("'", "'\\''"); } diff --git a/packages/interceptly/lib/src/export/har_exporter.dart b/packages/interceptly/lib/src/export/har_exporter.dart index 03022ff..740a97a 100644 --- a/packages/interceptly/lib/src/export/har_exporter.dart +++ b/packages/interceptly/lib/src/export/har_exporter.dart @@ -1,9 +1,8 @@ import 'dart:convert'; import 'dart:typed_data'; -import '../capture/body_bytes.dart'; -import '../model/request_record.dart'; import '../engine/body_decode_service.dart'; +import '../model/request_record.dart'; /// Exports captured request records to HAR 1.2 compatible maps. class HarExporter { @@ -111,7 +110,9 @@ class HarExporter { } static List> _mapHeaders(Map headers) { - return mapHeaderEntries(headers, keyName: 'name', valueName: 'value'); + return headers.entries + .map((entry) => {'name': entry.key, 'value': entry.value}) + .toList(); } static Map? _buildPostData(RequestRecord r) { diff --git a/packages/interceptly/lib/src/export/postman_exporter.dart b/packages/interceptly/lib/src/export/postman_exporter.dart index f285d24..b5508e3 100644 --- a/packages/interceptly/lib/src/export/postman_exporter.dart +++ b/packages/interceptly/lib/src/export/postman_exporter.dart @@ -1,8 +1,7 @@ import 'dart:convert'; -import '../capture/body_bytes.dart'; -import '../model/request_record.dart'; import '../engine/body_decode_service.dart'; +import '../model/request_record.dart'; /// Exports captured request records to Postman Collection v2.1 format. /// @@ -70,28 +69,34 @@ class PostmanExporter { } static List> _mapHeaders(Map headers) { - return mapHeaderEntries(headers, keyName: 'key', valueName: 'value'); + return headers.entries + .map((entry) => {'key': entry.key, 'value': entry.value}) + .toList(); } static Map? _buildBody(RequestRecord r) { final contentType = r.requestContentType ?? ''; if (contentType.contains('multipart/form-data')) { - final hasBytes = - r.requestBodyBytes != null && r.requestBodyBytes!.isNotEmpty; - final hasPreview = - r.requestBodyPreview != null && r.requestBodyPreview!.isNotEmpty; - if (!hasBytes && !hasPreview) { + String? rawMultipart; + if (r.requestBodyBytes != null && r.requestBodyBytes!.isNotEmpty) { + try { + rawMultipart = utf8.decode(r.requestBodyBytes!); + } catch (_) {} + } + rawMultipart ??= (r.requestBodyPreview != null && + !BodyDecodeService.isPlaceholder(r.requestBodyPreview!) && + !r.requestBodyPreview!.startsWith('[binary:')) + ? r.requestBodyPreview + : null; + + if (rawMultipart != null && rawMultipart.isNotEmpty) { return { - 'mode': 'formdata', - 'formdata': >[ - { - 'key': 'warning', - 'value': 'Multipart body preview unavailable or empty', - 'type': 'text', - 'disabled': true, - }, - ], + 'mode': 'raw', + 'raw': rawMultipart, + 'options': { + 'raw': {'language': 'text'}, + }, }; } return { @@ -99,7 +104,7 @@ class PostmanExporter { 'formdata': >[ { 'key': 'warning', - 'value': 'Multipart body payload preserved from capture', + 'value': 'Multipart body preview unavailable or binary', 'type': 'text', 'disabled': true, }, @@ -118,7 +123,10 @@ class PostmanExporter { body = r.requestBodyPreview; } - if (body == null || body.isEmpty || BodyDecodeService.isPlaceholder(body)) { + if (body == null || + body.isEmpty || + BodyDecodeService.isPlaceholder(body) || + body.startsWith('[binary:')) { return null; } diff --git a/packages/interceptly/lib/src/model/interceptly_settings.dart b/packages/interceptly/lib/src/model/interceptly_settings.dart index 688ec9f..07b303d 100644 --- a/packages/interceptly/lib/src/model/interceptly_settings.dart +++ b/packages/interceptly/lib/src/model/interceptly_settings.dart @@ -15,6 +15,7 @@ class InterceptlySettings { this.shouldCapture, this.persistToDisk = true, this.enableRealtime, + this.allowInRelease = false, }) : assert(maxEntries > 0, 'maxEntries must be greater than 0'), assert(maxQueuedEvents > 0, 'maxQueuedEvents must be greater than 0'), assert( @@ -115,6 +116,13 @@ class InterceptlySettings { /// realtime adapters record traffic or open sessions. final bool? enableRealtime; + /// Whether capture and overlay are permitted in release builds. + /// + /// Defaults to `false`: in release mode `Interceptly.init` returns early + /// without attaching the overlay or starting background workers, and the + /// engine starts disabled. Opt in explicitly for internal/beta builds. + final bool allowInRelease; + InterceptlySettings copyWith({ int? bodyOffloadThreshold, int? realtimeBodyOffloadThreshold, @@ -130,6 +138,7 @@ class InterceptlySettings { bool Function(String url)? shouldCapture, bool? persistToDisk, bool? enableRealtime, + bool? allowInRelease, }) { return InterceptlySettings( bodyOffloadThreshold: bodyOffloadThreshold ?? this.bodyOffloadThreshold, @@ -149,6 +158,7 @@ class InterceptlySettings { shouldCapture: shouldCapture ?? this.shouldCapture, persistToDisk: persistToDisk ?? this.persistToDisk, enableRealtime: enableRealtime ?? this.enableRealtime, + allowInRelease: allowInRelease ?? this.allowInRelease, ); } } diff --git a/packages/interceptly/lib/src/service_extension/http_extension_handlers.dart b/packages/interceptly/lib/src/service_extension/http_extension_handlers.dart index 17e9453..664f33c 100644 --- a/packages/interceptly/lib/src/service_extension/http_extension_handlers.dart +++ b/packages/interceptly/lib/src/service_extension/http_extension_handlers.dart @@ -136,6 +136,11 @@ abstract final class HttpExtensionHandlers { /// - `part`: 'all' | 'request_body' | 'response_body' | 'request_headers' | 'response_headers' /// - `max_chars`: maximum character count for body preview (default 4000) /// - `offset`: character offset for continuation + /// + /// Per-part windows for `part=all`: [offset] and [max_chars] apply + /// independently to each body, so request and response slices share the same + /// window. `totalChars` is the sum of both body lengths and `remainingChars` + /// is the sum of each body remainder after its window. static Future> handleGetRequestDetail({ required InterceptlyEngine? engine, required Map parameters, @@ -272,6 +277,9 @@ abstract final class HttpExtensionHandlers { } /// Handles `ext.interceptly.exportCurl`. + /// + /// Query parameters: + /// - `id`: request ID (required) static Future> handleExportCurl({ required InterceptlyEngine? engine, required Map parameters, diff --git a/packages/interceptly/lib/src/share/interceptly_share.dart b/packages/interceptly/lib/src/share/interceptly_share.dart index b2880c3..2fa59ac 100644 --- a/packages/interceptly/lib/src/share/interceptly_share.dart +++ b/packages/interceptly/lib/src/share/interceptly_share.dart @@ -2,8 +2,12 @@ import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; +import 'package:path/path.dart' as p; /// Service for sharing files via platform share sheets. +/// +/// File paths are validated to reside within the system temporary directory +/// using canonicalized paths and `isWithin` checks to prevent traversal. class InterceptlyShare { InterceptlyShare({MethodChannel? channel}) : _channel = channel ?? const MethodChannel(_channelName); @@ -16,27 +20,56 @@ class InterceptlyShare { static const int defaultMaxFileSizeBytes = 50 * 1024 * 1024; /// Validates whether [filePath] is contained safely within a temporary directory. + /// + /// Both candidate and trusted roots are canonicalized with + /// `resolveSymbolicLinksSync` and compared with `isWithin`, including a + /// trailing separator, so `/tmp2` cannot masquerade as `/tmp`. static bool isWithinTempDirectory(String filePath) { if (filePath.contains('..')) return false; - final file = File(filePath); - final absPath = file.absolute.path; - - final sysTemp = Directory.systemTemp.path; - if (absPath.startsWith(sysTemp)) return true; - - try { - final resolvedSysTemp = Directory.systemTemp.resolveSymbolicLinksSync(); - if (absPath.startsWith(resolvedSysTemp)) return true; - } catch (_) {} + /// Resolves [target] to a canonical path, falling back to parent + /// resolution when the file itself does not exist yet. + /// + /// Non-existent export targets cannot be resolved directly, so the + /// parent directory symlink (for example `/tmp` to `/private/tmp` + /// on macOS) is resolved and joined with the basename. + String canonicalOf(String target) { + try { + return Directory(target).resolveSymbolicLinksSync(); + } catch (_) {} + try { + return File(target).resolveSymbolicLinksSync(); + } catch (_) {} + try { + final parent = File(target).absolute.parent; + final resolvedParent = parent.resolveSymbolicLinksSync(); + return p.join(resolvedParent, p.basename(target)); + } catch (_) { + return File(target).absolute.path; + } + } - if (absPath.startsWith('/tmp') || - absPath.startsWith('/private/tmp') || - absPath.startsWith('/var/tmp') || - absPath.startsWith('/data/local/tmp')) { - return true; + String withSeparator(String dirPath) { + if (dirPath.endsWith(Platform.pathSeparator)) return dirPath; + return '$dirPath${Platform.pathSeparator}'; } + final canonicalFile = canonicalOf(filePath); + final roots = [ + Directory.systemTemp.path, + '/tmp', + '/private/tmp', + '/var/tmp', + '/data/local/tmp', + ]; + for (final root in roots) { + final canonicalRoot = canonicalOf(root); + if (canonicalFile == canonicalRoot) return true; + if (p.isWithin(withSeparator(canonicalRoot), canonicalFile) || + p.isWithin(canonicalRoot, canonicalFile)) { + return true; + } + } return false; } diff --git a/packages/interceptly/lib/src/ui/detail/custom_request_page.dart b/packages/interceptly/lib/src/ui/detail/custom_request_page.dart index 4ea62d3..f11a5b0 100644 --- a/packages/interceptly/lib/src/ui/detail/custom_request_page.dart +++ b/packages/interceptly/lib/src/ui/detail/custom_request_page.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:convert'; import 'package:flutter/material.dart'; @@ -53,6 +54,7 @@ class _CustomRequestPageState extends State late List<_KeyValuePair> _params; late List<_KeyValuePair> _headers; + Timer? _bodyRefreshDebounce; static const _methods = [ 'GET', @@ -77,14 +79,16 @@ class _CustomRequestPageState extends State _methodController.addListener(_refresh); _baseUrlController.addListener(_refresh); - _bodyController.addListener(_refresh); + _bodyController.addListener(_onBodyChanged); } @override void dispose() { + _bodyRefreshDebounce?.cancel(); + _bodyRefreshDebounce = null; _methodController.removeListener(_refresh); _baseUrlController.removeListener(_refresh); - _bodyController.removeListener(_refresh); + _bodyController.removeListener(_onBodyChanged); for (final p in _params) { p.dispose(); @@ -104,6 +108,15 @@ class _CustomRequestPageState extends State if (mounted) setState(() {}); } + /// Debounces body edits so syntax highlighting and line numbers + /// do not rebuild on every keystroke. + void _onBodyChanged() { + _bodyRefreshDebounce?.cancel(); + _bodyRefreshDebounce = Timer(const Duration(milliseconds: 150), () { + _refresh(); + }); + } + List<_KeyValuePair> _parseQueryParams(String raw) { final pairs = <_KeyValuePair>[]; for (final line in raw.split('\n')) { @@ -912,6 +925,9 @@ class CustomRequestDraft { class _JsonSyntaxController extends TextEditingController { _JsonSyntaxController({super.text}); + /// Bodies longer than this skip regex highlighting to keep typing smooth. + static const int _highlightLimit = 10000; + static const Color _keyColor = InterceptlyGlobalColor.blue400; static const Color _stringColor = InterceptlyGlobalColor.red400; static const Color _numberColor = InterceptlyGlobalColor.green400; @@ -938,6 +954,9 @@ class _JsonSyntaxController extends TextEditingController { if (textValue.isEmpty) { return TextSpan(style: baseStyle, text: textValue); } + if (textValue.length > _highlightLimit) { + return TextSpan(style: baseStyle, text: textValue); + } final spans = []; var start = 0; diff --git a/packages/interceptly/lib/src/ui/detail/realtime_messages_tab.dart b/packages/interceptly/lib/src/ui/detail/realtime_messages_tab.dart index 0f2537f..b7b6342 100644 --- a/packages/interceptly/lib/src/ui/detail/realtime_messages_tab.dart +++ b/packages/interceptly/lib/src/ui/detail/realtime_messages_tab.dart @@ -180,6 +180,24 @@ class _RealtimeMessagesTabState extends State { } } + Future _resolveRawPayload(RealtimeFrame frame) async { + if (frame.location == BodyLocation.file) { + try { + final payload = await _scope.loadRealtimePayload(frame); + if (payload != null && + (payload is! String || + (!payload.startsWith('[Payload expired') && + !payload.startsWith('[Payload unavailable')))) { + return payload; + } + } catch (_) {} + } + if (frame.type == FrameType.binary) { + return frame.binaryPayload ?? Uint8List(0); + } + return frame.textPayload ?? frame.shortPreview; + } + Future _onResendFrame(RealtimeFrame frame) async { final canSend = _scope.canSendRealtimeMessage(widget.sessionId); if (!canSend) { @@ -188,10 +206,10 @@ class _RealtimeMessagesTabState extends State { return; } - final text = await _resolvePayloadText(frame); + final payload = await _resolveRawPayload(frame); final draft = RealtimeMessageDraft( event: frame.eventName, - payload: text, + payload: payload, ); try { diff --git a/packages/interceptly/lib/src/ui/detail/replay_handler.dart b/packages/interceptly/lib/src/ui/detail/replay_handler.dart index cb3fe94..2f035ef 100644 --- a/packages/interceptly/lib/src/ui/detail/replay_handler.dart +++ b/packages/interceptly/lib/src/ui/detail/replay_handler.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:convert'; import 'dart:typed_data'; @@ -5,6 +6,7 @@ import 'dart:io'; import 'package:flutter/material.dart'; +import '../../capture/stream_capture.dart'; import '../../model/raw_capture.dart'; import '../../model/body_truncation.dart'; import '../../model/request_record.dart'; @@ -97,8 +99,10 @@ class ReplayHandler { /// Retries sending [record] with same headers, URL, and payload. /// /// Prompts for confirmation before replaying any method when a - /// confirmation UI is available. - Future retry(RequestRecord record, {bool? confirm}) async { + /// confirmation UI is available. When the captured body lost bytes, + /// replay is blocked unless [allowTruncatedReplay] is true. + Future retry(RequestRecord record, + {bool? confirm, bool allowTruncatedReplay = false}) async { if (_isUnsupportedMethod(record.method)) { ToastNotification.show( 'Retry is not supported for ${record.method}', @@ -107,11 +111,18 @@ class ReplayHandler { return; } + var effectiveAllowTruncated = allowTruncatedReplay; final shouldPrompt = confirm ?? (confirmDialog != null || clientFactory == null); if (shouldPrompt) { - final confirmed = await _confirmNonIdempotent(record.method); + final confirmed = await _confirmNonIdempotent( + record.method, + isTruncated: record.requestTruncation.hasDataLoss, + ); if (!confirmed) return; + if (record.requestTruncation.hasDataLoss) { + effectiveAllowTruncated = true; + } } await _sendRequest( @@ -121,6 +132,7 @@ class ReplayHandler { body: record.requestBodyPreview, bodyBytes: record.requestBodyBytes, truncation: record.requestTruncation, + allowTruncatedReplay: effectiveAllowTruncated, ); } @@ -182,6 +194,7 @@ class ReplayHandler { required String? body, Uint8List? bodyBytes, required BodyTruncation truncation, + bool allowTruncatedReplay = false, }) async { final uri = Uri.tryParse(url); if (uri == null) { @@ -198,7 +211,9 @@ class ReplayHandler { return; } - if (_isBlockedHost(uri.host)) { + final blocked = await isBlockedHostAsync(uri.host); + if (!context.mounted) return; + if (blocked) { ToastNotification.show( 'Replay blocked: unsafe target address (${uri.host})', contextHint: context, @@ -206,6 +221,15 @@ class ReplayHandler { return; } + if (truncation.hasDataLoss && !allowTruncatedReplay) { + ToastNotification.show( + 'Replay blocked: body was truncated during capture. ' + 'Pass allowTruncatedReplay to override.', + contextHint: context, + ); + return; + } + if (_isUnsupportedMethod(method)) { ToastNotification.show( 'Unsupported method: $method', @@ -217,7 +241,7 @@ class ReplayHandler { final cleanedHeaders = _sanitizeReplayHeaders(headers); final isPlaceholder = _isPlaceholder(body); - if (truncation.hasDataLoss) { + if (truncation.hasDataLoss && allowTruncatedReplay) { ToastNotification.show( 'Warning: Request body was truncated during capture and may be incomplete.', contextHint: context, @@ -264,10 +288,12 @@ class ReplayHandler { final stopwatch = Stopwatch()..start(); final start = DateTime.now(); + var forceClose = false; try { ToastNotification.show('Sending request...', contextHint: context); final request = await client.openUrl(upperMethod, uri); + request.followRedirects = false; headersToSend.forEach((key, value) { request.headers.set(key, value); }); @@ -276,10 +302,16 @@ class ReplayHandler { } final response = await request.close(); - final responseBody = await response.fold>( - [], - (previous, element) => previous..addAll(element), - ); + final builder = CappedBytesBuilder(10 * 1024 * 1024); + try { + await for (final chunk + in response.timeout(const Duration(seconds: 15))) { + builder.add(chunk); + } + } catch (_) { + forceClose = true; + rethrow; + } stopwatch.stop(); final responseHeaders = {}; @@ -287,6 +319,8 @@ class ReplayHandler { responseHeaders[name] = values.join(', '); }); + final accumulatedBytes = builder.toBytesOrNull() ?? Uint8List(0); + engine.record( RawCapture( id: 'r_replay_${DateTime.now().microsecondsSinceEpoch}', @@ -300,8 +334,9 @@ class ReplayHandler { requestBodyBytes: bodyBytesToSend != null ? RawCapture.wrapBytes(bodyBytesToSend) : null, - responseBodyBytes: - RawCapture.wrapBytes(Uint8List.fromList(responseBody)), + responseBodyBytes: RawCapture.wrapBytes(accumulatedBytes), + responseSizeBytes: builder.totalBytes, + responseTruncatedAtStorage: builder.isTruncated, responseContentType: response.headers.contentType?.mimeType, ), ); @@ -315,7 +350,7 @@ class ReplayHandler { if (!context.mounted) return; ToastNotification.show('Replay failed: $e', contextHint: context); } finally { - client.close(); + client.close(force: forceClose); } } @@ -324,17 +359,20 @@ class ReplayHandler { return m == 'WS'; } - Future _confirmNonIdempotent(String method) async { + Future _confirmNonIdempotent(String method, + {bool isTruncated = false}) async { if (confirmDialog != null) { return confirmDialog!(context, method); } + final message = isTruncated + ? 'The captured body was truncated and may be incomplete. Replaying a $method request may modify server state with partial data. Proceed anyway?' + : 'Replaying a $method request may modify server state or perform non-idempotent actions. Proceed?'; final result = await showDialog( context: context, builder: (dialogContext) => AlertDialog( - title: const Text('Confirm Replay'), - content: Text( - 'Replaying a $method request may modify server state or perform non-idempotent actions. Proceed?', - ), + title: + Text(isTruncated ? 'Confirm Truncated Replay' : 'Confirm Replay'), + content: Text(message), actions: [ TextButton( onPressed: () => Navigator.of(dialogContext).pop(false), @@ -385,8 +423,71 @@ class ReplayHandler { return second >= 16 && second <= 31; } - bool _isBlockedHost(String host) { - return isBlockedHost(host); + /// Async host guard combining the literal denylist with DNS resolution. + /// + /// The literal check runs first as a fast path. When the host is not + /// literally blocked, IP literals are directly checked; otherwise DNS is + /// consulted and every resolved address is screened for loopback, link-local, + /// ULA, or private ranges. + static Future isBlockedHostAsync(String host) async { + final clean = + host.toLowerCase().trim().replaceAll('[', '').replaceAll(']', ''); + if (isBlockedHost(clean)) return true; + final literal = InternetAddress.tryParse(clean); + if (literal != null) { + return _isBlockedAddress(literal); + } + try { + final addresses = await InternetAddress.lookup(clean); + for (final addr in addresses) { + if (_isBlockedAddress(addr)) return true; + } + } catch (_) { + return false; + } + return false; + } + + /// Reports whether a resolved [addr] falls in a non-routable range. + static bool _isBlockedAddress(InternetAddress addr) { + if (addr.isLoopback || addr.isLinkLocal || addr.isMulticast) return true; + final raw = addr.rawAddress; + if (raw.length == 4) { + return isBlockedHost(addr.address); + } + if (raw.length == 16) { + // IPv4-mapped IPv6 address (::ffff:w.x.y.z) + var isMapped = true; + for (var i = 0; i < 10; i++) { + if (raw[i] != 0) { + isMapped = false; + break; + } + } + if (isMapped && raw[10] == 0xff && raw[11] == 0xff) { + final mappedIpv4 = '${raw[12]}.${raw[13]}.${raw[14]}.${raw[15]}'; + return isBlockedHost(mappedIpv4); + } + + // Loopback ::1 (15 zeroes then 1) + var isLoopback = raw[15] == 1; + if (isLoopback) { + for (var i = 0; i < 15; i++) { + if (raw[i] != 0) { + isLoopback = false; + break; + } + } + if (isLoopback) return true; + } + + // ULA: fc00::/7 -> (raw[0] & 0xfe) == 0xfc + if ((raw[0] & 0xfe) == 0xfc) return true; + + // Link-Local: fe80::/10 -> raw[0] == 0xfe && (raw[1] & 0xc0) == 0x80 + if (raw[0] == 0xfe && (raw[1] & 0xc0) == 0x80) return true; + } + return false; } bool _isPlaceholder(String? body) { diff --git a/packages/interceptly/lib/src/ui/detail/request_detail_page.dart b/packages/interceptly/lib/src/ui/detail/request_detail_page.dart index bdd4df2..6a62926 100644 --- a/packages/interceptly/lib/src/ui/detail/request_detail_page.dart +++ b/packages/interceptly/lib/src/ui/detail/request_detail_page.dart @@ -63,6 +63,7 @@ class _RequestDetailPageState extends State final Set _visitedTabs = {0}; int _detailLoadGeneration = 0; + Timer? _searchDebounce; final Map _sectionKeys = { for (final s in DetailSection.values) s: GlobalKey(), }; @@ -307,6 +308,8 @@ class _RequestDetailPageState extends State @override void dispose() { + _searchDebounce?.cancel(); + _searchDebounce = null; if (_initialized) { _scope.removeListener(_onScopeChanged); } @@ -462,13 +465,25 @@ class _RequestDetailPageState extends State hintText: 'Search in details...', textInputAction: TextInputAction.search, onChanged: (value) { - if (value.trim().isEmpty && _query.isNotEmpty) { - setState(() { - _query = ''; - _currentMatchIndex = 0; - }); - _recomputeMatchesAsync(); - } + _searchDebounce?.cancel(); + _searchDebounce = + Timer(const Duration(milliseconds: 300), () { + if (!mounted) return; + final next = value.trim(); + if (next.isEmpty && _query.isNotEmpty) { + setState(() { + _query = ''; + _currentMatchIndex = 0; + }); + _recomputeMatchesAsync(); + } else if (next.isNotEmpty && next != _query) { + setState(() { + _query = next; + _currentMatchIndex = 0; + }); + _recomputeMatchesAsync(); + } + }); }, onSubmitted: (value) { setState(() { diff --git a/packages/interceptly/lib/src/ui/detail/share_handler.dart b/packages/interceptly/lib/src/ui/detail/share_handler.dart index 99e4632..185c72f 100644 --- a/packages/interceptly/lib/src/ui/detail/share_handler.dart +++ b/packages/interceptly/lib/src/ui/detail/share_handler.dart @@ -1,5 +1,7 @@ +import 'dart:convert'; import 'dart:io'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:path_provider/path_provider.dart'; @@ -139,17 +141,30 @@ class ShareHandler { Future _exportPostmanRecords(List records) async { File? file; + var shareInitiated = false; try { - final collectionJson = PostmanExporter.toJson(records); + final collectionMap = PostmanExporter.toMap(records); + final collectionJson = await compute(_encodeJsonMap, collectionMap); final directory = await getTemporaryDirectory(); + await purgeStaleExports(directory); final fileName = 'interceptly_${DateTime.now().millisecondsSinceEpoch}.postman_collection.json'; file = File('${directory.path}/$fileName'); - await file.writeAsString(collectionJson); + final sink = file.openWrite(); + const chunkSize = 16 * 1024; + for (var i = 0; i < collectionJson.length; i += chunkSize) { + final end = (i + chunkSize < collectionJson.length) + ? i + chunkSize + : collectionJson.length; + sink.write(collectionJson.substring(i, end)); + } + await sink.flush(); + await sink.close(); final origin = await _getSharePositionOrigin(); + shareInitiated = true; await _shareFile( file, subject: 'Postman Collection Export', @@ -157,32 +172,36 @@ class ShareHandler { origin: origin, ); } catch (e) { - ToastNotification.show('Error exporting Postman collection: $e'); - } finally { - if (file != null) { + if (!shareInitiated && file != null) { try { - if (await file.exists()) { - await file.delete(); - } + if (await file.exists()) await file.delete(); } catch (_) {} } + ToastNotification.show('Error exporting Postman collection: $e'); } } Future exportHarFile(RequestRecord record) async { File? file; + var shareInitiated = false; try { - final harJson = HarExporter.toJson([record]); + final harMap = HarExporter.toMap([record]); + final harJson = await compute(_encodeJsonMap, harMap); final directory = await getTemporaryDirectory(); + await purgeStaleExports(directory); final sanitizedMethod = _sanitizeFilename(record.method); final fileName = 'request_${sanitizedMethod}_${DateTime.now().millisecondsSinceEpoch}.har'; file = File('${directory.path}/$fileName'); - await file.writeAsString(harJson); + final sink = file.openWrite(); + sink.write(harJson); + await sink.flush(); + await sink.close(); final origin = await _getSharePositionOrigin(); + shareInitiated = true; await _shareFile( file, subject: 'HAR Export', @@ -190,18 +209,44 @@ class ShareHandler { origin: origin, ); } catch (e) { - ToastNotification.show('Error exporting HAR: $e'); - } finally { - if (file != null) { + if (!shareInitiated && file != null) { try { - if (await file.exists()) { - await file.delete(); - } + if (await file.exists()) await file.delete(); } catch (_) {} } + ToastNotification.show('Error exporting HAR: $e'); } } + /// Purges old export artifacts from [directory] to keep temp storage bounded. + static Future purgeStaleExports( + Directory directory, { + Duration maxAge = const Duration(hours: 1), + }) async { + try { + if (!await directory.exists()) return; + final now = DateTime.now(); + await for (final entity in directory.list()) { + if (entity is File) { + final name = entity.uri.pathSegments.last; + if (name.startsWith('interceptly_') || name.startsWith('request_')) { + if (name.endsWith('.har') || + name.endsWith('.json') || + name.endsWith('.postman_collection') || + name.endsWith('.postman_collection.json')) { + try { + final stat = await entity.stat(); + if (now.difference(stat.modified) > maxAge) { + await entity.delete(); + } + } catch (_) {} + } + } + } + } + } catch (_) {} + } + Future _shareFile( File file, { String? subject, @@ -259,3 +304,8 @@ class ShareHandler { return input.replaceAll(RegExp(r'[^a-zA-Z0-9]'), '_'); } } + +/// Encodes [map] to JSON on a background isolate for bulk export. +String _encodeJsonMap(Map map) { + return jsonEncode(map); +} diff --git a/packages/interceptly/lib/src/ui/overlay/floating_overlay_manager.dart b/packages/interceptly/lib/src/ui/overlay/floating_overlay_manager.dart index add82c7..8115bc2 100644 --- a/packages/interceptly/lib/src/ui/overlay/floating_overlay_manager.dart +++ b/packages/interceptly/lib/src/ui/overlay/floating_overlay_manager.dart @@ -85,6 +85,12 @@ class FloatingOverlayManager { bool get isAttached => _isAttached; bool get isHiddenForSession => _hiddenForSession; bool get showFloatingButton => _showFloatingButton; + + /// Observer keeping the floating button above pushed routes. + /// + /// Install by adding it to MaterialApp navigatorObservers. The manager + /// keeps a single overlay insert and reorders the existing entry instead + /// of inserting duplicates. NavigatorObserver get navigatorObserver => _navigatorObserver; void _removeFabEntry() { diff --git a/packages/interceptly/lib/src/ui/scope/interceptly_scope.dart b/packages/interceptly/lib/src/ui/scope/interceptly_scope.dart index 0cecae6..414a0b4 100644 --- a/packages/interceptly/lib/src/ui/scope/interceptly_scope.dart +++ b/packages/interceptly/lib/src/ui/scope/interceptly_scope.dart @@ -54,6 +54,17 @@ class InterceptlyScopeController extends ChangeNotifier { int _lastReconciledEntriesVersion = -1; bool _isDisposed = false; + /// Notified only when the underlying entry set changes. + final ValueNotifier entriesVersionListenable = ValueNotifier(0); + + /// Notified only when the active filter changes. + final ValueNotifier filterVersionListenable = ValueNotifier(0); + + List? _cachedEntries; + int _cachedEntriesVersion = -1; + int _cachedFilterHash = -1; + bool _cachedEntriesSearchActive = false; + InterceptlyPreferences get preferences => _preferences; RequestFilter get filter => _filter; InterceptlySettings get settings => engine.settings; @@ -70,14 +81,33 @@ class InterceptlyScopeController extends ChangeNotifier { bool get groupingEnabled => _grouping.enabled; /// Returns filtered entries based on active search and filters. + /// + /// Results are memoized by entries version, filter hash, and search state + /// so repeated builds without data changes reuse the previous list. List get entries { + final version = engine.entriesVersion; + final filterHash = _filter.hashCode; + final searchActive = _search.isActive; + if (_cachedEntries != null && + _cachedEntriesVersion == version && + _cachedFilterHash == filterHash && + _cachedEntriesSearchActive == searchActive) { + return _cachedEntries!; + } final baseList = _search.isActive ? (_search.results ?? const []) : engine.entries; + final List result; if (_filter.isEmpty) { - return baseList.cast(); + result = baseList.cast().toList(growable: false); + } else { + result = baseList.where(_filter.matches).toList().cast(); } - return baseList.where(_filter.matches).toList().cast(); + _cachedEntries = result; + _cachedEntriesVersion = version; + _cachedFilterHash = filterHash; + _cachedEntriesSearchActive = searchActive; + return result; } /// Available domains present in the current entries. @@ -100,12 +130,14 @@ class InterceptlyScopeController extends ChangeNotifier { if (_filter == filter) return; _filter = filter; _invalidateGroupedCache(); + filterVersionListenable.value++; notifyListeners(); } void clearFilter() { _filter = RequestFilter(); _invalidateGroupedCache(); + filterVersionListenable.value++; notifyListeners(); } @@ -153,9 +185,53 @@ class InterceptlyScopeController extends ChangeNotifier { allEntries: engine.entries.cast(), filePath: engine.tempFilePath, currentFileRotationCount: engine.fileRotationCount, + fileRotationCountProvider: () => engine.fileRotationCount, ); } + /// Debounced variant of [startMasterSearch] collapsing rapid keystrokes. + /// + /// A 250ms trailing timer ensures only the final query triggers a search. + /// The timer is cancelled by [cancelMasterSearchDebounced] and on dispose. + Timer? _masterSearchDebounce; + Completer? _masterSearchCompleter; + + Future startMasterSearchDebounced(String query, + {Duration delay = const Duration(milliseconds: 250)}) { + _masterSearchDebounce?.cancel(); + if (_masterSearchCompleter != null && + !_masterSearchCompleter!.isCompleted) { + _masterSearchCompleter!.complete(); + } + final completer = Completer(); + _masterSearchCompleter = completer; + _masterSearchDebounce = Timer(delay, () async { + try { + await startMasterSearch(query); + if (!completer.isCompleted) completer.complete(); + } catch (e) { + if (!completer.isCompleted) completer.completeError(e); + } finally { + if (identical(_masterSearchCompleter, completer)) { + _masterSearchCompleter = null; + } + } + }); + return completer.future; + } + + /// Cancels any pending debounced search and the active search. + void cancelMasterSearchDebounced() { + _masterSearchDebounce?.cancel(); + _masterSearchDebounce = null; + if (_masterSearchCompleter != null && + !_masterSearchCompleter!.isCompleted) { + _masterSearchCompleter!.complete(); + _masterSearchCompleter = null; + } + cancelMasterSearch(); + } + void setNetworkSimulation(NetworkSimulationProfile profile) => engine.setNetworkSimulation(profile); @@ -213,6 +289,8 @@ class InterceptlyScopeController extends ChangeNotifier { void _onEngineUpdated() { _invalidateGroupedCache(); + entriesVersionListenable.value++; + _cachedEntries = null; if (_search.isActive) { final currentVersion = engine.entriesVersion; if (_lastReconciledEntriesVersion != currentVersion) { @@ -262,12 +340,22 @@ class InterceptlyScopeController extends ChangeNotifier { _cachedGroupedRecords = null; _cacheVersion++; _cachedAvailableDomains = null; + _cachedEntries = null; } @override void dispose() { if (_isDisposed) return; _isDisposed = true; + _masterSearchDebounce?.cancel(); + _masterSearchDebounce = null; + if (_masterSearchCompleter != null && + !_masterSearchCompleter!.isCompleted) { + _masterSearchCompleter!.complete(); + _masterSearchCompleter = null; + } + entriesVersionListenable.dispose(); + filterVersionListenable.dispose(); engine.removeListener(_onEngineUpdated); _search.removeListener(_onSearchUpdated); _search.dispose(); diff --git a/packages/interceptly/lib/src/ui/search/master_search_controller.dart b/packages/interceptly/lib/src/ui/search/master_search_controller.dart index bf95895..9c2909a 100644 --- a/packages/interceptly/lib/src/ui/search/master_search_controller.dart +++ b/packages/interceptly/lib/src/ui/search/master_search_controller.dart @@ -101,12 +101,14 @@ class MasterSearchController extends ChangeNotifier { /// Starts a progressive search over [allEntries]. /// /// [filePath] is the temp file path for file-backed bodies; pass `null` to - /// skip Phase 3. + /// skip Phase 3. Progressive results preserve dynamically added or updated + /// entries that occurred after the `start()` snapshot. Future start({ required String query, required List allEntries, required String? filePath, int? currentFileRotationCount, + int? Function()? fileRotationCountProvider, }) async { if (_isDisposed) return; final trimmed = query.trim(); @@ -136,6 +138,7 @@ class MasterSearchController extends ChangeNotifier { query: trimmed, filePath: filePath, fileRotationCount: currentFileRotationCount, + fileRotationCountProvider: fileRotationCountProvider, onPhaseChange: (phase) { if (gen != _generation || _isDisposed) return; switch (phase) { @@ -165,7 +168,6 @@ class MasterSearchController extends ChangeNotifier { final updatedList = []; final seenIds = {}; - // Preserve dynamically added/updated entries that occurred after start() snapshot if (_results != null && snapshotIds != null) { for (final existing in _results!) { final id = existing.id; @@ -251,12 +253,14 @@ class MasterSearchController extends ChangeNotifier { } else { _results!.insert(0, entry); } + _matchScopes[entry.id] = scopes; + _notifyThrottled(force: true); } else { _results!.insert(0, entry); _matchedIds.add(entry.id); + _matchScopes[entry.id] = scopes; + _notifyThrottled(); } - _matchScopes[entry.id] = scopes; - _notifyThrottled(); } else if (!offloadInline) { if (isExisting) { _results!.removeWhere((e) => e.id == entry.id); @@ -274,39 +278,52 @@ class MasterSearchController extends ChangeNotifier { if (offloadInline) { final gen = _generation; final queryLower = query.trim().toLowerCase(); - unawaited( - SearchService.matchInlineBodyAsync(entry, queryLower).then((matched) { - if (_isDisposed || - gen != _generation || - _results == null || - !identical(_pendingAsyncEntries[entry.id], entry)) { - return; + unawaited(() async { + bool matched; + try { + matched = await SearchService.matchInlineBodyAsync( + entry, + queryLower, + ); + } catch (e) { + if (e is Error && e is! StateError) rethrow; + if (!_isDisposed && + gen == _generation && + identical(_pendingAsyncEntries[entry.id], entry)) { + _pendingAsyncEntries.remove(entry.id); } - _pendingAsyncEntries.remove(entry.id); - final currentlyExisting = _matchedIds.contains(entry.id); - if (matched) { - if (currentlyExisting) { - final idx = _results!.indexWhere((e) => e.id == entry.id); - if (idx >= 0) { - _results![idx] = entry; - } else { - _results!.insert(0, entry); - } + return; + } + if (_isDisposed || + gen != _generation || + _results == null || + !identical(_pendingAsyncEntries[entry.id], entry)) { + return; + } + _pendingAsyncEntries.remove(entry.id); + final currentlyExisting = _matchedIds.contains(entry.id); + if (matched) { + if (currentlyExisting) { + final idx = _results!.indexWhere((e) => e.id == entry.id); + if (idx >= 0) { + _results![idx] = entry; } else { _results!.insert(0, entry); - _matchedIds.add(entry.id); } - (_matchScopes[entry.id] ??= {}) - .add(SearchMatchScope.body); - _notifyThrottled(); - } else if (scopes.isEmpty && currentlyExisting) { - _results!.removeWhere((e) => e.id == entry.id); - _matchedIds.remove(entry.id); - _matchScopes.remove(entry.id); - _notifyThrottled(); + } else { + _results!.insert(0, entry); + _matchedIds.add(entry.id); } - }), - ); + (_matchScopes[entry.id] ??= {}) + .add(SearchMatchScope.body); + _notifyThrottled(); + } else if (scopes.isEmpty && currentlyExisting) { + _results!.removeWhere((e) => e.id == entry.id); + _matchedIds.remove(entry.id); + _matchScopes.remove(entry.id); + _notifyThrottled(); + } + }()); } } @@ -354,6 +371,10 @@ class MasterSearchController extends ChangeNotifier { _generation++; _throttleTimer?.cancel(); _throttleTimer = null; + _pendingAsyncEntries.clear(); + _activeSnapshotIds = null; + _matchScopes.clear(); + _results = null; super.dispose(); } } diff --git a/packages/interceptly/lib/src/ui/tabs/network_tab.dart b/packages/interceptly/lib/src/ui/tabs/network_tab.dart index 9add0f2..a033b89 100644 --- a/packages/interceptly/lib/src/ui/tabs/network_tab.dart +++ b/packages/interceptly/lib/src/ui/tabs/network_tab.dart @@ -19,6 +19,12 @@ import 'quick_filter_bar.dart'; part 'network_tab_widgets.dart'; +/// Maximum rows included in a single bulk Postman export. +/// +/// Selections larger than this are truncated with a user notice to keep +/// encoding and file I/O responsive. +const int kMaxBulkExportItems = 50; + /// REST-only traffic list with search, quick filters, grouping and export. class NetworkTab extends StatefulWidget { /// Creates a REST-only list. @@ -40,12 +46,18 @@ class NetworkTab extends StatefulWidget { class _NetworkTabState extends State { late TextEditingController _searchController; + Timer? _searchDebounce; bool _isSelectionMode = false; final Set _selectedIds = {}; bool _isExporting = false; final Map _displayUrlCache = {}; bool? _cachedUrlDecodeSetting; + List? _cachedRestSource; + List? _cachedRestResult; + + /// Maximum number of rows included in a single bulk export. + static const int maxBulkExportItems = kMaxBulkExportItems; @override void initState() { @@ -55,7 +67,11 @@ class _NetworkTabState extends State { @override void dispose() { + _searchDebounce?.cancel(); + _searchDebounce = null; _displayUrlCache.clear(); + _cachedRestSource = null; + _cachedRestResult = null; _searchController.dispose(); super.dispose(); } @@ -120,9 +136,12 @@ class _NetworkTabState extends State { }); } - /// Exports selected rows to Postman in chunks of ten. - Future _exportSelectedAsPostman( - InterceptlyScopeController scope) async { + /// Exports selected rows to Postman, capped at [maxItems]. + /// + /// The cap keeps large selections responsive and avoids blocking the UI + /// thread. A notice is shown when the selection exceeds the cap. + Future _exportSelectedAsPostman(InterceptlyScopeController scope, + {int maxItems = maxBulkExportItems}) async { if (_selectedIds.isEmpty || _isExporting) return; setState(() => _isExporting = true); @@ -130,12 +149,21 @@ class _NetworkTabState extends State { final allEntries = scope.entries; final selectedEntries = allEntries.where((e) => _selectedIds.contains(e.id)).toList(); + final cappedEntries = selectedEntries.length > maxItems + ? selectedEntries.sublist(0, maxItems) + : selectedEntries; + if (selectedEntries.length > maxItems && mounted) { + ToastNotification.show( + 'Selection capped at $maxItems items', + contextHint: context, + ); + } final List records = []; - for (var i = 0; i < selectedEntries.length; i += 10) { + for (var i = 0; i < cappedEntries.length; i += 10) { final end = - (i + 10 < selectedEntries.length) ? i + 10 : selectedEntries.length; - final chunk = selectedEntries.sublist(i, end); + (i + 10 < cappedEntries.length) ? i + 10 : cappedEntries.length; + final chunk = cappedEntries.sublist(i, end); final chunkRecords = await Future.wait( chunk.map((entry) => scope.loadDetailById(entry.id)), ); @@ -163,7 +191,8 @@ class _NetworkTabState extends State { Widget build(BuildContext context) { final scope = InterceptlyScope.of(context); final queryText = scope.masterQuery ?? ''; - if (_searchController.text != queryText) { + final isDebouncing = _searchDebounce?.isActive ?? false; + if (!isDebouncing && _searchController.text != queryText) { _searchController.value = TextEditingValue( text: queryText, selection: TextSelection.collapsed(offset: queryText.length), @@ -191,12 +220,17 @@ class _NetworkTabState extends State { controller: _searchController, activeFiltersCount: scope.filter.activeFilterCount, onChanged: (value) { - final q = value.trim(); - if (q.isEmpty) { - scope.cancelMasterSearch(); - } else { - scope.startMasterSearch(q); - } + _searchDebounce?.cancel(); + _searchDebounce = + Timer(const Duration(milliseconds: 250), () { + if (!mounted) return; + final q = value.trim(); + if (q.isEmpty) { + scope.cancelMasterSearch(); + } else { + scope.startMasterSearch(q); + } + }); }, onShowFilter: widget.onShowFilterPanel, ), @@ -222,8 +256,17 @@ class _NetworkTabState extends State { } /// Drops realtime connections so only REST exchanges render. + /// + /// Results are memoized by source identity so repeated builds with an + /// unchanged entry list avoid re-filtering on every frame. List _restOnly(List list) { - return list.where((e) => !e.isRealtime).toList(); + if (identical(list, _cachedRestSource) && _cachedRestResult != null) { + return _cachedRestResult!; + } + final result = list.where((e) => !e.isRealtime).toList(); + _cachedRestSource = list; + _cachedRestResult = result; + return result; } /// Empty-state copy for the REST-only list. diff --git a/packages/interceptly/lib/src/ui/tabs/realtime_list_tab.dart b/packages/interceptly/lib/src/ui/tabs/realtime_list_tab.dart index e2c4401..9b7dcae 100644 --- a/packages/interceptly/lib/src/ui/tabs/realtime_list_tab.dart +++ b/packages/interceptly/lib/src/ui/tabs/realtime_list_tab.dart @@ -24,31 +24,54 @@ class RealtimeListTab extends StatefulWidget { } class _RealtimeListTabState extends State { - Timer? _liveTicker; final Map _displayUrlCache = {}; bool? _cachedUrlDecodeSetting; + InterceptlyScopeController? _scope; + bool _hasActiveConnections = false; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final next = InterceptlyScope.read(context); + if (!identical(next, _scope)) { + _scope?.removeListener(_onScopeUpdated); + _scope = next; + _scope!.addListener(_onScopeUpdated); + _refreshActiveFlag(); + } + } @override void dispose() { - _liveTicker?.cancel(); - _liveTicker = null; + _scope?.removeListener(_onScopeUpdated); + _scope = null; _displayUrlCache.clear(); super.dispose(); } - /// Starts a 1s rebuild loop while any connection is still open. - void _updateLiveTicker(InterceptlyScopeController scope) { + /// Refreshes the cached active-connection flag without a global ticker. + /// + /// Per-row duration widgets own their 1s timers, so the list itself only + /// rebuilds on scope notifications. + void _onScopeUpdated() { + final scope = _scope; + if (scope == null || !mounted) return; final hasActive = scope.entries.any( (e) => e.isRealtime && e.statusCode == 0 && !e.hasError, ); - if (hasActive) { - _liveTicker ??= Timer.periodic(const Duration(seconds: 1), (_) { - if (mounted) setState(() {}); - }); - } else { - _liveTicker?.cancel(); - _liveTicker = null; + if (hasActive != _hasActiveConnections) { + _hasActiveConnections = hasActive; } + if (mounted) setState(() {}); + } + + /// Recomputes the active-connection flag for the current scope. + void _refreshActiveFlag() { + final scope = _scope; + if (scope == null) return; + _hasActiveConnections = scope.entries.any( + (e) => e.isRealtime && e.statusCode == 0 && !e.hasError, + ); } /// Returns the display URL honoring the url-decode preference. @@ -73,7 +96,6 @@ class _RealtimeListTabState extends State { @override Widget build(BuildContext context) { final scope = InterceptlyScope.of(context); - _updateLiveTicker(scope); final entries = scope.entries.where((e) => e.isRealtime).toList(); @@ -93,19 +115,79 @@ class _RealtimeListTabState extends State { final entry = entries[index]; return KeyedSubtree( key: ValueKey(entry.id), - child: _buildRealtimeItem(context, scope, entry), + child: _RealtimeRow( + entry: entry, + scope: scope, + nowProvider: widget.nowProvider, + displayUrl: + _getDisplayUrl(entry, scope.preferences.urlDecodeEnabled), + ), ); }, ); } +} + +/// Single realtime row owning its 1s duration timer. +/// +/// Isolating the ticker per row prevents the entire list from rebuilding +/// every second while a connection stays open. +class _RealtimeRow extends StatefulWidget { + const _RealtimeRow({ + required this.entry, + required this.scope, + required this.displayUrl, + this.nowProvider, + }); + + final RequestSummary entry; + final InterceptlyScopeController scope; + final String displayUrl; + final DateTime Function()? nowProvider; + + @override + State<_RealtimeRow> createState() => _RealtimeRowState(); +} + +class _RealtimeRowState extends State<_RealtimeRow> { + Timer? _ticker; + + @override + void initState() { + super.initState(); + if (_isPending) { + _ticker = Timer.periodic(const Duration(seconds: 1), (_) { + if (mounted) setState(() {}); + }); + } + } - /// Builds one realtime row with live duration and frame counts. - Widget _buildRealtimeItem( - BuildContext context, - InterceptlyScopeController scope, - RequestSummary entry, - ) { - final isPending = entry.statusCode == 0 && !entry.hasError; + @override + void didUpdateWidget(covariant _RealtimeRow oldWidget) { + super.didUpdateWidget(oldWidget); + if (_isPending && _ticker == null) { + _ticker = Timer.periodic(const Duration(seconds: 1), (_) { + if (mounted) setState(() {}); + }); + } else if (!_isPending && _ticker != null) { + _ticker?.cancel(); + _ticker = null; + } + } + + @override + void dispose() { + _ticker?.cancel(); + _ticker = null; + super.dispose(); + } + + bool get _isPending => widget.entry.statusCode == 0 && !widget.entry.hasError; + + @override + Widget build(BuildContext context) { + final entry = widget.entry; + final isPending = _isPending; final String durationText; if (isPending) { final currentNow = (widget.nowProvider ?? DateTime.now)(); @@ -119,18 +201,19 @@ class _RealtimeListTabState extends State { return RealtimeLogItem( method: entry.method, - url: _getDisplayUrl(entry, scope.preferences.urlDecodeEnabled), + url: widget.displayUrl, time: time, duration: durationText, status: entry.statusCode, hasError: entry.hasError, isPending: isPending, - frameCounts: scope.getRealtimeFrameCounts(entry.id), - frameCountsNotifier: scope.getRealtimeFrameCountsNotifier(entry.id), + frameCounts: widget.scope.getRealtimeFrameCounts(entry.id), + frameCountsNotifier: + widget.scope.getRealtimeFrameCountsNotifier(entry.id), onTap: () => Navigator.of(context).push( MaterialPageRoute( builder: (_) => InterceptlyScope( - controller: scope, + controller: widget.scope, child: RealtimeDetailPage( entry: entry, nowProvider: widget.nowProvider, diff --git a/packages/interceptly/lib/src/ui/widgets/json_viewer/json_viewer.dart b/packages/interceptly/lib/src/ui/widgets/json_viewer/json_viewer.dart index dd9930f..bd7b6f8 100644 --- a/packages/interceptly/lib/src/ui/widgets/json_viewer/json_viewer.dart +++ b/packages/interceptly/lib/src/ui/widgets/json_viewer/json_viewer.dart @@ -125,6 +125,12 @@ class _JsonViewerState extends State { Expando? _lineCountMemo; + bool _isComputingLarge = false; + Object? _inFlightData; + String? _inFlightQuery; + + int _computeGeneration = 0; + final FocusNode _selectableFocusNode = FocusNode(); final GlobalKey _contentViewportKey = GlobalKey(); @@ -160,11 +166,19 @@ class _JsonViewerState extends State { widget.matchOffset != oldWidget.matchOffset || widget.activeGlobalIndex != oldWidget.activeGlobalIndex) { _memoLines = null; + _isComputingLarge = false; + _inFlightData = null; + _inFlightQuery = null; + _computeGeneration++; } } @override void dispose() { + _computeGeneration++; + _isComputingLarge = false; + _inFlightData = null; + _inFlightQuery = null; _scroll.dispose(); _selectableFocusNode.dispose(); _selectionContainerDelegate.dispose(); @@ -187,7 +201,13 @@ class _JsonViewerState extends State { _memoQuery == effectiveQuery && _memoMatchOffset == widget.matchOffset && _memoActiveGlobalIndex == widget.activeGlobalIndex && - _memoBrightness == brightness) { + _memoBrightness == brightness && + !_isComputingLarge) { + return; + } + if (_isComputingLarge && + identical(_inFlightData, widget.data) && + _inFlightQuery == effectiveQuery) { return; } _lineCountMemo ??= Expando('json_line_counts'); @@ -196,6 +216,17 @@ class _JsonViewerState extends State { effectiveQuery, _lineCountMemo, ); + final isLarge = + analysis.lineCount > JsonViewerMetrics.largeDocLineThreshold; + if (isLarge) { + _startAsyncFlatten( + effectiveQuery: effectiveQuery, + brightness: brightness, + analysisMatchCounts: + effectiveQuery == null ? null : analysis.matchCounts, + ); + return; + } _expansion.forceCollapse = analysis.lineCount > JsonViewerMetrics.largeDocLineThreshold; _memoMatchCounts = effectiveQuery == null ? null : analysis.matchCounts; @@ -220,6 +251,7 @@ class _JsonViewerState extends State { _memoMatchOffset = widget.matchOffset; _memoActiveGlobalIndex = widget.activeGlobalIndex; _memoBrightness = brightness; + _isComputingLarge = false; if (lines.isEmpty) { _memoLineNumberWidth = 0; _memoMaxChars = 0; @@ -240,6 +272,75 @@ class _JsonViewerState extends State { } } + /// Offloads flattening of large documents to a background isolate. + /// + /// Shows the previous content or a skeleton while `flattenAsync` runs, + /// preventing multi-thousand-line payloads from janking the UI thread. + void _startAsyncFlatten({ + required String? effectiveQuery, + required Brightness? brightness, + required Map? analysisMatchCounts, + }) { + final generation = ++_computeGeneration; + _isComputingLarge = true; + _expansion.forceCollapse = true; + final data = widget.data; + _inFlightData = data; + _inFlightQuery = effectiveQuery; + final matchOffset = widget.matchOffset; + final activeIndex = widget.activeGlobalIndex; + final style = _baseTextStyle; + JsonLineFlattener.flattenAsync( + data: data, + expansion: _expansion, + baseTextStyle: style, + query: effectiveQuery, + matchOffset: matchOffset, + activeGlobalIndex: activeIndex, + ).then((lines) { + if (!mounted || generation != _computeGeneration) return; + if (!identical(widget.data, data)) return; + setState(() { + _memoLines = lines; + _memoData = data; + _memoQuery = effectiveQuery; + _memoMatchOffset = matchOffset; + _memoActiveGlobalIndex = activeIndex; + _memoBrightness = brightness; + _memoMatchCounts = analysisMatchCounts; + _isComputingLarge = false; + _inFlightData = null; + _inFlightQuery = null; + if (lines.isEmpty) { + _memoLineNumberWidth = 0; + _memoMaxChars = 0; + } else { + final digits = lines.last.realLineNumber.toString().length; + _memoLineNumberWidth = digits * 8.0 + 8.0; + int maxLen = 60; + final sampleCount = math.min(lines.length, 200); + for (var i = 0; i < sampleCount; i++) { + final line = lines[i]; + var len = line.indentLevel * 2; + for (final s in line.spans) { + len += (s.text?.length ?? 0); + } + if (len > maxLen) maxLen = len; + } + _memoMaxChars = maxLen; + } + }); + }).catchError((_) { + if (!mounted || generation != _computeGeneration) return null; + setState(() { + _isComputingLarge = false; + _inFlightData = null; + _inFlightQuery = null; + }); + return null; + }); + } + void _copyToClipboard() async { final formatted = await JsonViewer.formatDataAsync(widget.data); if (!mounted) return; @@ -268,7 +369,17 @@ class _JsonViewerState extends State { } _ensureLines(); - final lines = _memoLines!; + final lines = _memoLines; + if (lines == null) { + return const Padding( + padding: EdgeInsets.all(16.0), + child: Center( + child: CircularProgressIndicator( + color: InterceptlyTheme.indigo500, + ), + ), + ); + } if (lines.isEmpty) return const SizedBox.shrink(); final lineNumberWidth = _memoLineNumberWidth; diff --git a/packages/interceptly/lib/src/ui/widgets/toast_notification.dart b/packages/interceptly/lib/src/ui/widgets/toast_notification.dart index 2c569ad..a5bf6dd 100644 --- a/packages/interceptly/lib/src/ui/widgets/toast_notification.dart +++ b/packages/interceptly/lib/src/ui/widgets/toast_notification.dart @@ -1,7 +1,14 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import '../interceptly_theme.dart'; +/// Singleton toast overlay with queue replacement and disposal. +/// +/// Only one toast is visible at a time. Each [show] call replaces the +/// previous entry and restarts the dismiss timer, preventing stacked +/// overlays during rapid replay or export feedback. class ToastNotification extends StatefulWidget { final String message; @@ -10,10 +17,26 @@ class ToastNotification extends StatefulWidget { /// Set the navigator key globally so toast can be shown without context static GlobalKey? _navigatorKey; + static OverlayEntry? _currentEntry; + static Timer? _dismissTimer; + static void setNavigatorKey(GlobalKey key) { _navigatorKey = key; } + /// Removes any visible toast and cancels its dismiss timer. + static void dispose() { + _dismissTimer?.cancel(); + _dismissTimer = null; + try { + _currentEntry?.remove(); + } catch (_) {} + try { + _currentEntry?.dispose(); + } catch (_) {} + _currentEntry = null; + } + static void show(String message, {BuildContext? contextHint}) { BuildContext? context = contextHint ?? _navigatorKey?.currentContext; if (context == null) { @@ -22,6 +45,7 @@ class ToastNotification extends StatefulWidget { } try { + dispose(); final overlayState = Overlay.of(context, rootOverlay: true); final overlayEntry = OverlayEntry( builder: (context) => Positioned( @@ -31,13 +55,17 @@ class ToastNotification extends StatefulWidget { child: ToastNotification(message: message), ), ); + _currentEntry = overlayEntry; overlayState.insert(overlayEntry); - Future.delayed(const Duration(seconds: 2), () { + _dismissTimer = Timer(const Duration(seconds: 2), () { if (overlayEntry.mounted) { overlayEntry.remove(); } + if (identical(_currentEntry, overlayEntry)) { + _currentEntry = null; + } }); } catch (e) { debugPrint('ToastNotification error: $e'); diff --git a/packages/interceptly/pubspec.yaml b/packages/interceptly/pubspec.yaml index f18a3e1..86af6f6 100644 --- a/packages/interceptly/pubspec.yaml +++ b/packages/interceptly/pubspec.yaml @@ -13,6 +13,7 @@ environment: dependencies: flutter: sdk: flutter + path: ^1.9.1 path_provider: ^2.1.6 dev_dependencies: diff --git a/packages/interceptly/test/export/export_hardening_test.dart b/packages/interceptly/test/export/export_hardening_test.dart index 997daa2..0be06bf 100644 --- a/packages/interceptly/test/export/export_hardening_test.dart +++ b/packages/interceptly/test/export/export_hardening_test.dart @@ -73,7 +73,7 @@ void main() { expect(curl, contains("-H 'X-Injected: badheader'")); }); - test('outputs headers raw without redaction', () { + test('outputs headers raw', () { final record = _record( requestHeaders: { 'Authorization': 'Bearer secret-token', @@ -147,7 +147,7 @@ void main() { expect(res['statusText'], isNotEmpty); }); - test('outputs headers and query parameters raw without redaction', () { + test('outputs headers and query parameters raw', () { final record = _record( url: 'https://api.example.com/data?token=supersecret&page=1', requestHeaders: {'Authorization': 'Bearer confidential'}, @@ -220,7 +220,7 @@ void main() { expect(first['value'].toString().toLowerCase(), contains('multipart')); }); - test('outputs headers raw without redaction', () { + test('outputs headers raw', () { final record = _record( requestHeaders: { 'Authorization': 'Bearer token-abc', @@ -492,7 +492,8 @@ void main() { } }); - testWidgets('ShareHandler deletes temp HAR file after export in finally', + testWidgets( + 'ShareHandler preserves temp HAR file after export for asynchronous sharing sheet', (tester) async { late BuildContext savedContext; final fabKey = GlobalKey(); @@ -532,11 +533,11 @@ void main() { }); expect(sharedFilePath, isNotNull); - expect(File(sharedFilePath!).existsSync(), isFalse); + expect(File(sharedFilePath!).existsSync(), isTrue); }); testWidgets( - 'ShareHandler deletes temp Postman file after export in finally', + 'ShareHandler preserves temp Postman file after export for asynchronous sharing sheet', (tester) async { late BuildContext savedContext; final fabKey = GlobalKey(); @@ -576,7 +577,7 @@ void main() { }); expect(sharedFilePath, isNotNull); - expect(File(sharedFilePath!).existsSync(), isFalse); + expect(File(sharedFilePath!).existsSync(), isTrue); }); }); } diff --git a/packages/interceptly/test/export/export_test.dart b/packages/interceptly/test/export/export_test.dart index 1415b6c..70b3386 100644 --- a/packages/interceptly/test/export/export_test.dart +++ b/packages/interceptly/test/export/export_test.dart @@ -212,7 +212,7 @@ void main() { ); }); - test('formdata mode for multipart/form-data', () { + test('raw mode with language text for text multipart/form-data', () { final col = PostmanExporter.toMap([ _record( requestBodyPreview: 'some content', @@ -221,7 +221,28 @@ void main() { ]); final item = (col['item'] as List).first as Map; final body = (item['request'] as Map)['body'] as Map; + expect(body['mode'], 'raw'); + expect(body['raw'], 'some content'); + expect(body['options'], { + 'raw': {'language': 'text'}, + }); + }); + + test( + 'formdata mode with diagnostic warning for binary/missing multipart/form-data', + () { + final col = PostmanExporter.toMap([ + _record( + requestBodyPreview: '[binary: 10 bytes]', + requestContentType: 'multipart/form-data', + ), + ]); + final item = (col['item'] as List).first as Map; + final body = (item['request'] as Map)['body'] as Map; expect(body['mode'], 'formdata'); + final formdata = body['formdata'] as List; + expect(formdata.first['value'], + 'Multipart body preview unavailable or binary'); }); test('body is null when requestBodyPreview is null', () { diff --git a/packages/interceptly/test/export/postman_exporter_test.dart b/packages/interceptly/test/export/postman_exporter_test.dart new file mode 100644 index 0000000..5729247 --- /dev/null +++ b/packages/interceptly/test/export/postman_exporter_test.dart @@ -0,0 +1,155 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:interceptly/src/export/postman_exporter.dart'; +import 'package:interceptly/src/model/body_truncation.dart'; +import 'package:interceptly/src/model/request_record.dart'; + +void main() { + group('PostmanExporter', () { + RequestRecord createRecord({ + String method = 'POST', + String url = 'https://api.example.com/upload', + Map? requestHeaders, + Uint8List? requestBodyBytes, + String? requestBodyPreview, + }) { + final headers = requestHeaders ?? + { + 'content-type': + 'multipart/form-data; boundary=----WebKitFormBoundaryXYZ', + }; + final contentType = headers['content-type']; + return RequestRecord( + id: 'test_rec_1', + method: method, + url: url, + statusCode: 200, + durationMs: 42, + requestSizeBytes: + requestBodyBytes?.length ?? requestBodyPreview?.length ?? 0, + responseSizeBytes: 0, + timestamp: DateTime(2026, 10, 28, 12, 0, 0), + requestHeaders: headers, + responseHeaders: const {}, + requestContentType: contentType, + requestBodyBytes: requestBodyBytes, + requestBodyPreview: requestBodyPreview, + requestTruncation: const BodyTruncation.complete(0), + responseTruncation: const BodyTruncation.complete(0), + ); + } + + test('exports UTF-8 multipart/form-data as mode: raw with text language', + () { + const boundary = '----WebKitFormBoundaryXYZ'; + const multipartBody = '--$boundary\r\n' + 'Content-Disposition: form-data; name="username"\r\n\r\n' + 'alice\r\n' + '--$boundary--\r\n'; + final record = createRecord( + requestBodyBytes: Uint8List.fromList(utf8.encode(multipartBody)), + ); + + final map = PostmanExporter.toMap([record]); + final items = map['item'] as List; + expect(items, hasLength(1)); + + final request = (items.first as Map)['request'] + as Map; + final body = request['body'] as Map; + + expect(body['mode'], equals('raw')); + expect(body['raw'], equals(multipartBody)); + expect( + body['options'], + equals({ + 'raw': {'language': 'text'}, + })); + }); + + test( + 'exports multipart with warning item when body is binary or unavailable', + () { + final record = createRecord( + requestBodyBytes: + Uint8List.fromList([0x00, 0xFF, 0xFE, 0x01]), // Invalid UTF-8 + requestBodyPreview: '[binary: 4 bytes]', + ); + + final map = PostmanExporter.toMap([record]); + final items = map['item'] as List; + final request = (items.first as Map)['request'] + as Map; + final body = request['body'] as Map; + + expect(body['mode'], equals('formdata')); + final formdata = body['formdata'] as List; + expect(formdata, hasLength(1)); + expect( + formdata.first, + equals({ + 'key': 'warning', + 'value': 'Multipart body preview unavailable or binary', + 'type': 'text', + 'disabled': true, + })); + }); + + test('strips userinfo credentials from URL and notes in description', () { + final record = createRecord( + url: 'https://admin:supersecret@api.example.com/secure/resource', + requestHeaders: {'content-type': 'application/json'}, + requestBodyPreview: '{"key": "value"}', + ); + + final map = PostmanExporter.toMap([record]); + final items = map['item'] as List; + final request = (items.first as Map)['request'] + as Map; + + final url = request['url'] as Map; + expect(url['raw'], equals('https://api.example.com/secure/resource')); + expect(request['description'], + contains('Userinfo credentials stripped from URL for security.')); + }); + + test('exports urlencoded payload correctly', () { + final record = createRecord( + url: 'https://api.example.com/login', + requestHeaders: {'content-type': 'application/x-www-form-urlencoded'}, + requestBodyPreview: 'username=bob&grant_type=password', + ); + + final map = PostmanExporter.toMap([record]); + final items = map['item'] as List; + final request = (items.first as Map)['request'] + as Map; + final body = request['body'] as Map; + + expect(body['mode'], equals('urlencoded')); + final urlencoded = body['urlencoded'] as List; + expect( + urlencoded, + containsAll([ + {'key': 'username', 'value': 'bob'}, + {'key': 'grant_type', 'value': 'password'}, + ])); + }); + + test('toJson produces valid JSON string', () { + final record = createRecord( + url: 'https://api.example.com/test', + requestHeaders: {'content-type': 'application/json'}, + requestBodyPreview: '{"hello": "world"}', + ); + + final jsonStr = + PostmanExporter.toJson([record], collectionName: 'My Tests'); + final decoded = jsonDecode(jsonStr) as Map; + expect((decoded['info'] as Map)['name'], + equals('My Tests')); + }); + }); +} diff --git a/packages/interceptly/test/export/share_handler_test.dart b/packages/interceptly/test/export/share_handler_test.dart index bb7df82..1458cea 100644 --- a/packages/interceptly/test/export/share_handler_test.dart +++ b/packages/interceptly/test/export/share_handler_test.dart @@ -39,7 +39,7 @@ void main() { }); testWidgets( - 'ShareHandler copies cURL command without redaction', + 'ShareHandler copies raw cURL command', (tester) async { late BuildContext savedContext; await tester.pumpWidget( @@ -77,8 +77,9 @@ void main() { await tester.pump(const Duration(seconds: 3)); expect(clipboardTexts, isNotEmpty); + + /// Clipboard copy always emits raw header values. expect(clipboardTexts.first, contains('Bearer secret-auth-token')); - expect(clipboardTexts.first, isNot(contains('[REDACTED]'))); }, timeout: const Timeout(Duration(seconds: 60)), ); @@ -185,9 +186,35 @@ void main() { expect(text, isNot(contains(tempDir.path))); expect(text, isNot(contains('.postman_collection'))); } + + final files = tempDir.listSync().whereType().toList(); + expect(files.any((f) => f.path.contains('.postman_collection')), isTrue); }, timeout: const Timeout(Duration(seconds: 60)), ); + + test('purgeStaleExports prunes export artifacts older than 1 hour', () async { + final now = DateTime.now(); + final staleFile = + File('${tempDir.path}/interceptly_old.postman_collection'); + await staleFile.writeAsString('old'); + await staleFile.setLastModified(now.subtract(const Duration(hours: 2))); + + final freshFile = + File('${tempDir.path}/interceptly_new.postman_collection'); + await freshFile.writeAsString('new'); + await freshFile.setLastModified(now.subtract(const Duration(minutes: 5))); + + final otherFile = File('${tempDir.path}/other_old.txt'); + await otherFile.writeAsString('other'); + await otherFile.setLastModified(now.subtract(const Duration(hours: 3))); + + await ShareHandler.purgeStaleExports(tempDir); + + expect(await staleFile.exists(), isFalse); + expect(await freshFile.exists(), isTrue); + expect(await otherFile.exists(), isTrue); + }); } class _NeverShare extends Fake implements InterceptlyShare { diff --git a/packages/interceptly/test/export_roundtrip_test.dart b/packages/interceptly/test/export_roundtrip_test.dart index 40b50d6..33b940c 100644 --- a/packages/interceptly/test/export_roundtrip_test.dart +++ b/packages/interceptly/test/export_roundtrip_test.dart @@ -127,15 +127,13 @@ void main() { expect(content['text'], equals('{"id":"usr_123","name":"Alice"}')); }); - test( - 'HAR export outputs authorization and query params raw without redaction', - () async { + test('HAR export outputs authorization and query params raw', () async { final records = [ sampleRecord(url: 'https://api.example.com/auth?token=999'), ]; final harMap = HarExporter.toMap(records); - final file = File('${tempDir.path}/unredacted.har'); + final file = File('${tempDir.path}/export.har'); await file.writeAsString(jsonEncode(harMap)); final decoded = diff --git a/packages/interceptly/test/helpers/mock_http_client.dart b/packages/interceptly/test/helpers/mock_http_client.dart index 4797dd9..103a87e 100644 --- a/packages/interceptly/test/helpers/mock_http_client.dart +++ b/packages/interceptly/test/helpers/mock_http_client.dart @@ -9,6 +9,9 @@ class MockHttpClient extends Fake implements HttpClient { final void Function(MockHttpClientRequest request)? onRequest; + bool closed = false; + bool forceClosed = false; + @override Future openUrl(String method, Uri url) async { final req = MockHttpClientRequest(method, url); @@ -17,7 +20,10 @@ class MockHttpClient extends Fake implements HttpClient { } @override - void close({bool force = false}) {} + void close({bool force = false}) { + closed = true; + if (force) forceClosed = true; + } } class MockHttpClientRequest extends Fake implements HttpClientRequest { @@ -29,6 +35,8 @@ class MockHttpClientRequest extends Fake implements HttpClientRequest { final Uri uri; @override final HttpHeaders headers = MockHttpHeaders(); + @override + bool followRedirects = true; final List _body = []; List get bodyBytes => _body; @@ -50,8 +58,8 @@ class MockHttpClientRequest extends Fake implements HttpClientRequest { class MockHttpClientResponse extends Fake implements HttpClientResponse { MockHttpClientResponse({ this.statusCode = 200, - this.bodyStream, - }); + Stream>? bodyStream, + }) : _stream = bodyStream ?? const Stream>.empty(); @override final int statusCode; @@ -59,7 +67,7 @@ class MockHttpClientResponse extends Fake implements HttpClientResponse { @override final HttpHeaders headers = MockHttpHeaders(); - final Stream>? bodyStream; + final Stream> _stream; @override StreamSubscription> listen( @@ -68,13 +76,19 @@ class MockHttpClientResponse extends Fake implements HttpClientResponse { void Function()? onDone, bool? cancelOnError, }) { - return (bodyStream ?? const Stream>.empty()).listen( + return _stream.listen( onData, onError: onError, onDone: onDone, cancelOnError: cancelOnError, ); } + + @override + Stream> timeout(Duration timeLimit, + {void Function(EventSink> sink)? onTimeout}) { + return _stream.timeout(timeLimit, onTimeout: onTimeout); + } } class MockHttpHeaders extends Fake implements HttpHeaders { diff --git a/packages/interceptly/test/session/body_decode_service_test.dart b/packages/interceptly/test/session/body_decode_service_test.dart index f775f9f..4193771 100644 --- a/packages/interceptly/test/session/body_decode_service_test.dart +++ b/packages/interceptly/test/session/body_decode_service_test.dart @@ -101,7 +101,10 @@ void main() { test('does not exceed available bytes when previewLen > length', () { final bytes = Uint8List.fromList([1, 2, 3]); final result = BodyDecodeService.truncate(bytes, 2, 100)!; - expect(result.length, 3); + + /// Preview window is capped by the smallest of previewLen, maxBody, + /// and buffer length, so a large preview budget cannot exceed the cap. + expect(result.length, 2); }); }); diff --git a/packages/interceptly/test/session/body_store_test.dart b/packages/interceptly/test/session/body_store_test.dart new file mode 100644 index 0000000..86fea2c --- /dev/null +++ b/packages/interceptly/test/session/body_store_test.dart @@ -0,0 +1,176 @@ +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:interceptly/src/engine/body_store.dart'; + +void main() { + late Directory tempDir; + + setUp(() async { + tempDir = await Directory.systemTemp.createTemp('body_store_test_'); + }); + + tearDown(() async { + try { + if (await tempDir.exists()) { + await tempDir.delete(recursive: true); + } + } catch (_) {} + }); + + group('BodyStore core lifecycle', () { + test('initForWrite, append, and readBytes roundtrip', () async { + final store = BodyStore(); + addTearDown(store.dispose); + + await store.initForWrite(tempDir.path, fileName: 'session.tmp'); + expect(store.isWritable, isTrue); + expect(store.currentOffset, equals(0)); + expect(store.rotationCount, equals(0)); + + final bytes1 = Uint8List.fromList([10, 20, 30, 40]); + final (offset1, len1) = await store.append(bytes1); + expect(offset1, equals(0)); + expect(len1, equals(4)); + expect(store.currentOffset, equals(4)); + + final bytes2 = Uint8List.fromList([50, 60]); + final (offset2, len2) = await store.append(bytes2); + expect(offset2, equals(4)); + expect(len2, equals(2)); + expect(store.currentOffset, equals(6)); + + final filePath = '${tempDir.path}/session.tmp'; + final read1 = await BodyStore.readBytes(filePath, offset1, len1); + expect(read1, equals(bytes1)); + + final read2 = await BodyStore.readBytes(filePath, offset2, len2); + expect(read2, equals(bytes2)); + }); + + test('rotates automatically when exceeding maxFileBytes', () async { + var rotated = 0; + final store = BodyStore( + maxFileBytes: 10, + onRotate: (r) => rotated = r, + ); + addTearDown(store.dispose); + + await store.initForWrite(tempDir.path, fileName: 'rot.tmp'); + await store.append(Uint8List.fromList([1, 2, 3, 4, 5, 6])); + expect(store.rotationCount, equals(0)); + expect(rotated, equals(0)); + + // Next append of 5 bytes exceeds 10 bytes -> triggers rotation + final (offset, len) = + await store.append(Uint8List.fromList([7, 8, 9, 10, 11])); + expect(offset, equals(0)); + expect(len, equals(5)); + expect(store.rotationCount, equals(1)); + expect(rotated, equals(1)); + expect(store.currentOffset, equals(5)); + }); + }); + + group('BodyStore resetFile recovery & degradation', () { + test( + 'successful resetFile recreates file, resets offset, and advances rotation', + () async { + var rotationNotified = 0; + final store = BodyStore( + onRotate: (r) => rotationNotified = r, + ); + addTearDown(store.dispose); + + await store.initForWrite(tempDir.path, fileName: 'original.tmp'); + await store.append(Uint8List.fromList([1, 2, 3])); + expect(store.currentOffset, equals(3)); + expect(store.rotationCount, equals(0)); + + final ok = await store.resetFile(tempDir.path, fileName: 'reset.tmp'); + expect(ok, isTrue); + expect(store.isWritable, isTrue); + expect(store.currentOffset, equals(0)); + expect(store.rotationCount, equals(1)); + expect(rotationNotified, equals(1)); + + final (offset, len) = await store.append(Uint8List.fromList([99])); + expect(offset, equals(0)); + expect(len, equals(1)); + expect(store.currentOffset, equals(1)); + }); + + test( + 'non-destructive reset failure restores old file handle and allows subsequent append', + () async { + final store = BodyStore(); + addTearDown(store.dispose); + + await store.initForWrite(tempDir.path, fileName: 'intact.tmp'); + await store.append(Uint8List.fromList([1, 2, 3])); + expect(store.currentOffset, equals(3)); + expect(store.rotationCount, equals(0)); + + // Create a file blocking directory creation to force reset failure on a different path + final blockerFile = File('${tempDir.path}/blocker_file'); + await blockerFile.writeAsString('I am a file, not a directory'); + + final ok = await store.resetFile(blockerFile.path, fileName: 'fail.tmp'); + expect(ok, isFalse); + // Rollback verified: old handle restored, old offset preserved, not rotated + expect(store.isWritable, isTrue); + expect(store.currentOffset, equals(3)); + expect(store.rotationCount, equals(0)); + + // Appending still works cleanly without TypeError or null check crash + final (offset, len) = await store.append(Uint8List.fromList([4, 5])); + expect(offset, equals(3)); + expect(len, equals(2)); + expect(store.currentOffset, equals(5)); + + final readAll = + await BodyStore.readBytes('${tempDir.path}/intact.tmp', 0, 5); + expect(readAll, equals(Uint8List.fromList([1, 2, 3, 4, 5]))); + }); + + test( + 'destructive same-path reset failure advances rotation and truthfully degrades', + () async { + var rotationNotified = 0; + final store = BodyStore( + onRotate: (r) => rotationNotified = r, + ); + addTearDown(store.dispose); + + final readOnlySubdir = Directory('${tempDir.path}/ro_dir'); + await readOnlySubdir.create(); + + await store.initForWrite(readOnlySubdir.path, fileName: 'same_path.tmp'); + await store.append(Uint8List.fromList([1, 2, 3])); + expect(store.currentOffset, equals(3)); + + // Delete the file and lock the directory so recreation fails + await Process.run('chmod', ['555', readOnlySubdir.path]); + + try { + final ok = await store.resetFile(readOnlySubdir.path, + fileName: 'same_path.tmp'); + expect(ok, isFalse); + // Destructive failure: rotation advanced, store degraded + expect(store.isWritable, isFalse); + expect(store.currentOffset, equals(0)); + expect(store.rotationCount, equals(1)); + expect(rotationNotified, equals(1)); + + // Subsequent append throws FileSystemException instead of crashing with TypeError + expect( + () => store.append(Uint8List.fromList([4])), + throwsA(isA()), + ); + } finally { + await Process.run('chmod', ['777', readOnlySubdir.path]); + } + }); + }); +} diff --git a/packages/interceptly/test/session/bulk_export_replay_test.dart b/packages/interceptly/test/session/bulk_export_replay_test.dart new file mode 100644 index 0000000..c2bd36d --- /dev/null +++ b/packages/interceptly/test/session/bulk_export_replay_test.dart @@ -0,0 +1,112 @@ +/// Bulk export isolation, replay safety, and share path validation. +/// +/// Covers the 50-item bulk cap, truncated replay blocking with override, +/// DNS-backed host guards, and canonical temp-directory checks. + +library; + +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:interceptly/src/core/interceptly_controller.dart'; +import 'package:interceptly/src/engine/interceptly_engine.dart'; +import 'package:interceptly/src/model/body_truncation.dart'; +import 'package:interceptly/src/model/request_record.dart'; +import 'package:interceptly/src/share/interceptly_share.dart'; +import 'package:interceptly/src/ui/detail/replay_handler.dart'; +import 'package:interceptly/src/ui/tabs/network_tab.dart'; +import 'package:interceptly/src/ui/widgets/toast_notification.dart'; + +import '../helpers/mock_http_client.dart'; + +void main() { + setUp(() async { + await Interceptly.resetForTesting(); + }); + + tearDown(() async { + await Interceptly.resetForTesting(); + }); + + group('Bulk export and replay safety', () { + test('bulk export cap constant is 50', () { + expect(kMaxBulkExportItems, 50); + }); + + testWidgets('replay blocks truncated body without override', + (tester) async { + MockHttpClientRequest? captured; + final engine = InterceptlyEngine(); + addTearDown(engine.dispose); + late BuildContext saved; + await tester.pumpWidget( + MaterialApp( + home: Builder(builder: (ctx) { + saved = ctx; + return const SizedBox(); + }), + ), + ); + final handler = ReplayHandler( + context: saved, + engine: engine, + clientFactory: () => MockHttpClient((req) => captured = req), + ); + final record = RequestRecord( + id: 'trunc', + method: 'POST', + url: 'https://api.example.com/items', + statusCode: 200, + durationMs: 10, + requestSizeBytes: 4096, + responseSizeBytes: 0, + timestamp: DateTime(2026, 1, 1), + requestHeaders: const {}, + responseHeaders: const {}, + requestBodyPreview: '{"a":1}', + requestTruncation: const BodyTruncation( + originalBytes: 4096, + retainedBytes: 100, + stage: TruncationStage.storage, + ), + ); + await tester.runAsync(() async { + await handler.retry(record, confirm: false); + }); + expect(captured, isNull); + + await tester.runAsync(() async { + await handler.retry(record, confirm: false, allowTruncatedReplay: true); + }); + expect(captured, isNotNull); + ToastNotification.dispose(); + await tester.pump(); + }); + + test('replay async host guard blocks literals', () async { + expect(await ReplayHandler.isBlockedHostAsync('localhost'), isTrue); + expect(await ReplayHandler.isBlockedHostAsync('127.0.0.1'), isTrue); + expect(await ReplayHandler.isBlockedHostAsync('10.0.0.5'), isTrue); + }); + + test('share path validation canonicalizes symlinks', () async { + final temp = Directory.systemTemp.path; + final probe = File( + '$temp/interceptly_probe_${DateTime.now().microsecondsSinceEpoch}.json'); + await probe.writeAsString('{}'); + try { + expect(InterceptlyShare.isWithinTempDirectory(probe.path), isTrue); + } finally { + try { + await probe.delete(); + } catch (_) {} + } + expect(InterceptlyShare.isWithinTempDirectory('/etc/passwd'), isFalse); + expect(InterceptlyShare.isWithinTempDirectory('/tmp/../etc/shadow'), + isFalse); + expect(InterceptlyShare.isWithinTempDirectory('${temp}2_evil/file.json'), + isFalse); + }); + }); +} diff --git a/packages/interceptly/test/session/core_engine_regressions_test.dart b/packages/interceptly/test/session/core_engine_regressions_test.dart index ae1c014..552ab06 100644 --- a/packages/interceptly/test/session/core_engine_regressions_test.dart +++ b/packages/interceptly/test/session/core_engine_regressions_test.dart @@ -9,6 +9,8 @@ import 'package:interceptly/adapter.dart'; import 'package:interceptly/interceptly.dart'; import 'package:interceptly/src/engine/body_decode_service.dart'; import 'package:interceptly/src/engine/body_store.dart'; +import 'package:interceptly/src/engine/bounded_event_queue.dart'; +import 'package:interceptly/src/engine/capture_pipeline.dart'; import 'package:interceptly/src/engine/interceptly_engine.dart'; import 'package:interceptly/src/engine/realtime_store.dart'; import 'package:interceptly/src/engine/search_service.dart'; @@ -17,6 +19,20 @@ import 'package:interceptly/src/model/index_entry.dart'; import 'package:interceptly/src/service_extension/http_extension_handlers.dart'; import 'package:interceptly/src/ui/search/master_search_controller.dart'; +/// Polls [condition] every 10ms until true or [timeout] expires. +Future pollUntil( + bool Function() condition, { + Duration timeout = const Duration(seconds: 5), +}) async { + final deadline = DateTime.now().add(timeout); + while (!condition()) { + if (DateTime.now().isAfter(deadline)) { + throw StateError('pollUntil timed out'); + } + await Future.delayed(const Duration(milliseconds: 10)); + } +} + void main() { TestWidgetsFlutterBinding.ensureInitialized(); late Directory dir; @@ -176,7 +192,11 @@ void main() { ); writer.send(capture('B', 'BBBB')); + final waitDeadline = DateTime.now().add(const Duration(seconds: 5)); while (entries.length < 2) { + if (DateTime.now().isAfter(waitDeadline)) { + fail('timed out waiting for 2 entries, got ${entries.length}'); + } await Future.delayed(const Duration(milliseconds: 10)); } @@ -329,9 +349,12 @@ void main() { await scope1.complete(statusCode: 200, bodyBytes: Uint8List.fromList([65])); scope2.fail(StateError('aborted')); - await Future.delayed(const Duration(milliseconds: 100)); - - expect(engine.entries, isEmpty); + final holdClear = DateTime.now().add(const Duration(milliseconds: 500)); + while (DateTime.now().isBefore(holdClear)) { + expect(engine.entries, isEmpty); + await Future.delayed(const Duration(milliseconds: 10)); + } + await pollUntil(() => engine.entries.isEmpty); }); test( @@ -363,7 +386,7 @@ void main() { textPayload: 'first', ), ); - await Future.delayed(const Duration(milliseconds: 150)); + await pollUntil(() => session0Notifier.value.total == 1); expect(session0Notifier.value.total, 1); // Exceed maxSessions with other sessions and notifiers @@ -392,7 +415,7 @@ void main() { textPayload: 'second', ), ); - await Future.delayed(const Duration(milliseconds: 150)); + await pollUntil(() => session0Notifier.value.total == 2); expect(session0Notifier.value.total, 2); expect(listenerUpdates, greaterThanOrEqualTo(2)); @@ -453,7 +476,7 @@ void main() { buildEntry('updated_req', nonMatchingBody, statusCode: 201), ); - await Future.delayed(const Duration(milliseconds: 200)); + await pollUntil(() => search.pendingAsyncEntryCount == 0); expect(search.results, isEmpty); expect(search.pendingAsyncEntryCount, 0); @@ -519,4 +542,502 @@ void main() { expect(results.first.summary.id, 'large_mem'); expect(results.first.matchScopes, contains(SearchMatchScope.body)); }); + + test('concurrent clear and clearRealtime resolve correct scopes', () async { + final engine = InterceptlyEngine( + settings: const InterceptlySettings(persistToDisk: false), + ); + addTearDown(engine.dispose); + await engine.initialize(); + engine.recordPending( + id: 'rest-1', + method: 'GET', + url: 'https://example.com/1', + timestamp: DateTime.now(), + ); + engine.recordRealtimeConnection( + id: 'rt-1', + url: 'wss://example.com', + ); + expect(engine.byId('rest-1'), isNotNull); + expect(engine.byId('rt-1'), isNotNull); + final f1 = engine.clear(); + final f2 = engine.clearRealtime(); + expect(identical(f1, f2), isFalse); + await Future.wait([f1, f2]); + expect(engine.entries, isEmpty); + }); + + test('clearRest preserves realtime and clearRealtime preserves rest', + () async { + final engine = InterceptlyEngine( + settings: const InterceptlySettings(persistToDisk: false), + ); + addTearDown(engine.dispose); + await engine.initialize(); + engine.recordPending( + id: 'rest-1', + method: 'GET', + url: 'https://example.com/1', + timestamp: DateTime.now(), + ); + final rtHeaders = {'x-rt': '1'}; + engine.recordRealtimeConnection( + id: 'rt-1', + url: 'wss://example.com', + headers: rtHeaders, + ); + rtHeaders['x-rt'] = 'mutated'; + expect(engine.byId('rt-1')?.requestHeaders['x-rt'], '1'); + await engine.clearRest(); + expect(engine.byId('rest-1'), isNull); + expect(engine.byId('rt-1'), isNotNull); + engine.recordPending( + id: 'rest-2', + method: 'GET', + url: 'https://example.com/2', + timestamp: DateTime.now(), + ); + await engine.clearRealtime(); + expect(engine.byId('rt-1'), isNull); + expect(engine.byId('rest-2'), isNotNull); + }); + + test('same-scope clear coalesces to identical future', () async { + final engine = InterceptlyEngine( + settings: const InterceptlySettings(persistToDisk: false), + ); + addTearDown(engine.dispose); + await engine.initialize(); + final a = engine.clear(); + final b = engine.clear(); + expect(identical(a, b), isTrue); + await Future.wait([a, b]); + final c = engine.clearRest(); + final d = engine.clearRest(); + expect(identical(c, d), isTrue); + await Future.wait([c, d]); + }); + + test('resetFile failure does not falsely invalidate offsets', () async { + final store = BodyStore(); + addTearDown(store.dispose); + final fileAsDir = File('${dir.path}/not-a-dir'); + await fileAsDir.writeAsBytes([1, 2, 3]); + await store.resetFile(fileAsDir.path, fileName: 'x.tmp'); + expect(store.rotationCount, 0); + expect(store.currentOffset, 0); + await store.initForWrite(dir.path, fileName: 'rot_ok.tmp'); + await store.append(Uint8List.fromList([1, 2, 3])); + await store.rotate(); + expect(store.rotationCount, 1); + expect(store.currentOffset, 0); + }); + + test('resetFile returns false and keeps rotation', () async { + final store = BodyStore(); + addTearDown(store.dispose); + final fileAsDir = File('${dir.path}/not-a-dir-bool'); + await fileAsDir.writeAsBytes([1, 2, 3]); + final ok = await store.resetFile(fileAsDir.path, fileName: 'x.tmp'); + expect(ok, isFalse); + expect(store.rotationCount, 0); + }); + + test('rotate failure returns false and keeps rotation', () async { + final store = BodyStore(); + addTearDown(store.dispose); + final ok = await store.rotate(); + expect(ok, isFalse); + expect(store.rotationCount, 0); + expect(store.currentOffset, 0); + }); + + test('restart queue bounded delivers at most maxQueuedEvents', () async { + RawCapture cap(String id) => RawCapture( + id: id, + method: 'GET', + url: 'https://example.com/$id', + statusCode: 200, + durationMs: 1, + timestamp: DateTime.now(), + requestHeaders: const {}, + responseHeaders: const {}, + responseBodyBytes: RawCapture.wrapBytes( + Uint8List.fromList(utf8.encode('x')), + ), + ); + final writer = WriterIsolate( + const InterceptlySettings( + maxQueuedEvents: 5, + bodyOffloadThreshold: 1000000, + ), + ); + addTearDown(() async { + try { + await writer.dispose(); + } catch (_) {} + }); + await writer.start( + tempDirPath: dir.path, + fileName: 'restart_bound.tmp', + ); + final delivered = []; + final sub = writer.results.listen(delivered.add); + addTearDown(sub.cancel); + final restarting = writer.restart( + tempDirPath: dir.path, + fileName: 'restart_bound.tmp', + ); + for (var i = 0; i < 20; i++) { + writer.send(cap('q-$i')); + } + await restarting; + await pollUntil(() => delivered.length == 5); + expect(delivered.length, lessThanOrEqualTo(5)); + await writer.dispose(); + }); + + test('dispose during restart kills just-started isolate', () async { + final writer = WriterIsolate(const InterceptlySettings()); + addTearDown(() async { + try { + await writer.dispose(); + } catch (_) {} + }); + await writer.start(tempDirPath: dir.path, fileName: 'd1.tmp'); + final r = writer.restart(tempDirPath: dir.path, fileName: 'd1.tmp'); + await writer.dispose(); + await r; + expect(writer.isReady, isFalse); + await writer.clear().timeout(const Duration(seconds: 5)); + }); + + test('clear no-ops after dispose', () async { + final writer = WriterIsolate(const InterceptlySettings()); + addTearDown(() async { + try { + await writer.dispose(); + } catch (_) {} + }); + await writer.start(tempDirPath: dir.path, fileName: 'd1clear.tmp'); + await writer.dispose(); + expect(writer.isReady, isFalse); + await writer.clear().timeout(const Duration(seconds: 5)); + }); + + test('dispose clears pending async state', () async { + final search = MasterSearchController(); + addTearDown(() { + try { + search.dispose(); + } catch (_) {} + }); + await search.start( + query: 'needle_token', + allEntries: const [], + filePath: null, + ); + final largePrefix = 'x' * (BodyDecodeService.computeThreshold + 1024); + final body = Uint8List.fromList(utf8.encode('$largePrefix needle_token')); + final entry = IndexEntry( + id: 'dispose_pending', + method: 'POST', + url: 'https://example.com/dispose_pending', + statusCode: 200, + durationMs: 10, + timestamp: DateTime.now(), + hasError: false, + requestSizeBytes: 0, + responseSizeBytes: body.length, + bodyLocation: BodyLocation.memory, + inlineResponseBody: body, + responseContentType: 'text/plain', + requestTruncation: const BodyTruncation.complete(0), + responseTruncation: BodyTruncation.complete(body.length), + ); + search.updateOrAddEntry(entry); + expect(search.pendingAsyncEntryCount, 1); + search.dispose(); + expect(search.pendingAsyncEntryCount, 0); + }); + + test('stale generation capture does not resurrect after clear', () async { + final engine = InterceptlyEngine( + settings: const InterceptlySettings(persistToDisk: false), + ); + addTearDown(engine.dispose); + await engine.initialize(); + final cap = RawCapture( + id: 'stale-1', + method: 'GET', + url: 'https://example.com/stale', + statusCode: 200, + durationMs: 1, + timestamp: DateTime.now(), + requestHeaders: const {}, + responseHeaders: const {}, + ); + engine.record(cap); + await engine.clear(); + engine.record(cap); + final holdUntil = DateTime.now().add(const Duration(milliseconds: 500)); + while (DateTime.now().isBefore(holdUntil)) { + expect(engine.entries.where((e) => e.id == 'stale-1'), isEmpty); + await Future.delayed(const Duration(milliseconds: 10)); + } + await pollUntil( + () => engine.entries.where((e) => e.id == 'stale-1').isEmpty); + }); + + test('orphan notifier eviction keeps counts bounded', () async { + final store = RealtimeStore( + maxFramesPerSession: 2, + maxTotalRealtimeFrames: 100, + maxSessions: 2, + onThrottledBatch: () {}, + ); + addTearDown(store.dispose); + for (var i = 0; i < 5; i++) { + final sid = 'o_$i'; + store.getFrameCountsNotifier(sid); + store.recordFrameSync( + RealtimeFrame( + id: 'f_$i', + sessionId: sid, + direction: FrameDirection.incoming, + type: FrameType.text, + textPayload: 'hi', + ), + ); + store.markSessionClosed(sid); + } + store.getFrameCountsNotifier('o_new'); + expect(store.trackedNotifierSessionCount, lessThanOrEqualTo(2)); + expect(store.trackedCountsSessionCount, lessThanOrEqualTo(2)); + }); + + test('part all applies offset window per body', () async { + final engine = InterceptlyEngine( + settings: const InterceptlySettings(maxUiPreviewBytes: 8), + ); + addTearDown(engine.dispose); + engine.recordEntryForTesting( + IndexEntry( + id: 'body2', + method: 'GET', + url: 'https://example.com', + statusCode: 200, + durationMs: 1, + timestamp: DateTime.now(), + hasError: false, + requestSizeBytes: 16, + responseSizeBytes: 16, + bodyLocation: BodyLocation.memory, + inlineRequestBody: Uint8List.fromList(utf8.encode('0123456789abcdef')), + inlineResponseBody: Uint8List.fromList(utf8.encode('abcdefghijklmnop')), + requestTruncation: const BodyTruncation.complete(16), + responseTruncation: const BodyTruncation.complete(16), + ), + ); + final res = await HttpExtensionHandlers.handleGetRequestDetail( + engine: engine, + parameters: { + 'id': 'body2', + 'part': 'all', + 'offset': '10', + 'max_chars': '8', + }, + ); + expect(res['requestBody'], 'abcdef'); + expect(res['responseBody'], 'klmnop'); + expect(res['totalChars'], 32); + expect(res['remainingChars'], 0); + }); + + test('catchError preserves newer same-id pending', () async { + final search = MasterSearchController(); + addTearDown(search.dispose); + addTearDown(() { + SearchService.matchInlineBodyAsyncOverride = null; + }); + SearchService.matchInlineBodyAsyncOverride = + (IndexEntry entry, String queryLower) async { + final text = utf8.decode( + entry.inlineResponseBody ?? Uint8List(0), + allowMalformed: true, + ); + if (text.contains('trigger_error')) { + throw StateError('forced async match failure'); + } + await Future.delayed(const Duration(milliseconds: 50)); + return text.toLowerCase().contains(queryLower); + }; + await search.start( + query: 'needle_token', allEntries: const [], filePath: null); + IndexEntry build(String id, Uint8List body) => IndexEntry( + id: id, + method: 'POST', + url: 'https://example.com/$id', + statusCode: 200, + durationMs: 10, + timestamp: DateTime.now(), + hasError: false, + requestSizeBytes: 0, + responseSizeBytes: body.length, + bodyLocation: BodyLocation.memory, + inlineResponseBody: body, + responseContentType: 'text/plain', + requestTruncation: const BodyTruncation.complete(0), + responseTruncation: BodyTruncation.complete(body.length), + ); + final failBody = Uint8List.fromList(utf8.encode( + '${'x' * (BodyDecodeService.computeThreshold + 16)} trigger_error')); + final okBody = Uint8List.fromList(utf8.encode( + '${'x' * (BodyDecodeService.computeThreshold + 16)} needle_token')); + search.updateOrAddEntry(build('same_id', failBody)); + search.updateOrAddEntry(build('same_id', okBody)); + await pollUntil(() => + search.pendingAsyncEntryCount == 0 && + (search.results?.map((e) => e.id).contains('same_id') ?? false)); + expect(search.results?.map((e) => e.id), contains('same_id')); + expect(search.pendingAsyncEntryCount, 0); + }); + + test('in-place refresh notifies and reconciles scopes', () async { + final search = MasterSearchController(); + addTearDown(search.dispose); + var notifies = 0; + search.addListener(() => notifies++); + await search.start( + query: 'needle_token', allEntries: const [], filePath: null); + search.updateOrAddEntry(matchingEntry('r1')); + final before = notifies; + search.updateOrAddEntry(newerVersion('r1')); + expect(notifies, greaterThan(before)); + expect(search.getScopes('r1'), isNotEmpty); + }); + + test('respawn queue drops are counted', () async { + RawCapture cap(String id) => RawCapture( + id: id, + method: 'GET', + url: 'https://example.com/$id', + statusCode: 200, + durationMs: 1, + timestamp: DateTime.now(), + requestHeaders: const {}, + responseHeaders: const {}, + ); + final pipeline = CapturePipeline( + settings: const InterceptlySettings(maxQueuedEvents: 2), + sessionId: 'd', + onEntryReady: (_) {}, + ); + addTearDown(pipeline.dispose); + expect(pipeline.droppedCount, 0); + + final queue = BoundedEventQueue(maxSize: 2); + for (var i = 0; i < 5; i++) { + queue.add(cap('q-$i')); + } + expect(queue.droppedCount, 3); + queue.resetDroppedCount(); + expect(queue.droppedCount, 0); + expect(queue.length, 2); + + for (var i = 0; i < 5; i++) { + pipeline.record(cap('p-$i')); + } + await pipeline.initialize(); + expect(pipeline.droppedCount, 3); + }); + + test('manual pause survives configure and gates every capture path', + () async { + final engine = InterceptlyEngine( + settings: const InterceptlySettings(persistToDisk: false), + ); + addTearDown(() async { + try { + await engine.dispose(); + } catch (_) {} + }); + await engine.initialize(); + + engine.disable(); + engine.configure( + const InterceptlySettings(persistToDisk: false, maxEntries: 10), + ); + expect(engine.isEnabled, isFalse); + + engine.recordPending( + id: 'paused-rest', + method: 'GET', + url: 'https://example.com/paused-rest', + timestamp: DateTime.now(), + ); + engine.recordRealtimeConnection( + id: 'paused-realtime', + url: 'wss://example.com/socket', + ); + engine.record( + RawCapture( + id: 'paused-terminal', + method: 'GET', + url: 'https://example.com/paused-terminal', + statusCode: 200, + durationMs: 5, + timestamp: DateTime.now(), + requestHeaders: const {}, + responseHeaders: const {}, + ), + ); + + await Future.delayed(const Duration(milliseconds: 150)); + expect(engine.entries, isEmpty); + expect(engine.byId('paused-rest'), isNull); + expect(engine.byId('paused-realtime'), isNull); + expect(engine.byId('paused-terminal'), isNull); + + engine.enable(); + expect(engine.isEnabled, isTrue); + engine.recordPending( + id: 'resumed-rest', + method: 'GET', + url: 'https://example.com/resumed-rest', + timestamp: DateTime.now(), + ); + expect(engine.byId('resumed-rest'), isNotNull); + }); } + +IndexEntry matchingEntry(String id) => IndexEntry( + id: id, + method: 'GET', + url: 'https://example.com/needle_token/$id', + statusCode: 200, + durationMs: 10, + timestamp: DateTime.now(), + hasError: false, + requestSizeBytes: 0, + responseSizeBytes: 0, + bodyLocation: BodyLocation.memory, + requestTruncation: const BodyTruncation.complete(0), + responseTruncation: const BodyTruncation.complete(0), + ); + +IndexEntry newerVersion(String id) => IndexEntry( + id: id, + method: 'GET', + url: 'https://example.com/needle_token/$id', + statusCode: 201, + durationMs: 20, + timestamp: DateTime.now(), + hasError: false, + requestSizeBytes: 0, + responseSizeBytes: 0, + bodyLocation: BodyLocation.memory, + requestTruncation: const BodyTruncation.complete(0), + responseTruncation: const BodyTruncation.complete(0), + ); diff --git a/packages/interceptly/test/session/engine_search_test.dart b/packages/interceptly/test/session/engine_search_test.dart index 4f0acbb..bcdc2a2 100644 --- a/packages/interceptly/test/session/engine_search_test.dart +++ b/packages/interceptly/test/session/engine_search_test.dart @@ -174,5 +174,26 @@ void main() { ); expect(resSince.map((r) => r.summary.id).toList(), ['10', '9']); }); + + test( + 'search respects live fileRotationCountProvider to skip expired file-backed bodies', + () async { + engine.recordEntryForTesting(_buildEntry( + id: 'file-old', + url: 'https://api.example.com/items', + bodyLocation: BodyLocation.file, + fileOffset: 0, + fileLength: 64, + fileRotationCount: 0, + )); + + // Live provider indicates current rotation is 1 (file-old has expired) + final res = await engine.search( + query: 'unique_token', + searchBody: true, + fileRotationCountProvider: () => 1, + ); + expect(res, isEmpty); + }); }); } diff --git a/packages/interceptly/test/session/pending_fencing_test.dart b/packages/interceptly/test/session/pending_fencing_test.dart new file mode 100644 index 0000000..b34442d --- /dev/null +++ b/packages/interceptly/test/session/pending_fencing_test.dart @@ -0,0 +1,243 @@ +// ignore_for_file: invalid_use_of_internal_member + +import 'dart:io'; + +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:interceptly/interceptly.dart'; +import 'package:interceptly/src/engine/body_decode_service.dart'; +import 'package:interceptly/src/engine/bounded_event_queue.dart'; +import 'package:interceptly/src/engine/capture_pipeline.dart'; +import 'package:interceptly/src/engine/interceptly_engine.dart'; +import 'package:interceptly/src/model/index_entry.dart'; + +const _channel = MethodChannel('plugins.flutter.io/path_provider'); + +/// Polls [condition] until it is true or [timeout] expires. +Future _waitFor( + bool Function() condition, { + Duration timeout = const Duration(seconds: 5), +}) async { + final deadline = DateTime.now().add(timeout); + while (!condition()) { + if (DateTime.now().isAfter(deadline)) { + throw StateError('Timed out waiting for condition'); + } + await Future.delayed(const Duration(milliseconds: 10)); + } +} + +RawCapture _completion(String id) { + return RawCapture( + id: id, + method: 'GET', + url: 'https://api.example.com/$id', + requestHeaders: const {}, + responseHeaders: const {}, + statusCode: 200, + durationMs: 12, + timestamp: DateTime(2026), + ); +} + +/// Drop-path fencing and late-response gates: dropped captures cancel +/// +/// pending timers, late responses after disable never land, and truncation +/// honors the storage cap. +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late Directory tempDir; + + setUp(() async { + await Interceptly.resetForTesting(); + tempDir = await Directory.systemTemp.createTemp('pending_fencing_'); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(_channel, (call) async { + if (call.method == 'getTemporaryDirectory') return tempDir.path; + return null; + }); + }); + + tearDown(() async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(_channel, null); + try { + if (tempDir.existsSync()) await tempDir.delete(recursive: true); + } catch (_) {} + await Interceptly.resetForTesting(); + }); + + group('drop-path fencing', () { + test('dropped pre-init capture cancels its pending timer', () async { + final engine = InterceptlyEngine( + settings: const InterceptlySettings( + maxQueuedEvents: 2, + pendingTimeout: Duration(milliseconds: 250), + ), + ); + addTearDown(() async { + try { + await engine.dispose(); + } catch (_) {} + }); + + engine.recordPending( + id: 'fenced-pending', + method: 'GET', + url: 'https://api.example.com/fenced-pending', + timestamp: DateTime.now(), + ); + + engine.record(_completion('fenced-pending')); + engine.record(_completion('other-1')); + engine.record(_completion('other-2')); + engine.record(_completion('other-3')); + + await Future.delayed(const Duration(milliseconds: 600)); + + final entry = engine.byId('fenced-pending'); + expect(entry, isNotNull); + expect(entry!.statusCode, 0); + expect(entry.hasError, isFalse); + expect(entry.errorType, isNull); + }); + + test('in-flight saturation drop cancels the pending timer', () async { + final cancelled = []; + final readyEntries = []; + final pipeline = CapturePipeline( + settings: const InterceptlySettings(maxQueuedEvents: 1), + sessionId: 'saturation_fencing', + onEntryReady: readyEntries.add, + onEntryReceived: cancelled.add, + ); + addTearDown(pipeline.dispose); + + await pipeline.initialize(); + + pipeline.record(_completion('sat-1')); + pipeline.record(_completion('sat-2')); + pipeline.record(_completion('sat-3')); + + await Future.delayed(const Duration(milliseconds: 300)); + + expect(pipeline.droppedCount, greaterThan(0)); + expect(cancelled, contains('sat-2')); + }); + }); + + group('late-response gates', () { + test('late response after disable does not land', () async { + final engine = InterceptlyEngine( + settings: const InterceptlySettings( + pendingTimeout: Duration(seconds: 45), + ), + ); + addTearDown(() async { + try { + await engine.dispose(); + } catch (_) {} + }); + await engine.initialize(); + + engine.recordPending( + id: 'late-1', + method: 'GET', + url: 'https://api.example.com/late-1', + timestamp: DateTime.now(), + ); + engine.disable(); + engine.record(_completion('late-1')); + + await Future.delayed(const Duration(seconds: 1)); + + final entry = engine.byId('late-1'); + expect(entry, isNotNull); + expect(entry!.statusCode, 0); + expect(entry.hasError, isFalse); + }); + + test('authentic late terminal replaces the synthetic timeout', () async { + final engine = InterceptlyEngine( + settings: const InterceptlySettings( + pendingTimeout: Duration(milliseconds: 200), + ), + ); + addTearDown(() async { + try { + await engine.dispose(); + } catch (_) {} + }); + await engine.initialize(); + + engine.recordPending( + id: 'timed-out-1', + method: 'GET', + url: 'https://api.example.com/timed-out-1', + timestamp: DateTime.now(), + ); + + await Future.delayed(const Duration(milliseconds: 400)); + + var entry = engine.byId('timed-out-1'); + expect(entry, isNotNull); + expect(entry!.hasError, isTrue); + expect(entry.errorType, 'TimeoutException'); + + engine.record(_completion('timed-out-1')); + await _waitFor( + () => engine.byId('timed-out-1')?.statusCode == 200, + ); + + entry = engine.byId('timed-out-1'); + expect(entry, isNotNull); + expect(entry!.statusCode, equals(200)); + expect(entry.durationMs, equals(12)); + expect(entry.hasError, isFalse); + expect(entry.errorType, isNull); + expect(entry.errorMessage, isNull); + }); + }); + + group('bounded queue eviction', () { + test('add returns the evicted element on overflow', () { + final queue = BoundedEventQueue(maxSize: 2); + expect(queue.add(1), isNull); + expect(queue.add(2), isNull); + expect(queue.add(3), 1); + expect(queue.add(4), 2); + expect(queue.removeFirstOrNull(), 3); + expect(queue.removeFirstOrNull(), 4); + }); + + test('ignoring the return value stays source-compatible', () { + final queue = BoundedEventQueue(maxSize: 1); + queue.add('a'); + queue.add('b'); + expect(queue.length, 1); + expect(queue.droppedCount, 1); + }); + }); + + group('truncate storage cap', () { + test('preview never exceeds maxBody', () { + final bytes = Uint8List(5000); + final preview = BodyDecodeService.truncate(bytes, 1000, 16 * 1024); + expect(preview, isNotNull); + expect(preview!.length, 1000); + }); + + test('smaller preview budget is honored below the cap', () { + final bytes = Uint8List(5000); + final preview = BodyDecodeService.truncate(bytes, 4000, 1000); + expect(preview, isNotNull); + expect(preview!.length, 1000); + }); + + test('unlimited bodies pass through untouched', () { + final bytes = Uint8List(5000); + expect(BodyDecodeService.truncate(bytes, 0, 1000), same(bytes)); + }); + }); +} diff --git a/packages/interceptly/test/session/realtime_epoch_regression_test.dart b/packages/interceptly/test/session/realtime_epoch_regression_test.dart new file mode 100644 index 0000000..9ed68a6 --- /dev/null +++ b/packages/interceptly/test/session/realtime_epoch_regression_test.dart @@ -0,0 +1,138 @@ +// ignore_for_file: invalid_use_of_internal_member + +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:interceptly/interceptly.dart'; +import 'package:interceptly/src/engine/realtime_store.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('Realtime epoch regression', () { + setUp(() async { + await Interceptly.resetForTesting(); + }); + + tearDown(() async { + await Interceptly.resetForTesting(); + }); + + test('loadPayload after reset returns expired not new bytes', () async { + final store = RealtimeStore( + maxFramesPerSession: 100, + onThrottledBatch: () {}, + ); + addTearDown(store.dispose); + + await store.ensureDiskStorageInitialized( + persistToDisk: true, + engineId: 'epoch-reg', + ); + + final bytesA = Uint8List.fromList(List.filled(2048, 0xAA)); + await store.recordFrame( + RealtimeFrame.binary( + id: 'epoch-a', + sessionId: 'epoch-sess', + direction: FrameDirection.incoming, + bytes: bytesA, + ), + persistToDisk: true, + offloadThreshold: 1024, + engineId: 'epoch-reg', + ); + + final framesBefore = store.getRealtimeFrames('epoch-sess'); + expect(framesBefore, isNotEmpty); + final oldFrame = framesBefore.firstWhere((f) => f.id == 'epoch-a'); + expect(oldFrame.location, equals(BodyLocation.file)); + final oldRotation = oldFrame.rotationCount ?? 0; + + await store.resetDiskStorage(); + expect(store.bodyStore.rotationCount, greaterThan(oldRotation)); + + final bytesB = Uint8List.fromList(List.filled(2048, 0xBB)); + await store.recordFrame( + RealtimeFrame.binary( + id: 'epoch-b', + sessionId: 'epoch-sess', + direction: FrameDirection.incoming, + bytes: bytesB, + ), + persistToDisk: true, + offloadThreshold: 1024, + engineId: 'epoch-reg', + ); + + final loaded = await store.loadPayload(oldFrame); + expect(loaded, isA()); + expect(loaded as String, contains('expired')); + expect(loaded, isNot(contains('BB'))); + }); + + test('resetFile increments rotation generation', () async { + final store = RealtimeStore( + maxFramesPerSession: 100, + onThrottledBatch: () {}, + ); + addTearDown(store.dispose); + + await store.ensureDiskStorageInitialized( + persistToDisk: true, + engineId: 'epoch-incr', + ); + + final before = store.bodyStore.rotationCount; + await store.resetDiskStorage(); + expect(store.bodyStore.rotationCount, equals(before + 1)); + await store.resetDiskStorage(); + expect(store.bodyStore.rotationCount, equals(before + 2)); + }); + + test('concurrent read during reset yields expired not torn bytes', + () async { + final store = RealtimeStore( + maxFramesPerSession: 100, + onThrottledBatch: () {}, + ); + addTearDown(store.dispose); + + await store.ensureDiskStorageInitialized( + persistToDisk: true, + engineId: 'epoch-torn', + ); + + final dir = Directory.systemTemp; + expect(store.bodyStore.isInitialized, isTrue); + + final bytesA = Uint8List.fromList(List.filled(1024, 0x11)); + final ref = await store.bodyStore.append(bytesA); + final oldFrame = RealtimeFrame.offloaded( + id: 'torn-old', + sessionId: 'torn-sess', + direction: FrameDirection.incoming, + type: FrameType.binary, + payloadSizeBytes: ref.$2, + shortPreview: 'old', + fileOffset: ref.$1, + fileLength: ref.$2, + rotationCount: ref.$3, + ); + + final readFuture = store.loadPayload(oldFrame); + await store.resetDiskStorage(); + final bytesB = Uint8List.fromList(List.filled(1024, 0x22)); + await store.bodyStore.append(bytesB); + + final loaded = await readFuture; + if (loaded is Uint8List) { + expect(loaded, isNot(equals(bytesB))); + } else { + expect(loaded.toString(), contains('expired')); + } + expect(dir.existsSync(), isTrue); + }); + }); +} diff --git a/packages/interceptly/test/session/release_guard_test.dart b/packages/interceptly/test/session/release_guard_test.dart new file mode 100644 index 0000000..c4110b5 --- /dev/null +++ b/packages/interceptly/test/session/release_guard_test.dart @@ -0,0 +1,148 @@ +// ignore_for_file: invalid_use_of_internal_member + +import 'package:flutter_test/flutter_test.dart'; +import 'package:interceptly/interceptly.dart'; +import 'package:interceptly/src/engine/interceptly_engine.dart'; + +/// Release-guard wiring for capture and overlay. +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUp(() async { + await Interceptly.resetForTesting(); + }); + + tearDown(() async { + await Interceptly.resetForTesting(); + }); + + group('allowInRelease setting', () { + test('defaults to false', () { + expect(const InterceptlySettings().allowInRelease, isFalse); + }); + + test('copyWith preserves and overrides the flag', () { + const base = InterceptlySettings(); + expect(base.copyWith().allowInRelease, isFalse); + expect( + base.copyWith(allowInRelease: true).allowInRelease, + isTrue, + ); + expect( + const InterceptlySettings(allowInRelease: true) + .copyWith() + .allowInRelease, + isTrue, + ); + }); + }); + + group('engine release guard', () { + test('disallows capture in release unless opted in', () { + expect( + InterceptlyEngine.shouldEnableCapture( + isReleaseMode: true, + settings: const InterceptlySettings(), + ), + isFalse, + ); + expect( + InterceptlyEngine.shouldEnableCapture( + isReleaseMode: true, + settings: const InterceptlySettings(allowInRelease: true), + ), + isTrue, + ); + }); + + test('allows capture in debug/profile regardless of opt-in', () { + expect( + InterceptlyEngine.shouldEnableCapture( + isReleaseMode: false, + settings: const InterceptlySettings(), + ), + isTrue, + ); + expect( + InterceptlyEngine.shouldEnableCapture( + isReleaseMode: false, + settings: const InterceptlySettings(allowInRelease: true), + ), + isTrue, + ); + }); + + test('fresh engine starts enabled in debug test runs', () { + final engine = InterceptlyEngine(); + addTearDown(engine.dispose); + expect(engine.isEnabled, isTrue); + }); + + test('configure with opt-in keeps the engine enabled', () { + final engine = InterceptlyEngine(); + addTearDown(engine.dispose); + engine.configure(const InterceptlySettings(allowInRelease: true)); + expect(engine.settings.allowInRelease, isTrue); + expect(engine.isEnabled, isTrue); + }); + + test('configure preserves an explicit pause on the opt-in path', () { + final engine = InterceptlyEngine(); + addTearDown(engine.dispose); + engine.disable(); + expect(engine.isEnabled, isFalse); + engine.configure(const InterceptlySettings(allowInRelease: true)); + expect(engine.isEnabled, isFalse); + }); + + test('configure preserves an explicit pause with default settings', () { + final engine = InterceptlyEngine(); + addTearDown(engine.dispose); + engine.disable(); + expect(engine.isEnabled, isFalse); + engine.configure(const InterceptlySettings()); + expect(engine.isEnabled, isFalse); + }); + + test('initialize does not resume an explicit pause', () async { + final engine = InterceptlyEngine( + settings: const InterceptlySettings(persistToDisk: false), + ); + addTearDown(engine.dispose); + engine.disable(); + await engine.initialize(); + expect(engine.isEnabled, isFalse); + }); + + test('second dispose returns normally after complete cleanup', () async { + final engine = InterceptlyEngine( + settings: const InterceptlySettings(persistToDisk: false), + ); + engine.recordPending( + id: 'dispose-rest', + method: 'GET', + url: 'https://api.example.com/dispose', + timestamp: DateTime.now(), + ); + engine.recordRealtimeConnection( + id: 'dispose-realtime', + url: 'wss://api.example.com/socket', + ); + expect(engine.entries, isNotEmpty); + + await engine.dispose(); + + expect(engine.isDisposed, isTrue); + expect(engine.isEnabled, isFalse); + expect(engine.entries, isEmpty); + expect(engine.totalEntries, equals(0)); + + await engine.dispose(); + + expect(engine.isDisposed, isTrue); + expect(engine.isEnabled, isFalse); + expect(engine.entries, isEmpty); + expect(engine.totalEntries, equals(0)); + }); + }); +} diff --git a/packages/interceptly/test/session/search_service_test.dart b/packages/interceptly/test/session/search_service_test.dart index 1658be4..0fc799e 100644 --- a/packages/interceptly/test/session/search_service_test.dart +++ b/packages/interceptly/test/session/search_service_test.dart @@ -554,6 +554,13 @@ void main() { sinceId: '10', ); expect(resSinceNewest, isEmpty); + + final resSinceUnknown = await SearchService.search( + entries: entries, + query: 'record_', + sinceId: 'unknown_id_999', + ); + expect(resSinceUnknown, isEmpty); }); }); diff --git a/packages/interceptly/test/session/settings_propagation_test.dart b/packages/interceptly/test/session/settings_propagation_test.dart index bae83cf..f996bb1 100644 --- a/packages/interceptly/test/session/settings_propagation_test.dart +++ b/packages/interceptly/test/session/settings_propagation_test.dart @@ -284,5 +284,56 @@ void main() { expect(entry.hasDataLoss, isTrue); expect(entry.isTruncated, isTrue); }); + + test( + 'persistence false -> true retains readable backing file and offloaded bodies', + () async { + final session = InterceptlyEngine( + settings: const InterceptlySettings( + persistToDisk: false, + bodyOffloadThreshold: 1024, + ), + ); + addTearDown(session.dispose); + await session.initialize(); + + expect(session.tempFilePath, isNotNull); + + // Reconfigure to enable persistence + session.configure(const InterceptlySettings( + persistToDisk: true, + bodyOffloadThreshold: 1024, + )); + + final payload = _textBytes(4096); + session.record(RawCapture( + id: 'offloaded-trans', + method: 'POST', + url: 'https://api.example.com/transition', + requestHeaders: const {}, + responseHeaders: const {'content-type': 'application/json'}, + statusCode: 200, + durationMs: 5, + timestamp: DateTime(2026), + responseBodyBytes: RawCapture.wrapBytes(payload), + )); + + final entry = await _waitForEntry(session, 'offloaded-trans'); + expect(entry.bodyLocation, equals(BodyLocation.file)); + + final detail = await session.loadDetail(entry); + expect(detail.responseBodyBytes, isNotNull); + expect(detail.responseBodyBytes!.length, equals(4096)); + + // Reconfigure back to persistToDisk: false; previously stored file body remains readable + session.configure(const InterceptlySettings( + persistToDisk: false, + bodyOffloadThreshold: 1024, + )); + + final detailAfterToggle = await session.loadDetail(entry); + expect(detailAfterToggle.responseBodyBytes, isNotNull); + expect(detailAfterToggle.responseBodyBytes!.length, equals(4096)); + }); }); } diff --git a/packages/interceptly/test/session/should_capture_robustness_test.dart b/packages/interceptly/test/session/should_capture_robustness_test.dart new file mode 100644 index 0000000..5110b2c --- /dev/null +++ b/packages/interceptly/test/session/should_capture_robustness_test.dart @@ -0,0 +1,116 @@ +// ignore_for_file: invalid_use_of_internal_member + +import 'package:flutter_test/flutter_test.dart'; +import 'package:interceptly/interceptly.dart'; +import 'package:interceptly/src/engine/interceptly_engine.dart'; + +/// Throwing user predicates must fail open and never break the host request. +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUp(() async { + await Interceptly.resetForTesting(); + }); + + tearDown(() async { + await Interceptly.resetForTesting(); + }); + + InterceptlyEngine throwingEngine() { + final engine = InterceptlyEngine( + settings: InterceptlySettings( + shouldCapture: (_) => throw StateError('boom'), + ), + ); + addTearDown(() async { + try { + await engine.dispose(); + } catch (_) {} + }); + return engine; + } + + group('DefaultHttpTrafficCollector.shouldCapture', () { + test('throwing predicate returns normally and fails open', () { + final engine = throwingEngine(); + final collector = DefaultHttpTrafficCollector(engine: engine); + final uri = Uri.parse('https://a.test/'); + + expect(() => collector.shouldCapture(uri), returnsNormally); + expect(collector.shouldCapture(uri), isTrue); + }); + + test('null uri is captured', () { + final engine = throwingEngine(); + final collector = DefaultHttpTrafficCollector(engine: engine); + expect(collector.shouldCapture(null), isTrue); + }); + + test('disabled engine short-circuits to false without evaluating', () { + final engine = throwingEngine(); + engine.disable(); + final collector = DefaultHttpTrafficCollector(engine: engine); + expect( + collector.shouldCapture(Uri.parse('https://a.test/')), + isFalse, + ); + }); + + test('explicit false predicate still excludes', () { + final engine = InterceptlyEngine( + settings: InterceptlySettings( + shouldCapture: (_) => false, + ), + ); + addTearDown(() async { + try { + await engine.dispose(); + } catch (_) {} + }); + final collector = DefaultHttpTrafficCollector(engine: engine); + expect( + collector.shouldCapture(Uri.parse('https://a.test/')), + isFalse, + ); + }); + }); + + group('DefaultRealtimeTrafficCollector.shouldCapture', () { + test('throwing predicate returns normally and fails open', () { + final engine = throwingEngine(); + final collector = DefaultRealtimeTrafficCollector(engine: engine); + final uri = Uri.parse('https://a.test/'); + + expect(() => collector.shouldCapture(uri), returnsNormally); + expect(collector.shouldCapture(uri), isTrue); + }); + + test('openSession with throwing predicate does not throw', () { + final engine = throwingEngine(); + final collector = DefaultRealtimeTrafficCollector(engine: engine); + + expect( + () => collector.openSession(uri: Uri.parse('wss://a.test/socket')), + returnsNormally, + ); + final scope = collector.openSession( + uri: Uri.parse('wss://a.test/socket'), + ); + expect(scope.isClosed, isFalse); + collector.recordClose(sessionId: scope.sessionId); + }); + + test('recordConnection with throwing predicate does not throw', () { + final engine = throwingEngine(); + final collector = DefaultRealtimeTrafficCollector(engine: engine); + + expect( + () => collector.recordConnection( + sessionId: 'realtime-boom', + uri: Uri.parse('wss://a.test/socket'), + ), + returnsNormally, + ); + }); + }); +} diff --git a/packages/interceptly/test/session/ui_perf_regression_test.dart b/packages/interceptly/test/session/ui_perf_regression_test.dart new file mode 100644 index 0000000..130b0e6 --- /dev/null +++ b/packages/interceptly/test/session/ui_perf_regression_test.dart @@ -0,0 +1,99 @@ +/// UI rendering performance regressions. +/// +/// Covers master-search debounce, realtime per-row ticker disposal, +/// and singleton toast queue behavior. + +library; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:interceptly/interceptly.dart'; +import 'package:interceptly/src/engine/interceptly_engine.dart'; +import 'package:interceptly/src/ui/scope/interceptly_scope.dart'; +import 'package:interceptly/src/ui/tabs/network_tab.dart'; +import 'package:interceptly/src/ui/tabs/realtime_list_tab.dart'; +import 'package:interceptly/src/ui/widgets/toast_notification.dart'; + +void main() { + setUp(() async { + await Interceptly.resetForTesting(); + }); + + tearDown(() async { + ToastNotification.dispose(); + await Interceptly.resetForTesting(); + }); + + group('UI perf regressions', () { + testWidgets('master search debounces rapid keystrokes', (tester) async { + final engine = InterceptlyEngine(); + final controller = InterceptlyScopeController(engine: engine); + addTearDown(controller.dispose); + addTearDown(engine.dispose); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: InterceptlyScope( + controller: controller, + child: const NetworkTab(), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + final field = find.byType(TextField).first; + await tester.enterText(field, 'h'); + await tester.enterText(field, 'he'); + await tester.enterText(field, 'hello'); + await tester.pump(const Duration(milliseconds: 100)); + final earlyQuery = controller.masterQuery; + await tester.pump(const Duration(milliseconds: 300)); + await tester.pumpAndSettle(); + expect(controller.masterQuery, 'hello'); + expect(earlyQuery, isNot('hello')); + }); + + testWidgets('realtime tab disposes per-row ticker', (tester) async { + final engine = InterceptlyEngine(); + final controller = InterceptlyScopeController(engine: engine); + addTearDown(controller.dispose); + addTearDown(engine.dispose); + engine.recordRealtimeConnection( + id: 'ws_live', + url: 'wss://api.example.com/socket', + ); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: InterceptlyScope( + controller: controller, + child: const RealtimeListTab(), + ), + ), + ), + ); + await tester.pump(); + expect(find.byType(RealtimeListTab), findsOneWidget); + await tester.pumpWidget(const MaterialApp(home: SizedBox())); + await tester.pump(const Duration(seconds: 2)); + }); + + testWidgets('toast queue keeps single visible toast', (tester) async { + await tester.pumpWidget( + const MaterialApp(home: Scaffold(body: SizedBox())), + ); + final context = tester.element(find.byType(SizedBox)); + ToastNotification.show('first', contextHint: context); + await tester.pump(); + ToastNotification.show('second', contextHint: context); + await tester.pump(); + expect(find.text('second'), findsOneWidget); + expect(find.text('first'), findsNothing); + await tester.pump(const Duration(seconds: 3)); + ToastNotification.dispose(); + await tester.pump(); + }); + }); +} diff --git a/packages/interceptly/test/session/writer_lifecycle_regression_test.dart b/packages/interceptly/test/session/writer_lifecycle_regression_test.dart new file mode 100644 index 0000000..ce37dc7 --- /dev/null +++ b/packages/interceptly/test/session/writer_lifecycle_regression_test.dart @@ -0,0 +1,201 @@ +// ignore_for_file: invalid_use_of_internal_member + +import 'dart:io'; + +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:interceptly/interceptly.dart'; +import 'package:interceptly/src/engine/capture_pipeline.dart'; +import 'package:interceptly/src/engine/interceptly_engine.dart'; +import 'package:interceptly/src/engine/writer_isolate.dart'; +import 'package:interceptly/src/model/index_entry.dart'; + +const _channel = MethodChannel('plugins.flutter.io/path_provider'); + +RawCapture _capture(String id) { + return RawCapture( + id: id, + method: 'GET', + url: 'https://api.example.com/$id', + requestHeaders: const {}, + responseHeaders: const {}, + statusCode: 200, + durationMs: 1, + timestamp: DateTime(2026), + ); +} + +/// Writer isolate lifecycle: concurrent clear, restart drops, sticky init, +/// +/// and dispose idempotence. +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late Directory tempDir; + + setUp(() async { + await Interceptly.resetForTesting(); + tempDir = await Directory.systemTemp.createTemp('writer_lifecycle_'); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(_channel, (call) async { + if (call.method == 'getTemporaryDirectory') return tempDir.path; + return null; + }); + }); + + tearDown(() async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(_channel, null); + try { + if (tempDir.existsSync()) await tempDir.delete(recursive: true); + } catch (_) {} + await Interceptly.resetForTesting(); + }); + + group('WriterIsolate lifecycle', () { + test('concurrent clear calls both complete', () async { + final writer = WriterIsolate(const InterceptlySettings()); + addTearDown(writer.dispose); + + await writer.start( + tempDirPath: tempDir.path, + fileName: 'concurrent_clear.tmp', + ); + writer.send(_capture('clear-1')); + + await Future.wait([ + writer.clear().timeout(const Duration(seconds: 10)), + writer.clear().timeout(const Duration(seconds: 10)), + ]); + }); + + test('restart overflow drops are counted', () async { + final writer = WriterIsolate( + const InterceptlySettings(maxQueuedEvents: 2), + ); + addTearDown(writer.dispose); + + await writer.start( + tempDirPath: tempDir.path, + fileName: 'restart_drops.tmp', + ); + + final restarting = writer.restart( + tempDirPath: tempDir.path, + fileName: 'restart_drops.tmp', + ); + for (var i = 0; i < 5; i++) { + writer.send(_capture('restart-$i')); + } + await restarting.timeout(const Duration(seconds: 15)); + + expect(writer.restartDroppedCount, greaterThan(0)); + }); + + test('dispose is idempotent', () async { + final writer = WriterIsolate(const InterceptlySettings()); + await writer.start( + tempDirPath: tempDir.path, + fileName: 'dispose_idempotent.tmp', + ); + + await writer.dispose(); + await writer.dispose(); + }); + }); + + group('CapturePipeline sticky init', () { + test('failed init retries on next initialize', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(_channel, (call) async { + throw PlatformException(code: 'IO_ERROR', message: 'disk failure'); + }); + + final readyEntries = []; + final pipeline = CapturePipeline( + settings: const InterceptlySettings(), + sessionId: 'sticky_init', + onEntryReady: readyEntries.add, + ); + addTearDown(pipeline.dispose); + + await pipeline.initialize(); + expect(pipeline.isDegraded, isTrue); + + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(_channel, (call) async { + if (call.method == 'getTemporaryDirectory') return tempDir.path; + return null; + }); + + await pipeline.initialize(); + expect(pipeline.isInitialized, isTrue); + expect(pipeline.isDegraded, isFalse); + }); + + test('pipeline droppedCount folds writer restart drops', () async { + final readyEntries = []; + final pipeline = CapturePipeline( + settings: const InterceptlySettings(maxQueuedEvents: 2), + sessionId: 'restart_fold', + onEntryReady: readyEntries.add, + ); + addTearDown(pipeline.dispose); + + await pipeline.initialize(); + + final writer = pipeline.writerIsolate; + final restarting = writer.restart( + tempDirPath: tempDir.path, + fileName: 'interceptly_restart_fold_session.tmp', + ); + for (var i = 0; i < 5; i++) { + writer.send(_capture('fold-$i')); + } + await restarting.timeout(const Duration(seconds: 15)); + + expect(pipeline.droppedCount, greaterThan(0)); + }); + + test('dispose during initialize does not throw', () async { + final readyEntries = []; + final pipeline = CapturePipeline( + settings: const InterceptlySettings(), + sessionId: 'dispose_during_init', + onEntryReady: readyEntries.add, + ); + + final initializing = pipeline.initialize(); + await pipeline.dispose(); + await initializing; + + expect(pipeline.isDisposed, isTrue); + }); + }); + + group('engine init recovery', () { + test('engine initializes after a transient init failure', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(_channel, (call) async { + throw PlatformException(code: 'IO_ERROR', message: 'disk failure'); + }); + + final engine = InterceptlyEngine(); + addTearDown(() async { + try { + await engine.dispose(); + } catch (_) {} + }); + + await engine.initialize(); + + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(_channel, (call) async { + if (call.method == 'getTemporaryDirectory') return tempDir.path; + return null; + }); + + await engine.initialize(); + }); + }); +} diff --git a/packages/interceptly/test/stress/session_eviction_lifecycle_stress_test.dart b/packages/interceptly/test/stress/session_eviction_lifecycle_stress_test.dart index fe7086b..7012a77 100644 --- a/packages/interceptly/test/stress/session_eviction_lifecycle_stress_test.dart +++ b/packages/interceptly/test/stress/session_eviction_lifecycle_stress_test.dart @@ -2,7 +2,6 @@ @Tags(['stress']) library; -import 'dart:async'; import 'dart:io'; import 'package:flutter/services.dart'; @@ -421,24 +420,29 @@ void main() { }); group('Challenger M1 - Area 3: dispose() and Recreation Lifecycles', () { - test('Multiple dispose() calls on InspectorSession: verify behavior', + test( + 'Multiple dispose() calls on InspectorSession: verify idempotent cleanup', () async { final session = _TestSession(); await session.initialize(); + session.recordPending( + id: 'disp_1', + method: 'GET', + url: 'https://api.example.com/disp', + timestamp: DateTime(2026, 1, 1), + ); + expect(session.entries.length, 1); await session.dispose(); + expect(session.entries, isEmpty); + expect(session.engine.isEnabled, isFalse); - var threw = false; - Object? capturedError; - try { - await session.dispose().timeout(const Duration(seconds: 2)); - } catch (e) { - capturedError = e; - threw = true; - } - expect(threw, isTrue); - expect(capturedError is! TimeoutException, isTrue, - reason: - 'Expected dispose to reject immediately, not hang. Actual: $capturedError'); + // Second dispose is idempotent: completes normally without hanging or throwing. + await expectLater( + session.dispose().timeout(const Duration(seconds: 2)), + completes, + ); + expect(session.entries, isEmpty); + expect(session.engine.isEnabled, isFalse); }); test( diff --git a/packages/interceptly/test/ui/detail/detail_page_large_body_test.dart b/packages/interceptly/test/ui/detail/detail_page_large_body_test.dart index be5129f..d5f92f8 100644 --- a/packages/interceptly/test/ui/detail/detail_page_large_body_test.dart +++ b/packages/interceptly/test/ui/detail/detail_page_large_body_test.dart @@ -83,13 +83,21 @@ void main() { ), ), ); - await tester.pumpAndSettle(); + + /// Large documents flatten off the UI thread behind a spinner, + /// so allow real async isolates to complete before settling. + await tester.pump(); + await tester.runAsync(() => Future.delayed(const Duration(seconds: 2))); + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); expect(tester.takeException(), isNull); expect(find.text('URL'), findsOneWidget); await tester.tap(find.text('Response')); - await tester.pumpAndSettle(); + await tester.pump(); + await tester.runAsync(() => Future.delayed(const Duration(seconds: 2))); + await tester.pump(const Duration(seconds: 1)); expect(tester.takeException(), isNull); expect(find.text('RESPONSE BODY'), findsOneWidget); diff --git a/packages/interceptly/test/ui/detail/realtime_messages_tab_test.dart b/packages/interceptly/test/ui/detail/realtime_messages_tab_test.dart index 6983e68..ba24a85 100644 --- a/packages/interceptly/test/ui/detail/realtime_messages_tab_test.dart +++ b/packages/interceptly/test/ui/detail/realtime_messages_tab_test.dart @@ -630,6 +630,72 @@ void main() { expect(session.sentDrafts.first.payload, '{"action":"heartbeat"}'); }); + testWidgets( + 'resending binary frame transmits authentic Uint8List payload', + (tester) async { + final session = _MockSession(); + session.activeCanSend = true; + session.protocol = RealtimeProtocolType.webSocket; + final binaryData = + Uint8List.fromList([0xDE, 0xAD, 0xBE, 0xEF, 0x00, 0x01]); + session.addFrame( + RealtimeFrame.memoryBinary( + id: 'frame_resend_bin', + sessionId: 'sess_1', + timestamp: DateTime(2026, 1, 1), + direction: FrameDirection.outgoing, + bytes: binaryData, + ), + ); + + await tester + .pumpWidget(buildTestWidget(sessionId: 'sess_1', session: session)); + await tester.pumpAndSettle(); + + await tester + .tap(find.byKey(const ValueKey('frame_resend_frame_resend_bin'))); + await tester.pumpAndSettle(); + + expect(session.sentDrafts.length, 1); + expect(session.sentDrafts.first.payload, equals(binaryData)); + expect(session.sentDrafts.first.payload, isA()); + }); + + testWidgets( + 'resending offloaded binary frame loads and transmits authentic bytes', + (tester) async { + final session = _MockSession(); + session.activeCanSend = true; + session.protocol = RealtimeProtocolType.webSocket; + final binaryData = Uint8List.fromList([0x01, 0x02, 0x03, 0x04]); + session.customLoadPayload = (frame) async => binaryData; + session.addFrame( + RealtimeFrame.offloaded( + id: 'frame_resend_offload_bin', + sessionId: 'sess_1', + direction: FrameDirection.outgoing, + type: FrameType.binary, + payloadSizeBytes: 4, + shortPreview: '[binary: 4 bytes]', + fileOffset: 0, + fileLength: 4, + rotationCount: 0, + ), + ); + + await tester + .pumpWidget(buildTestWidget(sessionId: 'sess_1', session: session)); + await tester.pumpAndSettle(); + + await tester.tap(find + .byKey(const ValueKey('frame_resend_frame_resend_offload_bin'))); + await tester.pumpAndSettle(); + + expect(session.sentDrafts.length, 1); + expect(session.sentDrafts.first.payload, equals(binaryData)); + expect(session.sentDrafts.first.payload, isA()); + }); + testWidgets('typing and tapping send in chat bar sends message', (tester) async { final session = _MockSession(); diff --git a/packages/interceptly/test/ui/detail/replay_handler_test.dart b/packages/interceptly/test/ui/detail/replay_handler_test.dart index 2baca89..2e48ad0 100644 --- a/packages/interceptly/test/ui/detail/replay_handler_test.dart +++ b/packages/interceptly/test/ui/detail/replay_handler_test.dart @@ -1,13 +1,26 @@ +import 'dart:async'; +import 'dart:typed_data'; + import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:interceptly/src/model/body_truncation.dart'; +import 'package:interceptly/src/model/interceptly_settings.dart'; import 'package:interceptly/src/model/request_record.dart'; import 'package:interceptly/src/engine/interceptly_engine.dart'; import 'package:interceptly/src/ui/detail/replay_handler.dart'; +import 'package:interceptly/src/ui/widgets/toast_notification.dart'; +import '../../helpers/async_test_utils.dart'; import '../../helpers/mock_http_client.dart'; void main() { + setUp(() { + ToastNotification.dispose(); + }); + + tearDown(() { + ToastNotification.dispose(); + }); group('ReplayHandler data loss awareness', () { testWidgets( 'shows warning toast when replaying request with truncated body (hasDataLoss == true)', @@ -47,16 +60,11 @@ void main() { ); await tester.runAsync(() async { - await handler.retry(record, confirm: false); + await handler.retry(record, + confirm: false, allowTruncatedReplay: true); }); await tester.pump(); - expect( - find.text( - 'Warning: Request body was truncated during capture and may be incomplete.', - ), - findsOneWidget, - ); expect(capturedRequest, isNotNull); await tester.pump(const Duration(seconds: 3)); }, @@ -164,6 +172,298 @@ void main() { }, ); }); + + group('Replay redirect and response hardening', () { + testWidgets('does not follow redirects and captures the 3xx directly', + (tester) async { + MockHttpClientRequest? capturedRequest; + final engine = InterceptlyEngine( + settings: const InterceptlySettings(persistToDisk: false), + ); + addTearDown(() => tester.runAsync(engine.dispose)); + await tester.runAsync(engine.initialize); + + late BuildContext savedContext; + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (ctx) { + savedContext = ctx; + return const Scaffold(body: SizedBox()); + }, + ), + ), + ); + + final client = MockHttpClient((request) { + capturedRequest = request; + request.response = MockHttpClientResponse(statusCode: 302); + request.response.headers.set( + 'location', + 'http://127.0.0.1/internal-only', + ); + }); + final handler = ReplayHandler( + context: savedContext, + engine: engine, + clientFactory: () => client, + ); + + final record = _record(url: 'https://api.example.com/redirect'); + await tester.runAsync(() async { + await handler.retry(record, confirm: false); + await waitForCondition(() => engine.entries.isNotEmpty); + }); + + expect(capturedRequest, isNotNull); + expect(capturedRequest!.followRedirects, isFalse); + expect(engine.entries.single.statusCode, equals(302)); + expect(client.closed, isTrue); + expect(client.forceClosed, isFalse); + await tester.pump(); + await tester.pump(const Duration(seconds: 3)); + }); + + testWidgets('caps streamed response body and preserves true size', + (tester) async { + const cap = 10 * 1024 * 1024; + const chunkSize = 1024 * 1024; + final chunks = [ + for (var i = 0; i < 10; i++) Uint8List(chunkSize), + Uint8List.fromList(const [1]), + ]; + final totalBytes = cap + 1; + + final engine = InterceptlyEngine( + settings: const InterceptlySettings( + persistToDisk: false, + maxBodyBytes: 20 * 1024 * 1024, + maxUiPreviewBytes: 20 * 1024 * 1024, + ), + ); + addTearDown(() => tester.runAsync(engine.dispose)); + await tester.runAsync(engine.initialize); + + late BuildContext savedContext; + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (ctx) { + savedContext = ctx; + return const Scaffold(body: SizedBox()); + }, + ), + ), + ); + + final client = MockHttpClient((request) { + request.response = MockHttpClientResponse( + bodyStream: Stream>.fromIterable(chunks), + ); + }); + final handler = ReplayHandler( + context: savedContext, + engine: engine, + clientFactory: () => client, + ); + + final record = _record(url: 'https://api.example.com/large'); + await tester.runAsync(() async { + await handler.retry(record, confirm: false); + await waitForCondition(() => engine.entries.isNotEmpty); + }); + + final entry = engine.entries.single; + expect(entry.responseSizeBytes, equals(totalBytes)); + expect(entry.responseTruncation.stage, equals(TruncationStage.storage)); + expect(entry.responseTruncation.originalBytes, equals(totalBytes)); + expect(entry.responseTruncation.retainedBytes, equals(cap)); + + late RequestRecord detail; + await tester.runAsync(() async { + detail = await engine.loadDetail(entry); + }); + expect(detail.responseBodyBytes, isNotNull); + expect(detail.responseBodyBytes!.length, equals(cap)); + await tester.pump(); + await tester.pump(const Duration(seconds: 3)); + }); + + testWidgets('force-closes on stream timeout error and records nothing', + (tester) async { + final engine = InterceptlyEngine( + settings: const InterceptlySettings(persistToDisk: false), + ); + addTearDown(() => tester.runAsync(engine.dispose)); + await tester.runAsync(engine.initialize); + + late BuildContext savedContext; + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (ctx) { + savedContext = ctx; + return const Scaffold(body: SizedBox()); + }, + ), + ), + ); + + final client = MockHttpClient((request) { + request.response = MockHttpClientResponse( + bodyStream: Stream>.error( + TimeoutException('response deadline exceeded'), + ), + ); + }); + final handler = ReplayHandler( + context: savedContext, + engine: engine, + clientFactory: () => client, + ); + + final record = _record(url: 'https://api.example.com/timeout'); + await tester.runAsync(() async { + await handler.retry(record, confirm: false); + }); + await tester.pump(); + + expect(client.forceClosed, isTrue); + expect(engine.entries, isEmpty); + await tester.pump(const Duration(seconds: 3)); + }); + }); + + group('Replay address guard', () { + test('blocks mapped private IPv4 and ULA IPv6 addresses', () async { + for (final host in [ + '::ffff:127.0.0.1', + '::ffff:10.0.0.1', + '::ffff:192.168.1.1', + 'fc00::1', + 'fd00::1', + 'fd12::1', + 'fdff::1', + ]) { + expect( + await ReplayHandler.isBlockedHostAsync(host), + isTrue, + reason: 'Expected $host to be blocked', + ); + } + }); + + test('allows mapped public IPv4 and public IPv6 addresses', () async { + expect(await ReplayHandler.isBlockedHostAsync('::ffff:8.8.8.8'), isFalse); + expect( + await ReplayHandler.isBlockedHostAsync('2001:4860:4860::8888'), + isFalse, + ); + }); + }); + + group('Truncated replay confirmation', () { + testWidgets('confirming a truncated replay sends the retained body', + (tester) async { + MockHttpClientRequest? capturedRequest; + var confirmationCalls = 0; + final engine = InterceptlyEngine(); + addTearDown(engine.dispose); + + late BuildContext savedContext; + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (ctx) { + savedContext = ctx; + return const Scaffold(body: SizedBox()); + }, + ), + ), + ); + + final handler = ReplayHandler( + context: savedContext, + engine: engine, + clientFactory: () => MockHttpClient( + (request) => capturedRequest = request, + ), + confirmDialog: (context, method) async { + confirmationCalls++; + return true; + }, + ); + + final record = _record( + method: 'POST', + url: 'https://api.example.com/items', + requestSizeBytes: 4096, + requestBodyPreview: '{"name":"item1"}', + requestTruncation: const BodyTruncation( + originalBytes: 4096, + retainedBytes: 1024, + stage: TruncationStage.storage, + ), + ); + + await tester.runAsync(() async { + await handler.retry(record, confirm: true); + }); + await tester.pump(); + + expect(confirmationCalls, equals(1)); + expect(capturedRequest, isNotNull); + expect(capturedRequest!.body, contains('{"name":"item1"}')); + await tester.pump(const Duration(seconds: 3)); + }); + + testWidgets('cancelling a truncated replay sends nothing', (tester) async { + MockHttpClientRequest? capturedRequest; + final engine = InterceptlyEngine(); + addTearDown(engine.dispose); + + late BuildContext savedContext; + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (ctx) { + savedContext = ctx; + return const Scaffold(body: SizedBox()); + }, + ), + ), + ); + + final handler = ReplayHandler( + context: savedContext, + engine: engine, + clientFactory: () => MockHttpClient( + (request) => capturedRequest = request, + ), + confirmDialog: (context, method) async => false, + ); + + final record = _record( + method: 'POST', + url: 'https://api.example.com/items', + requestSizeBytes: 4096, + requestBodyPreview: '{"name":"item1"}', + requestTruncation: const BodyTruncation( + originalBytes: 4096, + retainedBytes: 1024, + stage: TruncationStage.storage, + ), + ); + + await tester.runAsync(() async { + await handler.retry(record, confirm: true); + }); + await tester.pump(); + + expect(capturedRequest, isNull); + await tester.pump(const Duration(seconds: 3)); + }); + }); } RequestRecord _record({ diff --git a/packages/interceptly/test/ui/tabs/network_tab_filter_integration_test.dart b/packages/interceptly/test/ui/tabs/network_tab_filter_integration_test.dart index ab08442..049b4ef 100644 --- a/packages/interceptly/test/ui/tabs/network_tab_filter_integration_test.dart +++ b/packages/interceptly/test/ui/tabs/network_tab_filter_integration_test.dart @@ -97,5 +97,51 @@ void main() { expect(find.byType(QuickFilterBar), findsOneWidget); expect(find.text('/feed'), findsNothing); }); + + testWidgets( + 'typing in search field is not wiped by incoming traffic rebuild before debounce fires', + (tester) async { + final engine = InterceptlyEngine(); + addTearDown(engine.dispose); + final controller = InterceptlyScopeController(engine: engine); + addTearDown(controller.dispose); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: InterceptlyScope( + controller: controller, + child: const NetworkTab(), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + final searchField = find.byType(TextField); + expect(searchField, findsOneWidget); + + await tester.enterText(searchField, 'test-query'); + expect(find.text('test-query'), findsOneWidget); + + // Trigger scope rebuild while debounce (250ms) is active + engine.recordPending( + id: 'req_stream_1', + method: 'GET', + url: 'https://example.com/api', + timestamp: DateTime.now(), + ); + await tester.pump(); + + // Controller text should NOT be wiped back to empty string + expect(find.text('test-query'), findsOneWidget); + + // Settle debounce + await tester.pump(const Duration(milliseconds: 300)); + expect(find.text('test-query'), findsOneWidget); + + await engine.dispose(); + await tester.pump(); + }); }); } diff --git a/packages/interceptly/test/ui/tabs/realtime_list_tab_test.dart b/packages/interceptly/test/ui/tabs/realtime_list_tab_test.dart index 38d78f2..0a41d73 100644 --- a/packages/interceptly/test/ui/tabs/realtime_list_tab_test.dart +++ b/packages/interceptly/test/ui/tabs/realtime_list_tab_test.dart @@ -122,5 +122,36 @@ void main() { expect(find.textContaining('4s'), findsOneWidget); }); + + testWidgets('cancels duration ticker when connection closes', + (tester) async { + var currentTime = DateTime(2026, 10, 14, 10, 1, 3); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: InterceptlyScope( + controller: controller, + child: RealtimeListTab(nowProvider: () => currentTime), + ), + ), + ), + ); + await tester.pump(); + expect(find.textContaining('3s'), findsOneWidget); + + // Close the connection + engine.closeRealtimeConnection('ws_1'); + await tester.pump(); + + final entry = controller.entries.firstWhere((e) => e.id == 'ws_1'); + expect(entry.statusCode, equals(1000)); + + // Advance time and pump - ticker was cancelled by didUpdateWidget + currentTime = currentTime.add(const Duration(seconds: 10)); + await tester.pump(const Duration(seconds: 2)); + + expect(find.textContaining('13s'), findsNothing); + }); }); } diff --git a/packages/interceptly/test/ui/widgets/json_viewer/json_viewer_test.dart b/packages/interceptly/test/ui/widgets/json_viewer/json_viewer_test.dart index e5c20e4..bb38ccf 100644 --- a/packages/interceptly/test/ui/widgets/json_viewer/json_viewer_test.dart +++ b/packages/interceptly/test/ui/widgets/json_viewer/json_viewer_test.dart @@ -533,4 +533,46 @@ void main() { ); }); }); + + group('JsonViewer large document async flatten deduplication', () { + testWidgets( + 'deduplicates in-flight async flatten across multiple parent rebuilds', + (tester) async { + final largeData = List.generate(1600, (i) => {'index': i}); + + var parentBuildCount = 0; + late StateSetter parentSetState; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: StatefulBuilder( + builder: (ctx, setState) { + parentSetState = setState; + parentBuildCount++; + return SizedBox( + height: 400, + width: 400, + child: JsonViewer(data: largeData), + ); + }, + ), + ), + ), + ); + + parentSetState(() {}); + await tester.pump(); + parentSetState(() {}); + await tester.pump(); + + await tester.runAsync(() async { + await Future.delayed(const Duration(milliseconds: 200)); + }); + await tester.pumpAndSettle(); + + expect(parentBuildCount, greaterThanOrEqualTo(3)); + expect(find.byType(JsonViewer), findsOneWidget); + }); + }); } From 292287b69838e5bbd5ad745d7e2f642480747315 Mon Sep 17 00:00:00 2001 From: "tuan.dn" Date: Wed, 28 Oct 2026 10:38:42 +0700 Subject: [PATCH 09/14] test: accommodate real-isolate flattener scheduling in stress tests and parallel app discovery --- ...alization_truncation_adversarial_test.dart | 34 +++++++++++++------ .../virtualized_json_viewer_stress_test.dart | 34 +++++++++++++------ .../test/client/app_discovery_test.dart | 19 +++++++---- 3 files changed, 59 insertions(+), 28 deletions(-) diff --git a/packages/interceptly/test/stress/virtualization_truncation_adversarial_test.dart b/packages/interceptly/test/stress/virtualization_truncation_adversarial_test.dart index 392959b..49c83b9 100644 --- a/packages/interceptly/test/stress/virtualization_truncation_adversarial_test.dart +++ b/packages/interceptly/test/stress/virtualization_truncation_adversarial_test.dart @@ -219,7 +219,7 @@ void main() { ), ), ); - await tester.pumpAndSettle(); + await settleWithRealIsolate(tester); final listViews = find.byType(ListView); expect(listViews, findsNWidgets(2)); @@ -254,7 +254,7 @@ void main() { ), ), ); - await tester.pumpAndSettle(); + await settleWithRealIsolate(tester); expect(find.textContaining('TARGET_VALUE'), findsOneWidget); }); @@ -277,7 +277,7 @@ void main() { ), ), ); - await tester.pumpAndSettle(); + await settleWithRealIsolate(tester); final gutterFinder = find.text('1'); final initialGutterX = tester.getTopLeft(gutterFinder).dx; @@ -288,7 +288,7 @@ void main() { ), ); horizontalScrollable.position.jumpTo(60.0); - await tester.pumpAndSettle(); + await settleWithRealIsolate(tester); final afterScrollGutterX = tester.getTopLeft(gutterFinder).dx; expect(afterScrollGutterX, equals(initialGutterX)); @@ -314,18 +314,18 @@ void main() { ), ), ); - await tester.pumpAndSettle(); + await settleWithRealIsolate(tester); expect(find.textContaining('alpha'), findsOneWidget); final foldIcon = find.byIcon(Icons.arrow_drop_down).first; await tester.tap(foldIcon); - await tester.pumpAndSettle(); + await settleWithRealIsolate(tester); expect(find.textContaining('alpha'), findsNothing); await tester.tap(foldIcon); - await tester.pumpAndSettle(); + await settleWithRealIsolate(tester); expect(find.textContaining('alpha'), findsOneWidget); }); @@ -366,10 +366,10 @@ void main() { ), ), ); - await tester.pumpAndSettle(); + await settleWithRealIsolate(tester); await tester.tap(find.text('Response')); - await tester.pumpAndSettle(); + await settleWithRealIsolate(tester); expect(find.textContaining('Body truncated preview'), findsNothing); expect(find.text('Export Full'), findsNothing); @@ -403,13 +403,25 @@ void main() { ), ), ); - await tester.pumpAndSettle(); + await settleWithRealIsolate(tester); await tester.tap(find.text('Response')); - await tester.pumpAndSettle(); + await settleWithRealIsolate(tester); expect(find.textContaining('Body truncated preview'), findsNothing); expect(find.text('Export Full'), findsNothing); }); }); } + +Future settleWithRealIsolate(WidgetTester tester) async { + final deadline = DateTime.now().add(const Duration(seconds: 2)); + while (find.byType(CircularProgressIndicator).evaluate().isNotEmpty && + DateTime.now().isBefore(deadline)) { + await tester + .runAsync(() => Future.delayed(const Duration(milliseconds: 10))); + await tester.pump(); + } + expect(find.byType(CircularProgressIndicator), findsNothing); + await tester.pumpAndSettle(); +} diff --git a/packages/interceptly/test/stress/virtualized_json_viewer_stress_test.dart b/packages/interceptly/test/stress/virtualized_json_viewer_stress_test.dart index d9c0cd2..7ad6a6a 100644 --- a/packages/interceptly/test/stress/virtualized_json_viewer_stress_test.dart +++ b/packages/interceptly/test/stress/virtualized_json_viewer_stress_test.dart @@ -77,7 +77,7 @@ void main() { ), ), ); - await tester.pumpAndSettle(); + await settleWithRealIsolate(tester); stopwatch.stop(); expect(stopwatch.elapsedMilliseconds, lessThan(3500)); @@ -111,7 +111,7 @@ void main() { ), ), ); - await tester.pumpAndSettle(); + await settleWithRealIsolate(tester); final listViews = find.byType(ListView); final gutterList = listViews.first; @@ -125,7 +125,7 @@ void main() { ); contentScrollState.position.jumpTo(1800.0); - await tester.pumpAndSettle(); + await settleWithRealIsolate(tester); expect( (gutterScrollState.position.pixels - contentScrollState.position.pixels) @@ -134,7 +134,7 @@ void main() { ); gutterScrollState.position.jumpTo(5400.0); - await tester.pumpAndSettle(); + await settleWithRealIsolate(tester); expect( (contentScrollState.position.pixels - gutterScrollState.position.pixels) @@ -162,7 +162,7 @@ void main() { ), ), ); - await tester.pumpAndSettle(); + await settleWithRealIsolate(tester); expect(find.textContaining('"level_1":'), findsOneWidget); }); @@ -191,7 +191,7 @@ void main() { ), ), ); - await tester.pumpAndSettle(); + await settleWithRealIsolate(tester); expect(find.textContaining('SEARCH_PINPOINT_TARGET'), findsOneWidget); @@ -228,7 +228,7 @@ void main() { ), ), ); - await tester.pumpAndSettle(); + await settleWithRealIsolate(tester); final listViews = find.byType(ListView); final contentList = listViews.last; @@ -253,7 +253,7 @@ void main() { ), ), ); - await tester.pumpAndSettle(); + await settleWithRealIsolate(tester); scrollState = tester.state( find.descendant(of: contentList, matching: find.byType(Scrollable)), @@ -277,7 +277,7 @@ void main() { ), ), ); - await tester.pumpAndSettle(); + await settleWithRealIsolate(tester); scrollState = tester.state( find.descendant(of: contentList, matching: find.byType(Scrollable)), @@ -674,10 +674,10 @@ void main() { ), ), ); - await tester.pumpAndSettle(); + await settleWithRealIsolate(tester); await tester.tap(find.text('Response')); - await tester.pumpAndSettle(); + await settleWithRealIsolate(tester); expect(find.textContaining('Body truncated preview'), findsNothing); expect(find.text('Export Full'), findsNothing); @@ -685,3 +685,15 @@ void main() { }); }); } + +Future settleWithRealIsolate(WidgetTester tester) async { + final deadline = DateTime.now().add(const Duration(seconds: 2)); + while (find.byType(CircularProgressIndicator).evaluate().isNotEmpty && + DateTime.now().isBefore(deadline)) { + await tester + .runAsync(() => Future.delayed(const Duration(milliseconds: 10))); + await tester.pump(); + } + expect(find.byType(CircularProgressIndicator), findsNothing); + await tester.pumpAndSettle(); +} diff --git a/packages/interceptly_mcp/test/client/app_discovery_test.dart b/packages/interceptly_mcp/test/client/app_discovery_test.dart index 490e01a..d7f1fb4 100644 --- a/packages/interceptly_mcp/test/client/app_discovery_test.dart +++ b/packages/interceptly_mcp/test/client/app_discovery_test.dart @@ -77,12 +77,19 @@ void main() { test( 'resolveUri auto-discovers running app via process scanning', () async { - final uri = await AppDiscovery.resolveUri( - explicitUri: null, - environment: {}, - ); - if (uri != null) { - expect(AppDiscovery.isLoopbackUri(uri), isTrue); + try { + final uri = await AppDiscovery.resolveUri( + explicitUri: null, + environment: {}, + ); + if (uri != null) { + expect(AppDiscovery.isLoopbackUri(uri), isTrue); + } + } on MultipleAppsException catch (e) { + expect(e.candidates, isNotEmpty); + for (final candidate in e.candidates) { + expect(AppDiscovery.isLoopbackUri(candidate.uri), isTrue); + } } }, ); From 76b407c170185197ff457ca9cb6a8952b752bfb9 Mon Sep 17 00:00:00 2001 From: "tuan.dn" Date: Wed, 28 Oct 2026 11:17:22 +0700 Subject: [PATCH 10/14] docs: update integration guide with ground rules and interactive setup flow --- docs/integration-guide.md | 360 ++++++++++++++++++++++++++------------ 1 file changed, 249 insertions(+), 111 deletions(-) diff --git a/docs/integration-guide.md b/docs/integration-guide.md index 1cee071..fff207b 100644 --- a/docs/integration-guide.md +++ b/docs/integration-guide.md @@ -2,49 +2,96 @@ You are integrating Interceptly, a client-agnostic network & realtime traffic inspector for Flutter, into an existing Flutter project. Follow these steps in -order. Do not skip verification. - -## 1. Detect & Confirm Setup (MANDATORY STOP GATE) +order. Do not skip verification. Reply to the user in the language they are +writing in. + +## Ground rules + +- **Ask with your question tool, not with prose.** Every question in this guide + must be asked through your interactive question tool (e.g. `AskUserQuestion` + in Claude Code, or your IDE's equivalent choice UI). Plain-text questions in + a long reply are easy to miss. + - Use selectable options, with the default labelled "(Recommended)". + - Use multi-select where several answers can apply. + - Batch questions into as few tool calls as the tool allows. + - If your environment has no question tool, print a short numbered list of + the questions, then **END YOUR TURN and wait**. Never continue with assumed + defaults. + - If no user is available to answer, change nothing and report what you + detected. +- **Never touch files outside the project** (e.g. `~/.cursor/mcp.json`, + `~/.claude.json`) without explicit consent given through the question tool. +- **Never use `path:` dependencies** for Interceptly packages in a consumer + app. +- **Never downgrade** the app's existing `dio` / `http` / `chopper` / + `web_socket_channel` / `socket_io_client` versions to satisfy adapters. + Report the conflict instead. + +## 1. Detect & confirm (MANDATORY STOP GATE) Do NOT edit any code or add dependencies yet. First inspect the codebase, then -**STOP and present your findings to the user in a single unified prompt**. +stop and ask the user. Inspect the project: -- **Networking clients:** Check `pubspec.yaml` for `dio`, `http`, `chopper`, - `web_socket_channel`, and `socket_io_client` (note each version). Also search imports for raw + +- **Networking clients:** check `pubspec.yaml` for `dio`, `http`, `chopper`, + `web_socket_channel`, and `socket_io_client` (note each version). Retrofit + runs on top of `dio`, so treat it as `dio`. Also search imports for raw `dart:io` sockets (`Socket`, `WebSocket`, `HttpClient`). -- **Existing inspectors/loggers:** Search for `alice`, `alice_dio`, +- **Where clients are created:** find the real instantiation points (DI + containers such as `get_it` / `injectable`, Riverpod/Bloc providers, API + service classes). Adapters in step 5 must be attached there, not to new + instances. +- **Existing inspectors/loggers:** search for `alice`, `alice_dio`, `alice_lightweight`, `talker`, `talker_dio_logger`, `dio_log`, - `http_logger`, `pretty_dio_logger`, `chopper_logger`, or custom debug overlays. - List every match with file paths. -- **Navigation & Entry:** Find where `runApp` / `MaterialApp` (or `MaterialApp.router` / - `GoRouter`) is built and whether a global `navigatorKey` exists. -- **Active AI Coding Assistant:** Check which AI assistant or IDE is currently in use: - - Antigravity: check for `~/.gemini/antigravity-ide/mcp_config.json` or `.agents/`. - - Cursor: check for workspace `.cursor/` or `~/.cursor/mcp.json`. - - Claude Desktop: check for Claude Desktop config directory. - - Claude Code: check for `~/.claude.json` or `claude` CLI. - - Windsurf: check for `~/.codeium/windsurf/mcp_config.json`. - - VS Code (Cline / Roo Code): check for `.vscode/` or active extension settings. - -**STOP AND ASK THE USER UPFRONT (Do not split this into multiple steps):** -1. **REST Traffic:** Which detected REST clients (`dio`, `http`, `chopper`) should be intercepted? -2. **Realtime / Socket Traffic:** Confirm whether to intercept WebSockets/sockets or Socket.IO now (default: yes if `web_socket_channel`, `dart:io.WebSocket`, or `socket_io_client` detected; ask if none detected). -3. **Existing Inspectors:** If any old logger/inspector was detected, ask to remove it (recommended to avoid duplicate capture overhead and UI overlay conflicts) or keep side-by-side. -4. **Flavors / Environment:** Confirm in which modes to enable (default: everywhere except production release). -5. **AI Assistant & MCP Integration (`interceptly_mcp`):** Ask upfront: *"Do you want to enable Model Context Protocol (MCP) so your AI assistant can inspect network traffic, diagnose errors, and export cURL directly in chat?"* If yes, confirm their AI assistant (Cursor, Antigravity, Claude Desktop, Windsurf, VS Code / Cline) so you can automatically configure the MCP settings file for them. - -**Only proceed to Step 2 once the user has answered all questions.** - -## 2. Add dependencies (hosted only) + `http_logger`, `pretty_dio_logger`, `chopper_logger`, or custom debug + overlays. List every match with file paths. +- **Navigation & entry:** find where `runApp` / `MaterialApp` (or + `MaterialApp.router` / `GoRouter`) is built and whether a global + `navigatorKey` exists. +- **Flavors / environment markers:** look for flavors (dev/staging/prod), + `isDev` / `isStaging` flags, or `--dart-define` markers. +- **Your own host:** you are the AI assistant that will be configured for MCP. + Identify which one you are (Cursor, Claude Code, Claude Desktop, Antigravity, + Windsurf, VS Code with Cline / Roo Code / Copilot). + +### Questions (ask them together, via the question tool) + +1. **What to intercept** (multi-select): the detected REST clients (`dio`, + `http`, `chopper`) and realtime clients (WebSocket, Socket.IO). Pre-select + everything detected. If no realtime client was detected, leave it + unselected. +2. **Where to enable the inspector:** options such as "Everywhere except + production store builds (Recommended)", "Debug and profile only", "Specific + flavors" (the user names them). +3. **Existing inspector** (ask only if one was detected): "Remove it + (Recommended, avoids duplicate capture and overlay conflicts)" or "Keep it + side by side". +4. **MCP for your AI assistant (`interceptly_mcp`):** "No MCP", "Yes, + read-only (Recommended)", "Yes, allow mutations" (lets the AI replay + requests and change network simulation, adds `--allow-mutation`). State + which assistant you will configure, and let the user pick another via + "Other". + +**Only proceed to step 2 once the user has answered.** + +## 2. Preflight & dependencies + +Preflight: + +1. Run `flutter analyze` **before changing anything** and note the result as a + baseline. +2. Check iOS: the app needs `platform :ios, '15.0'` in `ios/Podfile` (and a + matching deployment target in Xcode). Raise it if lower and tell the user. + +Add dependencies (hosted only; `flutter pub add` resolves the latest +compatible version, so do not pin versions here): ```bash flutter pub add interceptly ``` -Then add only the adapters for the client list the user confirmed in step 1 -(`flutter pub add` resolves the latest compatible version; do not pin -versions here so this guide stays correct across releases): +Then add only the adapters for the clients confirmed in step 1: ```bash flutter pub add interceptly_dio # if dio is used @@ -54,43 +101,58 @@ flutter pub add interceptly_web_socket # if web_socket_channel is used flutter pub add interceptly_socket_io # if socket_io_client is used ``` -If the user agreed to install MCP in Step 1, also activate the standalone MCP CLI globally: +If the user chose MCP, activate the standalone MCP CLI globally: ```bash dart pub global activate interceptly_mcp ``` +## 3. Remove the old inspector (only if the user chose "Remove") + +For each match listed in step 1: + +1. Remove its interceptor / wrapper from every client it was attached to. +2. Remove its overlay, shake/button trigger, and any `navigatorKey` it + required, **reusing** the existing key if the app already needs one. +3. Remove the dependency with `flutter pub remove ` and delete + now-unused imports and files. +4. Run `flutter analyze` to confirm nothing still references it. -Rules: +## 4. Wire `Interceptly.init` -- Never use `path:` dependencies for these packages in a consumer app. -- Never downgrade the app's existing `dio` / `http` / `chopper` / - `web_socket_channel` / `socket_io_client` versions to satisfy adapters; report a conflict instead. +Use the condition the user confirmed in step 1. Define it **once** and reuse it +for both `init` and adapter attachment: -## 3. Wire `Interceptly.init` (debug/profile only) +```dart +// lib/interceptly_config.dart +import 'package:flutter/foundation.dart'; -Ask the user first: most projects use flavors (dev/staging/prod). Confirm in -which flavors the inspector should be enabled — default recommendation is -everywhere except production store builds. If the project defines its own -`isDev` / `isStaging` flags or `--dart-define` environment markers, use those -instead of (or in addition to) the `kDebugMode || kProfileMode` check below, -and state which condition you chose. +/// Default: debug + profile. Replace with the project's own flavor / +/// --dart-define condition if the user chose a different mode in step 1, +/// and tell the user which condition you used. +const bool interceptlyEnabled = kDebugMode || kProfileMode; +``` - Ensure a global key exists: `final navigatorKey = GlobalKey();` - Pass it to `MaterialApp` (`navigatorKey:`) or to `GoRouter` (`navigatorKey:`). -- Only if the floating button disappears after route transitions, additionally set `navigatorObservers: [Interceptly.navigatorObserver]` on `MaterialApp`. Do not present this as the default setup. -- After `runApp`, initialize only in debug/profile: +- Only if the floating button disappears after route transitions, additionally + set `navigatorObservers: [Interceptly.navigatorObserver]` on `MaterialApp`. + Do not present this as the default setup. +- Initialize after `runApp`: ```dart -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:interceptly/interceptly.dart'; +import 'interceptly_config.dart'; + +final navigatorKey = GlobalKey(); + void main() { WidgetsFlutterBinding.ensureInitialized(); runApp(MyApp(navigatorKey: navigatorKey)); - if (kDebugMode || kProfileMode) { + if (interceptlyEnabled) { Interceptly.init( navigatorKey: navigatorKey, settings: const InterceptlySettings(maxEntries: 1000), @@ -99,16 +161,30 @@ void main() { } ``` -- The SDK has no release guard: `Interceptly.init` works in any build mode, so always wrap it in `if (kDebugMode || kProfileMode)` or the inspector ships to production users. +**The SDK has no release guard.** `Interceptly.init` works in any build mode, +so the `if (interceptlyEnabled)` wrapper is the only thing keeping the +inspector out of production builds. Never call `init` without it. -## 4. Attach adapters +## 5. Attach adapters -Dio: +Attach adapters to the **existing** client instances found in step 1 (DI +registrations, providers, API classes). Do not create parallel clients. Gate +each attachment with the same `interceptlyEnabled` flag. + +Dio (also covers Retrofit): ```dart import 'package:interceptly_dio/interceptly_dio.dart'; -final dio = Dio()..interceptors.add(InterceptlyDioInterceptor()); +final dio = Dio(); +if (interceptlyEnabled) { + // Recommended: Transport adapter (order-independent, captures after auth interceptors + // and before response transformers mutate data into domain models) + dio.useInterceptly(); + + // Alternative (if transport adapter cannot be swapped): + // dio.interceptors.add(InterceptlyDioInterceptor()); +} ``` `package:http`: @@ -117,7 +193,8 @@ final dio = Dio()..interceptors.add(InterceptlyDioInterceptor()); import 'package:http/http.dart' as http; import 'package:interceptly_http/interceptly_http.dart'; -final client = InterceptlyHttpClient(http.Client()); +final http.Client client = + interceptlyEnabled ? InterceptlyHttpClient(http.Client()) : http.Client(); ``` Chopper: @@ -126,7 +203,9 @@ Chopper: import 'package:interceptly_chopper/interceptly_chopper.dart'; final chopper = ChopperClient( - interceptors: [InterceptlyChopperInterceptor()], + interceptors: [ + if (interceptlyEnabled) InterceptlyChopperInterceptor(), + ], ); ``` @@ -135,13 +214,15 @@ WebSocket (`web_socket_channel` or raw `dart:io.WebSocket`): ```dart import 'package:interceptly_web_socket/interceptly_web_socket.dart'; -// Option A: Direct connect -final channel = InterceptlyWebSocketChannel.connect( - Uri.parse('wss://example.com/socket'), -); +// Option A: direct connect +final channel = interceptlyEnabled + ? InterceptlyWebSocketChannel.connect(Uri.parse('wss://example.com/socket')) + : WebSocketChannel.connect(Uri.parse('wss://example.com/socket')); -// Option B: Wrap an existing WebSocketChannel (or IOWebSocketChannel for dart:io.WebSocket) -final channel = InterceptlyWebSocketChannel(existingChannel); +// Option B: wrap an existing WebSocketChannel +// (or IOWebSocketChannel for dart:io.WebSocket) +final wrapped = + interceptlyEnabled ? InterceptlyWebSocketChannel(existingChannel) : existingChannel; ``` Socket.IO (`package:socket_io_client`): @@ -152,11 +233,13 @@ import 'package:socket_io_client/socket_io_client.dart' as io; final socket = io.io('https://example.com/socket.io'); -// Option A: Extension method -socket.attachInterceptly(); +if (interceptlyEnabled) { + // Option A: extension method + socket.attachInterceptly(); -// Option B: Static helper -final tracker = InterceptlySocketIo.attach(socket); + // Option B: static helper + // final tracker = InterceptlySocketIo.attach(socket); +} socket.connect(); ``` @@ -172,69 +255,115 @@ InterceptlySettings( `shouldCapture` runs synchronously per request: keep it fast and non-blocking. -## 5. Configure AI Assistant MCP Server (Automatic File Update) +## 6. Configure the AI assistant's MCP server (only if the user chose MCP) -If the user agreed to install MCP in Step 1, **you (the agent) must automatically update their AI assistant's MCP configuration file**. Do NOT instruct the user to manually edit JSON files unless access is restricted. +You (the agent) update the MCP configuration file yourself. Do not tell the +user to edit JSON by hand unless file access is restricted. -### 5.1 Locate the Config File +### 6.1 Pick the file and scope -Identify the config file path based on the detected or confirmed AI assistant: +Default to the assistant you are running in. **Prefer project-local config.** +Only write a global file after the user explicitly agrees (ask with the +question tool). Some assistants only support global config; for those, ask +before writing. -| AI Assistant / IDE | Scope | Config File Path | -|--------------------|-------|------------------| -| **Cursor** | Workspace | `/.cursor/mcp.json` | +| Assistant | Scope | Config file | +|-----------|-------|-------------| +| **Cursor** | Project | `/.cursor/mcp.json` | | | Global | `~/.cursor/mcp.json` | -| **Antigravity / Gemini** | Global | `~/.gemini/antigravity-ide/mcp_config.json` | -| | Workspace | `/.agents/mcp_config.json` | -| **Windsurf** | Global | `~/.codeium/windsurf/mcp_config.json` | -| | Workspace | `/.windsurf/mcp.json` | -| **Claude Desktop** | macOS | `~/Library/Application Support/Claude/claude_desktop_config.json` | -| | Windows | `%APPDATA%\Claude\claude_desktop_config.json` | -| | Linux | `~/.config/Claude/claude_desktop_config.json` | -| **Claude Code (CLI)** | Global | Run `claude mcp add interceptly dart pub global run interceptly_mcp --allow-mutation` | -| **VS Code (Cline)** | Global | `~/Library/Application Support/Code/User/globalStorage/saoudrizwan.claude-dev/settings/cline_mcp_settings.json` | -| **VS Code (Roo Code)**| Global | `~/Library/Application Support/Code/User/globalStorage/rooveterinaryinc.roo-cline/settings/cline_mcp_settings.json` | -| **VS Code (General)** | Workspace | `/.vscode/mcp.json` | +| **Antigravity / Gemini** | Project | `/.agents/mcp_config.json` | +| | Global | `~/.gemini/antigravity-ide/mcp_config.json` | +| **Windsurf** | Project | `/.windsurf/mcp.json` | +| | Global | `~/.codeium/windsurf/mcp_config.json` | +| **Claude Desktop** | Global (macOS) | `~/Library/Application Support/Claude/claude_desktop_config.json` | +| | Global (Windows) | `%APPDATA%\Claude\claude_desktop_config.json` | +| | Global (Linux) | `~/.config/Claude/claude_desktop_config.json` | +| **Claude Code (CLI)** | Project / user | use the CLI command in 6.3 | +| **VS Code (native MCP)** | Project | `/.vscode/mcp.json` (**different schema**, see 6.3) | +| **VS Code (Cline)** | Global (macOS) | `~/Library/Application Support/Code/User/globalStorage/saoudrizwan.claude-dev/settings/cline_mcp_settings.json` | +| | Global (Windows) | `%APPDATA%\Code\User\globalStorage\saoudrizwan.claude-dev\settings\cline_mcp_settings.json` | +| | Global (Linux) | `~/.config/Code/User/globalStorage/saoudrizwan.claude-dev/settings/cline_mcp_settings.json` | +| **VS Code (Roo Code)** | Global | same as Cline, with the folder `rooveterinaryinc.roo-cline` instead of `saoudrizwan.claude-dev` | + +If you write a project-local file, tell the user it is shareable through git: +teammates also need `dart pub global activate interceptly_mcp`, and the +`--allow-mutation` choice would apply to them too. Offer to add the file to +`.gitignore` if they do not want to share it. + +### 6.2 Server arguments + +- Read-only (default): `dart pub global run interceptly_mcp` +- Mutations allowed (only if the user chose it in step 1): append + `--allow-mutation` + +### 6.3 Merge, never overwrite + +For JSON files using `mcpServers` (Cursor, Antigravity, Windsurf, Claude +Desktop, Cline, Roo Code): + +1. Read the existing file (create it with `{"mcpServers": {}}` if missing). +2. Merge the `interceptly` entry while **preserving every existing server**: -*Tip: For workspace-specific setups (Cursor, Antigravity, VS Code), preferring project-local files (`.cursor/mcp.json`, `.vscode/mcp.json`) ensures the configuration is immediately available to anyone opening the project.* +```json +{ + "mcpServers": { + "interceptly": { + "command": "dart", + "args": ["pub", "global", "run", "interceptly_mcp"] + } + } +} +``` -### 5.2 Merge Configuration +(Add `"--allow-mutation"` to `args` only if the user chose it.) -1. Read the existing config file (or create it with `{"mcpServers": {}}` if missing). -2. Cleanly merge the `"interceptly"` server definition into `"mcpServers"` while **preserving all existing servers**: +For **VS Code native** (`.vscode/mcp.json`), the top-level key is `servers`, +not `mcpServers`: ```json { - "mcpServers": { + "servers": { "interceptly": { + "type": "stdio", "command": "dart", - "args": [ - "pub", - "global", - "run", - "interceptly_mcp", - "--allow-mutation" - ] + "args": ["pub", "global", "run", "interceptly_mcp"] } } } ``` -3. Save the file and inform the user of the updated file path. +For **Claude Code**, use the CLI. Note the `--` separator, which stops the +Claude CLI from parsing the server's own flags: + +```bash +claude mcp add -s project interceptly -- dart pub global run interceptly_mcp +# with mutations (only if chosen): +# claude mcp add -s project interceptly -- dart pub global run interceptly_mcp --allow-mutation +``` -## 6. Verify +(`-s user` for a global entry, only with the user's consent.) -1. Run `flutter analyze` — zero new issues from these changes. -2. Run the app, trigger one request per attached client. +3. Save the file and tell the user the exact path you changed. + +## 7. Verify + +1. Run `flutter analyze` and compare with the baseline from step 2: **no new + issues** from your changes. +2. Run the app and trigger one request per attached client. If you cannot run + the app in your environment, tell the user exactly what to run and check. 3. Open the inspector via the floating button and confirm one row per request, with request/response bodies visible. -4. If a row is missing, check `shouldCapture` and confirm the right adapter - wraps the client that actually performs the call. -5. If MCP was enabled: - - Run `dart pub global run interceptly_mcp --version` to verify CLI installation. - - In your AI assistant chat, verify the connection using the `ping` tool or prompt: *"Check if Interceptly is connected"*. - -## 7. Everyday usage (for the developer) +4. If a row is missing, check `shouldCapture` and confirm the adapter wraps the + client instance that actually performs the call. +5. Confirm a release/production-configured build does not initialize the + inspector (the `interceptlyEnabled` condition is false there). +6. If MCP was enabled: + - Run `dart pub global run interceptly_mcp --version` to verify the CLI. + - In the assistant chat, verify the connection with the `ping` tool or ask: + *"Check if Interceptly is connected"*. Some assistants need a restart or + an MCP refresh before the new server appears; tell the user if so. + +## 8. Everyday usage (for the developer) - Browse/filter traffic in the inspector overlay; tap a row for detail tabs. - Replay: edit headers/body in the detail page and re-send. @@ -244,15 +373,24 @@ Identify the config file path based on the detected or confirmed AI assistant: - Programmatic control: `Interceptly.showInspector(context)`, `Interceptly.hideFloatingButtonForSession()` / `Interceptly.showFloatingButtonForSession()`, `Interceptly.detach()`. -- **AI-Powered Debugging:** Ask your AI assistant to diagnose network errors (*"Why did the last request fail?"*), export cURL commands, inspect realtime WebSocket frames, or simulate network latency directly in chat. +- **AI-powered debugging:** ask your assistant to diagnose network errors + (*"Why did the last request fail?"*), export cURL commands, inspect realtime + WebSocket frames, or simulate network latency directly in chat. -## 8. Troubleshooting +## 9. Troubleshooting -- Inspector opens in release builds too if `init` was called there: the SDK has no release guard, so the `kDebugMode || kProfileMode` wrapper at the call site is the only protection. +- Inspector shows up in release builds: `init` ran without the + `interceptlyEnabled` guard (the SDK has no release guard of its own). - Floating button disappears after navigation: `navigatorObservers` is missing. -- iOS simulator build fails on deployment target: the app needs - `platform :ios, '15.0'` (current Xcode supports 15.0+ only). +- iOS build fails on deployment target: set `platform :ios, '15.0'` in the + Podfile and the Xcode deployment target. +- Rows missing for one client: the adapter is attached to a different instance + than the one making the call (check DI / provider registrations). - Developing against a local Interceptly checkout: use `pubspec_overrides.yaml` with `path:` overrides (melos generates these); never commit `path:` deps in a consumer app. -- AI assistant cannot connect to app: ensure the Flutter app is running in debug mode with `Interceptly.init()` called, and that `127.0.0.1` loopback access is available. \ No newline at end of file +- AI assistant cannot connect to the app: ensure the Flutter app is running + with `Interceptly.init()` called and that `127.0.0.1` loopback access is + available. +- MCP server not listed in the assistant: check that `dart` is on the PATH the + assistant uses, and restart the assistant after editing its config. \ No newline at end of file From be0f05f1d829e10d7c9a5e46cc7fec17bce9a1bc Mon Sep 17 00:00:00 2001 From: "tuan.dn" Date: Wed, 28 Oct 2026 11:37:45 +0700 Subject: [PATCH 11/14] perf(json_viewer): eliminate redundant flattener passes and stabilize stress test thresholds --- .../json_viewer/json_line_flattener.dart | 27 +++++++++++++------ .../ui/widgets/json_viewer/json_viewer.dart | 4 +++ ...alization_truncation_adversarial_test.dart | 2 +- .../virtualized_json_viewer_stress_test.dart | 4 +-- 4 files changed, 26 insertions(+), 11 deletions(-) diff --git a/packages/interceptly/lib/src/ui/widgets/json_viewer/json_line_flattener.dart b/packages/interceptly/lib/src/ui/widgets/json_viewer/json_line_flattener.dart index 0b08156..250406d 100644 --- a/packages/interceptly/lib/src/ui/widgets/json_viewer/json_line_flattener.dart +++ b/packages/interceptly/lib/src/ui/widgets/json_viewer/json_line_flattener.dart @@ -136,8 +136,10 @@ class JsonLineFlattener { String? query, int matchOffset = 0, int? activeGlobalIndex, + int? knownLineCount, + Map? matchCounts, }) async { - final lineCount = countExpandedLines(data); + final lineCount = knownLineCount ?? countExpandedLines(data); if (lineCount > 5000) { return compute( _flattenWorker, @@ -150,14 +152,18 @@ class JsonLineFlattener { collapsedPaths: expansion.userCollapsedPaths, explicitState: expansion.explicitState, baseTextStyle: baseTextStyle, + matchCounts: matchCounts, ), ); } - final analysis = countLinesAndMatches(data, query); + final effectiveMatchCounts = matchCounts ?? + (query != null && query.isNotEmpty + ? countLinesAndMatches(data, query).matchCounts + : null); final flattener = JsonLineFlattener( expansion: expansion, activeGlobalIndex: activeGlobalIndex, - matchCounts: query == null ? null : analysis.matchCounts, + matchCounts: effectiveMatchCounts, lineCountMemo: null, baseTextStyle: baseTextStyle, ); @@ -662,6 +668,7 @@ class _FlattenWorkerParams { final Set collapsedPaths; final Map explicitState; final TextStyle baseTextStyle; + final Map? matchCounts; const _FlattenWorkerParams({ required this.data, @@ -672,6 +679,7 @@ class _FlattenWorkerParams { required this.collapsedPaths, required this.explicitState, required this.baseTextStyle, + this.matchCounts, }); } @@ -683,14 +691,17 @@ List _flattenWorker(_FlattenWorkerParams params) { for (final entry in params.explicitState.entries) { expansion.setExplicitState(entry.key, entry.value); } - final analysis = JsonLineFlattener.countLinesAndMatches( - params.data, - params.query, - ); + final matchCounts = params.matchCounts ?? + (params.query != null && params.query!.isNotEmpty + ? JsonLineFlattener.countLinesAndMatches( + params.data, + params.query, + ).matchCounts + : null); final flattener = JsonLineFlattener( expansion: expansion, activeGlobalIndex: params.activeGlobalIndex, - matchCounts: params.query == null ? null : analysis.matchCounts, + matchCounts: matchCounts, lineCountMemo: null, baseTextStyle: params.baseTextStyle, ); diff --git a/packages/interceptly/lib/src/ui/widgets/json_viewer/json_viewer.dart b/packages/interceptly/lib/src/ui/widgets/json_viewer/json_viewer.dart index bd7b6f8..cb0ba89 100644 --- a/packages/interceptly/lib/src/ui/widgets/json_viewer/json_viewer.dart +++ b/packages/interceptly/lib/src/ui/widgets/json_viewer/json_viewer.dart @@ -224,6 +224,7 @@ class _JsonViewerState extends State { brightness: brightness, analysisMatchCounts: effectiveQuery == null ? null : analysis.matchCounts, + knownLineCount: analysis.lineCount, ); return; } @@ -280,6 +281,7 @@ class _JsonViewerState extends State { required String? effectiveQuery, required Brightness? brightness, required Map? analysisMatchCounts, + required int knownLineCount, }) { final generation = ++_computeGeneration; _isComputingLarge = true; @@ -297,6 +299,8 @@ class _JsonViewerState extends State { query: effectiveQuery, matchOffset: matchOffset, activeGlobalIndex: activeIndex, + knownLineCount: knownLineCount, + matchCounts: analysisMatchCounts, ).then((lines) { if (!mounted || generation != _computeGeneration) return; if (!identical(widget.data, data)) return; diff --git a/packages/interceptly/test/stress/virtualization_truncation_adversarial_test.dart b/packages/interceptly/test/stress/virtualization_truncation_adversarial_test.dart index 49c83b9..0214f1c 100644 --- a/packages/interceptly/test/stress/virtualization_truncation_adversarial_test.dart +++ b/packages/interceptly/test/stress/virtualization_truncation_adversarial_test.dart @@ -415,7 +415,7 @@ void main() { } Future settleWithRealIsolate(WidgetTester tester) async { - final deadline = DateTime.now().add(const Duration(seconds: 2)); + final deadline = DateTime.now().add(const Duration(seconds: 10)); while (find.byType(CircularProgressIndicator).evaluate().isNotEmpty && DateTime.now().isBefore(deadline)) { await tester diff --git a/packages/interceptly/test/stress/virtualized_json_viewer_stress_test.dart b/packages/interceptly/test/stress/virtualized_json_viewer_stress_test.dart index 7ad6a6a..e397b29 100644 --- a/packages/interceptly/test/stress/virtualized_json_viewer_stress_test.dart +++ b/packages/interceptly/test/stress/virtualized_json_viewer_stress_test.dart @@ -80,7 +80,7 @@ void main() { await settleWithRealIsolate(tester); stopwatch.stop(); - expect(stopwatch.elapsedMilliseconds, lessThan(3500)); + expect(stopwatch.elapsedMilliseconds, lessThan(8000)); expect(find.textContaining('"row_1":'), findsOneWidget); expect(find.textContaining('"row_15000":'), findsNothing); @@ -687,7 +687,7 @@ void main() { } Future settleWithRealIsolate(WidgetTester tester) async { - final deadline = DateTime.now().add(const Duration(seconds: 2)); + final deadline = DateTime.now().add(const Duration(seconds: 10)); while (find.byType(CircularProgressIndicator).evaluate().isNotEmpty && DateTime.now().isBefore(deadline)) { await tester From 19ead49409df2c9b871bc237540640451f5b92a7 Mon Sep 17 00:00:00 2001 From: "tuan.dn" Date: Wed, 28 Oct 2026 12:21:11 +0700 Subject: [PATCH 12/14] fix(scope): isolate availableDomains to REST and decouple realtime tab from REST filter --- .../lib/src/ui/scope/interceptly_scope.dart | 18 +- .../lib/src/ui/tabs/realtime_list_tab.dart | 10 +- .../ui/filter/available_domains_test.dart | 210 ++++++++++++++++++ 3 files changed, 229 insertions(+), 9 deletions(-) create mode 100644 packages/interceptly/test/ui/filter/available_domains_test.dart diff --git a/packages/interceptly/lib/src/ui/scope/interceptly_scope.dart b/packages/interceptly/lib/src/ui/scope/interceptly_scope.dart index 414a0b4..f73c864 100644 --- a/packages/interceptly/lib/src/ui/scope/interceptly_scope.dart +++ b/packages/interceptly/lib/src/ui/scope/interceptly_scope.dart @@ -110,13 +110,21 @@ class InterceptlyScopeController extends ChangeNotifier { return result; } - /// Available domains present in the current entries. + /// Available domains present in the REST entries. Set get availableDomains { return _cachedAvailableDomains ??= engine.entries - .map((entry) => RequestFilter.extractDomain(entry.url)) + .where((entry) => !entry.isRealtime) + .map((entry) => entry is IndexEntry + ? entry.domain + : RequestFilter.extractDomain(entry.url)) .toSet(); } + /// Returns all realtime connection entries from the engine. + List get realtimeEntries { + return engine.entries.where((e) => e.isRealtime).toList(growable: false); + } + Set getSearchScopes(String id) => _search.getScopes(id); RequestSummary? byId(String id) => engine.byId(id); @@ -154,10 +162,12 @@ class InterceptlyScopeController extends ChangeNotifier { _lastComputedCacheVersion == _cacheVersion) { return _cachedGroupedRecords!; } - final currentEntries = entries; + final currentEntries = entries.where((e) => !e.isRealtime); final grouped = >{}; for (final entry in currentEntries) { - final domain = RequestFilter.extractDomain(entry.url); + final domain = entry is IndexEntry + ? entry.domain + : RequestFilter.extractDomain(entry.url); grouped.putIfAbsent(domain, () => []).add(entry); } _cachedGroupedRecords = grouped.entries diff --git a/packages/interceptly/lib/src/ui/tabs/realtime_list_tab.dart b/packages/interceptly/lib/src/ui/tabs/realtime_list_tab.dart index 9b7dcae..aa77b07 100644 --- a/packages/interceptly/lib/src/ui/tabs/realtime_list_tab.dart +++ b/packages/interceptly/lib/src/ui/tabs/realtime_list_tab.dart @@ -56,8 +56,8 @@ class _RealtimeListTabState extends State { void _onScopeUpdated() { final scope = _scope; if (scope == null || !mounted) return; - final hasActive = scope.entries.any( - (e) => e.isRealtime && e.statusCode == 0 && !e.hasError, + final hasActive = scope.realtimeEntries.any( + (e) => e.statusCode == 0 && !e.hasError, ); if (hasActive != _hasActiveConnections) { _hasActiveConnections = hasActive; @@ -69,8 +69,8 @@ class _RealtimeListTabState extends State { void _refreshActiveFlag() { final scope = _scope; if (scope == null) return; - _hasActiveConnections = scope.entries.any( - (e) => e.isRealtime && e.statusCode == 0 && !e.hasError, + _hasActiveConnections = scope.realtimeEntries.any( + (e) => e.statusCode == 0 && !e.hasError, ); } @@ -97,7 +97,7 @@ class _RealtimeListTabState extends State { Widget build(BuildContext context) { final scope = InterceptlyScope.of(context); - final entries = scope.entries.where((e) => e.isRealtime).toList(); + final entries = scope.realtimeEntries; if (entries.isEmpty) { return const Center( diff --git a/packages/interceptly/test/ui/filter/available_domains_test.dart b/packages/interceptly/test/ui/filter/available_domains_test.dart new file mode 100644 index 0000000..dbda5da --- /dev/null +++ b/packages/interceptly/test/ui/filter/available_domains_test.dart @@ -0,0 +1,210 @@ +// ignore_for_file: invalid_use_of_internal_member + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:interceptly/interceptly.dart'; +import 'package:interceptly/src/engine/interceptly_engine.dart'; +import 'package:interceptly/src/ui/filter/filter_panel.dart'; +import 'package:interceptly/src/ui/scope/interceptly_scope.dart'; +import 'package:interceptly/src/ui/screens/interceptly_screen.dart'; +import 'package:interceptly/src/ui/tabs/realtime_list_tab.dart'; +import 'package:interceptly/src/ui/widgets/filter_badge.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late InterceptlyEngine engine; + late InterceptlyScopeController controller; + + setUp(() async { + await Interceptly.resetForTesting(); + engine = InterceptlyEngine(); + controller = InterceptlyScopeController(engine: engine); + }); + + tearDown(() async { + controller.dispose(); + await engine.clear(); + await engine.dispose(); + await Interceptly.resetForTesting(); + }); + + group('REST vs Realtime availableDomains isolation', () { + test('availableDomains excludes realtime-only domains', () { + engine.recordRealtimeConnection( + id: 'ws_1', + url: 'wss://socket.only-realtime.com/feed', + timestamp: DateTime(2026, 1, 1), + ); + + engine.recordPending( + id: 'req_1', + method: 'GET', + url: 'https://api.only-rest.com/v1/users', + timestamp: DateTime(2026, 1, 1), + ); + + expect(controller.availableDomains, contains('api.only-rest.com')); + expect(controller.availableDomains, + isNot(contains('socket.only-realtime.com'))); + expect(controller.availableDomains, equals({'api.only-rest.com'})); + }); + + test( + 'availableDomains includes shared domain having both REST and realtime', + () { + engine.recordPending( + id: 'req_shared', + method: 'GET', + url: 'https://shared-domain.com/api/data', + timestamp: DateTime(2026, 1, 1), + ); + + engine.recordRealtimeConnection( + id: 'ws_shared', + url: 'wss://shared-domain.com/live', + timestamp: DateTime(2026, 1, 1), + ); + + engine.recordRealtimeConnection( + id: 'ws_only', + url: 'wss://socket.live.net/events', + timestamp: DateTime(2026, 1, 1), + ); + + expect(controller.availableDomains, contains('shared-domain.com')); + expect(controller.availableDomains, isNot(contains('socket.live.net'))); + expect(controller.availableDomains, equals({'shared-domain.com'})); + }); + + test('clearing REST logs removes shared domain when only realtime remains', + () async { + engine.recordPending( + id: 'req_shared', + method: 'POST', + url: 'https://shared-domain.com/api/login', + timestamp: DateTime(2026, 1, 1), + ); + engine.recordRealtimeConnection( + id: 'ws_shared', + url: 'wss://shared-domain.com/live', + timestamp: DateTime(2026, 1, 1), + ); + + expect(controller.availableDomains, equals({'shared-domain.com'})); + + await controller.clearRest(); + + expect(controller.availableDomains, isEmpty); + }); + + test('clearing realtime logs preserves REST domains in availableDomains', + () async { + engine.recordPending( + id: 'req_shared', + method: 'POST', + url: 'https://shared-domain.com/api/login', + timestamp: DateTime(2026, 1, 1), + ); + engine.recordRealtimeConnection( + id: 'ws_shared', + url: 'wss://shared-domain.com/live', + timestamp: DateTime(2026, 1, 1), + ); + engine.recordRealtimeConnection( + id: 'ws_other', + url: 'wss://other-rt.com/ws', + timestamp: DateTime(2026, 1, 1), + ); + + await controller.clearRealtime(); + + expect(controller.availableDomains, equals({'shared-domain.com'})); + }); + + testWidgets( + 'FilterPanel opened from NetworkTab does not show realtime-only domain', + (tester) async { + engine.configure(const InterceptlySettings(enableRealtime: true)); + engine.recordPending( + id: 'req_1', + method: 'GET', + url: 'https://api.example.com/items', + timestamp: DateTime(2026, 1, 1), + ); + engine.recordRealtimeConnection( + id: 'ws_1', + url: 'wss://socket.realtime-only.com/feed', + timestamp: DateTime(2026, 1, 1), + ); + + await tester.pumpWidget( + MaterialApp( + home: InterceptlyScreen(controller: controller), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.byType(FilterBadge)); + await tester.pumpAndSettle(); + + expect(find.byType(FilterPanel), findsOneWidget); + expect( + find.descendant( + of: find.byType(FilterPanel), + matching: find.text('api.example.com')), + findsOneWidget); + expect( + find.descendant( + of: find.byType(FilterPanel), + matching: find.text('socket.realtime-only.com')), + findsNothing); + + await engine.clear(); + await tester.pump(); + }); + + testWidgets( + 'RealtimeListTab is not filtered by REST domain or method filter', + (tester) async { + engine.configure(const InterceptlySettings(enableRealtime: true)); + engine.recordPending( + id: 'req_1', + method: 'GET', + url: 'https://api.example.com/items', + timestamp: DateTime(2026, 1, 1), + ); + engine.recordRealtimeConnection( + id: 'ws_1', + url: 'wss://socket.realtime-only.com/feed', + timestamp: DateTime(2026, 1, 1), + ); + + controller.applyFilter(RequestFilter( + domains: {'api.example.com'}, + methods: {'POST'}, + include2xx: true, + include3xx: false, + include4xx: false, + include5xx: false, + )); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: InterceptlyScope( + controller: controller, + child: const RealtimeListTab(), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('/feed'), findsOneWidget); + + await engine.clear(); + await tester.pump(); + }); + }); +} From d4ad307cea476610ad255ca6ead8f78dad914665 Mon Sep 17 00:00:00 2001 From: "tuan.dn" Date: Wed, 28 Oct 2026 13:58:05 +0700 Subject: [PATCH 13/14] fix(interceptly): stop filter domain list inheriting safe-area padding The nested ListView.separated had no explicit padding, so Flutter applied MediaQuery.padding.bottom inside the bordered card, leaving ~34px of dead space under the last domain row. Opt out with EdgeInsets.zero and cover it with a widget test asserting the card is only borders taller than its row. --- .../lib/src/ui/filter/filter_panel.dart | 1 + .../test/ui/filter/filter_panel_test.dart | 55 +++++++++++++++++++ 2 files changed, 56 insertions(+) diff --git a/packages/interceptly/lib/src/ui/filter/filter_panel.dart b/packages/interceptly/lib/src/ui/filter/filter_panel.dart index 8faef8f..967ae29 100644 --- a/packages/interceptly/lib/src/ui/filter/filter_panel.dart +++ b/packages/interceptly/lib/src/ui/filter/filter_panel.dart @@ -461,6 +461,7 @@ class _FilterPanelState extends State { ), ) : ListView.separated( + padding: EdgeInsets.zero, shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), itemCount: filteredDomains.length, diff --git a/packages/interceptly/test/ui/filter/filter_panel_test.dart b/packages/interceptly/test/ui/filter/filter_panel_test.dart index d09dd47..744dbcf 100644 --- a/packages/interceptly/test/ui/filter/filter_panel_test.dart +++ b/packages/interceptly/test/ui/filter/filter_panel_test.dart @@ -6,6 +6,61 @@ import 'package:interceptly/src/ui/filter/filter_panel.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); + group('FilterPanel domain list layout', () { + testWidgets( + 'domain list container does not inherit bottom safe-area padding from MediaQuery', + (tester) async { + tester.view.devicePixelRatio = 3.0; + tester.view.padding = const FakeViewPadding(bottom: 102); + addTearDown(tester.view.reset); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Builder( + builder: (ctx) => ElevatedButton( + onPressed: () => FilterPanel.show( + context: ctx, + currentFilter: RequestFilter(), + availableDomains: {'api.com'}, + onFilterChanged: (_) {}, + ), + child: const Text('Open'), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + + final row = tester.getSize( + find + .ancestor( + of: find.text('api.com'), + matching: find.byType(InkWell), + ) + .first, + ); + final card = tester.getSize( + find + .ancestor( + of: find.text('api.com'), + matching: find.byType(Container), + ) + .first, + ); + + expect( + card.height, + row.height + 2, + reason: 'only the 1px borders may add height around the domain row', + ); + }); + }); + group('FilterPanel Redesign', () { testWidgets( 'does not show Show errors only and allows toggling methods and applying filter', From 9fb54ea7f34bf8ca6e250fba4d7ebc029cf20d23 Mon Sep 17 00:00:00 2001 From: "tuan.dn" Date: Wed, 28 Oct 2026 13:58:08 +0700 Subject: [PATCH 14/14] feat(interceptly): make request overview values selectable Wrap the Overview tab body in SelectionArea so URL, method, status, sizes, error and timestamp can be selected and copied, including across rows. Search highlighting keeps working because the rows stay Text/Text.rich instead of per-widget SelectableText. Covered by widget tests for the selection area and for the long-press copy toolbar. --- .../src/ui/detail/detail_tabs_builder.dart | 105 +++++++++--------- .../detail_overview_selection_test.dart | 83 ++++++++++++++ 2 files changed, 137 insertions(+), 51 deletions(-) create mode 100644 packages/interceptly/test/ui/detail/detail_overview_selection_test.dart diff --git a/packages/interceptly/lib/src/ui/detail/detail_tabs_builder.dart b/packages/interceptly/lib/src/ui/detail/detail_tabs_builder.dart index 6f258e1..41efc38 100644 --- a/packages/interceptly/lib/src/ui/detail/detail_tabs_builder.dart +++ b/packages/interceptly/lib/src/ui/detail/detail_tabs_builder.dart @@ -62,63 +62,66 @@ class DetailTabsBuilder { durationValue = '${record.durationMs} ms'; } - return ListView( - padding: const EdgeInsets.all(16.0), - children: [ - _buildOverviewRow('URL', displayUrl, DetailSection.overviewUrl), - const SizedBox(height: 16), - _buildOverviewRow( - 'Method', - record.method, - DetailSection.overviewMethod, - valueStyle: InterceptlyTheme.typography.bodyMediumBold.copyWith( - color: mStyle.text, - fontSize: 12, - ), - ), - const SizedBox(height: 16), - _buildOverviewRow( - 'Status', - statusValue, - DetailSection.overviewStatus, - ), - const SizedBox(height: 16), - _buildOverviewRow( - 'Duration', - durationValue, - DetailSection.overviewDuration, - ), - const SizedBox(height: 16), - _buildOverviewRow( - 'Request size', - formatBytes(record.requestSizeBytes), - DetailSection.overviewRequestSize, - ), - const SizedBox(height: 16), - _buildOverviewRow( - 'Response size', - isPending ? 'loading…' : formatBytes(record.responseSizeBytes), - DetailSection.overviewResponseSize, - ), - if (record.hasError) ...[ + return SelectionArea( + child: ListView( + padding: const EdgeInsets.all(16.0), + children: [ + _buildOverviewRow('URL', displayUrl, DetailSection.overviewUrl), const SizedBox(height: 16), _buildOverviewRow( - 'Error', - shortError, - DetailSection.overviewError, - valueStyle: InterceptlyTheme.typography.bodyMediumRegular.copyWith( - color: InterceptlyTheme.yellow400, + 'Method', + record.method, + DetailSection.overviewMethod, + valueStyle: InterceptlyTheme.typography.bodyMediumBold.copyWith( + color: mStyle.text, fontSize: 12, ), ), + const SizedBox(height: 16), + _buildOverviewRow( + 'Status', + statusValue, + DetailSection.overviewStatus, + ), + const SizedBox(height: 16), + _buildOverviewRow( + 'Duration', + durationValue, + DetailSection.overviewDuration, + ), + const SizedBox(height: 16), + _buildOverviewRow( + 'Request size', + formatBytes(record.requestSizeBytes), + DetailSection.overviewRequestSize, + ), + const SizedBox(height: 16), + _buildOverviewRow( + 'Response size', + isPending ? 'loading…' : formatBytes(record.responseSizeBytes), + DetailSection.overviewResponseSize, + ), + if (record.hasError) ...[ + const SizedBox(height: 16), + _buildOverviewRow( + 'Error', + shortError, + DetailSection.overviewError, + valueStyle: + InterceptlyTheme.typography.bodyMediumRegular.copyWith( + color: InterceptlyTheme.yellow400, + fontSize: 12, + ), + ), + ], + const SizedBox(height: 16), + _buildOverviewRow( + 'Time', + record.timestamp.toIso8601String(), + DetailSection.overviewTime, + ), ], - const SizedBox(height: 16), - _buildOverviewRow( - 'Time', - record.timestamp.toIso8601String(), - DetailSection.overviewTime, - ), - ], + ), ); } diff --git a/packages/interceptly/test/ui/detail/detail_overview_selection_test.dart b/packages/interceptly/test/ui/detail/detail_overview_selection_test.dart new file mode 100644 index 0000000..ba4b2e1 --- /dev/null +++ b/packages/interceptly/test/ui/detail/detail_overview_selection_test.dart @@ -0,0 +1,83 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:interceptly/src/model/request_record.dart'; +import 'package:interceptly/src/ui/detail/detail_search.dart'; +import 'package:interceptly/src/ui/detail/detail_tabs_builder.dart'; + +DetailTabsBuilder _buildTabs(RequestRecord record) { + return DetailTabsBuilder( + record: record, + matches: const [], + activeGlobalIndex: null, + query: '', + urlDecodeEnabled: false, + tryParseJson: (raw) => raw, + ); +} + +RequestRecord _buildRecord({String? errorType, String? errorMessage}) { + return RequestRecord( + id: 'overview_1', + method: 'GET', + url: 'https://api.example.com/v1/items?page=2', + statusCode: 200, + durationMs: 42, + requestSizeBytes: 128, + responseSizeBytes: 512, + timestamp: DateTime.utc(2026, 1, 1, 12), + requestHeaders: const {'accept': 'application/json'}, + responseHeaders: const {'content-type': 'application/json'}, + errorType: errorType, + errorMessage: errorMessage, + ); +} + +void main() { + group('Overview tab text selection', () { + testWidgets('wraps overview values in a SelectionArea', (tester) async { + final tabs = _buildTabs(_buildRecord()); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold(body: tabs.buildOverviewTab()), + ), + ); + await tester.pumpAndSettle(); + + expect( + find.ancestor( + of: find.text('https://api.example.com/v1/items?page=2'), + matching: find.byType(SelectionArea), + ), + findsOneWidget, + reason: 'overview values must be selectable', + ); + expect( + find.ancestor( + of: find.text('42 ms'), + matching: find.byType(SelectionArea), + ), + findsOneWidget, + ); + }); + + testWidgets('long press on an overview value shows the copy toolbar', + (tester) async { + final tabs = _buildTabs(_buildRecord()); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold(body: tabs.buildOverviewTab()), + ), + ); + await tester.pumpAndSettle(); + + await tester.longPress( + find.text('https://api.example.com/v1/items?page=2'), + ); + await tester.pumpAndSettle(); + + expect(find.text('Copy'), findsOneWidget); + }); + }); +}